From eaeab664a6fd9318e4eb4054c60e613cf9df4756 Mon Sep 17 00:00:00 2001 From: "Stephen C. Pope" Date: Wed, 3 Dec 2025 18:14:23 +0000 Subject: [PATCH 1/2] Decommission --- .github/ISSUE_TEMPLATE/bug.md | 25 - .github/ISSUE_TEMPLATE/feature.md | 13 - .github/workflows/public-ci.yml | 57 - LICENSE | 13 - README.md | 1782 +-------------- __init__.py | 0 descarteslabs/__init__.py | 73 - descarteslabs/auth/__init__.py | 18 - descarteslabs/auth/auth.py | 976 -------- descarteslabs/auth/tests/__init__.py | 0 descarteslabs/auth/tests/test_auth.py | 593 ----- descarteslabs/catalog/__init__.py | 15 - descarteslabs/compute/__init__.py | 15 - descarteslabs/config/__init__.py | 274 --- descarteslabs/config/settings.toml | 95 - descarteslabs/config/tests/__init__.py | 0 descarteslabs/config/tests/settings.toml | 3 - descarteslabs/config/tests/test_config.py | 250 -- descarteslabs/core/__init__.py | 1 - descarteslabs/core/catalog/__init__.py | 193 -- descarteslabs/core/catalog/attributes.py | 2021 ----------------- descarteslabs/core/catalog/band.py | 905 -------- descarteslabs/core/catalog/blob.py | 1116 --------- descarteslabs/core/catalog/blob_download.py | 33 - descarteslabs/core/catalog/blob_upload.py | 56 - descarteslabs/core/catalog/catalog_base.py | 1167 ---------- descarteslabs/core/catalog/catalog_client.py | 56 - descarteslabs/core/catalog/cli/__init__.py | 17 - descarteslabs/core/catalog/cli/bands.py | 65 - descarteslabs/core/catalog/cli/blobs.py | 182 -- descarteslabs/core/catalog/cli/cli.py | 29 - descarteslabs/core/catalog/cli/products.py | 171 -- .../core/catalog/cli/tests/__init__.py | 0 .../core/catalog/cli/tests/test_cli.py | 47 - descarteslabs/core/catalog/cli/utils.py | 19 - .../core/catalog/event_api_destination.py | 445 ---- descarteslabs/core/catalog/event_rule.py | 441 ---- descarteslabs/core/catalog/event_schedule.py | 361 --- .../core/catalog/event_subscription.py | 856 ------- descarteslabs/core/catalog/helpers.py | 161 -- descarteslabs/core/catalog/image.py | 1590 ------------- .../core/catalog/image_collection.py | 1146 ---------- descarteslabs/core/catalog/image_types.py | 76 - descarteslabs/core/catalog/image_upload.py | 566 ----- .../core/catalog/named_catalog_base.py | 222 -- descarteslabs/core/catalog/product.py | 466 ---- descarteslabs/core/catalog/scaling.py | 725 ------ descarteslabs/core/catalog/search.py | 653 ------ descarteslabs/core/catalog/task.py | 141 -- descarteslabs/core/catalog/tests/__init__.py | 0 descarteslabs/core/catalog/tests/base.py | 79 - descarteslabs/core/catalog/tests/mock_data.py | 1015 --------- .../core/catalog/tests/test_attributes.py | 1039 --------- descarteslabs/core/catalog/tests/test_band.py | 572 ----- descarteslabs/core/catalog/tests/test_blob.py | 1045 --------- .../core/catalog/tests/test_catalog_base.py | 746 ------ .../core/catalog/tests/test_download.py | 148 -- .../tests/test_event_api_destination.py | 617 ----- .../core/catalog/tests/test_event_rule.py | 514 ----- .../core/catalog/tests/test_event_schedule.py | 580 ----- .../catalog/tests/test_event_subscription.py | 776 ------- .../core/catalog/tests/test_filters.py | 189 -- .../core/catalog/tests/test_image.py | 1232 ---------- .../catalog/tests/test_image_collection.py | 402 ---- .../core/catalog/tests/test_image_upload.py | 615 ----- .../core/catalog/tests/test_product.py | 670 ------ .../core/catalog/tests/test_scaling.py | 891 -------- .../core/catalog/tests/test_search.py | 603 ----- .../core/catalog/tests/test_summary.py | 141 -- descarteslabs/core/client/__init__.py | 44 - descarteslabs/core/client/addons.py | 29 - descarteslabs/core/client/auth/README.md | 3 - descarteslabs/core/client/auth/__init__.py | 19 - .../core/client/auth/cli/__init__.py | 17 - descarteslabs/core/client/auth/cli/cli.py | 133 -- .../core/client/auth/cli/tests/__init__.py | 0 .../core/client/auth/cli/tests/test_cli.py | 147 -- descarteslabs/core/client/deprecation.py | 269 --- descarteslabs/core/client/exceptions.py | 15 - descarteslabs/core/client/scripts/__init__.py | 0 descarteslabs/core/client/scripts/__main__.py | 36 - descarteslabs/core/client/scripts/cli.py | 59 - .../core/client/scripts/lazy_group.py | 54 - .../core/client/scripts/tests/__init__.py | 0 .../core/client/scripts/tests/test_cli.py | 35 - .../core/client/services/__init__.py | 0 .../core/client/services/raster/__init__.py | 17 - .../client/services/raster/geotiff_utils.py | 450 ---- .../core/client/services/raster/raster.py | 776 ------- .../raster/smoke_tests/test_geotiff_utils.py | 213 -- .../client/services/raster/tests/__init__.py | 0 .../services/raster/tests/e2e/__init__.py | 0 .../raster/tests/e2e/iowa_geometry.py | 1135 --------- .../services/raster/tests/e2e/test_raster.py | 435 ---- .../raster/tests/test_geotiff_utils.py | 204 -- .../services/raster/tests/test_raster.py | 212 -- .../raster/tests/test_raster_rasterio.py | 163 -- .../core/client/services/service/__init__.py | 39 - .../client/services/service/api_service.py | 233 -- .../core/client/services/service/service.py | 852 ------- .../client/services/service/tests/__init__.py | 0 .../services/service/tests/test_service.py | 574 ----- descarteslabs/core/client/tests/__init__.py | 0 .../client/tests/test_clear_client_state.py | 37 - .../core/client/tests/test_deprecation.py | 145 -- descarteslabs/core/client/version.py | 15 - descarteslabs/core/common/__init__.py | 0 descarteslabs/core/common/client/__init__.py | 26 - .../core/common/client/attributes.py | 494 ---- descarteslabs/core/common/client/document.py | 222 -- descarteslabs/core/common/client/search.py | 299 --- descarteslabs/core/common/client/sort.py | 26 - .../core/common/client/tests/__init__.py | 0 .../common/client/tests/test_attributes.py | 331 --- .../core/common/client/tests/test_search.py | 65 - .../core/common/collection/__init__.py | 17 - .../core/common/collection/collection.py | 468 ---- .../core/common/collection/tests/__init__.py | 0 .../collection/tests/test_collection.py | 164 -- descarteslabs/core/common/display/__init__.py | 17 - descarteslabs/core/common/display/_display.py | 305 --- .../core/common/display/tests/__init__.py | 0 .../core/common/display/tests/test_display.py | 138 -- descarteslabs/core/common/dltile/__init__.py | 22 - descarteslabs/core/common/dltile/_tiling.py | 239 -- .../core/common/dltile/conversions.py | 135 -- .../core/common/dltile/exceptions.py | 29 - descarteslabs/core/common/dltile/rasterize.py | 216 -- .../core/common/dltile/tests/__init__.py | 0 .../common/dltile/tests/test_conversions.py | 117 - .../core/common/dltile/tests/test_dltiles.py | 354 --- descarteslabs/core/common/dltile/tile.py | 577 ----- descarteslabs/core/common/dltile/utils.py | 108 - descarteslabs/core/common/dltile/utm.py | 355 --- descarteslabs/core/common/dotdict/__init__.py | 17 - descarteslabs/core/common/dotdict/dotdict.py | 515 ----- .../core/common/dotdict/tests/__init__.py | 0 .../core/common/dotdict/tests/test_dotdict.py | 466 ---- descarteslabs/core/common/geo/__init__.py | 22 - descarteslabs/core/common/geo/geocontext.py | 1436 ------------ .../core/common/geo/tests/__init__.py | 0 .../core/common/geo/tests/test_geocontext.py | 477 ---- .../core/common/geo/tests/test_utils.py | 174 -- descarteslabs/core/common/geo/utils.py | 102 - descarteslabs/core/common/http/__init__.py | 24 - .../core/common/http/authorization.py | 49 - descarteslabs/core/common/http/proxy.py | 253 --- descarteslabs/core/common/http/retry.py | 46 - descarteslabs/core/common/http/service.py | 56 - descarteslabs/core/common/http/session.py | 448 ---- .../core/common/http/tests/__init__.py | 0 .../common/http/tests/test_authorization.py | 29 - .../core/common/http/tests/test_proxy.py | 167 -- .../core/common/http/tests/test_retry.py | 38 - .../core/common/http/tests/test_service.py | 54 - .../common/property_filtering/__init__.py | 39 - .../common/property_filtering/filtering.py | 856 ------- .../property_filtering/tests/__init__.py | 0 .../tests/test_filtering.py | 496 ---- .../core/common/registry/__init__.py | 17 - .../core/common/registry/registry.py | 69 - descarteslabs/core/common/retry/__init__.py | 22 - descarteslabs/core/common/retry/retry.py | 270 --- .../core/common/retry/tests/__init__.py | 0 .../core/common/retry/tests/test_retry.py | 208 -- .../core/common/shapely_support/__init__.py | 154 -- .../common/shapely_support/tests/__init__.py | 0 .../tests/test_shapely_support.py | 162 -- .../core/common/threading/__init__.py | 0 descarteslabs/core/common/threading/local.py | 49 - .../core/common/threading/tests/__init__.py | 0 .../core/common/threading/tests/test_local.py | 98 - descarteslabs/core/common/vector/__init__.py | 0 descarteslabs/core/common/vector/models.py | 54 - descarteslabs/core/compute/__init__.py | 30 - descarteslabs/core/compute/compute_client.py | 91 - descarteslabs/core/compute/exceptions.py | 22 - descarteslabs/core/compute/function.py | 1500 ------------ descarteslabs/core/compute/job.py | 593 ----- descarteslabs/core/compute/job_statistics.py | 121 - descarteslabs/core/compute/result.py | 133 -- descarteslabs/core/compute/tests/__init__.py | 0 descarteslabs/core/compute/tests/base.py | 219 -- .../core/compute/tests/data/test_data1.csv | 3 - .../core/compute/tests/data/test_data2.json | 5 - .../core/compute/tests/requirements.txt | 4 - .../core/compute/tests/test_function.py | 1037 --------- descarteslabs/core/compute/tests/test_job.py | 340 --- descarteslabs/core/geo/__init__.py | 21 - descarteslabs/core/third_party/__init__.py | 0 .../core/third_party/boltons/__init__.py | 17 - .../core/third_party/boltons/funcutils.py | 936 -------- descarteslabs/core/utils/__init__.py | 17 - descarteslabs/core/vector/README.md | 138 -- descarteslabs/core/vector/__init__.py | 30 - descarteslabs/core/vector/features.py | 493 ---- .../core/vector/images/the-coldest-lake.png | Bin 738665 -> 0 bytes descarteslabs/core/vector/layers.py | 32 - descarteslabs/core/vector/products.py | 258 --- descarteslabs/core/vector/tests/__init__.py | 0 descarteslabs/core/vector/tests/base.py | 199 -- .../core/vector/tests/test_features.py | 252 -- .../core/vector/tests/test_products.py | 158 -- descarteslabs/core/vector/tests/test_tiles.py | 45 - .../core/vector/tests/test_vector.py | 461 ---- descarteslabs/core/vector/tiles.py | 91 - descarteslabs/core/vector/util.py | 51 - descarteslabs/core/vector/vector.py | 1575 ------------- descarteslabs/core/vector/vector_client.py | 38 - descarteslabs/exceptions.py | 153 -- descarteslabs/geo/__init__.py | 15 - descarteslabs/utils/__init__.py | 15 - descarteslabs/vector/__init__.py | 15 - docs/examples/plot_create_product.py | 116 - docs/examples/plot_images_mosaic.py | 85 - docs/examples/plot_multi_product.py | 64 - docs/examples/plot_save_geotiff.py | 50 - docs/examples/plot_simple_viz.py | 42 - docs/examples/plot_timestacks.py | 70 - setup.py | 97 +- 220 files changed, 9 insertions(+), 57577 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/bug.md delete mode 100644 .github/ISSUE_TEMPLATE/feature.md delete mode 100644 .github/workflows/public-ci.yml delete mode 100644 LICENSE delete mode 100644 __init__.py delete mode 100644 descarteslabs/__init__.py delete mode 100644 descarteslabs/auth/__init__.py delete mode 100644 descarteslabs/auth/auth.py delete mode 100644 descarteslabs/auth/tests/__init__.py delete mode 100644 descarteslabs/auth/tests/test_auth.py delete mode 100644 descarteslabs/catalog/__init__.py delete mode 100644 descarteslabs/compute/__init__.py delete mode 100644 descarteslabs/config/__init__.py delete mode 100644 descarteslabs/config/settings.toml delete mode 100644 descarteslabs/config/tests/__init__.py delete mode 100644 descarteslabs/config/tests/settings.toml delete mode 100644 descarteslabs/config/tests/test_config.py delete mode 100644 descarteslabs/core/__init__.py delete mode 100644 descarteslabs/core/catalog/__init__.py delete mode 100644 descarteslabs/core/catalog/attributes.py delete mode 100644 descarteslabs/core/catalog/band.py delete mode 100644 descarteslabs/core/catalog/blob.py delete mode 100644 descarteslabs/core/catalog/blob_download.py delete mode 100644 descarteslabs/core/catalog/blob_upload.py delete mode 100644 descarteslabs/core/catalog/catalog_base.py delete mode 100644 descarteslabs/core/catalog/catalog_client.py delete mode 100644 descarteslabs/core/catalog/cli/__init__.py delete mode 100644 descarteslabs/core/catalog/cli/bands.py delete mode 100644 descarteslabs/core/catalog/cli/blobs.py delete mode 100644 descarteslabs/core/catalog/cli/cli.py delete mode 100644 descarteslabs/core/catalog/cli/products.py delete mode 100644 descarteslabs/core/catalog/cli/tests/__init__.py delete mode 100644 descarteslabs/core/catalog/cli/tests/test_cli.py delete mode 100644 descarteslabs/core/catalog/cli/utils.py delete mode 100644 descarteslabs/core/catalog/event_api_destination.py delete mode 100644 descarteslabs/core/catalog/event_rule.py delete mode 100644 descarteslabs/core/catalog/event_schedule.py delete mode 100644 descarteslabs/core/catalog/event_subscription.py delete mode 100644 descarteslabs/core/catalog/helpers.py delete mode 100644 descarteslabs/core/catalog/image.py delete mode 100644 descarteslabs/core/catalog/image_collection.py delete mode 100644 descarteslabs/core/catalog/image_types.py delete mode 100644 descarteslabs/core/catalog/image_upload.py delete mode 100644 descarteslabs/core/catalog/named_catalog_base.py delete mode 100644 descarteslabs/core/catalog/product.py delete mode 100644 descarteslabs/core/catalog/scaling.py delete mode 100644 descarteslabs/core/catalog/search.py delete mode 100644 descarteslabs/core/catalog/task.py delete mode 100644 descarteslabs/core/catalog/tests/__init__.py delete mode 100644 descarteslabs/core/catalog/tests/base.py delete mode 100644 descarteslabs/core/catalog/tests/mock_data.py delete mode 100644 descarteslabs/core/catalog/tests/test_attributes.py delete mode 100644 descarteslabs/core/catalog/tests/test_band.py delete mode 100644 descarteslabs/core/catalog/tests/test_blob.py delete mode 100644 descarteslabs/core/catalog/tests/test_catalog_base.py delete mode 100644 descarteslabs/core/catalog/tests/test_download.py delete mode 100644 descarteslabs/core/catalog/tests/test_event_api_destination.py delete mode 100644 descarteslabs/core/catalog/tests/test_event_rule.py delete mode 100644 descarteslabs/core/catalog/tests/test_event_schedule.py delete mode 100644 descarteslabs/core/catalog/tests/test_event_subscription.py delete mode 100644 descarteslabs/core/catalog/tests/test_filters.py delete mode 100644 descarteslabs/core/catalog/tests/test_image.py delete mode 100644 descarteslabs/core/catalog/tests/test_image_collection.py delete mode 100644 descarteslabs/core/catalog/tests/test_image_upload.py delete mode 100644 descarteslabs/core/catalog/tests/test_product.py delete mode 100644 descarteslabs/core/catalog/tests/test_scaling.py delete mode 100644 descarteslabs/core/catalog/tests/test_search.py delete mode 100644 descarteslabs/core/catalog/tests/test_summary.py delete mode 100644 descarteslabs/core/client/__init__.py delete mode 100644 descarteslabs/core/client/addons.py delete mode 100644 descarteslabs/core/client/auth/README.md delete mode 100644 descarteslabs/core/client/auth/__init__.py delete mode 100644 descarteslabs/core/client/auth/cli/__init__.py delete mode 100644 descarteslabs/core/client/auth/cli/cli.py delete mode 100644 descarteslabs/core/client/auth/cli/tests/__init__.py delete mode 100644 descarteslabs/core/client/auth/cli/tests/test_cli.py delete mode 100644 descarteslabs/core/client/deprecation.py delete mode 100644 descarteslabs/core/client/exceptions.py delete mode 100644 descarteslabs/core/client/scripts/__init__.py delete mode 100644 descarteslabs/core/client/scripts/__main__.py delete mode 100644 descarteslabs/core/client/scripts/cli.py delete mode 100644 descarteslabs/core/client/scripts/lazy_group.py delete mode 100644 descarteslabs/core/client/scripts/tests/__init__.py delete mode 100644 descarteslabs/core/client/scripts/tests/test_cli.py delete mode 100644 descarteslabs/core/client/services/__init__.py delete mode 100644 descarteslabs/core/client/services/raster/__init__.py delete mode 100644 descarteslabs/core/client/services/raster/geotiff_utils.py delete mode 100644 descarteslabs/core/client/services/raster/raster.py delete mode 100644 descarteslabs/core/client/services/raster/smoke_tests/test_geotiff_utils.py delete mode 100644 descarteslabs/core/client/services/raster/tests/__init__.py delete mode 100644 descarteslabs/core/client/services/raster/tests/e2e/__init__.py delete mode 100644 descarteslabs/core/client/services/raster/tests/e2e/iowa_geometry.py delete mode 100644 descarteslabs/core/client/services/raster/tests/e2e/test_raster.py delete mode 100644 descarteslabs/core/client/services/raster/tests/test_geotiff_utils.py delete mode 100644 descarteslabs/core/client/services/raster/tests/test_raster.py delete mode 100644 descarteslabs/core/client/services/raster/tests/test_raster_rasterio.py delete mode 100644 descarteslabs/core/client/services/service/__init__.py delete mode 100644 descarteslabs/core/client/services/service/api_service.py delete mode 100644 descarteslabs/core/client/services/service/service.py delete mode 100644 descarteslabs/core/client/services/service/tests/__init__.py delete mode 100644 descarteslabs/core/client/services/service/tests/test_service.py delete mode 100644 descarteslabs/core/client/tests/__init__.py delete mode 100644 descarteslabs/core/client/tests/test_clear_client_state.py delete mode 100644 descarteslabs/core/client/tests/test_deprecation.py delete mode 100644 descarteslabs/core/client/version.py delete mode 100644 descarteslabs/core/common/__init__.py delete mode 100644 descarteslabs/core/common/client/__init__.py delete mode 100644 descarteslabs/core/common/client/attributes.py delete mode 100644 descarteslabs/core/common/client/document.py delete mode 100644 descarteslabs/core/common/client/search.py delete mode 100644 descarteslabs/core/common/client/sort.py delete mode 100644 descarteslabs/core/common/client/tests/__init__.py delete mode 100644 descarteslabs/core/common/client/tests/test_attributes.py delete mode 100644 descarteslabs/core/common/client/tests/test_search.py delete mode 100644 descarteslabs/core/common/collection/__init__.py delete mode 100644 descarteslabs/core/common/collection/collection.py delete mode 100644 descarteslabs/core/common/collection/tests/__init__.py delete mode 100644 descarteslabs/core/common/collection/tests/test_collection.py delete mode 100644 descarteslabs/core/common/display/__init__.py delete mode 100644 descarteslabs/core/common/display/_display.py delete mode 100644 descarteslabs/core/common/display/tests/__init__.py delete mode 100644 descarteslabs/core/common/display/tests/test_display.py delete mode 100644 descarteslabs/core/common/dltile/__init__.py delete mode 100644 descarteslabs/core/common/dltile/_tiling.py delete mode 100644 descarteslabs/core/common/dltile/conversions.py delete mode 100644 descarteslabs/core/common/dltile/exceptions.py delete mode 100644 descarteslabs/core/common/dltile/rasterize.py delete mode 100644 descarteslabs/core/common/dltile/tests/__init__.py delete mode 100644 descarteslabs/core/common/dltile/tests/test_conversions.py delete mode 100644 descarteslabs/core/common/dltile/tests/test_dltiles.py delete mode 100644 descarteslabs/core/common/dltile/tile.py delete mode 100644 descarteslabs/core/common/dltile/utils.py delete mode 100644 descarteslabs/core/common/dltile/utm.py delete mode 100644 descarteslabs/core/common/dotdict/__init__.py delete mode 100644 descarteslabs/core/common/dotdict/dotdict.py delete mode 100644 descarteslabs/core/common/dotdict/tests/__init__.py delete mode 100644 descarteslabs/core/common/dotdict/tests/test_dotdict.py delete mode 100644 descarteslabs/core/common/geo/__init__.py delete mode 100644 descarteslabs/core/common/geo/geocontext.py delete mode 100644 descarteslabs/core/common/geo/tests/__init__.py delete mode 100644 descarteslabs/core/common/geo/tests/test_geocontext.py delete mode 100644 descarteslabs/core/common/geo/tests/test_utils.py delete mode 100644 descarteslabs/core/common/geo/utils.py delete mode 100644 descarteslabs/core/common/http/__init__.py delete mode 100644 descarteslabs/core/common/http/authorization.py delete mode 100644 descarteslabs/core/common/http/proxy.py delete mode 100644 descarteslabs/core/common/http/retry.py delete mode 100644 descarteslabs/core/common/http/service.py delete mode 100644 descarteslabs/core/common/http/session.py delete mode 100644 descarteslabs/core/common/http/tests/__init__.py delete mode 100644 descarteslabs/core/common/http/tests/test_authorization.py delete mode 100644 descarteslabs/core/common/http/tests/test_proxy.py delete mode 100644 descarteslabs/core/common/http/tests/test_retry.py delete mode 100644 descarteslabs/core/common/http/tests/test_service.py delete mode 100644 descarteslabs/core/common/property_filtering/__init__.py delete mode 100644 descarteslabs/core/common/property_filtering/filtering.py delete mode 100644 descarteslabs/core/common/property_filtering/tests/__init__.py delete mode 100644 descarteslabs/core/common/property_filtering/tests/test_filtering.py delete mode 100644 descarteslabs/core/common/registry/__init__.py delete mode 100644 descarteslabs/core/common/registry/registry.py delete mode 100644 descarteslabs/core/common/retry/__init__.py delete mode 100644 descarteslabs/core/common/retry/retry.py delete mode 100644 descarteslabs/core/common/retry/tests/__init__.py delete mode 100644 descarteslabs/core/common/retry/tests/test_retry.py delete mode 100644 descarteslabs/core/common/shapely_support/__init__.py delete mode 100644 descarteslabs/core/common/shapely_support/tests/__init__.py delete mode 100644 descarteslabs/core/common/shapely_support/tests/test_shapely_support.py delete mode 100644 descarteslabs/core/common/threading/__init__.py delete mode 100644 descarteslabs/core/common/threading/local.py delete mode 100644 descarteslabs/core/common/threading/tests/__init__.py delete mode 100644 descarteslabs/core/common/threading/tests/test_local.py delete mode 100644 descarteslabs/core/common/vector/__init__.py delete mode 100644 descarteslabs/core/common/vector/models.py delete mode 100644 descarteslabs/core/compute/__init__.py delete mode 100644 descarteslabs/core/compute/compute_client.py delete mode 100644 descarteslabs/core/compute/exceptions.py delete mode 100644 descarteslabs/core/compute/function.py delete mode 100644 descarteslabs/core/compute/job.py delete mode 100644 descarteslabs/core/compute/job_statistics.py delete mode 100644 descarteslabs/core/compute/result.py delete mode 100644 descarteslabs/core/compute/tests/__init__.py delete mode 100644 descarteslabs/core/compute/tests/base.py delete mode 100644 descarteslabs/core/compute/tests/data/test_data1.csv delete mode 100644 descarteslabs/core/compute/tests/data/test_data2.json delete mode 100644 descarteslabs/core/compute/tests/requirements.txt delete mode 100644 descarteslabs/core/compute/tests/test_function.py delete mode 100644 descarteslabs/core/compute/tests/test_job.py delete mode 100644 descarteslabs/core/geo/__init__.py delete mode 100644 descarteslabs/core/third_party/__init__.py delete mode 100644 descarteslabs/core/third_party/boltons/__init__.py delete mode 100644 descarteslabs/core/third_party/boltons/funcutils.py delete mode 100644 descarteslabs/core/utils/__init__.py delete mode 100644 descarteslabs/core/vector/README.md delete mode 100644 descarteslabs/core/vector/__init__.py delete mode 100644 descarteslabs/core/vector/features.py delete mode 100644 descarteslabs/core/vector/images/the-coldest-lake.png delete mode 100644 descarteslabs/core/vector/layers.py delete mode 100644 descarteslabs/core/vector/products.py delete mode 100644 descarteslabs/core/vector/tests/__init__.py delete mode 100644 descarteslabs/core/vector/tests/base.py delete mode 100644 descarteslabs/core/vector/tests/test_features.py delete mode 100644 descarteslabs/core/vector/tests/test_products.py delete mode 100644 descarteslabs/core/vector/tests/test_tiles.py delete mode 100644 descarteslabs/core/vector/tests/test_vector.py delete mode 100644 descarteslabs/core/vector/tiles.py delete mode 100644 descarteslabs/core/vector/util.py delete mode 100644 descarteslabs/core/vector/vector.py delete mode 100644 descarteslabs/core/vector/vector_client.py delete mode 100644 descarteslabs/exceptions.py delete mode 100644 descarteslabs/geo/__init__.py delete mode 100644 descarteslabs/utils/__init__.py delete mode 100644 descarteslabs/vector/__init__.py delete mode 100644 docs/examples/plot_create_product.py delete mode 100644 docs/examples/plot_images_mosaic.py delete mode 100644 docs/examples/plot_multi_product.py delete mode 100644 docs/examples/plot_save_geotiff.py delete mode 100644 docs/examples/plot_simple_viz.py delete mode 100644 docs/examples/plot_timestacks.py diff --git a/.github/ISSUE_TEMPLATE/bug.md b/.github/ISSUE_TEMPLATE/bug.md deleted file mode 100644 index fbfdc9ff..00000000 --- a/.github/ISSUE_TEMPLATE/bug.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -name: Bug Report -about: Create a report to help us improve - ---- - -## Expected Behavior - - -## Actual Behavior - - -## Steps to Reproduce - - 1. - 2. - 3. - -## Workarounds - -## Environment - -## Screenshots or Additional Context - -## Severity diff --git a/.github/ISSUE_TEMPLATE/feature.md b/.github/ISSUE_TEMPLATE/feature.md deleted file mode 100644 index fc57a9d8..00000000 --- a/.github/ISSUE_TEMPLATE/feature.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -name: Feature Request -about: Help us prioritize work for your needs - ---- - -## Feature - - -## Workarounds - - -## Priority diff --git a/.github/workflows/public-ci.yml b/.github/workflows/public-ci.yml deleted file mode 100644 index 3e357a59..00000000 --- a/.github/workflows/public-ci.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: CI -on: [push] - -jobs: - test: - name: ${{ matrix.os }} ${{ matrix.python-version }} - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ['3.10', '3.11', '3.12'] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - name: Install CI deps - run: | - python -m pip install --upgrade pip setuptools wheel - python -m pip install shapely -f ${{ secrets.CI_PIP_INDEX_URL }}/simple/shapely/index.html - - name: Build wheel - run: | - python -m pip wheel --no-deps --wheel-dir wheels -e . - python -m pip install -e .[complete,tests] - - name: Test Client - env: - TMPDIR: tempdir - DESCARTESLABS_ENV: testing - run: | - mkdir tempdir - python -m pytest -rfExXP --assert=plain --ignore-glob="*/smoke_tests" --ignore-glob="*/e2e" --ignore="descarteslabs/core/third_party" descarteslabs - rm -r tempdir - - name: Test CLI - env: - TMPDIR: tempdir - DESCARTESLABS_ENV: testing - run: | - mkdir tempdir - descarteslabs version - rm -r tempdir - - slack: - runs-on: ubuntu-latest - needs: [test] - if: ${{ always() && github.ref_name == 'master' }} - steps: - - name: Webhook - env: - EMOJI: ${{ needs.test.result == 'success' && ':party-hat:' || ':boom:' }} - STATUS: ${{ needs.test.result == 'success' && 'succeeded!' || 'failed.' }} - run: | - message=`sed "s/'/\\\\\\\\'/g" <<'!' - ${{ github.event.commits[0].message }} - ! - ` - PAYLOAD='{"text":"${{ env.EMOJI }} CI testing of ${{ github.event.repository.full_name }} has ${{ env.STATUS }}\nCommit <${{ github.event.commits[0].url }}|${{ github.sha }}> by ${{ github.event.commits[0].author.name }}: '"$message"'\n"}' - curl -s -X POST -H "Content-Type: application/json" -d "$PAYLOAD" ${{ secrets.slack_webhook }} diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 35dd3946..00000000 --- a/LICENSE +++ /dev/null @@ -1,13 +0,0 @@ -© 2025 EarthDaily Analytics Corp. - -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. diff --git a/README.md b/README.md index 8f4e9f6d..78c427eb 100644 --- a/README.md +++ b/README.md @@ -1,1781 +1,3 @@ -[![Build Status](https://github.com/descarteslabs/descarteslabs-python/actions/workflows/public-ci.yml/badge.svg)](https://github.com/descarteslabs/descarteslabs-python/actions/workflows/public-ci.yml) +The Descartes Labs Platform API and client has been discontinued and is no longer functional. -Descartes Labs Platform -======================= - -The Descartes Labs Platform is designed to answer some of the world’s most complex and pressing geospatial analytics questions. Our customers use the platform to build algorithms and models that transform their businesses quickly, efficiently, and cost-effectively. - -By giving data scientists and their line-of-business colleagues the best geospatial data and modeling tools in one package, we help turn AI into a core competency. - -Data science teams can use our scaling infrastructure to design models faster than ever, using our massive data archive or their own. - -Please visit [https://descarteslabs.com](https://descarteslabs.com) for more information about the Descartes Labs Platform and to request access. - -The `descarteslabs` python package, available at [https://pypi.org/project/descarteslabs/](https://pypi.org/project/descarteslabs/), provides client-side access to the Descartes Labs Platform for our customers. You must be a registered customer with access to our Descartes Labs Platform before you can make use of this package with our platform. - -The documentation for the latest release can be found at [https://docs.descarteslabs.com](https://docs.descarteslabs.com). For any issues please request Customer Support at [https://support.descarteslabs.com](https://support.descarteslabs.com). - -Changelog -========= - -## General - -- Support for Python 3.9 has been dropped, as it is reaching end of life an many dependencies no longer support it. - -## [4.0.0] - 2025-03-13 - -## General - -- Updated copyright message everywhere. - -## Auth - -- To prepare for multi-audience support in the clients, support for new custom claims in tokens has been added so that the user's unique namespace, which serves as a global id for the user, can be determined without resort to computing it on the fly. - -## Catalog - -- Fixed a bug where some geometries weren't supported by blob geometry properties. - -## Vector - -- *Breaking change* in the past, regardless of the ordering of columns when a `Table` was created, or the ordering of columns in the `TableOptions`, the result of a feature query would always place the `uuid` column last. As of this version, the behavior has been modified to preserve the ordering of columns, so that if no column list is provided in the options, the `uuid` column will appear in the position it has in the underlying table, as per the `Table.columns` property. Similarly if a query specifies an explicit list of columns in the `TableOptions`, `uuid` will appear in the same position in the result as it does in the supplied list of columns. However in keeping with prior behavior, if an explicit list of columns does not include `uuid`, it will always be added automatically at the end and will appear in the last column of the result. -- The `ilike` `property_filtering` expression type has been added to support case-insensitive wildcard matching of Vector Table column values. - -## [3.2.2] - 2024-11-14 - -## Catalog - -- Fixed a problem with unpickling Catalog objects pickled with an earlier version. Please be aware that we do not support the pickling of any Catalog objects, so if in doubt, don't do it! - -## [3.2.1] - 2024-10-21 - -## Authentication - -- The links for interacting with login and token generation have been updated to refer to `https://app.descarteslabs.com`. - -## Catalog - -- All `CatalogObject` classes which support the `owners`, `writers`, and `readers` fields have been refactored to derive this support from the new `AuthCatalogObject`. This change does not affect the behavior of any of these classes. The methods `AuthCatalogObject.user_is_owner()`, `AuthCatalogObject.user_can_write()`, and `AuthCatalogObject.user_can_read()` have been added to allow testing of permissions prior to attempting an operation such as updating or deleting the object. -- `EventSchedule` now has a read-only `expires` attribute which indicates when the schedule will be expired and deleted. -- `EventSubscription` now has a read-only `owner_role_arn` field which contains the AWS Role which will be used by certain `EventRule` targets that reside in an external AWS account. -- `EventRule` has been enhanced to support SQS Queue targets. -- Several new helper classes for use with `EventSubscription` are now supported: `EventSubscriptionSqsTarget`, `NewImageEventSubscription`, `NewStorageEventSubscription`, `NewVectorEventSubscription`, and `ComputeFunctionCompletedEventSubscription`. The latter supports events generated by the Compute service as described below. - -## Compute - -- The Compute service now generates a `compute-function-completed` event every time the number of outstanding (pending or running) jobs transitions to 0, akin to the `Function.wait_for_completion()` method. These can be used with the Catalog service events support to trigger other operations. - -## [3.2.0] - 2024-10-08 - -### General - -- Support for Python 3.8 has been removed -- Support for Python 3.12 has been added -- Some dependencies have been updated due to security flaws -- The dependency on `pytz` has been removed in favor of the standard `zoneinfo` package -- Minor changes and additions to the client exception hierarchy so that ClientErrors and ServerErrors are not conflated in the retry support. - -### Catalog - -- The Catalog now provides support for Events, essentially notifications of new or updated assets in the Catalog, including images and storage blobs. Additionally, scheduled calendar-based events can be defined. You can subscribe to these events to trigger running a Compute function of your choice. This makes it possible to set up automated processing of new imagery. See the [https://docs.descarteslabs.com/guides/catalog.html](Catalog Guide) and API documentation for more information. - -## [3.1.0] - 2024-05-09 - -### General - -- Due to declining support for Python 3.8 across the ecosystem, we have discontinued our support for Python 3.8. It is expected that the client will continue to function until Python 3.8 is End of Life (October 2024), but we can no longer test new releases against this version. - -### Catalog - -- The Catalog Storage Blob deletion methods have been enhanced to support waiting for completion of the operation. When a blob is deleted, it is removed immediately from the catalog and a background asynchronous task is launched to clean up the contents of the blob from the backing storage. If a blob is deleted and then a new blob with the identical id is immediately created and uploaded before this background task completes, it is possible for the background task to end up deleting the new blob contents. As of this release the `Blob` instance and class delete methods return a `BlobDeletionTaskStatus` object which provides a `wait_for_completion` method which can be used to wait until the background task completes and it is safe to create a new blob with the same id. For the `Blob.delete_many` method, the `wait_for_completion=True` parameter can be used to wait for all the supplied blobs to be completely deleted. Note that in the case of the `Blob.delete` class method, this is a very slight breaking change, as it used to return True or False, and now instead returns a `BlobDeletionTaskStatus` or `None`, which have the same truthiness and hence are very likely to behave identically in practical use. - -## [3.0.5] - 2024-03-21 - -Bugfix only - -### General - -- The `descarteslabs` client CLI script generated by the installation process was broken. Now it works! - -## [3.0.4] - 2024-03-20 - -A very minor release with some obscure bug fixes. - -### General - -- The `descarteslabs` client CLI has had an overhaul. Gone is the obsolete support for the Raster client, and added is support for querying Catalog Products, Bands, and Blobs and managing sharing for the same. -- Minor fixes to the authorization flow on login. - -### Catalog - -- Add testing of Blobs. - -### Compute - -- Corrected regular expressions used to parse the `memory` argument to the `Function` constructor. -- Improved documentation of the the `cpus` and `memory` arguments to the `Function` constructor. - - -## [3.0.3] - 2024-02-13 - -### General - -- Fixed a bug in seldom-used code to clear client state causing an import failure. - -## [3.0.2] - 2024-02-09 - -### Vector - -- Fixed a bug in `Table.visualize()` which could cause Authorization (401) errors when rendering tiles into an `ipyleaflet.Map`. - -### General - -- Bumped some minimum dependency version constraints to avoid security vulnerabilities. - -## [3.0.1] - 2024-02-07 - -### Vector - -- Fixed a bug in `Table.visualize()` that was causing it to fail. - -## [3.0.0] - 2024-02-01 - -Due to a number of breaking changes, the version has been bumped to 3.0.0. However, the vast majority -of typical use patterns in typical user code will not require changes. Please review the specifics -below. - -### Catalog - -- The `tags` attributes on Catalog objects can now contain up to 32 elements, each up to 1000 characters long. - But why would you even want to go there? -- *Breaking Change*: Derived bands, never supported in the AWS environment and catalog products, have been - removed. -- The new `Blob.delete_many` method may be used to delete large numbers of blobs efficiently. -- The `Blob.get_or_create` method didn't allow supplying `storage_type`, `namespace`, or `name` parameters. - Now it works as expected, either returning a saved Blob from the Catalog, or an unsaved blob that - you can use to upload and save its data. -- Image methods `ndarray` and `download` no longer pass the image's default geocontext geometry as a cutline. - This is to avoid problems when trying to raster a complete single image in its native CRS and resolution - where imperfect geometries (due to a simplistic projection to EPSG:4326) can cause some boundary pixels - to be masked. When passing in an explicit `GeoContext` to these methods, consider whether any cutline - geometry is required or not, to avoid these issues. - -### Compute - -- `Function` and `Job` objects now have a new `environment` attribute which can be used to define environment - variables for the jobs when they are run. -- *Breaking Change*: The `Function.map` method previously had no bound on how many jobs could be created at one time. - This led to operational problems with very large numbers of jobs. Now it submits jobs in batches (up to 1000 - jobs per batch) to avoid request timeouts, and is more robust on retryable errors so that duplicate jobs are not - submitted accidently. There is still no bound on how many jobs you may create with a single call to `Function.map`. - Additionally, since it is possible that some jobs may be successfully submitted, and others not, the return - value, while still behaving as a list of `Job`s, is now a `JobBulkCreateResult` object which has a `is_success` - and an `error` property which can be used to determine if all submissions were successful, what errors may - have occurred, and what jobs have actually been created. Only if the first batch fails hard will the method - raise an exception. -- The `Job.statistics` member is now typed as a `JobStatistics` object. -- The efficiency of deleting many jobs at once has been significantly improved using `Function.delete` and - `Function.delete_jobs`. It is still possible to encounter request timeouts with very large numbers of jobs; - workarounds are now documented in the API documentation for the `Function.delete_jobs` method. -- The `ComputeClient.check_credentials` method has been added, so that the client can determine if valid - user credentials have already been registered with the Compute service. - -### Vector - -- The Vector client library, previously available as the `descarteslabs-vector` package on PyPI, has - now been integrated into the Descartes Labs Python Client (this package). It should no longer be - installed separately. -- Visualization support (`ipyleaflet.Map`) is enabled when `ipyleaflet` is available. It is not - installed by default, but can be installed manually, or by installing the `descarteslabs` python - client with the `viz` extra (e.g. `pip install descarteslabs[viz]`). Note that in order to be - compatible with jupyterlab notebooks, the `visualize()` method no longer returns the layer, it - just adds it to the supplied map. -- The Vector package now has a `VectorClient` API client, with the usual support for `get_default_client()` - and `set_default_client()`. Most constructors and methods now accept an optional `client=` parameter - if you need to use something other than the default client. -- Configuration is now accomplished using the standard `descarteslabs.config` package. In particular, - the `vector_url` setting is used to specify the default Vector host. The `VECTOR_API_HOST` environment - variable is no longer consulted. -- Vector client methods now raise standard `descarteslabs.exceptions` Exception classes rather than - the `descarteslabs.vector.vector_exceptions` classes of the old client. -- The `is_spatial=` parameter previously accepted by many methods and functions is now deprecated - and ignored. It is not required because existing type information always determines if an operation - is spatial or not. Warnings will be generated if it is used. -- Be advised that feature upload and download (query) do not currently support or impose any limits, - and thus allow operations so large and slow that timeouts or other failures may occur. A future - version will implement limits and batching, so that large operations can be supported reliably. - Until then, the user may wish to implement their own batching were possible to avoid encountering - network limits and timeouts. - -### General - -- The old client version v1.12.1 is reaching end of life and will longer be supported as of February 2024. - You can expect the version to stop working at any point after that as legacy backend support is turned off. -- *Breaking Change*: The deprecated `Scenes` client API has been removed. -- *Breaking Change*: The deprecated `Metadata` client API has been removed. -- The minimum required version of `urllib3` has been bumped to 1.26.18 to address a security vulnerability. -- The minimum required version of `shapely` has been bumped to 2.0.0 to address thread safety issues. -- Python 3.7, formerly deprecated, is no longer supported. -- Python 3.12 is not yet officially supported due to the lack of support from `blosc`. However, if you - are able to provide a functional `blosc` on your own, then 3.12 should work. -- Urllib3 2.X is now supported. -- Geopandas, Pydantic, and PyArrow have been added as core dependencies to support the Vector client. -- For those users of the `clear_client_state` function (not common), the bands cache for the Catalog client - is now cleared also. - -## [2.1.2] - 2023-10-31 - -### Compute - -- `Function.delete_jobs` was failing to implement the `delete_results` parameter, so job result blobs - were not being deleted. This has been fixed. -- Add `delete_results` parameter to `Function.delete` for consistency. -- `Job.statistics` field added which contains statistics (CPU, memory, and network utilization) for the - job. This can be used to determine the minimal resources necessary for the `Function` after some - representative runs. - -## [2.1.1] - 2023-10-16 - -### Compute - -- Filtering on datetime attributes (such as `Function.created_at`) didn't previously work with anything - but `datetime` instances. Now it also handles iso format strings and unix timestamps (int or float). - -## [2.1.0] - 2023-10-04 - -### General - -- Following our lifecycle policy, client versions v1.11.0 and earlier are no longer supported. They may - cease to work with the Platform at any time. - -### Catalog - -- The Catalog `Blob` class now has a `get_data()` method which can be used to retrieve the blob - data directly given the id, without having to first retrieve the `Blob` metadata. - -### Compute - -- *Breaking Change* The status values for `Function` and `Job` objects have changed, to provide a - better experience managing the flow of jobs. Please see the updated Compute guide for a full explanation. - Because of the required changes to the back end, older clients (i.e. v2.0.3) are supported in a - best effort manner. Upgrading to this new client release is strongly advised for all users of the - Compute service. - -- *Breaking Change* The base images for Compute have been put on a diet. They are now themselves built - from "slim" Python images, and they no longer include the wide variety of extra Python packages that were - formerly included (e.g. TensorFlow, SciKit Learn, PyTorch). This has reduced the base image size by - an order of magnitude, making function build times and job startup overhead commensurately faster. - Any functions which require such additional packages can add them in as needed via the `requirements=` - parameter. While doing so will increase image size, it will generally still be much smaller and faster - than the prior "Everything and the kitchen sink" approach. Existing Functions with older images will continue - to work as always, but any newly minted `Function`` using the new client will be using one of the new - slim images. - -- Base images are now available for Python3.10 and Python3.11, in addition to Python3.8 and Python3.9. - -- Job results and logs are now integrated with Catalog Storage, so that results and logs can be - searched and retrieved directly using the Catalog client as well as using the methods in the Compute - client. Results are organized under `storage_type=StorageType.COMPUTE`, while logs are organized under - `storage_type=StorageType.LOGS`. - -- The new `ComputeResult` class can be used to wrap results from a `Function`, allowing the user to - specify additional attributes for the result which will be stored in the Catalog `Blob` metadata for - the result. This allows the function to specify properties such as `geometry`, `description`, - `expires`, `extra_attributes`, `writers` and `readers` for the result `Blob`. The use of - `ComputeResult` is not required. - -- A `Job` can now be assigned arbitrary tags (strings), and searched based on them. - -- A `Job` can now be retried on errors, and jobs track error reasons, exit codes, and execution counts. - -- `Function` and `Job` objects can now be filtered by class attributes (ex. - `Job.search().filter(Job.status == JobStatus.PENDING).collect()`). - -- The `Job.cancel()` method can now be used to cancel the execution of a job which is currently - pending or running. Pending jobs will immediately transition to `JobStatus.CANCELED` status, - while running jobs will pass through `JobStatus.CANCEL` (waiting for the cancelation to be - signaled to the execution engine), `JobStatus.CANCELING` (waiting for the execution to terminate), - and `JobStatus.CANCELED` (once the job is no longer executing). Cancelation of running jobs is - not guaranteed; a job may terminate successfully, or with a failure or timeout, before it can - be canceled. - -- The `Job.result()` method will raise an exception if the job does not have a status of - `JobStatus.SUCCESS`. If `Job.result()` yields an `None` value, this means that there was no - result (i.e. the execution returned a `None`). - -- The `Job.result_blob()` method will return the Catalog Storage Blob holding the result, if any. - -- The `Job.delete()` method will delete any job logs, but will not delete the job result unless - the `delete_results` parameter is supplied. - -- The `Function` object now has attributes `namespace` and `owner`. - -- The `Function.wait_for_completion()` and new `Function.as_completed()` methods provide a richer - set of functionality for waiting on and handling job completion. - -- The `Function.build_log()` method now returns the log contents as a string, rather than printing - the log contents. - -- The `Job.log()` method now returns the log contents as a list of strings, rather than printing the log - contents. Because logs can be unbounded in size, there's also a new `Job.iter_log()` method which returns - an iterator over the log lines. - -- The `requirements=` parameter to `Function` objects now supports more `pip` magic, allowing the use - of special `pip` controls such as `-f`. Also parsing of package versions has been loosened to allow - some more unusual version designators. - -- Changes to the `Function.map()` method, with the parameter name change of `iterargs` changed to `kwargs` - (the old name is still honored but deprecated), corrected documentation, and enhancements to support more - general iterators and mappings, allowing for a more functional programming style. - -- The compute package was restructured to make all the useful and relevant classes available at the top level. - -### Utils - -- Property filters can now be deserialized as well as serialized. - -## [2.0.3] - 2023-07-13 - -### Compute - -- Allow deletion of `Function` objects. - - Deleting a Function will deleted all associated Jobs. -- Allow deletion of `Job` objects. - - Deleting a Job will delete all associated resources (logs, results, etc). -- Added attribute filter to `Function` and `Job` objects. - - Attributes marked `filterable=True` can be used to filter objects on the compute backend api. - - Minor optimization to `Job.iter_results` which now uses backend filters to load successful jobs. -- `Function` bundling has been enhanced. - - New `include_modules` and `include_data` parameters allow for multiple other modules, non-code data files, etc to be added to the code bundle. - - The `requirements` parameter has been improved to allow a user to pass a path to their own `requirements.txt` file instead of a list of strings. - -## [2.0.2] - 2023-06-26 - -### Catalog - -- Allow data type `int32` in geotiff downloads. -- `BlobCollection` now importable from `descarteslabs.catalog`. - -### Documentation - -- Added API documentation for dynamic compute and vector - -## [2.0.1] - 2023-06-14 - -### Raster - -- Due to recent changes in `urllib3`, rastering operations were failing to retry certain errors which ought to be retried, causing more failures to propagate to the user than was desirable. This is now fixed. - -## [2.0.0] - 2023-06-12 - -(Release notes from all the 2.0.0 release candidates are summarized here for completeness.) - -### Supported platforms - -- Deprecated support for Python 3.7 (will end of life in July). -- Added support for Python 3.10 and Python 3.11 -- AWS-only client. For the time being, the AWS client can be used to communicate with the legacy GCP platform (e.g. `DESCARTESLABS_ENV=gcp-production`) but only supports those services that are supported on AWS (`catalog` and `scenes`). This support may break at any point in the future, so it is strictly transitional. - -### Dependencies - -- Removed many dependencies no longer required due to the removal of GCP-only features. -- Added support for Shapely 2.X. Note that user code may also be affected by breaking changes in - Shapely 2.X. Use of Shapely 1.8 is still supported. -- Updated requirements to avoid `urllib3>=2.0.0` which breaks all kinds of things. - -### Configuration - -- Major overhaul of the internals of the config process. To support other clients using namespaced packages within the `descarteslabs` package, the top level has been cleaned up, and most all the real code is down inside `descarteslabs.core`. End users should never have to import anything from `descarteslabs.core`. No more magic packages means that `pylint` will work well with code using `descarteslabs`. -- Configuration no longer depends upon the authorized user. - -### Catalog - -- Added support for data storage. The `Blob` class provides mechanism to upload, index, share, and retrieve arbitrary byte sequences (e.g. files). `Blob`s can be searched by namespace and name, geospatial coordinates (points, polygons, etc.), and tags. `Blob`s can be downloaded to a local file, or retrieved directly as a Python `bytes` object. `Blob`s support the same sharing mechanisms as `Product`s, with `owners`, `writers`, and `readers` attributes. -- Added support to `Property` for `prefix` filtering. -- The default `geocontext` for image objects no longer specifies a `resolution` but rather a `shape`, to ensure - that default rastering preserves the original data and alignment (i.e. no warping of the source image). -- As with `resolution`, you can now pass a `crs` parameter to the rastering methods (e.g. `Image.ndarray`, - `ImageCollection.stack`, etc.) to override the `crs` of the default geocontext. -- A bug in the code handling the default context for image collections when working with a product with a CRS based on degrees rather than meters has been fixed. Resolutions should always be specified in the units used by the CRS. - -### Compute - -- Added support for managed batch compute under the `compute` module. - -### Raster Client - -- Fixed a bug in the handling of small blocks (less than 512 x 512) that caused rasterio to generate bad download files (the desired image block would appear as a smaller sub-block rather than filling the resulting raster). - -### Geo - -- The defaulting of `align_pixels` has changed slightly for the `AOI` class. Previously it always defaulted to - `True`. Now the default is `True` if `resolution` is set, `False` otherwise. This ensures that when specifying - a `shape` and a `bounds` rather than a resolution,the `shape` is actually honored. -- When assigning a `resolution` to an `AOI`, any existing `shape` attribute is automatically unset, since the - two attributes are mutually exclusive. -- The validation of bounds for a geographic CRS has been slightly modified to account for some of the - irregularities of whole-globe image products, correcting unintended failures in the past. -- Fixed problem handling MultiPolygon and GeometryCollection when using Shapely 2.0. - - -## [2.0.0rc5] - 2023-06-01 - -### Catalog - -- Loosen up the restrictions on the allowed alphabet for Blob names. Now almost any printable - character is accepted save for newlines and commas. -- Added new storage types for Blobs: `StorageType.COMPUTE` (for Compute job results) and - `StorageType.DYNCOMP` (for saved `dynamic-compute` operations). - -### Compute - -- Added testing of the client. - -## [2.0.0rc4] - 2023-05-17 - -### Catalog - -- The defaulting of the `namespace` value for `Blob`s has changed slightly. If no namespace is specified, - it will default to `:` with the user's org name and unique user hash. Otherwise, any other value, - as before, will be prefixed with the user's org name if it isn't already so. -- `Blob.get` no longer requires a full id. Alternatively, you can give it a `name` and optionally a `namespace` - and a `storage_type`, and it will retrieve the `Blob`. -- Fixed a bug causing summaries of `Blob` searches to fail. - -### Compute - -- `Function.map` and `Function.rerun` now save the created `Job`s before returning. -- `Job.get` return values fixed, and removed an extraneous debug print. - -### General - -- Updated requirements to avoid `urllib3>=2.0.0` which break all kinds of things. - -## [2.0.0rc3] - 2023-05-03 - -### Geo - -- The defaulting of `align_pixels` has changed slightly for the `AOI` class. Previously it always defaulted to - `True`. Now the default is `True` if `resolution` is set, `False` otherwise. This ensures that when specifying - a `shape` and a `bounds` rather than a resolution,the `shape` is actually honored. -- When assigning a `resolution` to an `AOI`, any existing `shape` attribute is automatically unset, since the - two attributes are mutually exclusive. -- The validation of bounds for a geographic CRS has been slightly modified to account for some of the irregularities - of whole-globe image products, correcting unintended failures in the past. - -### Catalog - -- The default `geocontext` for image objects no longer specifies a `resolution` but rather a `shape`, to ensure - that default rastering preserves the original data and alignment (i.e. no warping of the source image). -- The `Blob.upload` and `Blob.upload_data` methods now return `self`, so they can be used in a fluent style. -- As with `resolution`, you can now pass a `crs` parameter to the rastering methods (e.g. `Image.ndarray`, - `ImageCollection.stack`, etc.) to override the `crs` of the default geocontext. - -### Compute - -- A bevy of fixes to the client. - -## [2.0.0rc2] - 2023-04-19 - -### Catalog - -- Added support for data storage. The `Blob` class provides mechanism to upload, index, share, and retrieve arbitrary byte sequences (e.g. files). `Blob`s can be searched by namespace and name, geospatial coordinates (points, polygons, etc.), and tags. `Blob`s can be downloaded to a local file, or retrieved directly as a Python `bytes` object. `Blob`s support the same sharing mechanisms as `Product`s, with `owners`, `writers`, and `readers` attributes. -- Added support to `Property` for `prefix` filtering. - -### Compute - -- Added method to update user credentials for a `Function`. -- Added methods to retrieve build and job logs. - -### General - -- Added support for Shapely=2.X. - -## [2.0.0rc1] - 2023-04-10 - -- This is an internal-only release. There is as of yet no updated documentation. However, the user-facing client APIs remain fully compatible with v1.12.1. - -### Compute - -- Added support for managed batch compute under the `compute` module. - -### Auth and Configuration - -- Removed the check on the Auth for configuration, since it is all AWS all the time. - -### Raster Client - -- Fixed a bug in the handling of small blocks (less than 512 x 512) that caused rasterio to generate bad download files (the desired image block would appear as a smaller sub-block rather than filling the resulting raster). - -## [2.0.0rc0] - 2023-03-16 - -- This is an internal-only release. There is as of yet no updated documentation. However, the user-facing client APIs remain fully compatible with v1.12.1. - -### Supported platforms - -- Deprecated support for Python 3.7 (will end of life in July). -- Added support for Python 3.10 and Python 3.11 -- AWS-only client. For the time being, the AWS client can be used to communicate with the legacy GCP platform (e.g. `DESCARTESLABS_ENV=gcp-production`) but only supports those services that are supported on AWS (`catalog` and `scenes`). This support may break at any point in the future, so it is strictly transitional. - -### Dependencies - -- Removed many dependencies no longer required due to the removal of GCP-only features. - -### Configuration - -- Major overhaul of the internals of the config process. To prepare for supporting other clients using namespaced packages within the `descarteslabs` package, the top level has been cleaned up, and most all the real code is down inside `descarteslabs.core`. However end users should never have to import anything from `descarteslabs.core`. No more magic packages means that `pylint` will work well with code using `descarteslabs`. -- GCP environments only support `catalog` and `scenes`. All other GCP-only features have been removed. - -### Catalog - -- A bug in the code handling the default context for image collections when working with a product with a CRS based on degrees rather than meters has been fixed. Resolutions should always be specified in the units used by the CRS. - -## [1.12.1] - 2023-02-06 - -### Workflows - -- Fixed a bug causing `descarteslabs.workflows.map.geocontext()` to fail with an import error. This problem - also affected the autoscaling feature of workflows map layers. - -### Catalog/Scenes/Raster - -- Fixed a bug causing downloads of single-band images to fail when utilizing rasterio. - -## [1.12.0] - 2023-02-01 - -### Catalog - -- Catalog V2 is now fully supported on the AWS platform, including user ingest. -- Catalog V2 has been enhanced to provide substantially all the functionality of the Scenes API. The `Image` class now - includes methods such as `ndarray` and `download`. A new `ImageCollection` class has been added, mirroring `SceneCollection`. - The various `Search` objects now support a new `collect` method which will return appropriate `Collection` types - (e.g. `ProductCollection`, `BandCollection`, and of course `ImageCollection`). Please see the updated Catalog V2 - guide and API documentation for more details. -- Previously, the internal implementation of the `physical_range` attribute on various band types was inconsistent with - that of `data_range` and `display_range`. It has now been made consistent, which means it will either not be set, - or will contain a 2-tuple of float values. It is no longer possible to explicitly set it to `None`. -- Access permissions for bands and images are now managed directly by the product. The `readers`, `writers`, and - `owners` attributes have been removed from all the `*Band` classes as well as the `Image` class. Also the - `Product.update_related_objects_permissions` and `Product.get_update_permissions_status` methods have been removed - as these are no longer necessary or supported. -- All searches for bands (other than derived bands) and images must specify one or more product ids in the filtering. - This requirement can be met by using the `bands()` and `images()` methods of a product to limit the search to that - product, or through a `filter(properties.product_id==...)` clause on the search. -- Products have a new `product_tier` attribute, which can only be set or modified by privileged users. -- The `Image.upload_ndarray` will now accept either an ndarray or a list of ndarrays, allowing multiple files per image. - The band definitions for the product must correspond to the order and properties of the multiple ndarrays. - -### Scenes - -- With the addition of the Scenes functionality to Catalog V2, you are strongly encouraged to migrate your Scenes-based - code to use Catalog V2 instead. Scenes will be deprecated in a future release. Some examples of migrating from Scenes - to Catalog V2 are included in the Catalog V2 guide. In the meantime the Scenes API has been completely reimplemented - to use Catalog V2 under the hood. From a user perspective, existing code using the Scenes API should continue to - function as normal, with the exception of a few differences around some little-used dark corners of the API. -- The Scenes `search_bands` now enforces the use of a non-empty `products=` parameter value. This was previously - documented but not enforced. - -### Metadata - -- With the addition of the Scenes functionality to Catalog V2, you are strongly encouraged to migrate your Metadata-based - code to use Catalog V2 instead. Metadata will be deprecated in a future release. -- As with Catalog and Scenes, one or more products must now be specified when searching for bands or images. - -### Raster - -- The Raster client API now requires a `bands=` parameter for all rastering operations, such as `raster`, `ndarray` - and `stack`. It no longer defaults to all bands defined on the product. - -### DLTile - -- An off-by-1/2-pixel problem was identified in the coordinate transforms underlying - `DLTile.rowcol_to_latlon` and `DLTile.latlon_to_rowcol`. The problem has been corrected, - and you can expect to see slight differences in the results of these two methods. - -### REST Clients - -- All the REST client types, such as `Metadata` and `Raster`, now support `get_default_client()` and `set_default_client()` - instances. This functionality was previously limited to the Catalog V2 `CatalogClient`. Whenever such a client is required, - the client libraries use `get_default_client()` rather than using the default constructor. This makes it easy to - comprehensively redirect the library to use a specially configured client when necessary. - -### Geo package - -- The `GeoContext` types that originally were part of the Scenes package are now available in the new `descarteslabs.geo` package, - with no dependencies on Scenes. This is the preferred location from which to import these classes. - -### Utils package - -- The `descarteslabs.utils` package, added in the previous release for the AWS client only, now exists in the GCP client - as well, and is the preferred location to pick up the `DotDict` and `DotList` classes, the `display` and `save_image` functions, - and the `Properties` class for property filtering in Catalog V2. -- The `display` method now has added support for multi-image plots, see the API documentation for the `figsize`, `nrows`, - `ncols` and `layout_direction` parameters. - -### Property filtering - -- The `property_filtering.GenericProperties` class has been replaced with `property_filtering.Properties`, but remains - for back compatibility. -- Property filters now support `isnull` and `isnotnull` operations. This can be very useful for properties which may or - may not be present, e.g. `properties.cloud_fraction.isnull | properties.cloud_fraction <= 0.2`. - -### Configuration and Authentication - -- The `Config` exceptions `RuntimeError` and `KeyError` were changed to `ConfigError` exceptions - from `descarteslabs.exceptions`. -- `Auth` now retrieves its URL from the `Config` settings. If no valid configuration can be found, - it reverts to the commercial service (`https://app.descarteslabs.com`). - -### General -- Dependencies for the descarteslabs library have been updated, but remain constrained to continue to support Python 3.7. -- Numerous bug fixes. - -## [1.11.0] - 2022-07-20 - -### Installation - -- The extra requirement options have changed. There are four extra requirement options now, `visualization`, `tables`, - `complete`, and `tests`. `visualization` pulls in extra requirements to support operating in a Jupyter notebook or - environment, enabling interactive maps and graphical displays. It is not required for operating in a "headless" - manner. `tables` pulls in extra requirements to support the `Tables` client. `complete` is the combination of - `visualization` and `tables`. `tests` pulls in extra requirements for running the tests. As always, - `pip install 'descarteslabs[complete]'` will install a fully enabled client. - -### Configuration - -- The Descartes Labs client now supports configuration to support operating in different environments. By default, - the client will configure itself for standard usage against the GCP platform (`"gcp-production"`), except in the case of AWS Marketplace users, for whom - the client will configure itself against the AWS platform (`"aws-production"`). - Alternate environments can be configured by setting the `DESCARTESLABS_ENV` environment variable before starting python, or by using a prelude like - ``` - from descarteslabs.config import Settings - Settings.select_env("environment-name") - ``` - before any other imports of any part of the descarteslabs client package. -- The new AWS Enterprise Accelerator release currently includes only Auth, Configuration - and the Scenes client. - -### Auth and Exceptions - -- The `descarteslabs.client.auth` package has moved to `descarteslabs.auth`. It is now imported - into the original location at `descarteslabs.client.auth` to continue to work with existing - code, but new code should use the new location. -- The `descarteslabs.client.exceptions` module has moved to `descarteslabs.exceptions`. It is - now imported into the original location at `descarteslabs.client.exceptions` to continue to - work with existing code, but new code should use the new location. - -### Scenes - -- Fixed an issue in `scenes.DLTile.from_shape` where there would be incomplete coverage of certain geometries. The function may now return more tiles than before. -- Added support for the new `all_touched` parameter to the different `GeoContext` types. Default behavior remains the same -as always, but if you set `all_touched=True` this communicates to the raster service that you want the image(s) rastered -using GDAL's `CUTLINE_ALL_TOUCHED` option which will change how source pixels are mapped to output pixels. This mode is -only recommended when using an AOI which is smaller than the source imagery pixel resolution. -- The DLTile support has been fixed to avoid generating gaps when tiling regions that span - a large distance north-to-south and straddle meridians which are boundaries between - UTM zones. So methods such as `DLTile.from_shape` may return more tiles than previously, - but properly covering the region. -- Added support for retrieving products and bands. - - Methods added: `get_product`, `get_band`, `get_derived_band`, `search_products`, - `search_bands`, `search_derived_bands`. - - Disallows search without `products` parameter. -- Scaling support has been enhanced to understand processing levels for newer products. The - `Scene.scaling_parameters` and `SceneCollection.scaling_parameters` methods now accept - a `processing_level` argument, and this will be factored in to the determination of - the default result data type and scaling for all rastering operations such as `Scene.ndarray` - and `SceneCollection.mosaic`. -- If the user provides the `rasterio` package (which implies providing GDAL), then rasterio - will be used to save any downloaded images as GeoTIFF, allowing for the use of compression. - Otherwise, by default the `tifffile` support will be used to generate the GeoTIFF files - but compression is not supported in this mode. -- As the Places client has been deprecated, so has any use of the `place=` parameter supported - by several of the Scenes functions and methods. - -### Catalog - -- (Core users only) Added support for specifying the image index to use when creating a new `Product`. -- Added support for defining per-processing-level `data_type`, `data_range`, `display_range` - and `physical_range` properties on processing level steps. - -### Discover - -- Added support for filtering `Assets` by type and name fields. - - Supported filter types `blob`, `folder`, `namespace`, `sym_link`, `sts_model`, and `vector`. Specifying multiple types will find assets matching any given type. - - The name field supports the following wildcards: - - `*` matches 0 or more of any character. - - `?` matches 1 of any character. - - Find assets matching type of `blob` and having a display name of `file name.json` or `file2name.txt` but **not** `filename.json`: - - `Discover().list_assets("asset/namespace/org:some_org", filters="type=blob&name=file?name.*")` - - `Discover().list_assets("asset/namespace/org:some_org", filters=AssetListFilter(type=AssetType.BLOB, name="file?name.*"))` - - Find assets of type `blob` or `vector`: - - `Discover().list_assets("asset/namespace/org:some_org", filters="type=blob,vector")` - - `Discover().list_assets("asset/namespace/org:some_org", filters=AssetListFilter(type=[AssetType.BLOB, AssetType.VECTOR], name="file?name.*"))` - -### Metadata - -- `Metadata.products` and `Metadata.available_products` now properly implement paging so that - by default, a DotList containing every matching product accessible to the user is returned. - -### Raster - -- If the user provides the `rasterio` package (which implies providing GDAL), then rasterio - will be used to save any downloaded images as GeoTIFF, allowing for the use of compression. - Otherwise, by default the `tifffile` support will be used to generate the GeoTIFF files - but compression is not supported in this mode. - -### Tables - -- Fixed an issue that caused a user's schema to be overwritten if they didn't provide a primary - key on table creation. -- Now uses Discover backend filtering for `list_tables()` instead of filtering on the client to - improve performance. -- `list_tables()` now supports filtering tables by name - - `Tables.list_tables(name="Test*.json")` - -### Tasks - -- New Tasks images for this release bump the versions of several dependencies, please see - the Tasks guide for detailed lists of dependencies. - -### Workbench - -- The new Workbench release bumps the versions of several dependencies. - -### Workflows - -- Added support for the new `all_touched` parameter to the different `GeoContext` types. - See description above under `Scenes`. - -### General - -- The Places client has been deprecated, and use thereof will generate a deprecation warning. -- The older Catalog V1 client has been deprecated, and use thereof will generate a deprecation - warning. Please use the Catalog V2 client in its place. -- Documentation has been updated to include the `AWS Enterprise Accelerator" release. -- With Python 2 far in the rearview mirror, the depedencies on the `six` python package have - been removed throughout the library, the distribution and all tasks images. - -## [1.10.0] - 2022-01-18 - -### Python Versions Supported - -- Added support for Python 3.9. -- Removed support for Python 3.6 which is now officially End Of Life. - -### Workflows -- Added support for organizational sharing. You can now share using the `Organization` type: - - `workflows.add_reader(Organization("some_org"))` - -### Discover - -- Added support for organizational sharing. You can now share using the `Organization` type: - - `asset.share(with_=Organization("some_org"), as_="Viewer")` -- Allow user to list their organization's namespace. - - `Discover().list_asset("asset/namespace/org:some_org")` -- Allow user to list their organization's users. - - `Discover().list_org_users()` - -### Tables - Added -- Added an **alpha** Tables client. The Tables module lets you organize, upload, and query tabular data and vector geometries. As an alpha release, we reserve the right to modify the Tables client API without any guarantees about backwards compatibility. See the [Tables API](https://docs.descarteslabs.com/descarteslabs/tables/readme.html) and [Tables Guide](https://docs.descarteslabs.com/guides/tables.html) documentation for more details. - -### Scenes -- Added the `progress=` parameter to the various rastering methods such as `Scene.ndarray`, - `Scene.download`, `SceneCollection.mosaic`, `SceneCollection.stack`, `SceneCollection.download` - and `SceneCollection.download_mosaic`. This can be used to enable or disable the display - of progress bars. - -### Tasks images -- Support for Python 3.9 images has been added, and support for Python 3.6 images has been removed. -- Many of the add on packages have been upgraded to more recently released versions. In particular, `tensorflow` was updated from version 2.3 to version 2.7. -- GPU support was bumped up from CUDA 10 to CUDA 11.2 - -## [1.9.1] - 2021-12-20 - -### Raster - -- Fixed a bug preventing retry-able errors (such as a 429) from being retried. - -## [1.9.0] - 2021-11-09 - -### Catalog - -- Allow retrieving Attribute as a class attribute. It used to raise an exception. - -### Scenes - -- Fixed a bug preventing the user from writing JPEG files with smaller than 256x256 tiles. -- Allow specifying a `NoData` value for non-JPEG GeoTIFF files. -- Include band description metadata in created GeoTIFF files. -- Support scaling parameters as lists as well as tuples. -- Add caching of band metadata to drastically reduce the number of metadata queries when creating `SceneCollections`. -- `DLTiles.from_shape` was failing to handle shape objects implementing the `__geo_interface__` API, most notably several of the Workflows `GeoContext` types. These now work as expected. -- Certain kinds of network issues could read to rastering operations raising an `IncompleteRead` exception. This is now correctly caught and retried within the client library. - -### Tasks - -- Users can now use `descarteslabs.tasks.update_credentials()` to update their task credentials in case they became outdated. - -### Workflows - -- We have introduced a hard limit of 120 as the number of outstanding Workflows compute jobs that a single user can have. This limit exists to minimize situations in which a user is unable to complete jobs in a timely manner by ensuring resources cannot be monopolized by any individual user. The API that backs the calls to `compute` will return a `descarteslabs.client.grpc.exceptions.ResourceExhausted` error if the caller has too many outstanding jobs. Prior to this release (1.9.0), these failures would be retried up to some small retry limit. With the latest client release however, the client will fail without retrying on an HTTP 429 (rate limit exceeded) error. For users with large (non-interactive) workloads who don’t mind waiting, we added a new `num_retries` parameter to the `compute` function; when specified, the client will handle any 429 errors and retry up to `num_retries` times. -- Workflows is currently optimized for interactive use cases. If you are submitting large numbers of long-running Workflows compute jobs with `block=False`, you should consider using Tasks and Scenes rather than the Workflows API. -- Removed `ResourceExhausted` exceptions from the list of exceptions we automatically catch and retry on for `compute` calls. - -### Documentation - -- Lots of improvements, additions, and clarifications in the API documentation. - -## [1.8.2] - 2021-07-12 - -### General - -- Workflows client no longer validates `processing_level` parameter values, as these have been enhanced to support new products and can only be validated server side. -- Catalog V2 bands now support the `vendor_band_name` field (known as `name_vendor` in Metadata/Catalog V1). -- Scenes support for masking in version 1.8.1 had some regressions which have been fixed. For this reason, version 1.8.1 has been pulled from PyPI. -- New task groups now default to a `maximum_concurrency` value of 5, rather than the previous 500. This avoids the common problem of deploying a task group with newly developed code, and having it scale up and turning small problems into big problems! You may still set values as large as 500. -- The Tasks client now provides an `update_group()` method which can be used to update many properties of an existing task group, including but not limited to `name`, `image`, `minimum_concurrency`, and `maximum_concurrency`. -- Improved testing across several sub-packages. -- Various documentation fixes. - -## [1.8.1] - 2021-06-22 - -** Version Deprecated ** Due to some regressions in the Scenes API, this version has been removed from PyPI. - -### General - -- Added a new `common.dltile` library that performs geospatial transforms and tiling operations. -- Upgraded various dependencies: `requests[security]>=2.25.1,<3`,`six>=1.15.0`,`blosc==1.10.2`,` mercantile>=1.1.3`,`Pillow>=8.1.1`,`protobuf>=3.14.0,<4`,`shapely>=1.7.1,<2`,`tqdm>=4.32.1`,`traitlets>=4.3.3,<6;python_version<'3.7'`,`traitlets==5.0.5,<6;python_version>='3.7'`,`markdown2>=2.4.0,<3`,`responses==0.12.1`,`freezegun==0.3.12`,`imagecodecs>=2020.5.30;python_version<'3.7'`,`imagecodecs>=2021.5.20;python_version>='3.7'`,`tifffile==2020.9.3;python_version<'3.7'`,`tifffile==2021.4.8;python_version>='3.7'` - -### Discover (alpha) - Added - -- Added an **alpha** Discover client. Discover allows users to organize and share assets with other users. As an alpha release, we reserve the right to modify the Discover client API without any guarantees about backwards compatibility. See the [Discover API documentation](https://docs.descarteslabs.com/descarteslabs/discover/readme.html) for more details. - -### Metadata/Catalog V1 - Changed - -- **breaking** Image (Scene) metadata now accepts and returns the `bucket` and `directory` fields as lists of strings, of a length equal to that - of the `files` fields. This allows the file assets making up an image to live in different locations. When creating new images, - a simple string can still be provided for these fields. It will automatically be converted to a list of (duplicated) strings as - necessary. As most users will never interact with these fields, the change should not affect user code. - -### Metadata/Catalog V1/Catalog V2 - Changed - -- `derived_params` field for Image (scene) metadata now supported for product-specific service-implemented "native derived bands" which may - only be created for core products. - -### Scenes - Changed - -- Scenes now uses the client-side `dltile` library to make DLTiles. This improves performance when creating a large number of DLTile objects. -- Scenes DLTile `from_shape` now has a parameter to return tile keys only instead of full tile objects. Usage details can be found [in the docs](https://docs.descarteslabs.com/descarteslabs/scenes/docs/geocontext.html#descarteslabs.scenes.geocontext.DLTile.from_shape). -- Scenes DLTile now has new methods: `iter_from_shape` that takes the same arguments as `from_shape` but returns an iterator ([from_shape docs](https://docs.descarteslabs.com/descarteslabs/scenes/docs/geocontext.html#descarteslabs.scenes.geocontext.DLTile.iter_from_shape)), `subtile` that adds the ability to subdivide tiles ([subtile docs](https://docs.descarteslabs.com/descarteslabs/scenes/docs/geocontext.html#descarteslabs.scenes.geocontext.DLTile.subtile)), and `rowcol_to_latlon` and `latlon_to_rowcol` which converts pixel coordinates to spatial coordinates and vice versa ([rowcol_to_latlon docs](https://docs.descarteslabs.com/descarteslabs/scenes/docs/geocontext.html#descarteslabs.scenes.geocontext.DLTile.rowcol_to_latlon) and [latlon_to_rowcol docs](https://docs.descarteslabs.com/descarteslabs/scenes/docs/geocontext.html#descarteslabs.scenes.geocontext.DLTile.latlon_to_rowcol)). -- Scenes DLTile now has a new parameter `tile_extent` which is the total size of the tile in pixels including padding. Usage details can be found [in the docs](https://docs.descarteslabs.com/descarteslabs/scenes/docs/geocontext.html#descarteslabs.scenes.geocontext.DLTile.tile_extent). -- **breaking** Removed the dependence on `Raster` for tiling. The `raster_client` parameter has been removed from the `from_latlon`, `from_key`, `from_shape`, and `assign`DLTile methods. -- Tiling using `from_shape` may return a different number of tiles compared to previous versions under certain conditions. These tiles are usually found in overlapping areas between UTM zones and should not affect the overall coverage. -- DLTile geospatial transformations are guaranteed to be within eight decimal points of the past implementation. -- DLTile errors now come from the `dltile` library and error messages should now be more informative. -- When specifying output bounds in a spatial reference system different from the underlying raster, a densified representation of the bounding box is used internally to ensure that the returned image fully covers the bounds. For certain methods (like `mosaic`) this may change the returned image dimensions, depending on the SRSs involved. -- **breaking** As with the Metadata v1 client changes, the `bucket` and `directory` fields of the Scene properties are now multi-valued lists. -- Scenes does not support writing GeoTiffs to file-like objects. Non-JPEG GeoTiffs are always uncompressed. - -### Raster - Changed -- `dltiles_from_shape`, `dltiles_from_latlon`, and `dltile` have been removed. **It is - strongly recommended to test any existing code which uses the Raster API when upgrading to this - release.** -- Fully masked arrays are now supported and are the default. Usage details can be found [in the docs](https://docs.descarteslabs.com/descarteslabs/client/services/raster/readme.html#descarteslabs.client.services.raster.Raster.ndarray) -- Added support to draw progress bar. Usage details can be found [in the docs](https://docs.stage.descarteslabs.com/descarteslabs/client/services/raster/readme.html). -- The signature and return value of `Raster.raster()` have changed. The `save=` parameter has been removed as the resulting download is always saved - to disk, to a file named by the `outfile_basename=` parameter. The method returns a tuple containing the name of the resulting file and the metadata - for the retrieval, which is now an ordinary Python dictionary. -- As with Scenes, when specifying output bounds in a spatial reference system different from the underlying raster, a densified representation of the bounding box is used internally to ensure that the returned image fully covers the bounds. For certain methods (like `mosaic`) this may change the returned image dimensions, depending on the SRSs involved. - -## [1.8.0] - 2021-06-08 - -Internal release only. See 1.8.1 above. - -## [1.7.1] - 2021-03-03 - -### General -- Upgraded various dependencies: `blosc==1.10.2`, `cachetools>=3.1.1`, `grpcio>=1.35.0,<2`, `ipyleaflet>=0.13.3,<1`, `protobuf>=3.14.0,<4`, `pyarrow>=3.0.0`, `pytz>=2021.1` -- Upgraded from using Travis to GitHub Actions for CI. - -### Catalog -- Added support for the `physical_range` property on `SpectralBand` and `MicrowaveBand`. - -### Workflows (channel `v0-19`) - Added -- Workflows sharing. Support has been added to manage sharing of `Workflow` objects with other authorized users. The `public` option for publishing workflows -has been removed now that `Workflow.add_public_reader()` provides the equivalent capability. See the [Workflows Guide](https://docs.descarteslabs.com/guides/workflows/sharing.html#sharing-workflows). -- Lots of improvements to [API documentation](https://docs.descarteslabs.com/descarteslabs/workflows/readme.html) and the [Workflows Guide](https://docs.descarteslabs.com/guides/workflows.html). - -### Workflows - Fixed -- Allow constructing `Float` instances from literal python integers. - -## [1.7.0] - 2021-03-02 - -### This release was withdrawn due to a compatibility problem - -## [1.6.1] - 2021-01-27 - -Fixes a few buglets which slipped through. This release continues to use the workflows channel `v0-18`. - -### Workflows - Fixed -- Fixed a problem with the defaulting of the visual options when generating tile URLs, making it possible - to toggle the checkerboard option on a layer and see the difference. -- Support `axis=list(...)` for `Image`. -- Corrected the results of doing arithmetic on two widgets (e.g. adding two `IntSlider`s together should yield` an `Int`). -- For single-band imagery `VizOption` will accept a single two-tuple for the `scales=` argument. - -## [1.6.0] - 2021-01-20 - -### Python Version Support -- Python 3.6 is now deprecated, and support will be removed in the next version. - -### Catalog -- Added support to Bands for new processing levels and processing step specifications - to support Landsat Collection 2. - -### Workflows (channel `v0-18`) - Added -- The new channel `v0-18` utilizes a new and improved backend infrastructure. Any previously saved workflows and jobs from earlier channels are not accessible from the new infrastructure, so you will need to recreate and persist (e.g. publish) new versions using `v0-18`. Older releases and older channels can continue to access your originals if needed. -- **`wf.widgets` lets you quickly explore data interactively.** Add widgets anywhere in your code just like normal values, and the widgets will display automatically when you call `.visualize`. -- **View shared Workflows and XYZs in GIS applications using WMTS.** Get the URL with `wf.wmts_url()`, `XYZ.wmts_url()`, `Workflow.wmts_url()`. - - Create publicly-accessible tiles and WMTS endpoints with `wf.XYZ(..., public=True)`. Anyone with the URL (which is a cryptographically random ID) can view the data, no login required. Set `days_to_expiration` to control how long the URL lasts. - - `wf.XYZ.list()` to iterate through all XYZ objects you've created, and `XYZ.delete` to delete them. - - Set default vizualization options (scales, colormap, bands, etc.) in `.publish` or `wf.XYZ` with `wf.VizOption`. These `viz_options` are used when displaying the published object in a GIS application, or with `wf.flows`. -- `ImageCollection.visualize()`: **display ImageCollections on `wf.map`**, and select the reduction operation (mean, median, mosaic, etc.) interactively -- `Image.reduction()` and `ImageCollection.reduction()` (like `ic.reduction("median", axis="images")`) to reduce an Image/ImageCollection with an operation provided by name -- `wf.map.controls` is accessible (you had to do `wf.map.map.controls` before) -- Access the parameters used in a Job with `Job.arguments` and `Job.geoctx`. - -### Workflows - Fixed -- Errors like `In 'or': : operand type(s) all returned NotImplemented from __array_ufunc__` when using the bitwise-or operator `|` are resolved. -- Errors when using computed values in the `wf.Datetime` constructor (like `wf.Datetime(wf.Int(2019) + 1)`) are resolved. -- `wf.Timedelta` can be constructed from floats, and supports all binary operations that Python does (support for `/, //, %, *` added) -- In `.rename_bands`, prohibit renaming a band to a name that already exists in the Image/ImageCollection. Previously, this would succeed, but cause downstream errors. -- `.bandinfo.get("bandname", {})` now works---before, providing `{}` would fail with a TypeError -- Indexing an `Any` object (like `wf.Any({"foo": 1})["foo"]`) behaves correctly -- `wf.Datetime`s constructed from strings containing timezone information are handled correctly - -### Workflows - Changed -- `.mask(new_mask)` ignores masked pixels in `new_mask`. Previously, masked pixels in `new_mask` were considered True, not False. Note that this is opposite of NumPy's behavior. -- If you `.publish` an object that depends on `wf.parameter`s or `wf.widgets`, it's automatically converted into a `wf.Function`. -- **breaking** `.compute` and `.inspect` no longer accept extra arguments that aren't required for the computation. If the object doesn't depend on any `wf.parameter`s or `wf.widgets`, passing extra keyword arguments will raise an error. Similarly, *not* providing keyword arguments for all parameters the object depends on will raise an error. -- **breaking** The `wf.XYZ` interface has changed; construct an XYZ with `wf.XYZ(...)` instead of `wf.XYZ.build(...).save()` -- Set `days_to_expiration` on `XYZ` objects. After this many days, the object is deleted. -- `Job` metadata is deleted after 10 days; `wf.Job.get(...)` on a job ID more than 10 days old will fail. Note that Job results have always been deleted after 10 days; now the metadata expires as well. -- `wf.Function` has better support for named arguments. Now, `f = wf.Function[{'x': wf.Int, 'y': wf.Str}, wf.Int]` requires two arguments `x` and `y`, and they can be given positionally (`f(1, "hi")`), by name in any order(`f(x=1, y="hi")` or `f(y="hi", x=1)`), or both (`f(1, y="hi")`). `wf.Function.from_callable` will generate a Function with the same names as the Python function you decorate or pass in. Therefore, when using `@wf.publish` as a decorator, the published Function will automatically have the same argument names as your Python function. - -## [1.5.0] - 2020-09-22 - -### Python Version Support -- Python 3.8 is now supported in the client. -- As Python 3.5 has reached End Of Life, it is no longer supported by the descarteslabs client. - -### Tasks Client -- Altered the behavior of Task function creation. Deprecation warnings will be issued when attempting to create - a Task function for which support will be removed in the near future. **It is - strongly recommended to test any existing code which uses the Tasks client when upgrading to this - release.** -- New tasks public images for for use with Python 3.8 are available. - -### Workflows (channel `v0-17`) - Fixed -- `.pick_bands` supports proxy `wf.Str` objects; `.unpack_bands` supports `wf.Str` and `wf.Tuple[wf.Str, ...]`. -- Better performance constructing a `wf.Array` from a `List` of numbers (like `wf.Array(ic.sum(["pixels", "bands"]))`) -- No more error using `@wf.publish` as a decorator on a function without a docstring - -## [1.4.1] - 2020-09-02 - -### Fixed -No more irrelevant `DeprecationWarning`s when importing the `descarteslabs` package ([#235](https://github.com/descarteslabs/descarteslabs-python/issues/235)). Deprecated functionality in the package will now show `FutureWarning`s instead. - -### Workflows (channel `v0-16`) - Fixed -- `wf.map.geocontext` doesn't raise an error about the CRS of the map -- `wf.flows` doesn't raise an error about versions from incompatible channels -## [1.4.0] - 2020-08-20 - -### Catalog client - -- Example code has been cleaned up. - -### Workflows (channel `v0-16`) - Added -- **Sharing of any Workflows object as a `Workflow`** with version and access control. Browse through shared `Workflow`s with the `wf.flows` browser widget. -- **Upload images to the DL catalog from Workflows jobs**. Usage details can be found [in the docs](https://docs.descarteslabs.com/descarteslabs/workflows/docs/destinations.html). -- `wf.np.median` -- `Job.cancel()` to cancel running jobs. -- Transient failures in Jobs are automatically retried, resulting in fewer errors. -- [Search widget](https://ipyleaflet.readthedocs.io/en/latest/api_reference/search_control.html) on `wf.map` by default. - -### Workflows - Fixed -- Bitwise operations on imagery no longer fail -- `wf.np.linspace` no longer fails when being called correctly -- `.median` is slightly less prone to OOM errors - -### Workflows - Changed -- **Breaking: Workflows sharing**: `wf.publish()` and `wf.use()` have new signatures, `wf.retrieve()` has been removed in favor of `wf.Workflow.get()` and `wf.VersionedGraft.get_version()` and the `wf.Workflow` object has been completely refactored. Detailed information is [in the docs](https://docs.descarteslabs.com/descarteslabs/workflows/docs/execution.html#module-descarteslabs.workflows.models). -- `Array.to_imagery` now accepts `KnownDict` for bandinfo and properties. -- `Number`s can now be constructed from `Str`s - -## [1.3.0] - 2020-06-12 - -### Workflows (channel `v0-15`) - Added -- **Output formats for `.compute` including GeoTIFF, JSON**, PyArrow, and MessagePack. Usage details can be found [in the docs](https://docs.descarteslabs.com/descarteslabs/workflows/docs/formats.html). -- **Destinations for Job results: download and email**. Usage details can be found [in the docs](https://docs.descarteslabs.com/descarteslabs/workflows/docs/destinations.html). -- **Save `.compute` outputs to a file** with the `file=` argument. -- **Pixel value inspector**: click in the map widget to view pixel values. -- **`wf.ifelse`** for simple conditional logic. -- NumPy functions including `hypot`, `bitwise_and`, `bitwise_or`, `bitwise_xor`, `bitwise_not`, `invert`, and `ldexp` -- Bitwise `Array` and `MaskedArray` operations -- `size` attribute on `Array` and `MaskedArray` -- `astype` function on `Array` and `MaskedArray` for changing the dtype -- `flatten` function on `Array` and `MaskedArray` for flattening into a 1D array -- `MaskedArray.compressed` for getting all unmasked data as a 1D array -- `get` function on `Dict` and `KnownDict` for providing a default value if a key does not exist -- `nbands` attribute on `Image` and `ImageCollection` -- `proxify` can handle `scenes.GeoContext`s -- `Dict.contains`, `Dict.length` - -### Workflows - Fixed -- **Fewer failures and hanging calls when connecting to the Workflows backend** (like `.compute`, `.visualize`, `Job.get`, etc.) -- **`wf.numpy.histogram` works correctly with computed values for `range` and `bins` (such as `range=[arr.min(), arr.max()]`)** -- More consistent throughput when a large number of jobs are submitted -- `Array`s can now be constructed from proxy `List`s -- `MaskedArray.filled` works correctly when passed Python values -- Long-running sessions (like Jupyter kernels) refresh credentials instead of failing with auth errors after many hours of use -- `wf.numpy.dot` and `wf.numpy.einsum` no longer fail when being called correctly -- Occasional errors like `('array-89199362e9a5d598fb5c82805136834d', 0, 0)` when calling `wf.compute()` with multiple values are resolved - -### Workflows - Changed -- **`pick_bands` accepts duplicate band names.** Enjoy easier Sentinel-1 `"vv vh vv"` visualizations! -- **`ImageCollection.from_id` is always ordered by date** -- `wf.numpy.percentile` no longer accepts an `axis` argument -- **breaking** `wf.Job` construction and interface changes: - - Use a single `wf.Job(..)` call instead of `wf.Job.build(...).execute()` to create and launch a Job - - New `Job.result_to_file` method - - `Job.status` is removed in favor of a single `Job.stage` - - `wf.TimeoutError` renamed to `wf.JobTimeoutError` - -## [1.2.0] - 2020-04-23 - -### Workflows (channel `v0-14`) - Added -- **191 functions from NumPy are available for Workflows `Array`s**, including parts of the `numpy.linalg` and `numpy.ma` submodules. See the full list [on the docs](https://docs.descarteslabs.com/descarteslabs/workflows/docs/types/numpy.html). -- `index_to_coords` and `coords_to_index` methods on `Image`/`ImageCollection`/`GeoContext` for converting between geospatial and array coordinates -- `value_at` function on `Image` and `ImageCollection` for extracting single pixel values at spatial coordinates. - -### Workflows - Fixed -- Using datetimes as parameters to `visualize` behaves correctly. - -## [1.1.3] - 2020-04-02 - -### Catalog client - -- Fixed a bug that prevented uploading ndarrays of type `uint8` - -### Workflows (channel `v0-13`) - Added -- Array support for `argmin`, `argmax`, `any`, `all` -- `pick_bands` supports an `allow_missing` kwarg to drop band names that may be missing from the data without an error. -- `wf.compute` supports passing lists or tuples of items to compute at the same time. Passing multiple items to `wf.compute`, rather than calling `obj.compute` for each separately, is usually faster. -- Casting from `Bool` to `Int`: `wf.Int(True)` -- Experimental `.inspect()` method for small computations during interactive use. - -### Workflows - Changed -- **[breaking]** Array no longer uses type parameters: now you construct an Array with `wf.Array([1, 2, 3])`, not `wf.Array[wf.Int, 1]([1, 2, 3])`. Remember, Array is an experimental API and will continue to make frequent breaking changes! -- Workflows now reuses the same gRPC client by default---so repeated or parallel calls to `.compute`, etc. will be faster. Calling `.compute` within a thread pool will also be significantly more efficient. - -### Workflows - Fixed -- `wf.numpy.histogram` correctly accepts a `List[Float]` as the `range` argument - -## [1.1.2] - 2020-03-12 - -1.1.2 fixes a bug which caused Workflows map layers to behave erratically when changing colormaps. - -## [1.1.1] - 2020-03-11 - -1.1.1 fixes a packaging issue that caused `import descarteslabs.workflows` to fail. - -It also makes NumPy an explicit dependency. NumPy was already a transitive dependency, so this shouldn't cause any changes. - -You should _NOT_ install version 1.1.0; 1.1.1 should be used instead in all circumstances. - -## [1.1.0] - 2020-03-11 - -### Catalog client - -- `Image.upload()` now emits a deprecation warning if the image has a `cs_code` or `projection` property. - The projection defined in the uploaded file is always used and applied to the resulting image in the Catalog. -- `Image.upload_ndarray()` now emits a deprecation warning if the image has both a `cs_code` and a `projection` - property. Only one of them may be supplied, and `cs_code` is given preference. - -### Scenes -- `SceneCollection.download_mosaic` has new default behavior for `mask_alpha` wherein the `alpha` band will be - used as a mask by default if it is available for all scenes in the collection, even if it is not specified in - the list of bands. - -### Workflows (channel `v0-12`) - Added -- **Experimental Array API** following the same syntax as NumPy arrays. It supports vectorized operations, broadcasting, - and multidimensional indexing. - - `ndarray` attribute of `Image` and `ImageCollection` will return a `MaskedArray`. - - Over 60 NumPy ufuncs are now callable with Workflows `Array`. - - Includes other useful `Array` functions like `min()`, `median()`, `transpose()`, `concatenate()`, `stack()`, `histogram()`, and `reshape()`. -- **`ImageCollection.sortby_composite()`** for creating an argmin/argmax composite of an `ImageCollection`. -- **Slicing** of `List`, `Tuple`, `Str`, and `ImageCollection`. -- `wf.range` for generating a sequence of numbers between start and stop values. -- `ImageCollectionGroupby.mosaic()` for applying `ImageCollection.mosaic` to each group. -- `wf.exp()`, `wf.square()`, `wf.log1p()`, `wf.arcsin()`, `wf.arccos()`, and `wf.arctan()` -- `Datetime.is_between()` for checking if a `Datetime` falls within a specified date range -- `FeatureCollection.contains()` -- Container operations on `GeometryCollection` including: - - `GeometryCollection.contains()` - - `GeometryCollection.sorted()` - - `GeometryCollection.map()` - - `GeometryCollection.filter()` - - `GeometryCollection.reduce()` -- `List` and `Tuple` can now be compared with other instances of their type via `__lt__()`, `__eq__()` etc. -- `List.__add__()` and `List.__mul__()` for concatenating and duplicating `List`s. - -### Workflows - Changed -- Products without alpha band and `nodata` value are rejected, instead of silently producing unwanted behavior. -- `ImageCollection.concat_bands` now throws a better error when trying to concatenate bands from another `ImageCollection` that is not the same length. -- `Any` is now promotable to all other types automatically. -- Better error when trying to iterate over Proxytypes. -- Interactive map: calls to `visualize` now clear layer errors. -- Interactive map: when setting scales, invalid values are highlighted in red. -- Interactive map: a scalebar is shown on the bottom-left by default. -- `ImageCollection.mosaic()` now in "last-on-top" order, which matches with GDAL and `dl.raster`. Use `mosaic(reverse=True)` for the same ordering as in v1.0.0. - -### Workflows - Fixed -- Better errors when specifying invalid type parameters for Proxytypes that require them. -- Field access on `Feature`, `FeatureCollection`, `Geometry`, and `GeomeryCollection` no longer fails. -- In `from_id`, processing level 'cubespline' no longer fails. - - -## [1.0.0] - 2020-01-20 - -| As of January 1st, 2020, the client library no longer supports Python 2. For more information, please contact dl.support@earthdaily.com. For help with porting to Python 3, please visit https://docs.python.org/3/howto/pyporting.html. | -| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | - -### Catalog client -- There is an entirely new backend supporting asynchronous uploads of image files and ndarrays with - the catalog client. There are minor changes to the `ImageUpload` class (a new `events` field has subsumed - `errors`, and the `job_id` field has been removed) but the basic interface is unchanged so most - code will keep functioning without any changes. -- It is now possible to cancel image uploads. -- Errors messages are now easier to read. -- Many improvements to the documentation. -- You can now create or retrieve an existing object using the `get_or_create` method. -- Retrieving a `Band` or `Image` by name is now possible by calling `get_band` or `get_image` on the - `Product` instance. You can also use the Product's `named_id` function to get a complete id for - images and bands. -- A new convenience function `make_valid_name` on `Image` and `Band` classes will return a sanitized - name without invalid characters. -- A new property `ATTRIBUTES` enumerates which attributes are available for a specific catalog object. -- Trying to set an attribute that does not exist will now raise `AttributeError`. -- `update_related_objects_permissions()` should no longer fail with a JSON serialization error. -- Setting a read-only attribute will now raise an `AttributeValidationError`. -- Saving a new object while one with the same id already exists will now raise a `ConflictError` - instead of `BadRequestError`. -- If a retrieved object has since been deleted from the catalog, saving any changes or trying to - reload it will now raise a `DeletedObjectError`. -- Resolution fields now accept string values such as "10m" or "0.008 degrees". If the value cannot - be parsed, an `AttributeValidationError` will be raised. -- Changes to the `extra_properties` attribute are now tracked correctly. - -### Packaging -- This release no longer supports Python 2. -- This package is now distributed as a Python 3 wheel which will speed up installation. - -### Workflows (channel `v0-11`) - Added -- **Handling of missing data** via empty ImageCollections - - `ImageCollection.from_id` returns an empty ImageCollection if no data exist for the given time/place, rather than an error - - `ImageCollection.filter` returns an empty ImageCollection if the predicate is False for every Image, rather than an error - - `Image.replace_empty_with` and `ImageCollection.replace_empty_with` for explicitly filling in missing data - - See the [Workflows guide](https://docs.descarteslabs.com/guides/workflows.html) for more information -- **Docstrings and examples** on every class and function! -- **Assigning new metadata to Image properties** & bandinfo: `Image.with_properties()`, `Image.with_bandinfo()` -- Interactive map: **colorbar legends** on layers with colormaps (requires matplotlib) -- **`Dict.from_pairs`**: construct a Dict from a sequence of key-value pairs -- Map displays a **fullscreen button** by default (**[breaking]** if your code adds one, you'll now get two) -- **`wf.concat`** for concatentating `Image` and `ImageCollection` objects - - `ImageCollection.concat` now accepts `Image` objects; new `Image.concat` accepts `Image` or `ImageCollection` -- **`ImageCollection.mosaic()`** -- `FeatureCollection.sorted()`, `FeatureCollection.length()`, `FeatureCollection.__reversed__()` -- `GeometryCollection.length()`, `GeometryCollection.__reversed__()` - -### Workflows - Changed -- **`wf.zip` now supports `ImageCollection`**, `FeatureCollection`, `GeometryCollection` as well as `List` and `Str` -- **Get a GeoContext for the current bounds of the map in any resolution, shape, or CRS** (including `"utm"`, which automatically picks the right UTM zone for you) with `wf.map.geocontext`. Also now returns a Scenes GeoContext for better introspection and use with Raster. -- Better backend type-checking displays the possible arguments for most functions if called incorrectly -- `arr_shape` included when calling `wf.GeoContext.compute()` -- More readable errors when communication with the backend fails -- Interactive map: layout handles being resized, for example setting `wf.map.layout.height = '1000px'` -- `Any` is no longer callable; `Any.cast` encouraged -- `remove_layer` and `clear_layers` moved from `wf.interactive.MapApp` class to `wf.interactive.Map` (non-breaking change) -- **[possibly breaking]** band renaming in binary operators only occurs when broadcasting: `red + red` is just `red`, rather than `red_add_red`. `red + blue` is still `red_add_blue`. Code which depends on accessing bands by name may need to change. - -### Workflows - Fixed -- `wf.where` propagates masks correctly, and handles metadata correctly with multi-band inputs -- `processing_level="surface"` actually returns surface-reflectance-processed imagery -- `ImageCollection.sorted()` works properly -- Viewing global-extent WGS84 images on the Workflows map no longer causes errors -- `List` proxytype no longer infinitely iterable in Python -- Repeated use of `axis="bands"` works correctly -- `ImageCollection.from_images` correctly aligns the bands of the inputs -- Numeric casting (`wf.Int(wf.Float(2.2))`) works as expected -- More descriptive error when constructing an invalid `wf.Datetime` -- Computing a single `Bool` value derived from imagery works correctly - - -## [0.28.1] - 2019-12-10 - -### Changed -- Update workflows client channel -- Workflows map UI is more stable: errors and layers won't fill the screen - -## [0.28.0] - 2019-12-09 -### Added -- Catalog client: Added an `update()` method that allows you to update multiple attributes at once. - -### Changed -- Catalog client: Images and Bands no longer reload the Product after calling `save` -- Catalog client: Various attributes that are lists now correctly track changes when modifying them with list methods (e.g. `Product.owners.append("foo")`) -- Catalog client: Error messages generated by the server have a nicer format -- Catalog client: Fix a bug that caused waiting for tasks to never complete -- The minimum `numpy` version has been bumped to 1.17.14 for Python version > 3.5, which addresses a bug with `scenes.display` - -### Workflows (channel `v0-10`) - Added -- `.compute()` is noticeably faster -- Most of the Python string API is now available on `workflows.Str` -- Interactive map: more descriptive error when not logged in to iam.descarteslabs.com -- Passing the wrong types into functions causes more descriptive and reliable errors - -### Workflows - Fixed -- `RST_STREAM` errors when calling `.compute()` have been eliminated -- `Image/ImageCollection.count()` is much faster -- `.buffer()` on vector types now works correctly -- Calling `.compute()` on a `GeometryCollection` works - -## [0.27.0] - 2019-11-18 -### Added - -- Catalog client: Added a `MaskBand.is_alpha` attribute to declare alpha channel behavior for a band. - -### Changed - -- The maximum number of `extra_properties` allowed for Catalog objects has been increased from 10 to 50. -- Fixed bug causing `SceneCollection.download` to fail. - -### Workflows (channel `v0-9`) - Added -- When you call `.compute()` on an `Image` or `ImageCollection`, the `GeoContext` is included on the result object (`ImageResult.geocontext`, `ImageCollectionResult.geocontext`) - -### Workflows - Fixed -- Passing a Workflows `Timedelta` object (instead of a `datetime.timedelta`) into functions expecting it now behaves correctly -- Arguments to the reducer function for `reduce` are now in the correct order - -## [0.26.0] - 2019-10-30 -### Added - -- A new catalog client in `descarteslabs.catalog` makes searching and managing products, bands and images easier. This client encompasses functionality previously split between the `descarteslabs.Metadata` and `descarteslabs.Catalog` client, which are now deprecated. Learn how to use the new API in the [Catalog guide](https://docs.descarteslabs.com/guides/catalog_v2.html). -- Property filtering expressions such as used in `scenes.search()` and `FeatureCollection.filter()` now support an `in_()` method. - -### Changed - -- `SceneCollection.download` previously always returned successfully even if one or more of the downloads failed. Now if any of the downloads fail, a RuntimeError is raised, which will detail which destination files failed and why. -- Fixed a bug where geometries used with the Scenes client had coordinates with reduced precision. - -### Workflows (channel `v0-8`) - Added -- **Interactive parameters**: add parameters to map layers and interactively control them using widgets -- **Spatial convolution** with `wf.conv2d` -- Result containers have helpful `repr`s when displayed -- `Datetime` and `Timedelta` are unpacked into `datetime.datetime` and `datetime.timedelta` objects when computed. - -### Workflows - Changed -- **[breaking]** Result containers moved to `descarteslabs/workflows/results` and renamed, appending "Result" to disambiguate (e.g. ImageResult and ImageCollectionResult) -- **[breaking] `.bands` and `.images` attributes of ImageResult and ImageCollectionResult renamed `.ndarray`** -- **[breaking]** When `compute`-ing an `Image` or `ImageCollection`, **the order of `bandinfo` is only correct for Python >= 3.6** -- Interactive maps: coordinates are displayed in lat, lon order instead of lon, lat for easier copy-pasting -- Interactive maps: each layer now has an associated output that is populated when running autoscale and deleted when the layer is removed -- Interactive maps: `Image.visualize` returns a `Layer` object, making it easier to adjust `Layer.parameters` or integrate with other widgets - -### Workflows - Fixed -- Composing operations onto imported Workflows no longer causes nondeterministic errors when computed -- Interactive maps: `remove_layer` doesn't cause an error -- No more errors when creating a `wf.parameter` for `Datetime` and other complex types -- `.where` no longer causes a backend error -- Calling `wf.map.geocontext()` when the map is not fully initialized raises an informative error -- Operations on numbers computed from raster data (like `img_collection.mean(axis=None)`) no longer fail when computed -- Colormap succeeds when the Image contains only 1 value - -## [0.25.0] - 2019-08-22 -### Added - -### Changed -- `Raster.stack` `max_workers` is limited to 25 workers, and will raise a warning and set the value to 25 if a value more than 25 is specified. - -### Workflows (channel `v0-7`) - Added -- Interactive maps: `clear_layers` and `remove_layer` methods -- ImageCollections: `reversed` operator -- ImageCollections: `concat` and `sorted` methods -- ImageCollections: `head`, `tail`, and `partition` methods for slicing -- ImageCollections: `where` method for filtering by condition -- ImageCollections `map_window` method for applying sliding windows -- ImageCollections: Indexing into ImageCollections is supported (`imgs[1]`) -- **[breaking]** Statistics functions are now applied to named axes -- DateTime, Timedelta, Geocontext, Bool, and Geometry are now computable -- ImageCollectionGroupby ProxyObject for grouping ImageCollection by properties, and applying functions over groups -- ImageCollections: `groupby` method -- `parameter` constructor - -### Workflows - Changed -- Interactive maps: autoscaling is now done in the background -- Tiles requests can now include parameters -- `median` is noticeably faster -- `count` is no longer breaks colormaps -- `map`, `filter`, and `reduce` are 2x faster in the "PREPARING" stage -- Significantly better performance for functions that reference variables outside their scope, like -``` -overall_comp = ndvi.mean(axis="images") -deltas = ndvi.map(lambda img: img - overall_comp) -``` -- Full support for floor-division (`//`) between Datetimes and Timedeltas (`imgs.filter(lambda img: img.properties['date'] // wf.Timedelta(days=14)`) - -### Workflows - Removed -- **[breaking]** `ImageCollection.one` (in favor of indexing) - -## [0.24.0] - 2019-08-01 -### Added -- `scenes.DLTile.assign(pad=...)` method added to ease creation of a tile in all ways indentical except for the padding. - -### Changed -- The parameter `nbits` has been deprecated for catalog bands. - -### Workflows (channel `v0-6`) - Added -- New interactive map, with GUI controls for multiple layers, scaling, and colormaps. -- Colormaps for single-band images. -- Map interface displays errors that occur while the backend is rendering images. -- ImageCollection compositing no longer changes band names (`red` does not become `red_mean`, for example). -- `.clip()` and `.scale()` methods for Image/ImageCollection. -- Support specifying raster resampler method. -- Support specifying raster processing level: `toa` (top-of-atmosphere) or `surface` [surface reflectance). -- No more tiles 400s for missing data; missing/masked pixels can optionally be filled with a checkerboard pattern. - -### Workflows - Changed -- Workflows `Image.concat` renamed `Image.concat_bands`. -- Data are left in `data_range` values if `physical_range` is not set, instead of scaling to the range `0..1`. -- Selecting the same band name twice (`img.pick_bands("vv vv")`) properly raises an error. -- Reduced `DeprecationWarning`s in Python 3.7. - -## [0.23.0] - 2019-07-12 -### Added -- Alpha Workflows API client has been added. Access to the Workflows backend is restricted; contact [support](https://descarteslabs.atlassian.net/servicedesk/customer/portals) for more information. -- Workflows support for Python 3 added in channel v0-5. - -### Changed - -## [0.22.0] - 2019-07-09 -### Added -- Scenes API now supports band scaling and output type specification for rastering methods. -- Methods in the Metadata, Raster, and Vector service clients that accepted GeoJSON geometries now also accept Shapely geometries. - -### Changed - -## [0.21.0] - 2019-06-19 -### Added -- Add support for user cython modules in tasks. - -### Changed -- Tasks webhook methods no longer require a `group_id` if a webhook id is provided. -- `catalog_id` property on images is no longer supported by the API -- Fix `scenes.display` handling of single band masked arrays with scalar masks -- Fix problems with incomplete `UploadTask` instances returned by `vectors.FeatureCollection.list_uploads` - -## [0.20.0] - 2019-06-04 -### Added -- Metadata, Catalog, and Scenes now support a new `storage_state` property for managing image metadata and filtering search results. `storage_state="available"` is the default for new images and indicates that the raster data for that scene is available on the Descartes Labs Platform. `storage_state="remote"` indicates that the raster data has not yet been processed and made available to client users. -- The following additional colormaps are now supported for bands – 'cool', 'coolwarm', 'hot', 'bwr', 'gist_earth', 'terrain'. Find more details about the colormaps [here](https://matplotlib.org/gallery/color/colormap_reference.html). -- `Scene.ndarray`, `SceneCollection.stack`, and `SceneCollection.mosaic` now support passing a string as the `mask_alpha` argument to allow users to specify an alternate band name to use for masking. -- Scenes now supports a new `save_image` function that allows a user to save a visualization given a filename and extension. -- Tasks now allows you to unambiguously get a function by group id using `get_function_by_id`. -- All Client APIs now accept a `retries` argument to override the default retry configuration. The default remains -the same as the prior behavior, which is to attempt 3 retries on errors which can be retried. - -### Changed -- Bands of different but compatible types can now be rastered together in `Scene.ndarray()` and `Scene.download()` as well as across multiple scenes in `SceneCollection.mosaic()`, `SceneCollection.stack()` and `SceneCollection.download()`. The result will have the most general data type. -- Vector client functions that accept a `geometry` argument now support passing Shapely shapes in addition to GeoJSON. - -### Fixed - -## [0.19.0] - 2019-05-06 -### Changed -- Removed deprecated method `Metadata.sources()` -- `FeatureCollection.filter(geometry)` will now raise an `InvalidQueryException` if you - try to overwrite an existing geometry in the filter chain. You can only set the - geometry once. - -### Fixed - -## [0.18.0] - 2019-04-18 -### Changed -- Many old and obsolete examples were removed from the package. -- `Scene.ndarray`, `SceneCollection.stack`, and `SceneCollection.mosaic` now will automatically mask alpha if the alpha band is available in the relevant scene(s), and will set `mask_alpha` to `False` if the alpha band does not exist. -- `FeatureCollection.add`, `FeatureCollection.upload`, `Vector.create_feature`, `Vector.create_features`, and `Vector.upload_features` all accept a `fix_geometry` string argument that determines how to handle certain problem geometries -including those which do not follow counter-clockwise winding order (which is required by the GeoJSON spec but not many -popular tools). Allowed values are ``reject`` (reject invalid geometries with an error), ``fix`` (correct invalid -geometries if possible and use this corrected value when creating the feature), and ``accept`` (the default) which will -correct the geometry for internal use but retain the original geometry in the results. -- `Vector.get_upload_results` and `Vector.get_upload_result` now accept a `pending` parameter to include pending uploads -in the results. Such pending results will have `status: PENDING` and, in lieu of a task id, the `id` attribute will contain -the upload id as returned by `Vector.upload_features` -- `UploadTask.status` no longer blocks until the upload task is completed, but rather returns the current status of the -upload job, which may be `PENDING`, `RUNNING`, `SUCCESS`, or `FAILURE`. -- The `FutureTask.ready` and `UploadTask.ready` property has been added to test whether the task has completed. -A return value of `True` means that if `get_result(wait=True)` were to be called, it would return without blocking. -- You can now export features to a storage `data` blob. To export from the -`vector` client, use `Vector.export_product_from_query()` with a storage key -and an optional query. This returns the task id of the export task. You -can ask for status using `Vector.get_export_results()` for all export tasks -or `Vector.get_export_result()` for a specific task by task id. -- FeatureCollection has been extended with this functionality with a -`FeatureCollection.export()` method that takes a storage key. This operates -on the filter chain that FeatureCollection represents, or the full product -if there is no filter chain. It returns an `ExportTask` which behaves -similar to the `FutureTask`. -- `Catalog.upload_image()` and `Catalog.upload_ndarray()` now will return an `upload_id` that can be used to query the status of that upload using `Catalog.upload_result()`. Note that the upload id is the image id and if you use identical image ids `Catalog.upload_result()` will only show the result of the most recent upload. - -### Fixed -- Several typical kinds of non-conforming GeoJSON which previously caused errors can now be accepted or -fixed by the `FeatureCollection` and `Vector` methods for adding or uploading new vector geometries. - -## [0.17.3] - 2019-03-06 -### Changed -- Fixed issues with `Catalog.upload_ndarray()` under Windows -- Added header to client requests to better debug retries - -### Fixed -- Improved error messages for Catalog client upload methods - -## [0.17.2] - 2019-02-26 -### Changed -- Tasks methods `create_function`, `create_or_get_function`, and `new_group` now have image as a required parameter -- The `name` parameter is renamed to `product_id` in `Vector.create_product`, and `FeatureCollection.create` and `FeatureCollection.copy`. The 'name' parameter is renamed to `new_product_id` in `Vector.create_product_from_query`. Using `name` will continue to work, but will be removed completely in future versions. -- The `name` parameter is no longer required, and is ignored for `Vector.replace_product`, `Vector.update_product`, `FeatureCollection.update` and `FeatureCollection.replace`. This parameter will be removed completely in future versions. - -### Added -- `Metadata.paged_search` has been added and essentially supports the original behavior of `Metadata.search` prior to release 0.16.0. -This method should generally be avoided in favor of `Metadata.features` (or `Metadata.search`). - -## [0.17.1] - 2019-02-11 -### Added - -### Changed -- Fixed typo in `UploadTask.status` which caused exception when handling certain failure conditions -- `FeatureCollection.upload` parameter `max_errors` was not being passed to Vector client. -- Ensure `cloudpickle==0.4.0` is version used when creating `Tasks`. -- Eliminate redundant queries from `FeatureCollection.list`. - -## [0.17.0] - 2019-02-07 -### Added -- `FeatureCollection.upload` and `Vector.upload_features` now accept an optional `max_errors` parameter to control how many errors are acceptable before declaring an upload a failure. -- `UploadTask` (as returned by `FeatureCollection.upload` and `Vector.list_uploads`) now has added attributes to better identify what was processed and what errors occurred. -- `Storage` now has added methods `set_file` and `get_file` to allow for better uploading and downloading, respectively, of large files. -- `Storage` class now has an `exists()` method that checks whether an object exists in storage at the location of a given `key` and returns a boolean. -- `Scenes.search` allows `limit=None` -- `FeatureCollection.delete_features` added to support deleting `Feature`s that match a `filter` -- `FeatureCollection.delete_features` and `FeatureCollection.wait_for_copy` now use `AsyncJob` to poll for asynchronous job completion. -- `Vector.delete_features_from_query` and `Vector.get_delete_features_status` added to support new `FeatureCollection` and `AsyncJob` methods. - -### Changed -- Fixed tasks bugs when including modules with relative paths in `sys.path` - -## [0.16.0] - 2019-01-28 -### Added -- Tasks now support passing modules, data and requirements along with the function code, allowing for a more complex and customized execution environment. -- Vector search query results now report their total number of results by means of the standard `len()` function. - -### Changed -- `Metadata.search` no longer has a 10,000-item limit, and the number of items returned will be closer to `limit`. This -method no longer accepts the `continuation_token` parameter. - -## [0.15.0] - 2019-01-09 -### Added -- Raster client can now handle arbitrarily large numbers of tiles generated from a shape using the new `iter_dltiles_from_shape()` method which allows you to iterate over large numbers of tiles in a time- and memory-efficient manner. Similarly the existing `dltiles_from_shape()` method can now handle arbitrarily large numbers of tiles although it can be very slow. -- Vector client `upload_features()` can now upload contents of a stream (e.g. `io.IOBase` derivative such as `io.StringIO`) as well as the contents of a named file. -- Vector FeatureCollection `add()` method can now handle an arbitrary number of Features. Use of the `upload_features()` method is still encouraged for large collections. -- Vector client now supports creating a new product from the results of a query against an existing product with the `create_product_from_query()` method. This support is also accessible via the new `FeatureCollection.copy()` method. -- XYZTile GeoContext class, helpful for rendering to web maps that use XYZ-style tiles in a spherical Mercator CRS. - -### Changed -- Tasks client FutureTask now instantiates a client if none provided (the default). -- Catalog client methods now properly handle `add_namespace` parameter. -- Vector Feature now includes valid geojson type 'Feature'. -- Tasks client now raises new GroupTerminalException if a task group stops accepting tasks. -- General documentation fixes. - -## [0.14.1] - 2018-11-26 -### Added -- Scenes and raster clients have a `processing_level` parameter that can be used to turn on surface reflectance processing for products that support it - -## [0.14.0] - 2018-11-07 -### Changed -- `scenes.GeoContext`: better defaults and `bounds_crs` parameter - - `bounds` are no longer limited to WGS84, but can be expressed in any `bounds_crs` - - New `Scene.default_ctx` uses a Scene's `geotrans` to more accurately determine a `GeoContext` that will result in no warping of the original data, better handling sinusoidal and other non-rectilinear coordinate reference systems. - - **Important:** the default GeoContexts will now return differently-sized rasters than before! - They will now be more accurate to the original, unwarped data, but if you were relying on the old defaults, you should now explicitly set the `bounds` to `geometry.bounds`, - `bounds_crs` to `"EPSG:4326"`, and `align_pixels` to True. -- `Scene.coverage` and `SceneCollection.filter_coverage` accept any geometry-like object, not just a `GeoContext`. - -## [0.13.2] - 2018-11-06 -### Changed -- `FutureTask` inheritance changed from `dict` to `object`. - -### Added -- Can now specify a GPU parameter for tasks. -- `Vectors.upload` allows you to upload a JSON newline delimited file. -- `Vectors.list_uploads` allows you to list all uploads for a vector product. -- `UploadTask` contains the information about an upload and is returned by both methods. - -## [0.13.1] - 2018-10-16 -### Changed -- `Vector.list_products` and `Vector.search_features` get `query_limit` and `page_size` parameters. - -### Fixed -- `Vector.upload_features` handles new response format. - -### Added -- Vector client support for retrieving status information about upload jobs. Added methods `Vector.get_upload_results` and `Vector.get_upload_result`. - -## [0.13.0] - 2018-10-05 -### Changed -- Shapely is now a full requirement of this package. Note: Windows users should visit https://docs.descarteslabs.com/installation.html#windows-users for installation guidance. -- Reduced the number of retries for some failure types. -- Resolved intermittent `SceneCollection.stack` bug that manifested as `AttributeError: 'NoneType' object has no attribute 'coords'` due to Shapely thread-unsafety. -- Tracking system environment to improve installation and support of different systems. - -### Added -- The vector service is now part of the public package. See `descarteslabs.vectors` and `descarteslabs.client.services.vector`. - - -## [0.12.0] - 2018-09-12 -### Changed -- Fixed SSL problems when copying clients to forked processes or sharing them among threads -- Removed extra keyword arguments from places client -- Added deprecation warnings for parameters that have been renamed in the Metadata client -- Scenes now exposes more parameters from raster and metadata -- Scenes `descarteslabs.scenes.search` will take a python datetime object in addition to a string -- Scenes will now allow Feature and FeatureCollection in addition to GeoJSON geometry types -- Fixed Scenes issue preventing access to products with multi-byte data but single-byte alpha bands - -### Added -- `Scene.download`, `SceneCollection.download`, and `SceneCollection.download_mosaic` methods -- Colormaps supported in `descarteslabs.scenes.display` -- Task namespaces are automatically created with the first task group - - -## [0.11.2] - 2018-08-24 -### Changed -- Moved metadata property filtering to common -- Deprecated `create_or_get_function` in tasks -- Renamed some examples - -## [0.11.1] - 2018-08-17 -### Added -- Namespaced auth environment variables: `DESCARTESLABS_CLIENT_SECRET` and `DESCARTESLABS_CLIENT_ID`. `CLIENT_SECRET` and `CLIENT_ID` will continue to work. -- Tasks runtime check for Python version. - -### Changed -- Documentation updates -- Example updates - -## [0.11.0] - 2018-07-12 -### Added -- Scenes package -- More examples - -### Changed -- Deprecated `add_namespace` argument in catalog client (defaults to `False` - now, formerly `True`) - -## [0.10.1] - 2018-05-30 -### Changed -- Added org to token scope -- Removed deprecated key usage - -## [0.10.0] - 2018-05-17 -### Added -- Tasks service - -## [0.9.1] - 2018-05-17 -### Changed -- Patched bug in catalog service for py3 - -## [0.9.0] - 2018-05-11 -### Added -- Catalog service -- Storage service - -## [0.8.1] - 2018-05-03 -### Changed -- Switched to `start_datetime` argument pattern instead of `start_date` -- Fixed minor regression with `descarteslabs.ext` clients -- Deprecated token param for `Service` class - -### Added -- Raster stack method - -## [0.8.0] - 2018-03-29 -### Changed -- Removed deprecated searching by `const_id` -- Removed deprecated raster band methods -- Deprecated `sat_id` parameter for metadata searches -- Changed documentation from readthedocs to https://docs.descarteslabs.com - -### Added -- Dot notation access to dictionaries returned by services - -## [0.7.0] - 2018-01-24 -### Changed -- Reorganization into a client submodule - -## [0.6.2] - 2018-01-10 -### Changed -- Fix regression for `NotFoundError` - -## [0.6.1] - 2018-01-09 -### Changed -- Reverted `descarteslabs.services.base` to `descarteslabs.services.service` - -## [0.6.0] - 2018-01-08 -### Changed -- Reorganization of services -- Places updated to v2 backend, provides units interface to statistics, which - carries some backwards incompatibility. - -### Added -- Places updated to v2 backend, provides units interface to statistics, which - carries some backwards incompatibility. - -## [0.5.0] - 2017-10-31 -### Added -- Blosc Support for raster array compression transport -- Scrolling support for large metadata searches - -### Changes -- Offset keyword argument in metadata.search has been deprecated. Please use the -metadata.features for iterating over large search results - -## [0.4.7] - 2017-10-09 -### Added -- Complex filtering expressions for image attributes - -### Fixes -- Raise explicitly on 409 response -- Keep retrying token refresh until token fully expired -- Fixed race condition when creating `.descarteslabs` directory - -## [0.4.6] - 2017-09-08 -### Added -- Added ext namespace -- Metadata multi-get - -### Fixes -- Fix OpenSSL install on OSX - -## [0.4.5] - 2017-08-29 -### Fixes -- Automatic retry on 504 -- Internal API refactoring / improvements for Auth - -## [0.4.4] - 2017-08-03 -### Added -- Add raster bands methods to metadata service. -- Deprecate raster band methods. -- Add `require_bands` param to derived bands search method. - -### Fixes -- Test suite replaces original token when finished running script tests. - -## [0.4.3] - 2017-07-18 -### Added -- Support for derived bands endpoints. -- Direct access to `const_id` to `product` translation. - -### Fixes -- `descarteslabs` scripts on windows OS. - -## [0.4.2] - 2017-07-05 -### Fixes -- Fix auth login - -## [0.4.1] - 2017-07-05 -### Added -- Add metadata.bands and metadata.products search/get capabilities. -- Add bands/products descriptions -- Additional Placetypes - -### Fixes -- Better error messages with timeouts -- Update to latest version of `requests` - -## [0.4.0] - 2017-06-22 -### Changes -- Major refactor of metadata.search - * Introduction of "Products" through `Metadata.products()` - * metadata entries id now concatenate the product id and the old metadata - keys. The original metadata keys are available through entry['key']. - * Additional sorting available. - -### Added -- Search & Raster using DLTile Feature GeoJSON or key. Uses output bounds, - resolution, and srs to ease searching and rasterizing imagery over tiles. - -### Fixes -- Better Error messaging - -## [0.3.3] - 2017-06-20 -### Added -- DLTile notebook -- `save` and `outfile_basename` in `Raster.raster()` - -### Fixes -- Fix metadata.features - - -## [0.3.2] - 2017-05-27 -### Fixes -- Strict "requests" versions needed due to upstream instability. - - -## [0.3.1] - 2017-05-17 -### Fixes -- Fix python 3 command line compatibility - - -## [0.3.0] - 2017-05-15 -### Changed -- API Change `descarteslabs`, `raster`, `metadata` have all been merged into - '`descarteslabs`'. '`descarteslabs login`' is now '`descarteslabs auth - login`', '`raster`'' is now '`descarteslabs raster`', etc. - -### Added -- A Changelog -- Testing around command-line scripts - -### Fixes -- Searching with cloud\_fraction = 0 -- dltile API documentation - -## [0.2.2] - 2017-05-04 -### Fixes -- Fix login bug -- Installation of "requests\[security\]" for python < 2.7.9 - -## [0.2.1] - 2017-04-18 -### Added -- Doctests - -### Fixes -- Python 3 login bug - -## [0.2.0] - 2017-04-11 -### Added -- Search by Fractions - -## [0.1.0] - 2017-03-24 -### Added -- Initial release of client library +Please see [The EarthDaily EarthOne Platform Client](https:github.com/earthdaily/earthone-python) for the currently supported API and Client. diff --git a/__init__.py b/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/descarteslabs/__init__.py b/descarteslabs/__init__.py deleted file mode 100644 index 252e80e5..00000000 --- a/descarteslabs/__init__.py +++ /dev/null @@ -1,73 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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. - -"""Descartes Labs Python Client - -.. code-block:: bash - - pip install descarteslabs[complete] - -Documentation is available at https://docs.descarteslabs.com. - -Source code and version information is at -https://github.com/descarteslabs/descarteslabs-python. - -The Descartes Labs Platform simplifies analysis of **global-scale raster data** -by providing: - - * Access to a catalog of petabytes of disparate geospatial data, - all normalized and interoperable through one **common interface** - * A Python client library to access these systems -""" - -# This enables the use of namespace packages for descarteslabs -# while still maintaining this __init__.py here in the core -# client package -from pkgutil import extend_path - -__path__ = extend_path(__path__, __name__) # noqa F821 - -from descarteslabs import auth -from descarteslabs import config -from descarteslabs import exceptions -from descarteslabs.core.client.version import __version__ - -from descarteslabs import geo -from descarteslabs import utils -from descarteslabs import catalog -from descarteslabs import compute -from descarteslabs import vector - -select_env = config.select_env -get_settings = config.get_settings -AWS_ENVIRONMENT = config.AWS_ENVIRONMENT -GCP_ENVIRONMENT = config.GCP_ENVIRONMENT - -__author__ = "Descartes Labs" - -__all__ = [ - "__version__", - "AWS_ENVIRONMENT", - "GCP_ENVIRONMENT", - "auth", - "catalog", - "compute", - "config", - "exceptions", - "geo", - "get_settings", - "select_env", - "utils", - "vector", -] diff --git a/descarteslabs/auth/__init__.py b/descarteslabs/auth/__init__.py deleted file mode 100644 index bf1d3463..00000000 --- a/descarteslabs/auth/__init__.py +++ /dev/null @@ -1,18 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 .auth import Auth - - -__all__ = ["Auth"] diff --git a/descarteslabs/auth/auth.py b/descarteslabs/auth/auth.py deleted file mode 100644 index 5b3cbcf3..00000000 --- a/descarteslabs/auth/auth.py +++ /dev/null @@ -1,976 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 base64 -import datetime -import errno -import json -import os -import random -import stat -import tempfile -import threading -import warnings -from hashlib import sha1 - -from descarteslabs.exceptions import AuthError, OauthError - -try: - # public client - from ..core.common.http import Retry, Session -except ImportError: - # inside monorepo - from ..common.http import Retry, Session - - -# This is only for the existing DL production tenant, and must remain in place -# until the tenant is completely replaced, if ever. -LEGACY_DELEGATION_CLIENT_IDS = ["ZOBAi4UROl5gKZIpxxlwOEfx8KpqXf2c"] - - -# copied from descarteslabs/common/threading/local.py, but we need -# it standalone here to avoid any dependencies on our own packages -# for client configuration purposes -class ThreadLocalWrapper(object): - """ - A wrapper around a thread-local object that gets created lazily in every - thread of every process via the given factory callable when it is - accessed. I.e., at most one instance per thread exists. - - In contrast to standard thread-locals this is compatible with multiple - processes. - """ - - def __init__(self, factory): - self._factory = factory - self._create_local(os.getpid()) - - def get(self): - self._init_local() - if not hasattr(self._local, "wrapped"): - self._local.wrapped = self._factory() - return self._local.wrapped - - def _init_local(self): - local_pid = os.getpid() - previous_pid = getattr(self._local, "_pid", None) - if previous_pid is None: - self._local._pid = local_pid - elif local_pid != previous_pid: - self._create_local(local_pid) - - def _create_local(self, pid): - self._local = threading.local() - self._local._pid = pid - - -DEFAULT_TOKEN_INFO_DIR = os.path.join(os.path.expanduser("~"), ".descarteslabs") -DEFAULT_TOKEN_INFO_PATH = os.path.join(DEFAULT_TOKEN_INFO_DIR, "token_info.json") -JWT_TOKEN_PREFIX = "jwt_token_" -DESCARTESLABS_CLIENT_ID = "DESCARTESLABS_CLIENT_ID" -DESCARTESLABS_CLIENT_SECRET = "DESCARTESLABS_CLIENT_SECRET" -DESCARTESLABS_REFRESH_TOKEN = "DESCARTESLABS_REFRESH_TOKEN" -DESCARTESLABS_TOKEN = "DESCARTESLABS_TOKEN" - -DESCARTESLABS_TOKEN_INFO_PATH = "DESCARTESLABS_TOKEN_INFO_PATH" - -DESCARTESLABS_CUSTOM_CLAIM_PREFIX = "earthdaily__dl__" - - -def base64url_decode(input): - """Helper method to base64url_decode a string. - - Parameter - --------- - input : str - A base64url_encoded string to decode. - """ - rem = len(input) % 4 - if rem > 0: - input += b"=" * (4 - rem) - - return base64.urlsafe_b64decode(input) - - -def makedirs_if_not_exists(path): - if not os.path.exists(path): - try: - os.makedirs(path) - os.chmod(path, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR) - except OSError as ex: - if ex.errno == errno.EEXIST: - pass - else: - raise - - -def get_default_domain(): - # See if we know the environment we're in, and if so use the - # correct `iam_url`. Use a default if we don't know the environment - from descarteslabs.config import peek_settings - - class DummyAuth: - payload = {} - - return peek_settings().iam_url - - -def get_app_domain(): - from descarteslabs.config import peek_settings - - class DummyAuth: - payload = {} - - return peek_settings().app_url - - -class Auth: - """Client used to authenticate with all Descartes Labs service APIs.""" - - RETRY_CONFIG = Retry( - total=5, - backoff_factor=random.uniform(1, 10), - allowed_methods=frozenset(["GET", "POST"]), - status_forcelist=[429, 500, 502, 503, 504], - ) - - AUTHORIZATION_ERROR = ( - "No valid authentication info found{}. " - "See https://docs.descarteslabs.com/authentication.html." - ) - - KEY_CLIENT_ID = "client_id" - KEY_CLIENT_SECRET = "client_secret" - KEY_REFRESH_TOKEN = "refresh_token" - KEY_SCOPE = "scope" - KEY_GRANT_TYPE = "grant_type" - KEY_TARGET = "target" - KEY_API_TYPE = "api_type" - KEY_JWT_TOKEN = "jwt_token" - KEY_ALT_JWT_TOKEN = "JWT_TOKEN" - - # The various prefixes that can be used in Catalog ACLs. - ACL_PREFIX_USER = "user:" # Followed by the user's sha1 hash - ACL_PREFIX_EMAIL = "email:" # Followed by the user's email - ACL_PREFIX_GROUP = "group:" # Followed by a lowercase group - ACL_PREFIX_ORG = "org:" # Followed by a lowercase org name - ACL_PREFIX_ACCESS = "access-id:" # Followed by the purchase-specific access id - # Note that the access-id, including the prefix `access_id:`, is matched against - # a group with the same name. In other words `group:access-id:` will - # match against `access-id:` (assuming the `` is identical). - - # these match the values in descarteslabs/common/services/python_auth/groups.py - ORG_ADMIN_SUFFIX = ":org-admin" - RESOURCE_ADMIN_SUFFIX = ":resource-admin" - - # These are cache keys for caching various data in the object's __dict__. - # These are scrubbed out with `_clear_cache()` when retrieving a new token. - KEY_PAYLOAD = "_payload" - KEY_ALL_ACL_SUBJECTS = "_aas" - KEY_ALL_ACL_SUBJECTS_AS_SET = "_aasas" - KEY_ALL_OWNER_ACL_SUBJECTS = "_aoas" - KEY_ALL_OWNER_ACL_SUBJECTS_AS_SET = "_aoasas" - - __attrs__ = [ - "domain", - "scope", - "leeway", - "token_info_path", - "client_id", - "client_secret", - "refresh_token", - "_token", - "_namespace", - "RETRY_CONFIG", - ] - - _default_token_info_path = object() # Just any unique object - - _instance = None # the default Auth instance - - def __init__( - self, - domain=None, - scope=None, - leeway=500, - token_info_path=_default_token_info_path, - client_id=None, - client_secret=None, - jwt_token=None, - refresh_token=None, - retries=None, - _suppress_warning=False, - ): - """Retrieves a JWT access token from a client id and refresh token for cli usage. - - By default and without arguments the credentials are retrieved from a - config file named ``token_info.json``. This file can be created by running - ``descarteslabs auth login`` from the command line. - - You can change the default location by setting the environment variable - ``DESCARTESLABS_TOKEN_INFO_PATH``. Make sure you do this **before** running - ``descarteslabs auth login`` so the credentials will be saved to the file - specified in the environment variable, and when still set when instantiating - this class, the credentials will be read from that file. - - To use a short-lived access token that will not be refreshed, either set the - environment variable ``DESCARTESLABS_TOKEN`` or use the ``jwt_token`` parameter. - - To use a long-lived refresh token that will be refreshed, either set the - environment variables ``DESCARTESLABS_CLIENT_ID`` and - ``DESCARTESLABS_CLIENT_SECRET`` or use the parameters ``client_id`` and - ``client_secret``. This will retrieve an access token which will be cached - between instances for the same combination of client id and client secret. - - If in addition to the client id and client secret you also specify a valid - short-lived access token, it will be used until it expires. - - Note that the environment variable ``DESCARTESLABS_REFRESH_TOKEN`` is identical - to ``DESCARTESLABS_CLIENT_SECRET`` and the parameter ``refresh_token`` is - identical to ``client_secret``. Use one or the other but not both. - - Although discouraged, it is possible to set one value as environment variable, - and pass the other value in as parameter. For example, one could set the - environment variable ``DESCARTESLABS_CLIENT_ID`` and only pass in the parameter - ``client_secret``. - - If you also specify a ``token_info_path`` that indicates which file to - read the credentials from. If used by itself, it works the same as - ``DESCARTESLABS_TOKEN_INFO_PATH`` and assuming the file exists and contains - valid credentials, you could switch between accounts this way. - - If you specify the ``token_info_path`` together with an additional - client id and client secret (whether retrieved through environment - variables or given using parameters), the given credentials will be - written to the given file. If this file already exists and contains - matching credentials, it will be used to retrieve the short-lived - access token and refreshes it when it expires. If the file already - exists and contains conflicting credentials, it will be overwritten - with the new credentials. - - Parameters - ---------- - - domain : str, default ``descarteslabs.config.get_settings().IAM_URL`` - The domain used for the credentials. You should normally never - change this. - scope : list(str), optional - The JWT access token fields to be included. You should normally - never have to use this. - leeway : int, default 500 - The leeway is given in seconds and is used as a safety cushion - for the expiration. If the expiration falls within the leeway, - the JWT access token will be renewed. - token_info_path : str, default ``~/.descarteslabs/token_info.json`` - Path to a JSON file holding the credentials. If not set and - credentials are provided through environment variables or through - parameters, this parameter will **not** be used. However, if no - credentials are provided through environment variables or through - parameters, it will default to ``~/.descarteslabs/token_info.json`` - and credentials will be retrieved from that file if present. If - explicitly set to ``None``, credentials will never be retrieved - from file and **must** be provided through environment variables - or parameters. - client_id : str, optional - The JWT client id. If provided it will take precedence over the - corresponding environment variable, or the credentials retrieved through - the file specified in ``token_info_path``. If this parameter is provided, - you **must** either provide a ``client_secret`` or ``refresh_token`` (but not - both). Access tokens retrieved this way will be cached without revealing - the client secret. - client_secret : str, optional - The refresh token used to retrieve short-lived access tokens. If provided - it will take precedence over the corresponding environment variable, or the - credentials retrieved through the file specified in ``token_info_path``. If - this parameter is provided, you **must** also provide a client id either as - a parameter or through an environment variable. Access tokens retrieved this - way will be cached without revealing the client secret. - jwt_token : str, optional - A short-lived JWT access token. If valid and used without other parameters, - it will be used for access. If used with a client id, the access token must - match or it will be discarded. If the access token is discarded either - because it expired or didn't match the given client id, and no client secret - has been given, no new access token can be retrieved and access will be - denied. If used with both client id and client secret, the token will be - cached and updated as needed without revealing the client secret. - refresh_token : str, optional - Identical to the ``client_secret``. You can only specify one or the other, - or if specified both, they must match. The refresh token takes precedence - over the client secret. - retries : Retry or int, optional - The number of retries and backoff policy; - by default 5 retries with a random backoff policy between 1 and 10 seconds. - - Raises - ------ - UserWarning - In case the refresh token and client secret differ. - In case the defailt or given ``token_info_path`` cannot be found. - In case no credentials can be found. - - Examples - -------- - >>> import descarteslabs - >>> # Use default credentials obtained through 'descarteslabs auth login' - >>> auth = descarteslabs.auth.Auth() - >>> # Your Descartes Labs user id - >>> auth.namespace # doctest: +SKIP - 'a54d88e06612d820bc3be72877c74f257b561b19' - >>> auth = descarteslabs.auth.Auth( - ... client_id="some-client-id", - ... client_secret="some-client-secret", - ... ) - >>> auth.namespace # doctest: +SKIP - '67f21eb1040f978fe1da32e5e33501d0f4a604ac' - >>> - """ - - # The logic here is murky and changed over time. Initially, the logic would - # retrieve *any* of the information from *any* of the sources. This resulted in - # the `token_info.json` being overwritten when you would use a different refresh - # token set in the environment or passed in. This was changed to make a - # distinction between data that is provided through the environment or as - # arguments, versus the data that is retrieved from `token_info.json`. This still - # allows arbitrary combinations of data provided through the environment and - # passed in as arguments. - - # In addition there are duplicate keys and arguments, which makes things even - # more unnecessarily complicated. For backward compatibility reasons we keep it - # as-is. Overall the core information consists of: - # client_id: The oauth application id. - # client_secret: Same as refresh_token. - # refresh_token: The oauth application refresh token. Refresh token has - # precedence over client_secret. - # _token: The short-lived jwt id token that can be generated from the - # refresh token if present. - - self.token_info_path = token_info_path - - if token_info_path is Auth._default_token_info_path: - token_info_path = None - self.token_info_path = os.environ.get( - DESCARTESLABS_TOKEN_INFO_PATH, DEFAULT_TOKEN_INFO_PATH - ) - - token_info = {} - - # First determine if we are getting our info from the args or environment - self.client_id = next( - ( - x - for x in ( - client_id, - os.environ.get(DESCARTESLABS_CLIENT_ID), - os.environ.get("CLIENT_ID"), - ) - if x is not None - ), - None, - ) - - self.client_secret = next( - ( - x - for x in ( - client_secret, - os.environ.get(DESCARTESLABS_CLIENT_SECRET), - os.environ.get("CLIENT_SECRET"), - ) - if x is not None - ), - None, - ) - - self.refresh_token = next( - ( - x - for x in ( - refresh_token, - os.environ.get(DESCARTESLABS_REFRESH_TOKEN), - ) - if x is not None - ), - None, - ) - - self._token = next( - ( - x - for x in ( - jwt_token, - os.environ.get(DESCARTESLABS_TOKEN), - ) - if x is not None - ), - None, - ) - - # Make sure self.refresh_token is set - if not self.refresh_token: - self.refresh_token = self.client_secret - - if self.client_id or self.refresh_token or self._token: - # Information is provided through the environment or as argument - if token_info_path: - # Explicit token_info.json file; see if we can use it... - if os.path.exists(self.token_info_path): - token_info = self._read_token_info(self.token_info_path) - - if ( - not self._token - and self.client_id == token_info.get(self.KEY_CLIENT_ID) - and self.refresh_token == token_info.get(self.KEY_REFRESH_TOKEN) - ): - self._token = token_info.get(self.KEY_JWT_TOKEN) - elif self.refresh_token and self.token_info_path: - # Make the saved JWT token file unique to the refresh token - token = self.refresh_token - token_sha1 = sha1(token.encode("utf-8")).hexdigest() - self.token_info_path = os.path.join( - DEFAULT_TOKEN_INFO_DIR, f"{JWT_TOKEN_PREFIX}{token_sha1}.json" - ) - - if self._token: - self._write_token_info( - self.token_info_path, {self.KEY_JWT_TOKEN: self._token} - ) - else: - self._token = self._read_token_info( - self.token_info_path, suppress_warning=True - ).get(self.KEY_JWT_TOKEN) - elif self.token_info_path: - # All information comes from the cached token_info.json file - token_info = self._read_token_info(self.token_info_path, _suppress_warning) - - self.client_id = token_info.get(self.KEY_CLIENT_ID) - self.client_secret = token_info.get(self.KEY_CLIENT_SECRET) - self.refresh_token = token_info.get(self.KEY_REFRESH_TOKEN) - self._token = next( - ( - x - for x in ( - token_info.get(self.KEY_ALT_JWT_TOKEN), - token_info.get(self.KEY_JWT_TOKEN), - ) - if x is not None - ), - None, - ) - - # The refresh token and client secret should be identical if both set - if ( - self.client_secret - and self.refresh_token - and self.client_secret != self.refresh_token - ): - warnings.warn( - "Authentication token mismatch: both the client secret and the " - "refresh token are provided but differ in value; " - "the refresh token will be used for authentication.", - stacklevel=2, - ) - - # Make sure they're identical. Refresh token has precedence. - if self.refresh_token: - self.client_secret = self.refresh_token - elif self.client_secret: - self.refresh_token = self.client_secret - - self.scope = next( - (x for x in (scope, token_info.get(self.KEY_SCOPE)) if x is not None), None - ) - - # Verify that the token is valid; otherwise clear it - if self._token: - try: - payload = self._get_payload(self._token) - except AuthError: - self._token = None - else: - if self._token_expired(payload) or ( - self.client_id and payload.get("aud") != self.client_id - ): - self._token = None - - if not _suppress_warning and not ( - self._token or (self.client_id and self.refresh_token) - ): - # Won't authn if we don't have a token or a client_id/refresh_token pair - warnings.warn(self.AUTHORIZATION_ERROR.format(""), stacklevel=2) - - self._namespace = None - - if retries is None: - retries = self.RETRY_CONFIG - - self._retry_config = retries - self._init_session() - self.leeway = leeway - - if domain is None: - domain = get_default_domain() - - self.domain = domain - - @classmethod - def from_environment_or_token_json(cls, **kwargs): - """Creates an Auth object from the given arguments. - - Creates an Auth object from the given arguments, - environment variables, or stored credentials. - - See :py:class:`Auth` for details. - """ - return Auth(**kwargs) - - def _init_session(self): - # Sessions can't be shared across threads or processes because the underlying - # SSL connection pool can't be shared. We create them thread-local to avoid - # intractable exceptions when users naively share clients e.g. when using - # multiprocessing. - self._session = ThreadLocalWrapper(self.build_session) - - def _token_expired(self, payload, leeway=0): - exp = payload.get("exp") - - if exp is not None: - now = ( - datetime.datetime.now(datetime.timezone.utc) - - datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc) - ).total_seconds() - - return now + leeway > exp - - return True # Must have exp - - @property - def token(self): - """Gets the short-lived JWT access token. - - Returns - ------- - str - The JWT token string. - - Raises - ------ - AuthError - Raised when incomplete credentials were provided. - OauthError - Raised when a token cannot be obtained or refreshed. - """ - if self._token is None: - self._get_token() - else: # might have token but could be close to expiration - payload = self._get_payload(self._token) - - if self._token_expired(payload, self.leeway): - try: - self._get_token() - except AuthError as e: - # Unable to refresh, raise if truly expired - if self._token_expired(payload): - raise e - - return self._token - - @property - def payload(self): - """Gets the token payload. - - Returns - ------- - dict - Dictionary containing the fields specified by scope, which may include: - - .. highlight:: none - - :: - - name: The name of the user. - groups: Groups to which the user belongs. - org: The organization to which the user belongs. - email: The email address of the user. - email_verified: True if the user's email has been verified. - sub: The user identifier. - exp: The expiration time of the token, in seconds since - the start of the unix epoch. - - Raises - ------ - AuthError - Raised when incomplete credentials were provided. - OauthError - Raised when a token cannot be obtained or refreshed. - """ - payload = self.__dict__.get(self.KEY_PAYLOAD) - - if payload is None: - payload = self._get_payload(self.token) - - # doctor custom claims - if DESCARTESLABS_CUSTOM_CLAIM_PREFIX: - for key in list(payload.keys()): - if key.startswith(DESCARTESLABS_CUSTOM_CLAIM_PREFIX): - payload[key[len(DESCARTESLABS_CUSTOM_CLAIM_PREFIX) :]] = ( - payload.pop(key) - ) - - self.__dict__[self.KEY_PAYLOAD] = payload - - return payload - - @staticmethod - def _get_payload(token): - if isinstance(token, str): - token = token.encode("utf-8") - - try: - # Anything that goes wrong here means it's a bad token - claims = token.split(b".")[1] - return json.loads(base64url_decode(claims).decode("utf-8")) - except Exception as e: - raise AuthError("Unable to read token {}: {}".format(token, e)) - - @property - def session(self): - return self._session.get() - - def build_session(self): - session = Session(self.domain, retries=self._retry_config) - # local testing will not have necessary certs - if self.domain.startswith("https://dev.localhost"): - session.verify = False - return session - - @staticmethod - def get_default_auth(): - """Retrieve the default Auth. - - This Auth is used whenever you don't explicitly set the Auth - when creating clients, etc. - """ - if Auth._instance is None: - Auth._instance = Auth() - - return Auth._instance - - @staticmethod - def set_default_auth(auth): - """Change the default Auth to the given Auth. - - This is the Auth that will be used whenever you don't explicitly set the - Auth when creating clients, etc. - """ - Auth._instance = auth - - @staticmethod - def _read_token_info(path, suppress_warning=False): - if os.environ.get("DESCARTESLABS_NO_JWT_CACHE", "").lower() == "true": - return {} - - try: - with open(path) as fp: - return json.load(fp) - except Exception as e: - if not suppress_warning: - warnings.warn( - "Unable to read token_info from {} with error {}.".format( - path, str(e) - ), - stacklevel=3, - ) - - return {} - - @staticmethod - def _write_token_info(path, token_info): - token_info_directory = os.path.dirname(path) - temp_prefix = ".{}.".format(os.path.basename(path)) - - fd = None - temp_path = None - suppress_warning = False - - try: - if Auth.KEY_JWT_TOKEN in token_info: - token = token_info[Auth.KEY_JWT_TOKEN] - - if isinstance(token, bytes): - token_info[Auth.KEY_JWT_TOKEN] = token.decode("utf-8") - - makedirs_if_not_exists(token_info_directory) - fd, temp_path = tempfile.mkstemp( - prefix=temp_prefix, dir=token_info_directory - ) - - if JWT_TOKEN_PREFIX in path: - token_info = {Auth.KEY_JWT_TOKEN: token_info[Auth.KEY_JWT_TOKEN]} - suppress_warning = True - - try: - with os.fdopen(fd, "w+") as fp: - json.dump(token_info, fp) - finally: - fd = None # Closed now - - os.chmod(temp_path, stat.S_IRUSR | stat.S_IWUSR) - - try: - os.rename(temp_path, path) - except FileExistsError: - # On windows remove the file first - os.remove(path) - os.rename(temp_path, path) - except Exception as e: - if not suppress_warning: - warnings.warn( - "Failed to save token: {}".format(e), - stacklevel=3, - ) - finally: - if fd is not None: - os.close(fd) - - if temp_path is not None and os.path.exists(temp_path): - os.remove(temp_path) - - def _get_token(self, timeout=100): - if self.client_id is None: - raise AuthError(self.AUTHORIZATION_ERROR.format(" (no client_id)")) - - if self.client_secret is None and self.refresh_token is None: - raise AuthError( - self.AUTHORIZATION_ERROR.format(" (no client_secret or refresh_token)") - ) - - if self.client_id in LEGACY_DELEGATION_CLIENT_IDS: - if self.scope is None: - scope = ["openid", "name", "groups", "org", "email"] - else: - scope = self.scope - params = { - self.KEY_SCOPE: " ".join(scope), - self.KEY_CLIENT_ID: self.client_id, - self.KEY_GRANT_TYPE: "urn:ietf:params:oauth:grant-type:jwt-bearer", - self.KEY_TARGET: self.client_id, - self.KEY_API_TYPE: "app", - self.KEY_REFRESH_TOKEN: self.refresh_token, - } - else: - params = { - self.KEY_CLIENT_ID: self.client_id, - self.KEY_GRANT_TYPE: "refresh_token", - self.KEY_REFRESH_TOKEN: self.refresh_token, - } - - if self.scope is not None: - params[self.KEY_SCOPE] = " ".join(self.scope) - - r = self.session.post("/token", json=params, timeout=timeout) - - if r.status_code != 200: - raise OauthError("Could not retrieve token: {}".format(r.text.strip())) - - data = r.json() - access_token = data.get("access_token") - id_token = data.get("id_token") # TODO(justin) remove legacy id_token usage - - if access_token is not None: - self._token = access_token - elif id_token is not None: - self._token = id_token - else: - raise OauthError("Could not retrieve token") - - # clear out payload and subjects cache - self._clear_cache() - - token_info = {} - - # Read the token from the token_info_path, and save it again - if self.token_info_path: - token_info = self._read_token_info( - self.token_info_path, suppress_warning=True - ) - - if ( - token_info.get(self.KEY_CLIENT_ID) != self.client_id - or token_info.get(self.KEY_CLIENT_SECRET) != self.client_secret - ): - # Not matching; better rewrite! - token_info = { - self.KEY_CLIENT_ID: self.client_id, - self.KEY_CLIENT_SECRET: self.client_secret, - self.KEY_REFRESH_TOKEN: self.refresh_token, - } - - token_info[self.KEY_JWT_TOKEN] = self._token - token_info.pop(self.KEY_ALT_JWT_TOKEN, None) # Remove alt key - self._write_token_info(self.token_info_path, token_info) - - @property - def namespace(self): - """Gets the user namespace (the Descartes Labs user id). - - Returns - ------- - str - The user namespace. - - Raises - ------ - AuthError - Raised when incomplete credentials were provided. - OauthError - Raised when a token cannot be obtained or refreshed. - """ - namespace = self._namespace - if namespace is None: - namespace = self.payload.get("userid") - if not namespace: - # legacy, compute it on the fly - namespace = sha1(self.payload["sub"].encode("utf-8")).hexdigest() - self._namespace = namespace - return namespace - - @property - def all_acl_subjects(self): - """ - A list of all ACL subjects identifying this user (the user itself, the org, the - groups) which can be used in ACL queries. - """ - subjects = self.__dict__.get(self.KEY_ALL_ACL_SUBJECTS) - - if subjects is None: - subjects = [self.ACL_PREFIX_USER + self.namespace] - - if email := self.payload.get("email"): - subjects.append(self.ACL_PREFIX_EMAIL + email.lower()) - - if org := self.payload.get("org"): - subjects.append(self.ACL_PREFIX_ORG + org) - - subjects += [ - self.ACL_PREFIX_GROUP + group for group in self._active_groups() - ] - self.__dict__[self.KEY_ALL_ACL_SUBJECTS] = subjects - - return subjects - - @property - def all_acl_subjects_as_set(self): - subjects_as_set = self.__dict__.get(self.KEY_ALL_ACL_SUBJECTS_AS_SET) - - if subjects_as_set is None: - subjects_as_set = set(self.all_acl_subjects) - self.__dict__[self.KEY_ALL_ACL_SUBJECTS_AS_SET] = subjects_as_set - - return subjects_as_set - - @property - def all_owner_acl_subjects(self): - """ - A list of ACL subjects identifying this user (the user itself, the org, - org admin and catalog admins) which can be used in owner ACL queries. - """ - subjects = self.__dict__.get(self.KEY_ALL_OWNER_ACL_SUBJECTS) - - if subjects is None: - subjects = [self.ACL_PREFIX_USER + self.namespace] - - subjects.extend( - [self.ACL_PREFIX_ORG + org for org in self.get_org_admins() if org] - ) - subjects.extend( - [ - self.ACL_PREFIX_ACCESS + access_id - for access_id in self.get_resource_admins() - if access_id - ] - ) - self.__dict__[self.KEY_ALL_OWNER_ACL_SUBJECTS] = subjects - - return subjects - - @property - def all_owner_acl_subjects_as_set(self): - subjects_as_set = self.__dict__.get(self.KEY_ALL_OWNER_ACL_SUBJECTS_AS_SET) - - if subjects_as_set is None: - subjects_as_set = set(self.all_owner_acl_subjects) - self.__dict__[self.KEY_ALL_OWNER_ACL_SUBJECTS_AS_SET] = subjects_as_set - - return subjects_as_set - - def get_org_admins(self): - # This retrieves the value of the org to be added if the user has one or - # more org-admin groups, otherwise the empty list. - return [ - group[: -len(self.ORG_ADMIN_SUFFIX)] - for group in self.payload.get("groups", []) - if group.endswith(self.ORG_ADMIN_SUFFIX) - ] - - def get_resource_admins(self): - # This retrieves the value of the access-id to be added if the user has one or - # more resource-admin groups, otherwise the empty list. - return [ - group[: -len(self.RESOURCE_ADMIN_SUFFIX)] - for group in self.payload.get("groups", []) - if group.endswith(self.RESOURCE_ADMIN_SUFFIX) - ] - - def _active_groups(self): - """ - Attempts to filter groups to just the ones that are currently valid for this - user. If they have a colon, the prefix leading up to the colon must be the - user's current org, otherwise the user should not actually have rights with - this group. - """ - org = self.payload.get("org") - for group in self.payload.get("groups", []): - parts = group.split(":") - - if len(parts) == 1: - yield group - elif org and parts[0] == org: - yield group - - def _clear_cache(self): - for key in ( - self.KEY_PAYLOAD, - self.KEY_ALL_ACL_SUBJECTS, - self.KEY_ALL_ACL_SUBJECTS_AS_SET, - self.KEY_ALL_OWNER_ACL_SUBJECTS, - self.KEY_ALL_OWNER_ACL_SUBJECTS_AS_SET, - ): - if key in self.__dict__: - del self.__dict__[key] - self._namespace = None - - def __getstate__(self): - return dict((attr, getattr(self, attr)) for attr in self.__attrs__) - - def __setstate__(self, state): - for name, value in state.items(): - setattr(self, name, value) - - self._init_session() - - -if __name__ == "__main__": - auth = Auth.get_default_auth() - - print(auth.token) diff --git a/descarteslabs/auth/tests/__init__.py b/descarteslabs/auth/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/descarteslabs/auth/tests/test_auth.py b/descarteslabs/auth/tests/test_auth.py deleted file mode 100644 index 1829d9cc..00000000 --- a/descarteslabs/auth/tests/test_auth.py +++ /dev/null @@ -1,593 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 base64 -import datetime -import json -import os -import tempfile -import unittest -import warnings -from unittest.mock import MagicMock, patch - -import pytest -import responses - -from descarteslabs.exceptions import AuthError - -from .. import auth as auth_module -from ..auth import Auth, DESCARTESLABS_CUSTOM_CLAIM_PREFIX, LEGACY_DELEGATION_CLIENT_IDS - - -def token_response_callback(request): - body = request.body - if not isinstance(body, str): - body = body.decode("utf-8") - - data = json.loads(body) - - required_fields = ["client_id", "grant_type", "refresh_token"] - legacy_required_fields = ["api_type", "target"] - - if not all(field in data for field in required_fields): - return 400, {"Content-Type": "application/json"}, json.dumps("missing fields") - - if data["grant_type"] == "urn:ietf:params:oauth:grant-type:jwt-bearer" and all( - field in data for field in legacy_required_fields - ): - return ( - 200, - {"Content-Type": "application/json"}, - json.dumps(dict(id_token="legacy-id-token")), - ) - - if data["grant_type"] == "refresh_token" and all( - field not in data for field in legacy_required_fields - ): - # note: this used to return both an access_token and an id_token - # but that isn't how IAM works anymore: it only returns an id_token. - # this isn't really OAuth2, but it is what it is. - return ( - 200, - {"Content-Type": "application/json"}, - json.dumps(dict(id_token="id-token")), - ) - return 400, {"Content-Type": "application/json"}, json.dumps(data) - - -def to_bytes(s): - if isinstance(s, str): - s = s.encode("utf-8") - return s - - -domain = "https://some_domain" - - -@patch("descarteslabs.auth.auth.get_default_domain", MagicMock(return_value=domain)) -class TestAuth(unittest.TestCase): - @classmethod - def setUpClass(cls): - cls.env = dict(os.environ) - os.environ.clear() - - @classmethod - def tearDownClass(cls): - os.environ.update(cls.env) - - def tearDown(self): - warnings.resetwarnings() - - def test_auth_client_refresh_match(self): - with warnings.catch_warnings(record=True) as caught_warnings: - auth = Auth( - client_id="client_id", - client_secret="secret", - refresh_token="mismatched_refresh_token", - ) - assert "mismatched_refresh_token" == auth.refresh_token - assert "mismatched_refresh_token" == auth.client_secret - - assert len(caught_warnings) == 1 - assert caught_warnings[0].category == UserWarning - assert "token mismatch" in str(caught_warnings[0].message) - - @responses.activate - def test_get_token(self): - responses.add( - responses.POST, - f"{domain}/token", - json=dict(access_token="access-token"), - status=200, - ) - auth = Auth(client_secret="client-secret", client_id="client-id") - auth._get_token() - - assert "access-token" == auth._token - - @responses.activate - def test_get_token_legacy(self): - responses.add( - responses.POST, - f"{domain}/token", - json=dict(id_token="id-token"), - status=200, - ) - auth = Auth(client_secret="client-secret", client_id="client-id") - auth._get_token() - - assert "id-token" == auth._token - - def test_payload(self): - auth = Auth() - - token_payload = { - "sub": "asdf", - f"{DESCARTESLABS_CUSTOM_CLAIM_PREFIX}groups": ["public"], - f"{DESCARTESLABS_CUSTOM_CLAIM_PREFIX}name": "some name", - f"{DESCARTESLABS_CUSTOM_CLAIM_PREFIX}org": "some-org", - f"{DESCARTESLABS_CUSTOM_CLAIM_PREFIX}userid": "1234", - "exp": 9999999999, - "aud": "client-id", - } - payload = { - "sub": "asdf", - "groups": ["public"], - "name": "some name", - "org": "some-org", - "userid": "1234", - "exp": 9999999999, - "aud": "client-id", - } - - token = b".".join( - [ - b"", - base64.urlsafe_b64encode(json.dumps(token_payload).encode("utf-8")), - b"", - ] - ) - with patch.object(Auth, "token", new=token): - auth = Auth() - assert payload == auth.payload - - @patch.object(Auth, "payload", new=dict(sub="asdf", userid="1234")) - def test_get_namespace(self): - auth = Auth(client_secret="client-secret", client_id="client-id") - assert auth.namespace == "1234" - - @patch.object(Auth, "payload", new=dict(sub="asdf")) - def test_get_legacy_namespace(self): - auth = Auth(client_secret="client-secret", client_id="client-id") - assert auth.namespace == "3da541559918a808c2402bba5012f6c60b27661c" - - def test_init_token_no_path(self): - token = b".".join( - ( - base64.b64encode(to_bytes(p)) - for p in ["header", json.dumps(dict(exp=9999999999, aud="foo")), "sig"] - ) - ) - auth = Auth(jwt_token=token, client_id="foo") - assert token == auth._token - - @responses.activate - def test_get_token_schema_internal_only(self): - responses.add_callback( - responses.POST, - f"{domain}/token", - callback=token_response_callback, - ) - auth = Auth(refresh_token="refresh-token", client_id="client-id") - auth._get_token() - - assert "id-token" == auth._token - - auth = Auth(client_secret="refresh-token", client_id="client-id") - auth._get_token() - - assert "id-token" == auth._token - - @unittest.skipUnless(len(LEGACY_DELEGATION_CLIENT_IDS) > 0, "No legacy client IDs") - @responses.activate - def test_get_token_schema_legacy_internal_only(self): - responses.add_callback( - responses.POST, - f"{domain}/token", - callback=token_response_callback, - ) - auth = Auth( - client_secret="client-secret", - client_id=LEGACY_DELEGATION_CLIENT_IDS[0], - ) - auth._get_token() - assert "legacy-id-token" == auth._token - - @patch.object(Auth, "_get_token") - def test_token(self, _get_token): - auth = Auth( - client_secret="client-secret", - client_id="client-id", - ) - token = b".".join( - ( - base64.b64encode(to_bytes(p)) - for p in [ - "header", - json.dumps(dict(exp=9999999999, aud="client-id")), - "sig", - ] - ) - ) - auth._token = token - - assert auth.token == token - _get_token.assert_not_called() - - @patch.object(Auth, "_get_token") - def test_token_expired(self, _get_token): - auth = Auth( - client_secret="client-secret", - client_id="client-id", - ) - token = b".".join( - ( - base64.b64encode(to_bytes(p)) - for p in ["header", json.dumps(dict(exp=0)), "sig"] - ) - ) - auth._token = token - - assert auth.token == token - _get_token.assert_called_once() - - @patch.object(Auth, "_get_token", side_effect=AuthError("error")) - def test_token_expired_autherror(self, _get_token): - auth = Auth( - client_secret="client-secret", - client_id="client-id", - ) - token = b".".join( - ( - base64.b64encode(to_bytes(p)) - for p in ["header", json.dumps(dict(exp=0)), "sig"] - ) - ) - auth._token = token - - with pytest.raises(AuthError): - auth.token - _get_token.assert_called_once() - - @patch.object(Auth, "_get_token", side_effect=AuthError("error")) - def test_token_in_leeway_autherror(self, _get_token): - auth = Auth( - client_secret="client-secret", - client_id="client-id", - ) - exp = ( - datetime.datetime.now(datetime.timezone.utc) - - datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc) - ).total_seconds() + auth.leeway / 2 - token = b".".join( - ( - base64.b64encode(to_bytes(p)) - for p in ["header", json.dumps(dict(exp=exp)), "sig"] - ) - ) - auth._token = token - - assert auth.token == token - _get_token.assert_called_once() - - def test_auth_init_env_vars(self): - warnings.simplefilter("ignore") - - environ = dict( - CLIENT_SECRET="secret_bar", - CLIENT_ID="id_bar", - DESCARTESLABS_CLIENT_SECRET="secret_foo", - DESCARTESLABS_CLIENT_ID="id_foo", - DESCARTESLABS_REFRESH_TOKEN="refresh_foo", - ) - - # should work with direct var - with patch.object(auth_module.os, "environ", environ): - auth = Auth( - client_id="client-id", - client_secret="client-secret", - refresh_token="client-secret", - ) - assert auth.client_secret == "client-secret" - assert auth.client_id == "client-id" - - # should work with namespaced env vars - with patch.object(auth_module.os, "environ", environ): - auth = Auth() - # when refresh_token and client_secret do not match, - # the Auth implementation sets both to the value of - # refresh_token - assert auth.client_secret == environ.get("DESCARTESLABS_REFRESH_TOKEN") - assert auth.client_id == environ.get("DESCARTESLABS_CLIENT_ID") - - # remove the namespaced ones, except the refresh token because - # Auth does not recognize a REFRESH_TOKEN environment variable - # and removing it from the dictionary would result in non-deterministic - # results based on the token_info.json file on the test runner disk - environ.pop("DESCARTESLABS_CLIENT_SECRET") - environ.pop("DESCARTESLABS_CLIENT_ID") - - # should fallback to legacy env vars - with patch.object(auth_module.os, "environ", environ): - auth = Auth() - assert auth.client_secret == environ.get("DESCARTESLABS_REFRESH_TOKEN") - assert auth.client_id == environ.get("CLIENT_ID") - - def test_set_token(self): - environ = dict(DESCARTESLABS_TOKEN="token") - - with patch.object(auth_module.os, "environ", environ): - with self.assertRaises(AuthError): - auth = Auth() - auth.payload - - with self.assertRaises(AuthError): - auth = Auth(jwt_token="token") - auth.payload - - def test_set_token_info_path(self): - environ = dict(DESCARTESLABS_TOKEN_INFO_PATH="token_info_path") - - with patch.object(auth_module.os, "environ", environ): - with self.assertRaises(AuthError): - auth = Auth() - assert auth.token_info_path == "token_info_path" - auth.payload - - with patch.object(auth_module.os, "environ", dict()): - with self.assertRaises(AuthError): - auth = Auth(token_info_path="token_info_path") - assert auth.token_info_path == "token_info_path" - auth.payload - - def test_cache_jwt_token(self): - token = b".".join( - ( - base64.b64encode(to_bytes(p)) - for p in ["header", json.dumps(dict(exp=9999999999, aud="foo")), "sig"] - ) - ).decode() - with patch.object(auth_module, "DEFAULT_TOKEN_INFO_DIR", "/tmp"): - # This instance should write out the jwt token to /tmp/... - Auth(client_id="foo", client_secret="bar", jwt_token=token) - # This instance should read it back in - a = Auth(client_id="foo", client_secret="bar") - assert a._token == token - - def test_clear_cached_jwt_token_expired(self): - token = b".".join( - ( - base64.b64encode(to_bytes(p)) - for p in ["header", json.dumps(dict(exp=0, aud="foo")), "sig"] - ) - ).decode() - with patch.object(auth_module, "DEFAULT_TOKEN_INFO_DIR", "/tmp"): - # This instance should write out the jwt token to /tmp/... - Auth(client_id="foo", client_secret="bar", jwt_token=token) - # This instance should clear it because the token is expired - a = Auth(client_id="foo", client_secret="bar") - assert a._token is None - - def test_clear_cached_jwt_token_different_client(self): - token = b".".join( - ( - base64.b64encode(to_bytes(p)) - for p in ["header", json.dumps(dict(exp=9999999999, aud="foo")), "sig"] - ) - ).decode() - with patch.object(auth_module, "DEFAULT_TOKEN_INFO_DIR", "/tmp"): - # This instance should write out the jwt token to /tmp/... - Auth(client_id="foo", client_secret="bar", jwt_token=token) - # This instance should clear it because the client_id differs - a = Auth(client_id="bar", client_secret="bar") - assert a._token is None - - def test_clear_cached_jwt_token_different_secret(self): - token = b".".join( - ( - base64.b64encode(to_bytes(p)) - for p in ["header", json.dumps(dict(exp=9999999999, aud="foo")), "sig"] - ) - ).decode() - with patch.object(auth_module, "DEFAULT_TOKEN_INFO_DIR", "/tmp"): - # This instance should write out the jwt token to /tmp/... - Auth(client_id="foo", client_secret="bar", jwt_token=token) - # This instance should clear it because the client_secret differs - a = Auth(client_id="foo", client_secret="foo") - assert a._token is None - - def test_no_valid_auth_info(self): - with warnings.catch_warnings(record=True) as caught_warnings: - Auth(client_id="client-id") - assert len(caught_warnings) == 1 - assert caught_warnings[0].category == UserWarning - assert "No valid authentication info found" in str( - caught_warnings[0].message - ) - - def test_token_info_file(self): - token = b".".join( - ( - base64.b64encode(to_bytes(p)) - for p in ["header", json.dumps(dict(exp=9999999999, aud="foo")), "sig"] - ) - ).decode() - with tempfile.NamedTemporaryFile(delete=False) as token_info_file: - token_info_file.write( - json.dumps( - { - "client_id": "foo", - "refresh_token": "bar", - "jwt_token": token, - } - ).encode() - ) - token_info_file.close() - - # This instance should read in that token - a = Auth( - client_id="foo", - client_secret="bar", - token_info_path=token_info_file.name, - ) - assert a._token == token - - def test_clear_token_info_file(self): - token = b".".join( - ( - base64.b64encode(to_bytes(p)) - for p in ["header", json.dumps(dict(exp=9999999999, aud="foo")), "sig"] - ) - ).decode() - with tempfile.NamedTemporaryFile(delete=False) as token_info_file: - token_info_file.write( - json.dumps( - { - "client_id": "foo", - "refresh_token": "bar", - "jwt_token": token, - } - ).encode() - ) - token_info_file.close() - - # This instance should not read in that token - a = Auth( - client_id="bar", - client_secret="bar", - token_info_path=token_info_file.name, - ) - assert a._token is None - - @responses.activate - def test_write_token_info_file(self): - token = b".".join( - ( - base64.b64encode(to_bytes(p)) - for p in ["header", json.dumps(dict(exp=9999999999, aud="foo")), "sig"] - ) - ).decode() - responses.add( - responses.POST, - f"{domain}/token", - json=dict(client_id="foo", client_secret="bar", id_token=token), - status=200, - ) - - with tempfile.NamedTemporaryFile(delete=False) as token_info_file: - token_info_file.close() - - # This instance should write the token - a = Auth( - client_id="foo", - client_secret="bar", - token_info_path=token_info_file.name, - ) - a.token - - # This instance should read the token - a = Auth( - client_id="foo", - client_secret="bar", - token_info_path=token_info_file.name, - ) - assert a._token == token - - # This instance should clear the token because the client_id doesn't match - a = Auth( - client_id="bar", - client_secret="bar", - token_info_path=token_info_file.name, - ) - assert a._token is None - - def test_domain(self): - a = Auth() - assert a.domain == domain - - def test_all_acl_subjects(self): - auth = Auth( - client_secret="client-secret", - client_id="client-id", - ) - token = b".".join( - ( - base64.b64encode(to_bytes(p)) - for p in [ - "header", - json.dumps( - dict( - sub="some|user", - groups=["public"], - org="some-org", - exp=9999999999, - aud="client-id", - ) - ), - "sig", - ] - ) - ) - auth._token = token - - assert { - Auth.ACL_PREFIX_USER + auth.namespace, - f"{Auth.ACL_PREFIX_GROUP}public", - f"{Auth.ACL_PREFIX_ORG}some-org", - } == set(auth.all_acl_subjects) - - def test_all_acl_subjects_ignores_bad_org_groups(self): - auth = Auth( - client_secret="client-secret", - client_id="client-id", - ) - token = b".".join( - ( - base64.b64encode(to_bytes(p)) - for p in [ - "header", - json.dumps( - dict( - sub="some|user", - groups=["public", "some-org:baz", "other:baz"], - org="some-org", - exp=9999999999, - aud="client-id", - ) - ), - "sig", - ] - ) - ) - auth._token = token - assert { - Auth.ACL_PREFIX_USER + auth.namespace, - f"{Auth.ACL_PREFIX_ORG}some-org", - f"{Auth.ACL_PREFIX_GROUP}public", - f"{Auth.ACL_PREFIX_GROUP}some-org:baz", - } == set(auth.all_acl_subjects) - - -if __name__ == "__main__": - unittest.main() diff --git a/descarteslabs/catalog/__init__.py b/descarteslabs/catalog/__init__.py deleted file mode 100644 index 48899a4a..00000000 --- a/descarteslabs/catalog/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 descarteslabs.core.catalog import * # noqa F401 F403 diff --git a/descarteslabs/compute/__init__.py b/descarteslabs/compute/__init__.py deleted file mode 100644 index 4e8d5545..00000000 --- a/descarteslabs/compute/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 descarteslabs.core.compute import * # noqa F401 F403 diff --git a/descarteslabs/config/__init__.py b/descarteslabs/config/__init__.py deleted file mode 100644 index e8381967..00000000 --- a/descarteslabs/config/__init__.py +++ /dev/null @@ -1,274 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import os -from threading import Lock - -import dynaconf - -from descarteslabs.exceptions import ConfigError - -GCP_ENVIRONMENT = "gcp-production" #: Standard GCP environment -AWS_ENVIRONMENT = "aws-production" #: Standard AWS environment - - -class Settings(dynaconf.Dynaconf): - """ - Configuration settings for the Descartes Labs client. - - Based on the ``Dynaconf`` package. This settings class supports configuration from - named "environments" in a ``settings.toml`` file as well as environment variables - with names that are prefixed with ``DESCARTESLABS_`` (or the prefix specified - in the ``envvar_prefix``). - - For the full capabilities of ``Dynaconf`` please consult https://www.dynaconf.com/. - - Note that normally ``Settings`` functions entirely automatically within the client. - However, it is possible to perform custom initialization programmatically. In order - to do this, the beginning of the client program must execute code like this: - - .. code-block:: - - from descarteslabs.config import Settings - Settings.select_env(...) - - Before importing or otherwise accessing anything else within the - :py:mod:`descarteslabs` package. - """ - - class _EnvDescriptor: - # Retrieve the correct env string for `peek_settings()` - def __get__(self, obj, objtype=None): - if obj is None: - if objtype._settings is None: - return None - else: - return objtype._settings.env_for_dynaconf - else: - return obj.env_for_dynaconf - - env = _EnvDescriptor() - """str : The current client configuration name or `None` of no environment was selected.""" - - # The global settings instance, can only be set once via select_env or get_settings - _settings = None - - _lock = Lock() - - @classmethod - def select_env(cls, env=None, settings_file=None, envvar_prefix="DESCARTESLABS"): - """ - Configure the Descartes Labs client. - - Parameters - ---------- - env : str, optional - Name of the environment to configure. Must appear in - ``descarteslabs/config/settings.toml`` If not supplied will be determined - from the `DESCARTESLABS_ENV` environment variable (or use the prefix - specified in the `envvar_prefix`_ENV), if set. Otherwise defaults to - `aws-production`. - settings_file : str, optional - If supplied, will be consulted for additional configuration overrides. These - are applied over those in the ``descarteslabs/config/settings.toml`` file, - but are themselves overwritten by any environment variable settings matching - the `envvar_prefix`. - envvar_prefix : str, optional - Prefix for environment variable names to consult for configuration - overrides. Environment variables with a leading prefix of - ``"_"`` will override the settings in the resulting - configuration after the settings file(s) have been consulted. - - Returns - ------- - Settings - Returns a ``Settings`` instance, a dict-like object - containing the configured settings for the client. - - Raises - ------ - ConfigError - If no client configuration could be established, or if an invalid - configuration name was specified, or if you try to change the - client configuration after the client is already configured. - """ - # Once the settings has been lazy evaluated, we cannot change it. - # The reviled double-check pattern. Actually ok with CPython and the GIL, - # but not necessarily any other Python implementation. - settings = cls._settings - - if settings is None: - with cls._lock: - settings = cls._settings - - if settings is None: - settings = cls._select_env( - env=env, - settings_file=settings_file, - envvar_prefix=envvar_prefix, - ) - - if settings is not None and env is not None and env != settings.ENV: - raise ConfigError( - f"Client configuration '{settings.ENV}' has already been selected" - ) - - return settings - - @classmethod - def get_settings(cls): - """ - Configure and retrieve the current or default settings for the client. - - Returns - ------- - Settings - Returns a ``Settings`` instance, a dict-like object - containing the configured settings for the client. - - Raises - ------ - ConfigError - If no client configuration could be established, or if an invalid - configuration name was specified, or if you try to change the - client configuration after the client is already configured. - """ - # The reviled double-check pattern. Actually ok with CPython and the GIL, - # but not necessarily any other Python implementation. - settings = cls._settings - - if settings is None: - with cls._lock: - settings = cls._settings - if settings is None: - settings = cls._select_env() - - return settings - - @classmethod - def peek_settings(cls, env=None, settings_file=None, envvar_prefix="DESCARTESLABS"): - """Retrieve the settings without configuring the client. - - Unlike :py:meth:`~Settings.get_settings` and :py:meth:`~Settings.select_env` - which both will configure the client, the :py:meth:`~Settings.peek_settings` - will not configure the client and :py:attr:`Settings.env` will not be set. - - See :py:meth:`select_env` for an explanation of the parameters, return value, - and exceptions that can be raised. - """ - selector = f"{envvar_prefix}_ENV" - original_selector_value = os.environ.get(selector) - - settings = cls._get_settings( - env=env, - settings_file=settings_file, - envvar_prefix=envvar_prefix, - ) - - # Return the environ back to its original state - if original_selector_value is None: - os.environ.pop(selector) - else: - os.environ[selector] = original_selector_value - - return settings - - @classmethod - def _select_env(cls, env=None, settings_file=None, envvar_prefix="DESCARTESLABS"): - # Assign to the global instance. - cls._settings = cls._get_settings( - env=env, settings_file=settings_file, envvar_prefix=envvar_prefix - ) - - # And return the settings object. - return cls._settings - - @classmethod - def _get_settings(cls, env=None, settings_file=None, envvar_prefix="DESCARTESLABS"): - # Get the settings. If the settings are retrieved successfully, the os.environ - # will contain the selector for the given settings. - selector = f"{envvar_prefix}_ENV" - original_selector_value = os.environ.get(selector) - - def restore_env(): - if original_selector_value is None: - os.environ.pop(selector) - else: - os.environ[selector] = original_selector_value - - if env: - os.environ[selector] = env - elif not os.environ.get(selector): - # Default it. - os.environ[selector] = "aws-production" - - builtin_settings_file = os.path.join( - os.path.dirname(os.path.abspath(__file__)), "settings.toml" - ) - - try: - # By default this will load from one or more settings files and - # then from the environment. - settings = cls( - # First load our client settings from TOML file. - settings_file=[builtin_settings_file], - # Then load the given settings from TOML file, if any. - includes=[] if not settings_file else [settings_file], - # Only allow TOML format. - core_loaders=["TOML"], - # Allow multiple environments ([default] is always used). - environments=True, - # Name of environment variable that selects the environment. - env_switcher=selector, - # Prefix to overwrite loaded settings, e.g,. {envvar_prefix}_MY_SETTING. - envvar_prefix=envvar_prefix, - ) - except Exception as e: - restore_env() - raise ConfigError(str(e)) from e - - try: - # Make sure we selected an environment! - assert settings.env_for_dynaconf - # default_domain must have been set - assert settings.default_domain - except (AttributeError, KeyError, AssertionError): - message = f"Client configuration '{os.environ[selector]}' doesn't exist!" - restore_env() - - if not env: - message += " Check your DESCARTESLABS_ENV environment variable." - - raise ConfigError(message) from None - - return settings - - -get_settings = Settings.get_settings -"""An alias for :py:meth:`Settings.get_settings`""" - -peek_settings = Settings.peek_settings -"""An alias for :py:meth:`Settings.peek_settings`""" - -select_env = Settings.select_env -"""An alias for :py:meth:`Settings.select_env`""" - -__all__ = [ - "AWS_ENVIRONMENT", - "GCP_ENVIRONMENT", - "Settings", - "get_settings", - "peek_settings", - "select_env", -] diff --git a/descarteslabs/config/settings.toml b/descarteslabs/config/settings.toml deleted file mode 100644 index 3b4f5c06..00000000 --- a/descarteslabs/config/settings.toml +++ /dev/null @@ -1,95 +0,0 @@ -[default] -domain = "descarteslabs.com" -log_level = "WARNING" -# default_domain must be overridden -default_domain = "" - -[aws-production] -default_domain = "@format production.aws.{this.DOMAIN}" -platform_url = "@format https://platform.{this.DEFAULT_DOMAIN}" - -app_url = "@format https://app.{this.DOMAIN}" -catalog_v2_url = "@format {this.PLATFORM_URL}/metadata/v1/catalog/v2" -compute_url = "@format {this.PLATFORM_URL}/compute/v1" -iam_url = "@format https://iam.{this.DEFAULT_DOMAIN}" -metadata_url = "@format {this.PLATFORM_URL}/metadata/v1" -raster_url = "@format {this.PLATFORM_URL}/raster/v2" -usage_url = "@format {this.PLATFORM_URL}/usage/v1" -userlimit_url = "@format {this.PLATFORM_URL}/userlimit/v1" -vector_url = "@format {this.PLATFORM_URL}/vector/v1" -yaas_url = "@format {this.PLATFORM_URL}/yaas/v1" - -[aws-staging] -default_domain = "@format staging.aws.{this.DOMAIN}" -platform_url = "@format https://platform.{this.DEFAULT_DOMAIN}" - -app_url = "@format https://app.{this.DOMAIN}" -catalog_v2_url = "@format {this.PLATFORM_URL}/metadata/v1/catalog/v2" -compute_url = "@format {this.PLATFORM_URL}/compute/v1" -iam_url = "@format https://iam.{this.DEFAULT_DOMAIN}" -metadata_url = "@format {this.PLATFORM_URL}/metadata/v1" -raster_url = "@format {this.PLATFORM_URL}/raster/v2" -usage_url = "@format {this.PLATFORM_URL}/usage/v1" -userlimit_url = "@format {this.PLATFORM_URL}/userlimit/v1" -vector_url = "@format {this.PLATFORM_URL}/vector/v1" -yaas_url = "@format {this.PLATFORM_URL}/yaas/v1" - -[aws-dev] -default_domain = "@format dev.aws.{this.DOMAIN}" -platform_url = "@format https://platform.{this.DEFAULT_DOMAIN}" - -app_url = "@format https://app.{this.DOMAIN}" -catalog_v2_url = "@format {this.PLATFORM_URL}/metadata/v1/catalog/v2" -compute_url = "@format {this.PLATFORM_URL}/compute/v1" -iam_url = "@format https://iam.{this.DEFAULT_DOMAIN}" -metadata_url = "@format {this.PLATFORM_URL}/metadata/v1" -raster_url = "@format {this.PLATFORM_URL}/raster/v2" -usage_url = "@format {this.PLATFORM_URL}/usage/v1" -userlimit_url = "@format {this.PLATFORM_URL}/userlimit/v1" -vector_url = "@format {this.PLATFORM_URL}/vector/v1" -yaas_url = "@format {this.PLATFORM_URL}/yaas/v1" - -[aws-freemium] -default_domain = "@format freemium.aws.{this.DOMAIN}" -platform_url = "@format https://platform.{this.DEFAULT_DOMAIN}" - -app_url = "@format https://app.{this.DOMAIN}" -catalog_v2_url = "@format {this.PLATFORM_URL}/metadata/v1/catalog/v2" -iam_url = "@format https://iam.{this.DEFAULT_DOMAIN}" -metadata_url = "@format {this.PLATFORM_URL}/metadata/v1" -raster_url = "@format {this.PLATFORM_URL}/raster/v2" -usage_url = "@format {this.PLATFORM_URL}/usage/v1" -userlimit_url = "@format {this.PLATFORM_URL}/userlimit/v1" - -[testing] -default_domain = "@format dev.aws.{this.DOMAIN}" -platform_url = "@format https://platform.{this.DEFAULT_DOMAIN}" -testing = true - -app_url = "@format https://app.{this.DOMAIN}" -catalog_v2_url = "@format {this.PLATFORM_URL}/metadata/v1/catalog/v2" -compute_url = "@format {this.PLATFORM_URL}/compute/v1" -iam_url = "@format https://iam.{this.DEFAULT_DOMAIN}" -metadata_url = "@format {this.PLATFORM_URL}/metadata/v1" -raster_url = "@format {this.PLATFORM_URL}/raster/v2" -usage_url = "@format {this.PLATFORM_URL}/usage/v1" -userlimit_url = "@format {this.PLATFORM_URL}/userlimit/v1" -vector_url = "@format {this.PLATFORM_URL}/vector/v1" -yaas_url = "@format {this.PLATFORM_URL}/yaas/v1" - -[local] -# feel free to mess with anything here as needed -default_domain = "dev.localhost" -platform_url = "@format https://platform.dev.aws.descarteslabs.com" -testing = true - -app_url = "@format https://app.{this.DOMAIN}" -catalog_v2_url = "@format {this.PLATFORM_URL}/metadata/v1/catalog/v2" -compute_url = "@format {this.PLATFORM_URL}/compute/v1" -iam_url = "@format https://{this.DEFAULT_DOMAIN}:8000" -metadata_url = "@format {this.PLATFORM_URL}/metadata/v1" -raster_url = "@format {this.PLATFORM_URL}/raster/v2" -usage_url = "@format {this.PLATFORM_URL}/usage/v1" -userlimit_url = "@format {this.PLATFORM_URL}/userlimit/v1" -vector_url = "@format {this.PLATFORM_URL}/vector/v1" -yaas_url = "@format {this.PLATFORM_URL}/yaas/v1" diff --git a/descarteslabs/config/tests/__init__.py b/descarteslabs/config/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/descarteslabs/config/tests/settings.toml b/descarteslabs/config/tests/settings.toml deleted file mode 100644 index 63ec9d4c..00000000 --- a/descarteslabs/config/tests/settings.toml +++ /dev/null @@ -1,3 +0,0 @@ -["testing"] -testing = "hello" -iam_url = "https://test_url" diff --git a/descarteslabs/config/tests/test_config.py b/descarteslabs/config/tests/test_config.py deleted file mode 100644 index 89819f1a..00000000 --- a/descarteslabs/config/tests/test_config.py +++ /dev/null @@ -1,250 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import os -import unittest -from copy import deepcopy -from unittest.mock import patch - -from descarteslabs.auth import Auth -from descarteslabs.exceptions import ConfigError - -from .. import Settings - - -class TestSettings(unittest.TestCase): - @classmethod - def setUpClass(cls): - # Save settings and environment - cls.settings = Settings._settings - cls.environ = deepcopy(os.environ) - - def setUp(self): - # Clear existing settings from test environment - Settings._settings = None - - def tearDown(self): - # Restore settings and environment - Settings._settings = self.settings - os.environ.clear() - os.environ.update(self.environ) - - def test_select_env_default(self): - settings = Settings.select_env() - self.assertEqual(settings.current_env, os.environ.get("DESCARTESLABS_ENV")) - self.assertEqual(id(settings), id(Settings._settings)) - self.assertEqual(id(settings), id(Settings.get_settings())) - - @patch.dict(os.environ, {"DESCARTESLABS_ENV": "aws-production"}) - def test_select_env_from_env(self): - settings = Settings.select_env() - self.assertEqual(settings.current_env, "aws-production") - self.assertEqual(id(settings), id(Settings._settings)) - self.assertEqual(id(settings), id(Settings.get_settings())) - - # environment must be patched because select_env will alter it - @patch.dict(os.environ, clear=True) - def test_select_env_from_string(self): - settings = Settings.select_env("aws-production") - self.assertEqual(settings.current_env, "aws-production") - self.assertEqual(id(settings), id(Settings._settings)) - self.assertEqual(id(settings), id(Settings.get_settings())) - - def test_select_env_from_settings_file(self): - settings = Settings.select_env( - settings_file=os.path.join(os.path.dirname(__file__), "settings.toml"), - ) - self.assertEqual(settings.current_env, os.environ.get("DESCARTESLABS_ENV")) - self.assertEqual(settings.testing, "hello") - - @patch.dict(os.environ, {"DESCARTESLABS_TESTING": "hello"}) - def test_select_env_override_from_env(self): - settings = Settings.select_env() - self.assertEqual(settings.current_env, os.environ.get("DESCARTESLABS_ENV")) - self.assertEqual(settings.testing, "hello") - - @patch.dict(os.environ, {"DL_ENV": "testing", "DL_TESTING": "hello"}) - def test_select_env_prefix(self): - settings = Settings.select_env(envvar_prefix="DL") - self.assertEqual(settings.current_env, "testing") - self.assertEqual(settings.testing, "hello") - - def test_get_settings(self): - settings = Settings.get_settings() - self.assertEqual(settings.current_env, os.environ.get("DESCARTESLABS_ENV")) - self.assertEqual(id(settings), id(Settings._settings)) - self.assertEqual(id(settings), id(Settings.get_settings())) - - def test_peek_settings(self): - current_env = os.environ["DESCARTESLABS_ENV"] - env = "aws-production" - settings = Settings.peek_settings(env) - assert os.environ["DESCARTESLABS_ENV"] == current_env - assert settings.env == env - assert Settings._settings is None - - def test_bad_env(self): - env = "non-existent" - - with self.assertRaises(ConfigError): - Settings.peek_settings(env) - - with self.assertRaises(ConfigError): - Settings.select_env(env) - - def test_default_auth(self): - a = Auth() - assert a.domain == "https://iam.dev.aws.descarteslabs.com" - - def test_auth_with_env(self): - with patch.dict(os.environ, {"DESCARTESLABS_ENV": "aws-production"}): - a = Auth() - assert a.domain == "https://iam.production.aws.descarteslabs.com" - - def test_auth_with_test_config(self): - Settings.select_env("aws-production") - a = Auth() - assert a.domain == "https://iam.production.aws.descarteslabs.com" - - def test_env(self): - peek1_env = "aws-dev" - env = "aws-staging" - - assert Settings.env is None - s1 = Settings.peek_settings(peek1_env) - assert s1.env == peek1_env - assert Settings.env is None - - s2 = Settings.select_env(env) - assert s2.env == env - assert s1.env == peek1_env - assert Settings.env == env - - -class VerifyValues(unittest.TestCase): - configs = { - "aws-dev": { - "APP_URL": "https://app.descarteslabs.com", - "CATALOG_V2_URL": "https://platform.dev.aws.descarteslabs.com/metadata/v1/catalog/v2", - "COMPUTE_URL": "https://platform.dev.aws.descarteslabs.com/compute/v1", - "IAM_URL": "https://iam.dev.aws.descarteslabs.com", - "LOG_LEVEL": "WARNING", - "METADATA_URL": "https://platform.dev.aws.descarteslabs.com/metadata/v1", - "PLATFORM_URL": "https://platform.dev.aws.descarteslabs.com", - "RASTER_URL": "https://platform.dev.aws.descarteslabs.com/raster/v2", - "USAGE_URL": "https://platform.dev.aws.descarteslabs.com/usage/v1", - "USERLIMIT_URL": "https://platform.dev.aws.descarteslabs.com/userlimit/v1", - "VECTOR_URL": "https://platform.dev.aws.descarteslabs.com/vector/v1", - "YAAS_URL": "https://platform.dev.aws.descarteslabs.com/yaas/v1", - }, - "aws-freemium": { - "APP_URL": "https://app.descarteslabs.com", - "CATALOG_V2_URL": "https://platform.freemium.aws.descarteslabs.com/metadata/v1/catalog/v2", - "IAM_URL": "https://iam.freemium.aws.descarteslabs.com", - "LOG_LEVEL": "WARNING", - "METADATA_URL": "https://platform.freemium.aws.descarteslabs.com/metadata/v1", - "PLATFORM_URL": "https://platform.freemium.aws.descarteslabs.com", - "RASTER_URL": "https://platform.freemium.aws.descarteslabs.com/raster/v2", - "USAGE_URL": "https://platform.freemium.aws.descarteslabs.com/usage/v1", - "USERLIMIT_URL": "https://platform.freemium.aws.descarteslabs.com/userlimit/v1", - }, - "aws-production": { - "APP_URL": "https://app.descarteslabs.com", - "CATALOG_V2_URL": "https://platform.production.aws.descarteslabs.com/metadata/v1/catalog/v2", - "COMPUTE_URL": "https://platform.production.aws.descarteslabs.com/compute/v1", - "IAM_URL": "https://iam.production.aws.descarteslabs.com", - "LOG_LEVEL": "WARNING", - "METADATA_URL": "https://platform.production.aws.descarteslabs.com/metadata/v1", - "PLATFORM_URL": "https://platform.production.aws.descarteslabs.com", - "RASTER_URL": "https://platform.production.aws.descarteslabs.com/raster/v2", - "USAGE_URL": "https://platform.production.aws.descarteslabs.com/usage/v1", - "USERLIMIT_URL": "https://platform.production.aws.descarteslabs.com/userlimit/v1", - "VECTOR_URL": "https://platform.production.aws.descarteslabs.com/vector/v1", - "YAAS_URL": "https://platform.production.aws.descarteslabs.com/yaas/v1", - }, - "aws-staging": { - "APP_URL": "https://app.descarteslabs.com", - "CATALOG_V2_URL": "https://platform.staging.aws.descarteslabs.com/metadata/v1/catalog/v2", - "COMPUTE_URL": "https://platform.staging.aws.descarteslabs.com/compute/v1", - "IAM_URL": "https://iam.staging.aws.descarteslabs.com", - "LOG_LEVEL": "WARNING", - "METADATA_URL": "https://platform.staging.aws.descarteslabs.com/metadata/v1", - "PLATFORM_URL": "https://platform.staging.aws.descarteslabs.com", - "RASTER_URL": "https://platform.staging.aws.descarteslabs.com/raster/v2", - "USAGE_URL": "https://platform.staging.aws.descarteslabs.com/usage/v1", - "USERLIMIT_URL": "https://platform.staging.aws.descarteslabs.com/userlimit/v1", - "VECTOR_URL": "https://platform.staging.aws.descarteslabs.com/vector/v1", - "YAAS_URL": "https://platform.staging.aws.descarteslabs.com/yaas/v1", - }, - "testing": { - "APP_URL": "https://app.descarteslabs.com", - "CATALOG_V2_URL": "https://platform.dev.aws.descarteslabs.com/metadata/v1/catalog/v2", - "COMPUTE_URL": "https://platform.dev.aws.descarteslabs.com/compute/v1", - "IAM_URL": "https://iam.dev.aws.descarteslabs.com", - "LOG_LEVEL": "WARNING", - "METADATA_URL": "https://platform.dev.aws.descarteslabs.com/metadata/v1", - "PLATFORM_URL": "https://platform.dev.aws.descarteslabs.com", - "RASTER_URL": "https://platform.dev.aws.descarteslabs.com/raster/v2", - "TESTING": True, - "USAGE_URL": "https://platform.dev.aws.descarteslabs.com/usage/v1", - "USERLIMIT_URL": "https://platform.dev.aws.descarteslabs.com/userlimit/v1", - "VECTOR_URL": "https://platform.dev.aws.descarteslabs.com/vector/v1", - "YAAS_URL": "https://platform.dev.aws.descarteslabs.com/yaas/v1", - }, - } - - def test_verify_configs(self): - for config_name, config in self.configs.items(): - settings = Settings.peek_settings(config_name) - - for key in config.keys(): - assert ( - config[key] == settings[key] - ), f"{config_name}: {key}: {config[key]} != {settings[key]}" - - def test_verify_as_dict(self): - for config_name, config in self.configs.items(): - settings = Settings.peek_settings(config_name) - settings = settings.as_dict() - - for key in config.keys(): - assert ( - config[key] == settings[key] - ), f"{config_name}: {key}: {config[key]} != {settings[key]}" - - def test_verify_get(self): - for config_name, config in self.configs.items(): - settings = Settings.peek_settings(config_name) - - for key in config.keys(): - value = settings.get(key) - - assert ( - config[key] == value - ), f"{config_name}: {key}: {config[key]} != {value}" - - def test_remaining_keys(self): - for config_name, config in self.configs.items(): - settings = Settings.peek_settings(config_name) - settings = settings.as_dict() - - for key in config.keys(): - settings.pop(key) - - settings.pop("DEFAULT_DOMAIN", None) - settings.pop("DOMAIN", None) - settings.pop("TOKEN_INFO_PATH", None) # picked up from test environment - - assert settings.pop("ENV") == config_name - assert len(settings) == 0, f"{config_name}: {settings}" diff --git a/descarteslabs/core/__init__.py b/descarteslabs/core/__init__.py deleted file mode 100644 index 54ad2a42..00000000 --- a/descarteslabs/core/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# this file must remain empty for the client setup diff --git a/descarteslabs/core/catalog/__init__.py b/descarteslabs/core/catalog/__init__.py deleted file mode 100644 index 37a54de5..00000000 --- a/descarteslabs/core/catalog/__init__.py +++ /dev/null @@ -1,193 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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. - -""" -The Catalog Service provides access to products, bands, and images -available from Descartes Labs. -""" - -from .task import TaskState -from .product import ( - DeletionTaskStatus, - Product, - ProductCollection, -) -from .band import ( - Band, - BandCollection, - BandType, - ClassBand, - Colormap, - DataType, - DerivedParamsAttribute, - GenericBand, - MaskBand, - MicrowaveBand, - ProcessingLevelsAttribute, - ProcessingStepAttribute, - SpectralBand, -) -from .blob import ( - Blob, - BlobCollection, - BlobDeletionTaskStatus, - BlobSearch, - BlobSummaryResult, - StorageType, -) -from .event_api_destination import ( - EventApiDestination, - EventApiDestinationCollection, - EventApiDestinationSearch, - EventConnectionParameter, -) -from .event_rule import EventRule, EventRuleCollection, EventRuleSearch, EventRuleTarget -from .event_schedule import EventSchedule, EventScheduleCollection, EventScheduleSearch -from .event_subscription import ( - ComputeFunctionCompletedEventSubscription, - EventSubscription, - EventSubscriptionCollection, - EventSubscriptionComputeTarget, - EventSubscriptionSearch, - EventSubscriptionSqsTarget, - EventSubscriptionTarget, - EventType, - NewImageEventSubscription, - NewStorageEventSubscription, - NewVectorEventSubscription, - Placeholder, - ScheduledEventSubscription, -) -from .image import Image, ImageSearch, ImageSummaryResult -from .image_types import ResampleAlgorithm, DownloadFileFormat -from .image_upload import ( - ImageUpload, - ImageUploadEvent, - ImageUploadEventSeverity, - ImageUploadEventType, - ImageUploadOptions, - ImageUploadStatus, - ImageUploadType, - OverviewResampler, -) -from .image_collection import ImageCollection -from .search import ( - AggregateDateField, - GeoSearch, - Interval, - Search, - SummarySearchMixin, -) -from .catalog_base import ( - AuthCatalogObject, - CatalogClient, - CatalogObject, - DeletedObjectError, - UnsavedObjectError, -) -from .named_catalog_base import NamedCatalogObject -from .attributes import ( - AttributeValidationError, - DocumentState, - File, - Resolution, - ResolutionUnit, - StorageState, -) - -from ..common.property_filtering import Properties - -properties = Properties() - -__all__ = [ - "AggregateDateField", - "AttributeValidationError", - "AuthCatalogObject", - "Band", - "BandCollection", - "BandType", - "Blob", - "BlobCollection", - "BlobDeletionTaskStatus", - "BlobSearch", - "BlobSummaryResult", - "CatalogClient", - "CatalogObject", - "ClassBand", - "Colormap", - "ComputeFunctionCompletedEventSubscription", - "DataType", - "DeletedObjectError", - "DeletionTaskStatus", - "DerivedParamsAttribute", - "DocumentState", - "DownloadFileFormat", - "EventApiDestination", - "EventApiDestinationCollection", - "EventApiDestinationSearch", - "EventConnectionParameter", - "EventRule", - "EventRuleCollection", - "EventRuleSearch", - "EventRuleTarget", - "EventSchedule", - "EventScheduleCollection", - "EventScheduleSearch", - "EventSubscription", - "EventSubscriptionCollection", - "EventSubscriptionComputeTarget", - "EventSubscriptionSearch", - "EventSubscriptionSqsTarget", - "EventSubscriptionTarget", - "EventType", - "File", - "GenericBand", - "GeoSearch", - "Image", - "ImageCollection", - "ImageSearch", - "ImageUpload", - "ImageUploadEvent", - "ImageUploadEventSeverity", - "ImageUploadEventType", - "ImageUploadOptions", - "ImageUploadStatus", - "ImageUploadType", - "ImageSummaryResult", - "Interval", - "MaskBand", - "MicrowaveBand", - "NamedCatalogObject", - "NewImageEventSubscription", - "NewStorageEventSubscription", - "NewVectorEventSubscription", - "OverviewResampler", - "Placeholder", - "ProcessingLevelsAttribute", - "ProcessingStepAttribute", - "Product", - "ProductCollection", - "properties", - "ResampleAlgorithm", - "Resolution", - "ResolutionUnit", - "ScheduledEventSubscription", - "Search", - "SpectralBand", - "StorageState", - "StorageType", - "SummarySearchMixin", - "TaskState", - "UnsavedObjectError", -] diff --git a/descarteslabs/core/catalog/attributes.py b/descarteslabs/core/catalog/attributes.py deleted file mode 100644 index f31d6064..00000000 --- a/descarteslabs/core/catalog/attributes.py +++ /dev/null @@ -1,2021 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 numbers -import re - -from collections.abc import Iterable, Mapping, MutableMapping, MutableSequence -from datetime import datetime, timezone -from enum import Enum - -from strenum import StrEnum - -from ..common.property_filtering import Expression -from ..common.shapely_support import ( - geometry_like_to_shapely, - shapely_to_geojson, -) - - -def parse_iso_datetime(date_str): - try: - # Metadata timestamps allow nanoseconds, but python only allows up to - # microseconds... Not rounding; just truncating (for better or worse) - if len(date_str) > 27: # len(YYYY-MM-DDTHH:MM:SS.mmmmmmZ) == 27 - date_str = date_str[0:26] + date_str[-1] - date = datetime.strptime(date_str, "%Y-%m-%dT%H:%M:%S.%fZ") - return date.replace(tzinfo=timezone.utc) - except ValueError: - # it's possible that a utc formatted time string from the server won't have - # a fractional seconds component - date = datetime.strptime(date_str, "%Y-%m-%dT%H:%M:%SZ") - return date.replace(tzinfo=timezone.utc) - - -def serialize_datetime(value): - """Serialize a value to a json-serializable type. - - See :meth:`Attribute.serialize`. - """ - return datetime.isoformat(value) if isinstance(value, datetime) else value - - -class AttributeValidationError(ValueError): - """There was a problem validating the corresponding attribute. - - This exception indicates that the attribute value may have been required, may be - incorrect, or cannot be serialized. - """ - - pass - - -class DocumentState(StrEnum): - """The state of the catalog object. - - Attributes - ---------- - UNSAVED : enum - The catalog object was never synchronized with the Descartes Labs catalog. - All values are considered modified and saving the catalog object will create - the corresponding object in the Descartes Labs catalog. - MODIFIED : enum - The catalog object was synchronized with the Descartes Labs catalog (using - :py:meth:`~descarteslabs.catalog.Product.get` or - :py:meth:`~descarteslabs.catalog.Product.save`), but at least one - attribute value has since been changed. You can - :py:meth:`~descarteslabs.catalog.Product.save` a modified catalog object - to update the object in the Descartes Labs catalog. - - Note that assigning an identical value does not change the state. - SAVED : enum - The catalog object has been fully synchronized with the Descartes Labs catalog - (using :py:meth:`~descarteslabs.catalog.Product.get` or - :py:meth:`~descarteslabs.catalog.Product.save`). - DELETED : enum - The catalog object has been deleted from the Descartes Labs catalog. Many - operations cannot be performed on ``DELETED`` objects. - - Note - ---- - A ``SAVED`` catalog object can still be out-of-date with respect to the Descartes - Labs catalog if there was an update from another client since the last - sycnronization. To re-synchronize a ``SAVED`` catalog object you can use - :py:meth:`~descarteslabs.catalog.Product.reload`. - """ - - SAVED = "saved" - MODIFIED = "modified" - UNSAVED = "unsaved" - DELETED = "deleted" - - -class StorageState(StrEnum): - """The storage state for an image or blob. - - Attributes - ---------- - AVAILABLE : enum - The data has been uploaded and can be retrieved or rastered. - REMOTE : enum - The data is has not been uploaded, but its location is known. - """ - - AVAILABLE = "available" - REMOTE = "remote" - - -class Attribute(object): - """A description of an attribute as received from the Descartes Labs catalog or - set by the end-user. - - Changing the value of an attribute will set the corresponding CatalogObject to - modified. - - Parameters - ---------- - mutable : bool - Whether this attribute can be changed. Set to ``True`` by default. If set - to ``False``, the attribute can be set once and after that can only be set - with the same value. If set with a different value, an - `AttributeValidationError` will be raised. - serializable : bool - Whether this attribute will be included during serialization. Set to ``True`` - by default. If set to ``False``, the attribute will be skipped during - serialization. - sticky : bool - Whether this attribute will be cleared when new attribute values are loaded - from the Descartes Labs catalog. Set to ``False`` by default. This is used - specifically for attributes that are only deserialised on the Descartes Labs - catalog (`load_only`). These attributes will never appear in the data from - the Descartes Labs catalog, and to allow them to persist you can set the _sticky - parameter to True. - readonly : bool - Whether this attribute can be set. Set to ``False`` by default. If set to - ``True``, the attribute can never be set and will raise an - `AttributeValidationError` it set. - """ - - _PARAM_MUTABLE = "mutable" - _PARAM_SERIALIZABLE = "serializable" - _PARAM_STICKY = "sticky" - _PARAM_READONLY = "readonly" - _PARAM_DOC = "doc" - - def __init__( - self, mutable=True, serializable=True, sticky=False, readonly=False, doc=None - ): - self._mutable = mutable - self._serializable = serializable - self._sticky = sticky - self._readonly = readonly - - if doc is not None: - self.__doc__ = doc - - def __get__(self, obj, objtype): - """Gets the value for this attribute on the given object.""" - if obj is None: - # Class attribute; different from the actual value! - return self - - return obj._attributes.get(self._attribute_name) - - def __set__(self, obj, value, validate=True): - """Sets the value for this attribute on the given object. - - Sets a value for this attribute on the given model object at the given attribute - name, deserializing it if necessary. Optionally indicates whether the data - should be validated. - - Parameters - ---------- - obj : object - The `CatalogObject` on which to set the value. - value : object - The value to set on the given `CatalogObject`. The value will be deserialized before being set. - validate : bool - Whether or not to check whether the value is allowed to be set and to - validate the value itself. ``True`` by default. - - Raises - ------ - AttributeValidationError - When `validate` is ``True``, and the attribute cannot be assigned to - (readonly or immutable) or the value is invalid. - """ - if validate: - self._raise_if_immutable_or_readonly("set", obj) - - value = self.deserialize(value, validate) - changed = not ( - self._attribute_name in obj._attributes - and obj._attributes[self._attribute_name] == value - ) - - # `_set_modified()` will raise exception if change is not allowed - obj._set_modified(self._attribute_name, changed, validate) - obj._attributes[self._attribute_name] = value - - def __delete__(self, obj, validate=True): - if validate: - self._raise_if_immutable_or_readonly("delete", obj) - - obj._attributes.pop(self._attribute_name, None) - - def _get_attr_params(self, **extra_params): - # We don't need _PARAM_DOC - params = { - self._PARAM_MUTABLE: self._mutable, - self._PARAM_SERIALIZABLE: self._serializable, - self._PARAM_STICKY: self._sticky, - self._PARAM_READONLY: self._readonly, - } - if extra_params is not None: - params.update(extra_params) - return params - - def _raise_if_immutable_or_readonly(self, operation, obj=None): - if self._readonly: - raise AttributeValidationError( - "Can't {} '{}' item because it is a readonly attribute".format( - operation, self._attribute_name - ) - ) - if not self._mutable and ( - obj is None or self._attribute_name in obj._attributes - ): - raise AttributeValidationError( - "Can't {} '{}' item because it is an immutable attribute".format( - operation, self._attribute_name - ) - ) - - def serialize(self, value, jsonapi_format=False): - """Serialize a value to a json-serializable type. - - Serializes a value for this attribute to a value that can be serialized to a - JSONAPI representation fit to send to the Descartes Labs catalog. - - Parameters - ---------- - value : object - Any Python object. - jsonapi_format : bool - Whether or not to prepend the attributes with a JSONAPI block. ``False`` - by default. This is only relevant for top-level catalog objects which may - be embedded as attributes. - - Returns - ------- - object - Any Python object. - """ - return value - - def deserialize(self, value, validate=True): - """Deserialize a value to a native type. - - Deserializes a value for this attribute from a plain python type, possibly - generated through JSONAPI deserialization as it comes from the Descartes Labs - catalog. Optionally indicates whether the data should be validated. - - Parameters - ---------- - value : object - Any Python object - validate : bool - Whether or not the value should be validated. This value is ``True`` be - default, and this method can raise an `AttributeValidationError` in that - case. - - Returns - ------- - object - Any Python object. - - Raises - ------ - AttributeValidationError - When `validate` is ``True`` and a validation error was encountered. - """ - return value - - -class TypedAttribute(Attribute): - """The value of the attribute is checked against the given type. - - Parameters - ---------- - attribute_type : type - The type of the attribute. - coerce : bool, optional - Whether a non-conforming value should be coerced to that type. ``False`` by - default. - **kwargs : optional - See `Attribute`. - """ - - def __init__(self, attribute_type, coerce=False, **kwargs): - super(TypedAttribute, self).__init__(**kwargs) - - self.attribute_type = attribute_type - self.coerce = coerce - - def deserialize(self, value, validate=True): - """Deserialize a value to a native type. - - Deserializes a value for this attribute from a plain python type, possibly - generated through JSONAPI deserialization as it comes from the Descartes Labs - catalog. Optionally indicates whether the data should be validated. - - Parameters - ---------- - value : object - Any Python object - validate : bool - Whether or not the value should be validated. This value is ``True`` by - default, and this method can raise an `AttributeValidationError` in that - case. - - Returns - ------- - object - Any Python object. - - Raises - ------ - AttributeValidationError - When `validate` is ``True`` and a validation error was encountered. - """ - if validate: - value = self.validate_value(value) - - return super(TypedAttribute, self).deserialize(value, validate=validate) - - def __set__(self, obj, value, validate=True): - """Assign the given value to the attribute. - - Raises - ------ - AttributeValidationError - If a coercion failed or if the value is not of the given type. - """ - if validate: - value = self.validate_value(value) - - super(TypedAttribute, self).__set__(obj, value, validate) - - def validate_value(self, value): - if self.attribute_type and value is not None: - if self.coerce: - try: - value = self.attribute_type(value) - except ValueError as e: - raise AttributeValidationError(e) - elif not isinstance(value, self.attribute_type): - raise AttributeValidationError( - "The attribute type is {} for {}".format( - self.attribute_type, self._attribute_name - ) - ) - - return value - - -class CatalogObjectReference(Attribute): - """A reference to another CatalogObject. - - An attribute that holds another CatalogObject, referenced by id through another - attribute that by convention should be the name of this attribute plus the suffix - "_id". - - Parameters - ---------- - reference_class : CatalogObject - The class for the CatalogObject instance that this attribute will hold a - reference to. - require_unsaved : bool, optional - If ``True``, the referenced CatalogObject instance must be in the `UNSAVED` state - when assigned. If ``False``, then the referenced CatalogOjbect must not be in the - `UNSAVED` state. ``False`` by default. - **kwargs : optional - See `Attribute`. - """ - - def __init__(self, reference_class, require_unsaved=False, **kwargs): - # Serializable defaults to `False` for reference objects - kwargs[self._PARAM_SERIALIZABLE] = kwargs.pop(self._PARAM_SERIALIZABLE, False) - super(CatalogObjectReference, self).__init__(**kwargs) - - self.reference_class = reference_class - self._require_unsaved = require_unsaved - - def __get__(self, obj, objtype): - """Gets the value for this attribute on the given object. - - Access the referenced object by looking it up in related objects or else on - the Descartes Labs catalog. Values are cached until this attribute or the - corresponding id field are modified. - """ - if obj is None: - return super(CatalogObjectReference, self).__get__(obj, objtype) - - cached_value = obj._attributes.get(self._attribute_name) - reference_id = getattr(obj, self.id_field) - if cached_value and cached_value.id == reference_id: - return cached_value - - if reference_id: - new_value = self.reference_class.get(reference_id, client=obj._client) - else: - new_value = None - - obj._attributes[self._attribute_name] = new_value - return new_value - - def __set__(self, obj, value, validate=True): - """Sets the value for this attribute on the given object. - - See :meth:`Attribute.__set__`. - - Sets a new referenced object. Must be a saved object of the correct type. - - Raises - ------ - AttributeValidationError - If the given reference is not a CatalogObject reference, or the referred-to - instance is `DocumentState.UNSAVED` and `require_unsaved` is ``False``, - or the referred-to instance not in `DocumentState.UNSAVED` and - `require_unsaved` is ``True`` - """ - if validate: - self._raise_if_immutable_or_readonly("set", obj) - - value = self.deserialize(value, validate=validate) - - if value is not None: - if not isinstance(value, self.reference_class): - raise AttributeValidationError( - "Expected {} instance for attribute '{}' but got '{}'".format( - self.reference_class.__name__, self._attribute_name, value - ) - ) - if not self._require_unsaved and value.state == DocumentState.UNSAVED: - raise AttributeValidationError( - "Can't assign unsaved related object to '{}'. Save it first.".format( - self._attribute_name - ) - ) - elif self._require_unsaved and value.state != DocumentState.UNSAVED: - raise AttributeValidationError( - "Can't assign saved related object to '{}'. Use a new unsaved object.".format( - self._attribute_name - ) - ) - - changed = not ( - self._attribute_name in obj._attributes - and obj._attributes[self._attribute_name] == value - ) - - # `_set_modified()` will raise exception if change is not allowed - obj._set_modified(self._attribute_name, changed, validate) - obj._attributes[self._attribute_name] = value - # Jam in the `id` - obj._set_modified(self.id_field, changed, validate=False) - obj._attributes[self.id_field] = None if value is None else value.id - - @property - def id_field(self): - return "{}_id".format(self._attribute_name) - - def serialize(self, value, jsonapi_format=False): - """Serialize a value to a json-serializable type. - - See :meth:`Attribute.serialize`. - """ - if hasattr(value, "serialize"): - return value.serialize(modified_only=False, jsonapi_format=jsonapi_format) - else: - return value - - def deserialize(self, value, validate=True): - """Deserialize a value to a native type. - - Deserializes a value for this attribute from a plain python type, possibly - generated through JSONAPI deserialization as it comes from the Descartes Labs - catalog. Optionally indicates whether the data should be validated. - - Parameters - ---------- - value : object - Any Python object - validate : bool - Whether or not the value should be validated. This value is ``True`` by - default, and this method can raise an `AttributeValidationError` in that - case. - - Returns - ------- - object - Any Python object. - - Raises - ------ - AttributeValidationError - When `validate` is ``True`` and a validation error was encountered. - """ - if isinstance(value, self.reference_class): - return value - - if ( - type(value) is not dict - or "data" not in value - or "attributes" not in value["data"] - ): - raise AttributeValidationError( - "A CatalogObjectResource expects a {} or a JSONApi Resource object: {}".format( - self.reference_class.__name__, - self._attribute_name, - ) - ) - - if value["data"]["type"] != self.reference_class._doc_type: - raise AttributeValidationError( - "CatalogObjectResource expects a doc type of {} but received {}: {}".format( - self.reference_class._doc_type, - value["type"], - self._attribute_name, - ) - ) - - # Note that the JSONAPI spec doesn't really allow nested resources. As such, we simply - # do not allow relationships at this level. - if "links" in value or "relationships" in value: - raise AttributeValidationError( - "A CatalogObjectResource does not support links or relationships: {}".format( - self._attribute_name, - ) - ) - - # we force unsaved state, as that is the only current use case, and because - # if it were a proper resource, it would have to be treated as a related object. - return self.reference_class( - _saved=False, id=value["data"].get("id"), **value["data"]["attributes"] - ) - - -class Timestamp(Attribute): - """A datetime backed timestamp. No validation is done.""" - - def serialize(self, value, jsonapi_format=False): - """Serialize a value to a json-serializable type. - - See :meth:`Attribute.serialize`. - """ - return serialize_datetime(value) - - def deserialize(self, value, validate=True): - """Deserialize a value to a native type. - - See :meth:`Attribute.deserialize`. - - Returns - ------- - datetime or str - Any Python object if `validate` is ``True``, otherwise a `datetime` instance - representing the timestamp, typically in UTC. - """ - if value is None or validate: - # In this case `validate` is a misnomer because we do not want to validate - # or deserialize datetimes set by the user on the client. - # Validation and timestamp parsing happens on the server. - return value - elif isinstance(value, datetime): - if value.tzinfo is None: - return value.replace(tzinfo=timezone.utc) - else: - return value - else: - try: - return parse_iso_datetime(value) - except ValueError: - # Not sure what's going on, but since this came from the service, - # don't raise an exception... - return value - - -class EnumAttribute(Attribute): - """An attribute backed by an enumeration. - - Parameters - ---------- - enum : enum - The enumeration that the value must confirm to. - **kwargs : optional - See `Attribute`. - """ - - def __init__(self, enum, **kwargs): - super(EnumAttribute, self).__init__(**kwargs) - - if not issubclass(enum, StrEnum) and ( - not issubclass(enum, str) or not issubclass(enum, Enum) - ): - raise TypeError( - "EnumAttribute expects a 'StrEnum' or a 'str' and 'Enum' mixin" - ) - self._enum_cls = enum - - def serialize(self, value, jsonapi_format=False): - """Serialize a value to a json-serializable type. - - See :meth:`Attribute.serialize`. - """ - if type(value) is self._enum_cls: - return value.value - else: - return value - - def deserialize(self, value, validate=True): - """Deserialize a value to a native type. - - See :meth:`Attribute.deserialize`. - - Returns - ------- - str - A string representing the enum value. - - Raises - ------ - AttributeValidationError - When a non-enum value is given. - """ - if validate: - # Validate that the value is allowed, but don't return the Enum instance - try: - return self._enum_cls(value).value - except ValueError as e: - raise AttributeValidationError(e) - else: - # No validation; allow values outside the enum range - return value - - -class GeometryAttribute(Attribute): - """An attribute that holds a geometry. - - Accepts geometry in a geojson-like format and always represents them as a shapely - shape. - """ - - def serialize(self, value, jsonapi_format=False): - """Serialize a value to a json-serializable type. - - See :meth:`Attribute.serialize`. - """ - return shapely_to_geojson(value) - - def deserialize(self, value, validate=True): - """Deserialize a value to a native type. - - See :meth:`Attribute.deserialize`. - - Returns - ------- - shapely.geometry.base.BaseGeometry - A shapely instance. - - Raises - ------ - AttributeValidationError - When the given value cannot be coerced into a geometry. - """ - if value is None: - return value - else: - try: - return geometry_like_to_shapely(value) - except (ValueError, TypeError) as ex: - raise AttributeValidationError(ex) - - -class BooleanAttribute(Attribute): - """An attribute with a boolean value. Exactly like the Python bool.""" - - def serialize(self, value, jsonapi_format=False): - """Serialize a value to a json-serializable type. - - See :meth:`Attribute.serialize`. - """ - return bool(value) - - def deserialize(self, value, validate=True): - """Deserialize a value to a native type. - - See :meth:`Attribute.deserialize`. - - Returns - ------- - bool - The boolean value. Note that any non-empty string, include "False" will - return ``True``. - """ - return bool(value) - - -class ExpressionAttribute(Attribute): - """An attribute that holds a property filtering Expression.""" - - def serialize(self, value, jsonapi_format=False): - """Serialize a value to a json-serializable type. - - See :meth:`Attribute.serialize`. - """ - if value is None: - return value - if jsonapi_format: - return json.dumps(value.jsonapi_serialize()) - else: - return value.serialize() - - def deserialize(self, value, validate=True): - """Deserialize a value to an Expression. - - See :meth:`Attribute.deserialize`. - - Returns - ------- - common.property_filtering.Expression - An expression instance. - - Raises - ------ - AttributeValidationError - When the given value cannot be parsed as an Expression. - """ - if value is None: - return value - elif isinstance(value, Expression): - return value - else: - try: - return Expression.parse(json.loads(value)) - except Exception as ex: - raise AttributeValidationError(ex) - - -class AttributeEqualityMixin(object): - """Tests for equality and inequality. - - A mixin that defines equality for classes that have an Attribute dictionary property - at `_attribute_types` and the values dictionary at `_attributes`. Equality is - defined as equality of all serializable attributes in serialized form. - """ - - def __eq__(self, other): - if not isinstance(other, self.__class__): - return False - - for name, attribute_type in self._attribute_types.items(): - if not attribute_type._serializable: - continue - if attribute_type.serialize( - self._attributes.get(name) - ) != attribute_type.serialize(other._attributes.get(name)): - return False - - return True - - -class ModelAttribute(Attribute): - """A class that allows for models to be registered and updated. - - Parameters - ---------- - **kwargs : optional - See `Attribute`. - """ - - def __init__(self, **kwargs): - self._model_objects = {} - super(ModelAttribute, self).__init__(**kwargs) - - def __set__(self, obj, value, validate=True): - """Sets the value for this attribute on the given object. - - See :meth:`Attribute.__set__`. - - This will also register the model with the given `ModelAttribute` instance - value and deregister the model from the old `ModelAttribute` instance value. - - Parameters - ---------- - value : ModelAttribute or object - The value will be deserialized to a `ModelAttribute`. - """ - if validate: - self._raise_if_immutable_or_readonly("set", obj) - - value = self.deserialize(value, validate=validate) - previous_value = obj._attributes.get(self._attribute_name, None) - - changed = not ( - self._attribute_name in obj._attributes and previous_value == value - ) - - # `_set_modified()` will raise exception if change is not allowed - obj._set_modified(self._attribute_name, changed, validate) - - # deregister the previous value and register the new one - if previous_value is not None: - previous_value._remove_model_object(obj) - if value is not None: - value._add_model_object(obj, self._attribute_name) - - obj._attributes[self._attribute_name] = value - - def __delete__(self, obj, validate=True): - """Delete the value for this attribute on the given object. - - It will remove the reference to the old value. - """ - if validate: - self._raise_if_immutable_or_readonly("delete", obj) - - previous_value = obj._attributes.pop(self._attribute_name, None) - if previous_value is not None: - previous_value._remove_model_object(obj) - - def _add_model_object(self, model, attr_name=None): - """Register a model and attribute name. - - Since we can reuse one ModelAttribute object across different - model object types, each with potentially different attribute names, - we register the name of the attribute on the specific model object, - to avoid propagating bad changes. - - Parameters - ---------- - model : CatalogObject - The model to add to the registered models for this value instance. - attr_name : str - The name of the attribute in this model that this value belongs to. - """ - id_ = id(model) - self._model_objects[id_] = (model, attr_name) - - def _remove_model_object(self, model): - """Deregister a model. - - Parameters - ---------- - model : CatalogObject - The model to remove from the registered models for this instance. - """ - id_ = id(model) - self._model_objects.pop(id_, None) - - def _set_modified(self, attr_name=None, changed=True, validate=True): - """Verify change on all the referenced model objects and trigger modification. - - The model can reject this change by raising an `AttributeValidationError` if - validate is ``True``. If the new value is identical to the old value, the - modification is **not** triggered (and changed will be ``False``). - - Parameters - ---------- - attr_name : str - The name of the attribute. ``None`` by default. Note that the attr_name - argument is ignored because of the chaining. - changed : bool - Whether or not the actual value changed. ``True`` by default. - validate : bool - Whether or not to verify that the value can be assigned. ``True`` by - default. - - Raises - ------ - AttributeValidationError - When `validate` is ``True`` and the attribute cannot be assigned to. - """ - for model_object, attr_name in self._model_objects.values(): - model_object._set_modified(attr_name, changed, validate) - - -class AttributeMeta(type): - """Apply the class attribute instances to the instance.""" - - _KEY_ATTR_TYPES = "_attribute_types" - _KEY_REF_ATTR_TYPES = "_reference_attribute_types" - _KEY_V1_PROPERTIES = "v1_properties" - - def __new__(cls, name, bases, attrs): - types = {} - references = {} - - # Register all declared attributes - for attr_name, attr_type in attrs.items(): - if isinstance(attr_type, Attribute): - types[attr_name] = attr_type - if isinstance(attr_type, CatalogObjectReference): - references[attr_name] = attr_type - - # Register this attribute's name with the instance - attr_type._attribute_name = attr_name - - # inherit attributes from base classes - for b in bases: - if hasattr(b, AttributeMeta._KEY_ATTR_TYPES): - for attr_name, attr_type in b._attribute_types.items(): - # Don't overwrite existing attrs - if attr_name not in types: - types[attr_name] = attr_type - if "_no_inherit" not in attrs or not attrs["_no_inherit"]: - # Add base attributes for documentation - # (sphinx doesn't inherit attrs) - attrs[attr_name] = attr_type - if hasattr(b, AttributeMeta._KEY_REF_ATTR_TYPES): - for attr_name, attr_type in b._reference_attribute_types.items(): - # Don't overwrite existing reference attrs - if attr_name not in references: - references[attr_name] = attr_type - - attrs["ATTRIBUTES"] = tuple( - k for k in types.keys() if k != AttributeMeta._KEY_V1_PROPERTIES - ) - attrs[AttributeMeta._KEY_ATTR_TYPES] = types - attrs[AttributeMeta._KEY_REF_ATTR_TYPES] = references - - return super(AttributeMeta, cls).__new__(cls, name, bases, attrs) - - -class MappingAttribute(ModelAttribute, AttributeEqualityMixin, metaclass=AttributeMeta): - """Base class for attributes that are mapping types. - - - Can be set using a mapping, or an instance of a MappingAttribute derived type. - - MappingAttributes differ from other Attribute subclasses in a few key respects: - - - MappingAttribute shouldn't ever be instantiated directly, but subclassed and the - subclass should be instantiated - - MappingAttribute subclasses have two "modes": they are instantiated on classes - directly, just like the other Attribute types they're also instantiated directly - and used in value assignments. - - MappingAttribute subclasses keep track of their own state, rather than delegating - this to the model object they're attached to. This allows these objects to be - instantiated directly without being attached to a model object, and it allows a - single instance to be attached to multiple model objects. Since they track their - own state, the model objects they're attached to retain references to instances - in their _attributes, like with other type (e.g. datetime). - - Parameters - ---------- - **kwargs : optional - See `Attribute`. - - Examples - -------- - The first way MappingAttributes are used is just like other attributes, - they are instantiated as part of a class definition, and the instance - is cached on the class. - - The other way mapping attributes are used is but instantiating a new instance and - assigning that instance to a model object. - >>> from descarteslabs.catalog.attributes import ( - ... MappingAttribute, - ... Attribute, - ... ) - >>> from descarteslabs.catalog import CatalogObject - >>> class MyMapping(MappingAttribute): - ... foo = Attribute() - >>> class ExampleCatalogObject(CatalogObject): - ... map_attr = MyMapping() - >>> my_map = MyMapping(foo="bar") - >>> obj1 = ExampleCatalogObject(map_attr=my_map) - >>> obj2 = ExampleCatalogObject(map_attr=my_map) - >>> assert obj1.map_attr is obj2.map_attr is my_map - >>> my_map.foo = "baz" - >>> assert obj1.is_modified - >>> assert obj2.is_modified - """ - - # this value is ONLY used for for instances of the attribute that - # are attached to class definitions. It's confusing to put this - # instantiation into __init__, because the value is only ever set - # from AttributeMeta.__new__, after it's already been instantiated - _attribute_name = None - - def __init__(self, **kwargs): - self._attributes = {} - - attr_params = { - self._PARAM_MUTABLE: kwargs.pop(self._PARAM_MUTABLE, True), - self._PARAM_SERIALIZABLE: kwargs.pop(self._PARAM_SERIALIZABLE, True), - self._PARAM_STICKY: kwargs.pop(self._PARAM_STICKY, False), - self._PARAM_READONLY: kwargs.pop(self._PARAM_READONLY, False), - self._PARAM_DOC: kwargs.pop(self._PARAM_DOC, None), - } - super(MappingAttribute, self).__init__(**attr_params) - - validate = kwargs.pop("validate", True) - for attr_name, value in kwargs.items(): - attr = ( - self.get_attribute_type(attr_name) - if validate - else self._attribute_types.get(attr_name) - ) - if attr is not None: - attr.__set__(self, value, validate=validate) - - def __repr__(self): - """A string representation for the instance. - - The representation is broken up over multiple lines for readability. - """ - sections = ["{}:".format(self.__class__.__name__)] - for key, val in sorted(self._attributes.items()): - val_sections = [" " + v for v in repr(val).split("\n")] - val = "\n".join(val_sections).strip() if len(val_sections) > 1 else val - sections.append(" {}: {}".format(key, val)) - return "\n".join(sections) - - def __setattr__(self, name, value): - """Set the value on the given attribute. - - Check that the attribute exists (unless it's a private attribute starting with - ``_``) before setting the value. - """ - if not name.startswith("_"): - # Make sure it's a proper attribute - self.get_attribute_type(name) - super(MappingAttribute, self).__setattr__(name, value) - - def get_attribute_type(self, name): - """Get the type definition for an attribute by name.""" - try: - return self._attribute_types[name] - except KeyError: - raise AttributeError( - "{} has no attribute {}".format(self.__class__.__name__, name) - ) - - def serialize(self, attrs, jsonapi_format=False): - """Serialize a value to a json-serializable type. - - See :meth:`Attribute.serialize`. - """ - if attrs is None: - return None - - if isinstance(attrs, MappingAttribute): - # mapping attribute objects hold their own state - data = attrs._attributes - elif isinstance(attrs, Mapping): - data = attrs - else: - return attrs - - serialized = {} - for name, value in data.items(): - attribute_type = self.get_attribute_type(name) - if attribute_type._serializable: - serialized[name] = attribute_type.serialize(value) - - return serialized - - def deserialize(self, values, validate=True): - """Deserialize a value to a native type. - - See :meth:`Attribute.deserialize`. - - Parameters - ---------- - values : dict or MappingAttribute - The values to use to initialize a new MappingAttribute. - - Returns - ------- - MappingAttribute - A `MappingAttribute` instance with the given values. - - Raises - ------ - AttributeValidationError - If the given value is not a `Mapping`, or the value does not - conform to the attribute type. - """ - if values is None: - return None - - if isinstance(values, MappingAttribute): - return values - - if not isinstance(values, Mapping): - raise AttributeValidationError( - "Expected a mapping or {} for attribute {}".format( - self.__class__.__name__, self._attribute_name - ) - ) - type_ = type(self) - return type_(validate=validate, **self._get_attr_params(**values)) - - -class ResolutionUnit(StrEnum): - """Valid units of measure for Resolution. - - Attributes - ---------- - METERS : enum - The resolution in meters. - DEGREES : enum - The resolution in degrees. - """ - - METERS = "meters" - DEGREES = "degrees" - - -class Resolution(MappingAttribute): - """A spatial pixel resolution with a unit. - - For example, ``Resolution(value=60, unit=ResolutionUnit.METERS)`` represents a - resolution of 60 meters per pixel. You can also use a string with a value and - unit, for example ``60m`` or ``1.2 deg.``. The available unit designations are: - - * m, meter, meters, metre, metres - * °, deg, degree, degrees - - Spaces between the value and unit are optional, as is a trailing period. - - Objects with resolution values can be filtered by a unitless number in which - case the value is always in meters. For example, retrieving all bands with - a resolution of 60 meters per pixel: - - >>> Band.search().filter(p.resolution == 60) # doctest: +SKIP - - Parameters - ---------- - values : Mapping or str - A mapping that either contains the `value` and `unit` key/value pairs, - or is a string that can be parsed to a `value` and a `unit`. - **kwargs : optional - See `Attribute`. - - Attributes - ---------- - value : float - The value of the resolution. - unit : str or ResolutionUnit - The unit the resolution is measured in. - """ - - _pattern = re.compile(r"([-0-9.]+)\s*([a-zA-Z.°]+)") - _unit_mapping = { - "m": ResolutionUnit.METERS, - "meter": ResolutionUnit.METERS, - "metre": ResolutionUnit.METERS, - "meters": ResolutionUnit.METERS, - "metres": ResolutionUnit.METERS, - "°": ResolutionUnit.DEGREES, - "deg": ResolutionUnit.DEGREES, - "degree": ResolutionUnit.DEGREES, - "degrees": ResolutionUnit.DEGREES, - } - - value = Attribute() - unit = EnumAttribute(ResolutionUnit) - - def __init__(self, values=None, **kwargs): - super(Resolution, self).__init__(**kwargs) - - if values is not None: - r = self.deserialize(values) - self.value = r.value - self.unit = r.unit - - def serialize(self, value, jsonapi_format=False): - """Serialize a value to a json-serializable type. - - See :meth:`Attribute.serialize`. - """ - # Serialize a single number as is - this supports filtering resolution - # attributes by meters. - if isinstance(value, numbers.Number): - return value - else: - return super(Resolution, self).serialize( - value, jsonapi_format=jsonapi_format - ) - - def deserialize(self, value, validate=True): - """Deserialize a value to a native type. - - See :meth:`Attribute.deserialize`. - - Parameters - ---------- - values : dict or MappingAttribute - The values to use to initialize a new MappingAttribute. The two keys that - can be used as ``value`` and ``unit``. - - Returns - ------- - Resolution - A `Resolution` instance with the given values. - - Raises - ------ - AttributeValidationError - If the value is not a `Resolution` or a mapping with a `value` and `unit` - key, or cannot be parsed into a compatible `value` and `unit`. - """ - if isinstance(value, str): - match = self._pattern.match(value) - unit = match and match.group(2).lower().rstrip(".") - - if not unit or unit not in self._unit_mapping: - raise AttributeValidationError( - "The given resolution string cannot be parsed: {}".format(value) - ) - - value = {"value": float(match.group(1)), "unit": self._unit_mapping[unit]} - - return super(Resolution, self).deserialize(value, validate) - - -class File(MappingAttribute): - """File definition for an Image. - - Attributes - ---------- - href : str - If the :py:class:`~descarteslabs.catalog.StorageState` is - :py:attr:`~descarteslabs.catalog.StorageState.AVAILABLE`, this field is required - and it must be a valid reference to either a JP2 or a GeoTiff file using the - ``gs`` scheme. If the :py:class:`~descarteslabs.catalog.StorageState` is - :py:attr:`~descarteslabs.catalog.StorageState.REMOTE`, this field is optional - and you can use one of the schemes ``gs``, ``http``, ``https``, ``ftp``, or - ``ftps``; if the scheme is ``gs``, it must be a valid reference - but can be any format. - size_bytes : int - Size of the file in bytes. Required when the - :py:class:`~descarteslabs.catalog.StorageState` is - :py:attr:`~descarteslabs.catalog.StorageState.AVAILABLE`. - hash : str - The md5 hash for the given file. Required when the - :py:class:`~descarteslabs.catalog.StorageState` is - :py:attr:`~descarteslabs.catalog.StorageState.AVAILABLE`. - provider_id : str - Optional ID for the external provider when the - :py:class:`~descarteslabs.catalog.StorageState` is - :py:attr:`~descarteslabs.catalog.StorageState.REMOTE`. - provider_href : str - A URI to describe the remote image in more detail. Either the `provider_href` - or the `href` must be specified when the - :py:class:`~descarteslabs.catalog.StorageState` is - :py:attr:`~descarteslabs.catalog.StorageState.REMOTE`. - """ - - href = Attribute() - size_bytes = Attribute() - hash = Attribute() - provider_id = Attribute() - provider_href = Attribute() - - -class ListAttribute(ModelAttribute, MutableSequence): - """Base class for attributes that are lists. - - Can be set using an iterable of items. The type is the same for all list items, - and created automatically to hold a given deserialized value if it's not already - that type. The type can reject the value with a `AttributeValidationError`. - - ListAttributes behave similarly to `MappingAttributes` but provide additional - operations that allow list-like interactions (slicing, appending, etc.) - - One major difference between ListAttributes and `MappingAttributes` is that - ListAttributes shouldn't be subclassed or instantiated directly - it's much easier - for users to construct and assign a list or iterable, and allow __set__ to handle - the coercing of the values to the correct type. - - Parameters - ---------- - attribute_type : Attribute - All items in the ListAttribute must be of the same Attribute type. The actual - values must be able to be deserialized by that Attribute type. - items : Iterable - An iterable of items from which to construct the initial content. - validate : bool - Whether or not to verify whether the values are valid for the given Attribute - type. ``True`` be default. - - Raises - ------ - AttributeValidationError - If any of the items cannot be successfully deserialized to the given attribute - type and `validate` is ``True``. - - Example - ------- - This is the recommended way to instantiate a ListAttribute, you don't maintain a - reference to the original list but the semantics are much cleaner. - - >>> from descarteslabs.catalog import CatalogObject, File - >>> from descarteslabs.catalog.attributes import ListAttribute - >>> class ExampleCatalogObject(CatalogObject): - ... files = ListAttribute(File) - >>> files = [ - ... File(href="https://foo.com/1"), - ... File(href="https://foo.com/2"), - ... ] - >>> obj = ExampleCatalogObject(files=files) - >>> assert obj.files is not files - """ - - # this value is ONLY used for for instances of the attribute that - # are attached to class definitions. It's confusing to put this - # instantiation into __init__, because the value is only ever set - # from AttributeMeta.__new__, after it's already been instantiated - _attribute_name = None - - def __init__(self, attribute_type, validate=True, items=None, **kwargs): - if isinstance(attribute_type, Attribute): - self._attribute_type = attribute_type - elif issubclass(attribute_type, Attribute): - self._attribute_type = attribute_type(**kwargs) - else: - raise AttributeValidationError( - "First argument for {} must be an Attribute type".format( - self.__class__.__name__ - ) - ) - # give the attribute_type our own name for meaningful error messages - self._attribute_type._attribute_name = self._attribute_name - self._items = [] - - super(ListAttribute, self).__init__(**kwargs) - - if items is not None: - self._items = [ - self._instantiate_item(item, validate=validate) for item in items - ] - - def __repr__(self): - """A string representation for this instance. - - The representation is broken up over multiple lines for readability. - """ - sections = [] - for item in self._items: - sections.append(repr(item)) - return "[" + ", ".join(sections) + "]" - - def _instantiate_item(self, item, validate=True, add_model=True): - """Handles coercing the provided value to the correct type. - - Handles coercing the provided value to the correct type, optionally registers - this instance of the ListAttribute as the model object for ModelAttribute - item types. - """ - item = self._attribute_type.deserialize(item, validate=validate) - - if add_model and isinstance(item, ModelAttribute): - item._add_model_object(self) - - return item - - def serialize(self, values, jsonapi_format=False): - """Serialize a value to a json-serializable type. - - See :meth:`Attribute.serialize`. - """ - if values is None: - return None - - return [ - self._attribute_type.serialize(v, jsonapi_format=jsonapi_format) - for v in values - ] - - def deserialize(self, values, validate=True): - """Deserialize a value to a native type. - - See :meth:`Attribute.deserialize`. - - Parameters - ---------- - values : Iterable - An iterator used to initialize a `ListAttribute` instance. - - Returns - ------- - ListAttribute - A `ListAttribute` with the given items. - - Raises - ------ - AttributeValidationError - If the value is not an iterable or if the value cannot be successfully - deserialized to the given attribute type and `validate` is ``True``. - """ - if values is None: - return None - - if isinstance(values, ListAttribute): - return values - - if not isinstance(values, Iterable) or isinstance(values, (str, bytes)): - raise AttributeValidationError( - "{} expects a non-string/bytes iterable for attribute {}, not {}".format( - self.__class__.__name__, - self._attribute_name, - values.__class__.__name__, - ) - ) - - # ensures subclasses are handled correctly - type_ = type(self) - return type_( - self._attribute_type, - validate=validate, - items=values, - **self._get_attr_params(), - ) - - # MutableSequence methods - - def __getitem__(self, n): - return self._items[n] - - def __setitem__(self, n, item): - self._raise_if_immutable_or_readonly("set") - previous_value = self._items[n] - - # handling slice assignment - if isinstance(n, slice): - try: - iter(item) - except TypeError: - # mimic the error you get from the builtin - raise TypeError("Can only assign an iterable") - - new_item = list(self._instantiate_item(o) for o in item) - else: - new_item = self._instantiate_item(item) - - # `_set_modified()` will raise exception if change is not allowed - self._set_modified(changed=(previous_value != new_item)) - # will throw IndexError which is what we want if previous value isn't set - self._items[n] = new_item - - # slicing returns a list of items - if not isinstance(n, slice): - previous_value = [previous_value] - - for val in previous_value: - if isinstance(val, MappingAttribute): - val._remove_model_object(self) - - def __delitem__(self, n): - self._raise_if_immutable_or_readonly("delete") - previous_value = self._items[n] - - # slicing returns a list of items - if not isinstance(n, slice): - previous_value = [previous_value] - - for val in previous_value: - if isinstance(val, MappingAttribute): - val._remove_model_object(self) - - new_items = list(self._items) - # will throw IndexError which is what we want if previous value isn't set - del new_items[n] - - # `_set_modified()` will raise exception if change is not allowed - self._set_modified(changed=(self._items != new_items)) - self._items = new_items - - def __len__(self): - return len(self._items) - - def insert(self, index, value): - self._raise_if_immutable_or_readonly("insert") - new_value = self._instantiate_item(value) - - # `_set_modified()` will raise exception if change is not allowed - self._set_modified() - self._items.insert(index, new_value) - - # Remaining Sequence methods - - def __add__(self, other): - # emulating how concatenation works for lists - if not isinstance(other, Iterable) or isinstance(other, (str, bytes)): - raise TypeError( - "{} can only concatenate non-string/bytes iterables" - "for attribute {}, not {}".format( - self.__class__.__name__, - self._attribute_name, - other.__class__.__name__, - ) - ) - - # this is a shallow copy operations, so we don't attach the new item to this - # model object - new_other = [self._instantiate_item(o, add_model=False) for o in other] - return self._items + new_other - - def __mul__(self, other): - return self._items * other - - def __imul__(self, other): - # `_set_modified()` will raise exception if change is not allowed - self._set_modified(changed=(self._items and other != 1)) - self._items *= other - return self - - def __rmul__(self, other): - return self._items * other - - def copy(self): - """Return a shallow copy of the list.""" - return self._items.copy() - - def sort(self, key=None, reverse=False): - self._raise_if_immutable_or_readonly("sort") - - """Stable sort *IN PLACE*.""" - new_items = list(self._items) - new_items.sort(key=key, reverse=reverse) - - # `_set_modified()` will raise exception if change is not allowed - self._set_modified(changed=(self._items != new_items)) - self._items = new_items - - # Comparison methods - - def __eq__(self, other): - if self is other: - return True - - if not isinstance(other, (self.__class__, Iterable)): - return False - - if len(self) != len(other): - return False - - for i1, i2 in zip(self, other): - if i1 != i2: - return False - - return True - - def __ge__(self, other): - if isinstance(other, self.__class__): - other = other._items - - # allow list __ge__ to raise/return - return self._items >= other - - def __gt__(self, other): - if isinstance(other, self.__class__): - other = other._items - - # allow list __gt__ to raise/return - return self._items > other - - def __le__(self, other): - if isinstance(other, self.__class__): - other = other._items - - # allow list __le__ to raise/return - return self._items <= other - - def __lt__(self, other): - if isinstance(other, self.__class__): - other = other._items - - # allow list __lt__ to raise/return - return self._items < other - - -class StringDictAttribute(ModelAttribute, MutableMapping): - """An attribute that contains properties (key/value pairs). - - Can be set using a dictionary of items or any `Mapping`, or an instance of this - attribute. All keys and values must be string. - StringDictAttribute behaves similar to dictionaries. - - Example - ------- - This is the recommended way to instantiate a StringDictAttribute, you don't - maintain a reference to the original list but the semantics are much cleaner. - - >>> from descarteslabs.catalog import CatalogObject - >>> from descarteslabs.catalog.attributes import StringDictAttribute - >>> class ExampleCatalogObject(CatalogObject): - ... headers = StringDictAttribute() - >>> headers = { - ... "x-header-1": "value1", - ... "x-header-2": "value2", - ... } - >>> obj = ExampleCatalogObject(headers=headers) - >>> assert obj.headers is not headers - >>> obj.headers["x-header-3"] = "value3" - """ - - # this value is ONLY used for for instances of the attribute that - # are attached to class definitions. It's confusing to put this - # instantiation into __init__, because the value is only ever set - # from AttributeMeta.__new__, after it's already been instantiated - _attribute_name = None - - def __init__(self, value=None, validate=True, **kwargs): - self._items = {} - - super(StringDictAttribute, self).__init__(**kwargs) - - if value is not None: - if validate: - for key, val in value.items(): - self.validate_key_and_value(key, val) - - self._items.update(value) - - def __repr__(self): - return "{}{}{}".format( - "{", - ", ".join( - [ - "{}: {}".format(repr(key), repr(value)) - for key, value in self._items.items() - ] - ), - "}", - ) - - def validate_key_and_value(self, key, value): - """Validate the key and value. - - The key must be a string, and the value either a string or a number. - """ - if not isinstance(key, str): - raise AttributeValidationError( - "Keys for property {} must be strings: {}".format( - self._attribute_name, key - ) - ) - elif not isinstance(value, str): - raise AttributeValidationError( - "The value for property {} with key {} must be a string: {}".format( - self._attribute_name, key, value - ) - ) - - def serialize(self, value, jsonapi_format=False): - """Serialize a value to a json-serializable type. - - See :meth:`Attribute.serialize`. - """ - if value is None: - return None - - # Shallow copy - return dict(value._items) - - def deserialize(self, value, validate=True): - """Deserialize a value to a native type. - - See :meth:`Attribute.deserialize`. - - Parameters - ---------- - value : dict or StringDictAttribute - A set of values to use to initialize a new StringDictAttribute - instance. All keys and values must be strings. - - Returns - ------- - StringDictAttribute - A `StringDictAttribute` with the given items. - - Raises - ------ - AttributeValidationError - If the value is not a mapping or any of the keys or values are not strings. - """ - if value is None: - return None - - if isinstance(value, StringDictAttribute): - return value - - if validate: - if not isinstance(value, Mapping): - raise AttributeValidationError( - "A StringDictAttribute expects a mapping: {}".format( - self._attribute_name - ) - ) - - for key, val in value.items(): - self.validate_key_and_value(key, val) - - return StringDictAttribute(value, validate=validate, **self._get_attr_params()) - - # Mapping methods - - def __getitem__(self, key): - return self._items[key] - - def __setitem__(self, key, value): - self._raise_if_immutable_or_readonly("set") - self.validate_key_and_value(key, value) - - old_value = self._items.get(key, None) - changed = key not in self._items or old_value != value - self._set_modified(changed=changed) - self._items[key] = value - - def __delitem__(self, key): - self._raise_if_immutable_or_readonly("delete") - if key in self._items: - self._set_modified(changed=True) - del self._items[key] - - def __iter__(self): - return iter(self._items) - - def __len__(self): - return len(self._items) - - -class ExtraPropertiesAttribute(ModelAttribute, MutableMapping): - """An attribute that contains properties (key/value pairs). - - Can be set using a dictionary of items or any `Mapping`, or an instance of this - attribute. All keys must be string and values can be string or numbers. - ExtraPropertiesAttribute behaves similar to dictionaries. - - Example - ------- - This is the recommended way to instantiate a ExtraPropertiesAttribute, you don't - maintain a reference to the original list but the semantics are much cleaner. - - >>> from descarteslabs.catalog import CatalogObject - >>> from descarteslabs.catalog.attributes import ExtraPropertiesAttribute - >>> class ExampleCatalogObject(CatalogObject): - ... extra_properties = ExtraPropertiesAttribute() - >>> properties = { - ... "prop1": "value1", - ... "prop2": "value2", - ... } - >>> obj = ExampleCatalogObject(extra_properties=properties) - >>> assert obj.extra_properties is not properties - >>> obj.extra_properties["prop3"] = "value3" - """ - - # this value is ONLY used for for instances of the attribute that - # are attached to class definitions. It's confusing to put this - # instantiation into __init__, because the value is only ever set - # from AttributeMeta.__new__, after it's already been instantiated - _attribute_name = None - - def __init__(self, value=None, validate=True, **kwargs): - self._items = {} - - super(ExtraPropertiesAttribute, self).__init__(**kwargs) - - if value is not None: - if validate: - for key, val in value.items(): - self.validate_key_and_value(key, val) - - self._items.update(value) - - def __repr__(self): - return "{}{}{}".format( - "{", - ", ".join( - [ - "{}: {}".format(repr(key), repr(value)) - for key, value in self._items.items() - ] - ), - "}", - ) - - def validate_key_and_value(self, key, value): - """Validate the key and value. - - The key must be a string, and the value either a string or a number. - """ - if not isinstance(key, str): - raise AttributeValidationError( - "Keys for property {} must be strings: {}".format( - self._attribute_name, key - ) - ) - elif not isinstance(value, (str, int, float)): - raise AttributeValidationError( - "The value for property {} with key {} must be a string or a number: {}".format( - self._attribute_name, key, value - ) - ) - - def serialize(self, value, jsonapi_format=False): - """Serialize a value to a json-serializable type. - - See :meth:`Attribute.serialize`. - """ - if value is None: - return None - - # Shallow copy - return dict(value._items) - - def deserialize(self, value, validate=True): - """Deserialize a value to a native type. - - See :meth:`Attribute.deserialize`. - - Parameters - ---------- - value : dict or ExtraPropertiesAttribute - A set of values to use to initialize a new ExtraPropertiesAttribute - instance. All keys must be strings, and values can be strings or numbers. - - Returns - ------- - ExtraPropertiesAttribute - A `ExtraPropertiesAttribute` with the given items. - - Raises - ------ - AttributeValidationError - If the value is not a mapping or any of the keys are not strings, or any - of the values are not strings or numbers. - """ - if value is None: - return None - - if isinstance(value, ExtraPropertiesAttribute): - return value - - if validate: - if not isinstance(value, Mapping): - raise AttributeValidationError( - "A ExtraPropertiesAttribute expects a mapping: {}".format( - self._attribute_name - ) - ) - - for key, val in value.items(): - self.validate_key_and_value(key, val) - - return ExtraPropertiesAttribute( - value, validate=validate, **self._get_attr_params() - ) - - # Mapping methods - - def __getitem__(self, key): - return self._items[key] - - def __setitem__(self, key, value): - self._raise_if_immutable_or_readonly("set") - self.validate_key_and_value(key, value) - - old_value = self._items.get(key, None) - changed = key not in self._items or old_value != value - self._set_modified(changed=changed) - self._items[key] = value - - def __delitem__(self, key): - self._raise_if_immutable_or_readonly("delete") - if key in self._items: - self._set_modified(changed=True) - del self._items[key] - - def __iter__(self): - return iter(self._items) - - def __len__(self): - return len(self._items) - - -class TupleAttribute(Attribute): - """An attribute that represents a tuple. - - The minimum and maximum size of the tuple can be specified. If the minimum and - maximum size are identical, the tuple must be exactly that size. - - Parameters - ---------- - attribute_type : type, optional - Each item in the tuple must be of the given type. - coerce : bool, optional - If an `attribute_type` is given, whether a non-conforming value should be - coerced to that type. ``False`` by default. - min : int, optional - The minimum number of items the tuple must contain. If not set, there is no - minimum. - max : int, optional - The maximum number of items the table can contain. If not set, there is no - maximum. - **kwargs : optional - See `Attribute`. - - Raises - ------ - ValueError - If a coercion failed. - AttributeValidationError - If the value doesn't confirm to the tuple specification. - """ - - def __init__( - self, - attribute_type=None, - coerce=False, - min_length=None, - max_length=None, - **kwargs, - ): - super(TupleAttribute, self).__init__(**kwargs) - - self.attribute_type = attribute_type - self.coerce = coerce - self.min_length = None if min_length is None else int(min_length) - self.max_length = None if max_length is None else int(max_length) - - def __set__(self, obj, value, validate=True): - """Sets the value for this attribute on the given object. - - See :meth:`Attribute.__set__`. - - Raises - ------ - AttributeValidationError - If the value cannot be coerced into a tuple, or if any of the tuple items - cannot be coerced into, or match the given attribute type, or if the tuple - length does not confirm to the given min_length and max_length. - """ - # Make sure it's a tuple - if isinstance(value, Iterable) and not isinstance(value, (str, bytes, tuple)): - value = tuple(value) - - if validate: - value = self.validate_value(value) - - super(TupleAttribute, self).__set__(obj, value, validate) - - def validate_value(self, value): - # Validate the value, optionally coercing it, and return the value - if not isinstance(value, tuple): - raise AttributeValidationError( - "You must specify a tuple for {}".format(self._attribute_name) - ) - - if self.attribute_type: - if self.coerce: - items = [] - for item in value: - if self.coerce: - try: - item = self.attribute_type(item) - except ValueError as e: - raise AttributeValidationError(e) - items.append(item) - elif not isinstance(item, self.attribute_type): - raise AttributeValidationError( - "Not all items are of type {} for {}".format( - self.attribute_type, self._attribute_name - ) - ) - if self.coerce: - value = tuple(items) - - if ( - self.min_length is not None - and self.min_length == self.max_length - and len(value) != self.min_length - ): - raise AttributeValidationError( - "Tuple must contain exactly {} items for {}".format( - self.min_length, self._attribute_name - ) - ) - - if self.min_length is not None and len(value) < self.min_length: - raise AttributeValidationError( - "Tuple must contain at least {} items for {}".format( - self.min_length, self._attribute_name - ) - ) - - if self.max_length is not None and len(value) < self.max_length: - raise AttributeValidationError( - "Tuple can contain up to {} items for {}".format( - self.max_length, self._attribute_name - ) - ) - - return value diff --git a/descarteslabs/core/catalog/band.py b/descarteslabs/core/catalog/band.py deleted file mode 100644 index d8f3c63e..00000000 --- a/descarteslabs/core/catalog/band.py +++ /dev/null @@ -1,905 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 collections.abc import Iterable, Mapping, MutableMapping - -from strenum import StrEnum - -from ..common.collection import Collection -from ..common.property_filtering import Properties -from .catalog_base import _new_abstract_class -from .named_catalog_base import NamedCatalogObject -from .attributes import ( - Attribute, - EnumAttribute, - Resolution, - BooleanAttribute, - ListAttribute, - TupleAttribute, - TypedAttribute, - ModelAttribute, - MappingAttribute, - AttributeValidationError, -) - - -properties = Properties() - - -class DataType(StrEnum): - """Valid data types for bands. - - Attributes - ---------- - BYTE : enum - An 8 bit unsigned integer value. - UINT16 : enum - A 16 bit unsigned integer value. - INT16 : enum - A 16 bit signed integer value. - UINT32 : enum - A 32 bit unsigned integer value. - INT32 : enum - A 32 bit signed integer value. - FLOAT32 : enum - A 32 bit single-precision floating-point format value. - FLOAT64 : enum - A 64 bit double-precision floating-point format value. - """ - - BYTE = "Byte" - UINT16 = "UInt16" - INT16 = "Int16" - UINT32 = "UInt32" - INT32 = "Int32" - FLOAT32 = "Float32" - FLOAT64 = "Float64" - - -class BandType(StrEnum): - """Types of bands with different data interpretation. - - The type of band is represented in the specific Band class being used - and is only for informative purposes. - - Attributes - ---------- - CLASS : enum - A band that maps a finite set of values that may not be continuous. - SPECTRAL : enum - A band that lies somewhere on the visible/NIR/SWIR electro-optical wavelength - spectrum. - MASK : enum - A binary band where by convention a 0 means masked and 1 means non-masked. - MICROWAVE : enum - A band that lies in the microwave spectrum, often from SAR or passive radar - sensors. - GENERIC : enum - An unspecified kind of band not fitting any other type. - """ - - CLASS = "class" - SPECTRAL = "spectral" - MASK = "mask" - MICROWAVE = "microwave" - GENERIC = "generic" - - -class Colormap(StrEnum): - """Predefined colormaps available to assign to bands. - - Most of these colormaps correspond directly to the built-in colormaps of the - same name in matplotlib. See - https://matplotlib.org/3.1.0/tutorials/colors/colormaps.html for an - overview and visual examples. - - Attributes - ---------- - MAGMA : enum - A perceptually uniform sequential colormap, equivalent to matplotlib's - built-in "magma" - INFERNO : enum - A perceptually uniform sequential colormap, equivalent to matplotlib's - built-in "inferno" - PLASMA : enum - A perceptually uniform sequential colormap, equivalent to matplotlib's - built-in "plasma" - VIRIDIS : enum - A perceptually uniform sequential colormap, equivalent to matplotlib's - built-in "viridis" - COOL : enum - A sequential colormap, equivalent to matplotlib's built-in "cool" - HOT : enum - A sequential colormap, equivalent to matplotlib's built-in "hot" - COOLWARM : enum - A diverging colormap, equivalent to matplotlib's built-in "coolwarm" - BWR : enum - A diverging colormap (blue-white-red), equivalent to matplotlib's - built-in "bwr" - GIST_EARTH : enum - A colormap designed to represent topography and water depths together, - equivalent to matplotlib's built-in "gist_earth" - TERRAIN : enum - A colormap designed to represent topography and water depths together, - equivalent to matplotlib's built-in "terrain" - CDL : enum - A standard colormap used in Cropland Data Layer (CDL) products, with a - distinct color for each class in such products - """ - - MAGMA = "magma" - INFERNO = "inferno" - PLASMA = "plasma" - VIRIDIS = "viridis" - COOL = "cool" - HOT = "hot" - COOLWARM = "coolwarm" - BWR = "bwr" - GIST_EARTH = "gist_earth" - TERRAIN = "terrain" - CDL = "cdl" - - -class ProcessingStepAttribute(MappingAttribute): - """Processing Levels Step. - - Attributes - ---------- - function : str - Name of the processing function to apply. Required. - parameter : str - Name of the parameter in the image metadata containing - the coefficients for the processing function. Required. - index : int - Optional index into the named parameter (an array) for the band. - data_type : str or DataType - Optional data type for pixel values in this band. - data_range : tuple(float, float) - Optional normal range of pixel values stored in the band. - display_range : tuple(float, float) - Optional normal range of pixel values stored in the band for display purposes. - physical_range : tuple(float, float) - Optional normal range of physical values stored in the band. - physical_range_unit : str - Optional unit of the physical range. - """ - - function = TypedAttribute(str) - parameter = TypedAttribute(str) - index = TypedAttribute(int) - data_type = EnumAttribute(DataType) - data_range = TupleAttribute( - min_length=2, - max_length=2, - coerce=True, - attribute_type=float, - ) - display_range = TupleAttribute( - min_length=2, - max_length=2, - coerce=True, - attribute_type=float, - ) - physical_range = TupleAttribute( - min_length=2, - max_length=2, - coerce=True, - attribute_type=float, - ) - physical_range_unit = Attribute() - - -class ProcessingLevelsAttribute(ModelAttribute, MutableMapping): - """An attribute that contains properties (key/value pairs). - - Can be set using a dictionary of items or any `Mapping`, or an instance of this - attribute. All keys must be string and values can be string or an iterable - of `ProcessingStepAttribute` items (or compatible mapping). - `ProcessingLevelsAttribute` behaves similar to dictionaries. - """ - - # this value is ONLY used for for instances of the attribute that - # are attached to class definitions. It's confusing to put this - # instantiation into __init__, because the value is only ever set - # from AttributeMeta.__new__, after it's already been instantiated - _attribute_name = None - - def __init__(self, value=None, validate=True, **kwargs): - self._items = {} - - super(ProcessingLevelsAttribute, self).__init__(**kwargs) - - if value is not None: - # we always validate, to correctly coerce the values - value = { - key: self.validate_key_and_value(key, val) for key, val in value.items() - } - - self._items.update(value) - - def __repr__(self): - return "{}{}{}".format( - "{", - ", ".join( - [ - "{}: {}".format(repr(key), repr(value)) - for key, value in self._items.items() - ] - ), - "}", - ) - - def validate_key_and_value(self, key, value): - """Validate the key and value. - - The key must be a string, and the value either a string or an iterable of - `ProcessingStepAttribute` items or a compatible mapping. - - Returns a fully formed value (a string or a ListAttribute of - `ProcessingStepAttribute` items) - """ - if not isinstance(key, str): - raise AttributeValidationError( - "Keys for property {} must be strings: {}".format( - self._attribute_name, key - ) - ) - if isinstance(value, str): - return value - elif isinstance(value, ListAttribute) and all( - map(lambda x: isinstance(x, ProcessingStepAttribute), value) - ): - value._add_model_object(self) - return value - elif isinstance(value, Iterable): - items = [] - for v in value: - if isinstance(v, ProcessingStepAttribute): - items.append(v) - elif isinstance(v, Mapping): - try: - items.append(ProcessingStepAttribute(**v)) - except AttributeError as ex: - raise AttributeValidationError( - "The value for property {} with key {} must" - " conform to a valid ProcessingStepAttribute: {}: {}".format( - self._attribute_name, key, v, ex - ) - ) - else: - break - else: - value = ListAttribute(ProcessingStepAttribute, items=items) - value._add_model_object(self) - return value - raise AttributeValidationError( - "The value for property {} with key {} must be a string" - " or an iterable of ProcessingStepAttribute: {}".format( - self._attribute_name, key, value - ) - ) - - def serialize(self, value, jsonapi_format=False): - """Serialize a value to a json-serializable type. - - See :meth:`Attribute.serialize`. - """ - if value is None: - return None - - # Shallow copy for strings, deserialize ListAttributes - return { - k: ( - v - if isinstance(v, str) - else v.serialize(v, jsonapi_format=jsonapi_format) - ) - for k, v in value.items() - } - - def deserialize(self, value, validate=True): - """Deserialize a value to a native type. - - See :meth:`Attribute.deserialize`. - - Parameters - ---------- - value : dict or `ProcessingLevelsAttribute` - A set of values to use to initialize a new `ProcessingLevelsAttribute` - instance. All keys must be strings, and values can be strings or numbers. - - Returns - ------- - ProcessingLevelsAttribute - A `ProcessingLevelsAttribute` with the given items. - - Raises - ------ - AttributeValidationError - If the value is not a mapping or any of the keys are not strings, or any - of the values are not strings or numbers. - """ - if value is None: - return None - - if isinstance(value, ProcessingLevelsAttribute): - return value - - if validate: - if not isinstance(value, Mapping): - raise AttributeValidationError( - "A ProcessingLevelsAttribute expects a mapping: {}".format( - self._attribute_name - ) - ) - - value = { - key: self.validate_key_and_value(key, val) for key, val in value.items() - } - - return ProcessingLevelsAttribute( - value, validate=validate, **self._get_attr_params() - ) - - # Mapping methods - - def __getitem__(self, key): - return self._items[key] - - def __setitem__(self, key, value): - self._raise_if_immutable_or_readonly("set") - value = self.validate_key_and_value(key, value) - - old_value = self._items.get(key, None) - changed = key not in self._items or old_value != value - self._set_modified(changed=changed) - self._items[key] = value - if isinstance(old_value, ModelAttribute): - old_value._remove_model_object(self) - - def __delitem__(self, key): - self._raise_if_immutable_or_readonly("delete") - if key in self._items: - self._set_modified(changed=True) - old_value = self._items.pop(key) - if isinstance(old_value, ModelAttribute): - old_value._remove_model_object(self) - - def __iter__(self): - return iter(self._items) - - def __len__(self): - return len(self._items) - - -class DerivedParamsAttribute(MappingAttribute): - """Derived Band Parameters Attribute. - - Attributes - ---------- - function : str - Name of the function to apply. Required. - bands : list(str) - Names of the bands used as input to the function. Required. - source_type : int - Optional index into the named parameter (an array) for the band. - """ - - function = TypedAttribute(str) - bands = ListAttribute(TypedAttribute(str), validate=True) - source_type = EnumAttribute( - DataType, - doc="str or DataType: The datatype for extracting pixels from the source bands.", - ) - - def deserialize(self, value, validate=True): - """Deserialize a value to a native type. - - See :meth:`Attribute.deserialize`. - - Parameters - ---------- - values : dict or MappingAttribute - The values to use to initialize a new MappingAttribute. - - Returns - ------- - DerivedParamsAttribute - A `DerivedParamsAttribute` instance with the given values. - - Raises - ------ - AttributeValidationError - If the value is not a `DerivedParamsAttribute` or a mapping with - a `function`, `bands`, and (optionally) `source_type key. - """ - result = super(DerivedParamsAttribute, self).deserialize(value, validate) - - if result: - if not result.function: - raise AttributeValidationError("'function' field required.") - if not result.bands: - raise AttributeValidationError("'bands' field required.") - - return result - - -class Band(NamedCatalogObject): - """A data band in images of a specific product. - - This is an abstract class that cannot be instantiated, but can be used for searching - across all types of bands. The concrete bands are represented by the derived - classes. - - Common attributes: - :attr:`~descarteslabs.catalog.GenericBand.id`, - :attr:`~descarteslabs.catalog.GenericBand.name`, - :attr:`~descarteslabs.catalog.GenericBand.product_id`, - :attr:`~descarteslabs.catalog.GenericBand.description`, - :attr:`~descarteslabs.catalog.GenericBand.type`, - :attr:`~descarteslabs.catalog.GenericBand.sort_order`, - :attr:`~descarteslabs.catalog.GenericBand.vendor_order`, - :attr:`~descarteslabs.catalog.GenericBand.data_type`, - :attr:`~descarteslabs.catalog.GenericBand.nodata`, - :attr:`~descarteslabs.catalog.GenericBand.data_range`, - :attr:`~descarteslabs.catalog.GenericBand.display_range`, - :attr:`~descarteslabs.catalog.GenericBand.resolution`, - :attr:`~descarteslabs.catalog.GenericBand.band_index`, - :attr:`~descarteslabs.catalog.GenericBand.file_index`, - :attr:`~descarteslabs.catalog.GenericBand.jpx_layer_index`. - :attr:`~descarteslabs.catalog.GenericBand.vendor_band_name`. - - To create a new band instantiate one of those specialized classes: - - * `SpectralBand`: A band that lies somewhere on the visible/NIR/SWIR electro-optical - wavelength spectrum. Specific attributes: - :attr:`~SpectralBand.physical_range`, - :attr:`~SpectralBand.physical_range_unit`, - :attr:`~SpectralBand.wavelength_nm_center`, - :attr:`~SpectralBand.wavelength_nm_min`, - :attr:`~SpectralBand.wavelength_nm_max`, - :attr:`~SpectralBand.wavelength_nm_fwhm`, - :attr:`~SpectralBand.processing_levels`, - :attr:`~SpectralBand.derived_params`. - * `MicrowaveBand`: A band that lies in the microwave spectrum, often from SAR or - passive radar sensors. Specific attributes: - :attr:`~MicrowaveBand.frequency`, - :attr:`~MicrowaveBand.bandwidth`, - :attr:`~MicrowaveBand.physical_range`, - :attr:`~MicrowaveBand.physical_range_unit`. - * `MaskBand`: A binary band where by convention a 0 means masked and 1 means - non-masked. The :attr:`~Band.data_range` and :attr:`~Band.display_range` for - masks is implicitly ``[0, 1]``. Specific attributes: - :attr:`~MaskBand.is_alpha`. - * `ClassBand`: A band that maps a finite set of values that may not be continuous to - classification categories (e.g. a land use classification). A visualization with - straight pixel values is typically not useful, so commonly a - :attr:`~ClassBand.colormap` is used. Specific attributes: - :attr:`~ClassBand.colormap`, - :attr:`~ClassBand.colormap_name`, - :attr:`~ClassBand.class_labels`. - * `GenericBand`: A generic type for bands that are not represented by the other band - types, e.g., mapping physical values like temperature or angles. Specific - attributes: - :attr:`~GenericBand.colormap`, - :attr:`~GenericBand.colormap_name`, - :attr:`~GenericBand.physical_range`, - :attr:`~GenericBand.physical_range_unit`, - :attr:`~GenericBand.processing_levels`, - :attr:`~GenericBand.derived_params`. - """ - - _DOC_DESCRIPTION = """A description with further details on the band. - - The description can be up to 80,000 characters and is used by - :py:meth:`Search.find_text`. - - *Searchable* - """ - _DOC_DATATYPE = "The data type for pixel values in this band." - _DOC_DATARANGE = """The range of pixel values stored in the band. - - The two floats are the minimum and maximum pixel values stored in this band. - """ - _DOC_COLORMAPNAME = """str or Colormap, optional: Name of a predefined colormap for display purposes. - - The colormap is applied when this band is rastered by itself in PNG or TIFF - format, including in UIs where imagery is visualized. - """ - _DOC_COLORMAP = """list(tuple), optional: A custom colormap for this band. - - A list of tuples, where each nested tuple is a 4-tuple of RGBA values to map - pixels whose value is the index of the list. E.g. the colormap ``[(100, 20, - 200, 255)]`` would map pixels whose value is 0 in the original band to the - RGBA color defined by ``(100, 20, 200, 255)``. The number of 4-tuples provided - can be up to the maximum of this band's data range. Omitted values will map - to black by default. - """ - _DOC_PHYSICALRANGE = ( - "tuple(float, float), optional: A physical range that pixel values map to." - ) - - _doc_type = "band" - _url = "/bands" - _derived_type_switch = "type" - _default_includes = ["product"] - # _collection_type set below due to circular problems - - description = Attribute(doc="str, optional: " + _DOC_DESCRIPTION) - type = EnumAttribute( - BandType, - doc="""str or BandType: The type of this band, directly corresponding to a `Band` derived class. - - The derived classes are `SpectralBand`, `MicrowaveBand`, `MaskBand`, - `ClassBand`, and `GenericBand`. The type never needs to be set explicitly, - this attribute is implied by the derived class used. The type of a band does - not necessarily affect how it is rastered, it mainly conveys useful information - about the data it contains. - - *Filterable*. - """, - ) - sort_order = TypedAttribute( - int, - doc="""int, optional: A number defining the default sort order for bands within a product. - - If not set for newly created bands, this will default to the current maximum - sort order + 1 in the product. - - *Sortable*. - """, - ) - vendor_order = TypedAttribute( - int, - doc="""int, optional: A number defining the ordering of bands within a product - as defined by the data vendor. 1-based. Used for indexing ``c6s_dlsr``. - Generally only used internally by certain core products. - - *Sortable*. - """, - ) - data_type = EnumAttribute(DataType, doc="str or DataType: " + _DOC_DATATYPE) - nodata = Attribute( - doc="""float, optional: A value representing missing data in a pixel in this band.""" - ) - data_range = TupleAttribute( - min_length=2, - max_length=2, - coerce=True, - attribute_type=float, - doc="tuple(float, float): " + _DOC_DATARANGE, - ) - display_range = TupleAttribute( - min_length=2, - max_length=2, - coerce=True, - attribute_type=float, - doc="""tuple(float, float): The range of pixel values for display purposes. - - The two floats are the minimum and maximum values indicating a default reasonable - range of pixel values usd when rastering this band for display purposes. - """, - ) - resolution = Resolution( - doc="""Resolution, optional: The spatial resolution of this band. - - *Filterable, sortable*. - """ - ) - band_index = TypedAttribute( - int, doc="int: The 0-based index into the source data to access this band." - ) - file_index = Attribute( - doc="""int, optional: The 0-based index into the list of source files. - - If there are multiple files, it maps the band index to the file index. It defaults - to 0 (first file). - """ - ) - jpx_layer_index = TypedAttribute( - int, - doc="""int, optional: The 0-based layer index if the source data is JPEG2000 with layers. - - Defaults to 0. - """, - ) - vendor_band_name = TypedAttribute( - str, - doc="""str, optional: The name of the band in the source file. - - Some source file types require that the band be indexed by name rather than by the ``band_index``. - """, - ) - processing_levels = ProcessingLevelsAttribute() - derived_params = DerivedParamsAttribute() - - def __new__(cls, *args, **kwargs): - return _new_abstract_class(cls, Band) - - def __init__(self, **kwargs): - if self._derived_type_switch not in kwargs: - kwargs[self._derived_type_switch] = self._derived_type - - super(Band, self).__init__(**kwargs) - - @classmethod - def search(cls, client=None, request_params=None, headers=None): - """A search query for all bands. - - Returns an instance of the - :py:class:`~descarteslabs.catalog.Search` class configured for - searching bands. Call this on the :py:class:`Band` base class to search all - types of bands or classes :py:class:`SpectralBand`, :py:class:`MicrowaveBand`, - :py:class:`MaskBand`, :py:class:`ClassBand` and :py:class:`GenericBand` to search - only a specific type of band. - - - Parameters - ---------- - client : :py:class:`CatalogClient` - A `CatalogClient` instance to use for requests to the Descartes Labs - catalog. - - Returns - ------- - :py:class:`~descarteslabs.catalog.Search` - An instance of the :py:class:`~descarteslabs.catalog.Search` class - """ - search = super(Band, cls).search( - client, request_params=request_params, headers=headers - ) - if cls._derived_type: - search = search.filter(properties.type == cls._derived_type) - return search - - -class BandCollection(Collection): - _item_type = Band - - -# set here due to circular references -Band._collection_type = BandCollection - - -class SpectralBand(Band): - """A band that lies somewhere on the visible/NIR/SWIR electro-optical wavelength spectrum. - - Instantiating a spectral band indicates that you want to create a *new* Descartes - Labs catalog spectral band. If you instead want to retrieve an existing catalog - spectral band use `Band.get() `, or if - you're not sure use `SpectralBand.get_or_create() - `. You can also use - `Band.search() `. Also see the example - for :py:meth:`~descarteslabs.catalog.Band.save`. - - Parameters - ---------- - client : CatalogClient, optional - A `CatalogClient` instance to use for requests to the Descartes Labs catalog. - The :py:meth:`~descarteslabs.catalog.CatalogClient.get_default_client` will - be used if not set. - kwargs : dict, optional - With the exception of readonly attributes (`created`, `modified`) and with the - exception of properties (`ATTRIBUTES`, `is_modified`, and `state`), any - attribute listed below can also be used as a keyword argument. Also see - `~SpectralBand.ATTRIBUTES`. - """ - - _derived_type = BandType.SPECTRAL.value - - physical_range = TupleAttribute( - min_length=2, - max_length=2, - coerce=True, - attribute_type=float, - doc=Band._DOC_PHYSICALRANGE, - ) - physical_range_unit = Attribute(doc="str, optional: Unit of the physical range.") - wavelength_nm_center = Attribute( - doc="""float, optional: Weighted center of min/max responsiveness of the band, in nm. - - *Filterable, sortable*. - """ - ) - wavelength_nm_min = Attribute( - doc="""float, optional: Minimum wavelength this band is sensitive to, in nm. - - *Filterable, sortable*. - """ - ) - wavelength_nm_max = Attribute( - doc="""float, optional: Maximum wavelength this band is sensitive to, in nm. - - *Filterable, sortable*. - """ - ) - wavelength_nm_fwhm = Attribute( - doc="""float, optional: Full width at half maximum value of the wavelength spread, in nm. - - *Filterable, sortable*. - """ - ) - - -class MicrowaveBand(Band): - """A band that lies in the microwave spectrum, often from SAR or passive radar sensors. - - Instantiating a microwave band indicates that you want to create a *new* Descartes - Labs catalog microwave band. If you instead want to retrieve an existing catalog - microwave band use `Band.get() `, or if - you're not sure use `MicrowaveBand.get_or_create() - `. You can also use - `Band.search() `. Also see the example - for :py:meth:`~descarteslabs.catalog.Band.save`. - - Parameters - ---------- - client : CatalogClient, optional - A `CatalogClient` instance to use for requests to the Descartes Labs catalog. - The :py:meth:`~descarteslabs.catalog.CatalogClient.get_default_client` will - be used if not set. - kwargs : dict, optional - With the exception of readonly attributes (`created`, `modified`) and with the - exception of properties (`ATTRIBUTES`, `is_modified`, and `state`), any - attribute listed below can also be used as a keyword argument. Also see - `~MicrowaveBand.ATTRIBUTES`. - """ - - _derived_type = BandType.MICROWAVE.value - - frequency = Attribute( - doc="""float, optional: Center frequency of the observed microwave in GHz. - - *Filterable, sortable*. - """ - ) - bandwidth = Attribute( - doc="""float, optional: Chirp bandwidth of the sensor in MHz. - - *Filterable, sortable*. - """ - ) - physical_range = TupleAttribute( - min_length=2, - max_length=2, - coerce=True, - attribute_type=float, - doc=Band._DOC_PHYSICALRANGE, - ) - physical_range_unit = Attribute(doc="str, optional: Unit of the physical range.") - - -class MaskBand(Band): - """A binary band where by convention a 0 means masked and 1 means non-masked. - - The `data_range` and `display_range` for masks is implicitly ``(0, 1)``. - - Instantiating a mask band indicates that you want to create a *new* Descartes - Labs catalog mask band. If you instead want to retrieve an existing catalog - mask band use `Band.get() `, or if - you're not sure use `MaskBand.get_or_create() - `. You can also use - `Band.search() `. Also see the example - for :py:meth:`~descarteslabs.catalog.Band.save`. - - Parameters - ---------- - client : CatalogClient, optional - A `CatalogClient` instance to use for requests to the Descartes Labs catalog. - The :py:meth:`~descarteslabs.catalog.CatalogClient.get_default_client` will - be used if not set. - kwargs : dict, optional - With the exception of readonly attributes (`created`, `modified`) and with the - exception of properties (`ATTRIBUTES`, `is_modified`, and `state`), any - attribute listed below can also be used as a keyword argument. Also see - `~MaskBand.ATTRIBUTES`. - """ - - _derived_type = BandType.MASK.value - - is_alpha = BooleanAttribute( - doc="""bool, optional: Whether this band should be useable as an alpha band during rastering. - - This enables special behavior for this band during rastering. If this is - ``True`` and the band appears as the last band in a raster operation (such as - :meth:`descarteslabs.catalog.imagecollection.ImageCollection.mosaic` or - :meth:`descarteslabs.catalog.imagecollection.ImageCollection.stack`) pixels - with a value of 0 in this band will be treated as transparent. - """ - ) - data_range = Attribute(mutable=False, doc="tuple(float, float), readonly: [0, 1].") - display_range = Attribute( - mutable=False, doc="tuple(float, float), readonly: [0, 1]." - ) - - -class ClassBand(Band): - """A band that maps a finite set of values that may not be continuous. - - For example land use classification. A visualization with straight pixel values - is typically not useful, so commonly a colormap is used. - - Instantiating a class band indicates that you want to create a *new* Descartes - Labs catalog class band. If you instead want to retrieve an existing catalog - class band use `Band.get() `, or if - you're not sure use `ClassBand.get_or_create() - `. You can also use - `Band.search() `. Also see the example - for :py:meth:`~descarteslabs.catalog.Band.save`. - - Parameters - ---------- - client : CatalogClient, optional - A `CatalogClient` instance to use for requests to the Descartes Labs catalog. - The :py:meth:`~descarteslabs.catalog.CatalogClient.get_default_client` will - be used if not set. - kwargs : dict, optional - With the exception of readonly attributes (`created`, `modified`) and with the - exception of properties (`ATTRIBUTES`, `is_modified`, and `state`), any - attribute listed below can also be used as a keyword argument. Also see - `~ClassBand.ATTRIBUTES`. - """ - - _derived_type = BandType.CLASS.value - - colormap_name = EnumAttribute(Colormap, doc=Band._DOC_COLORMAPNAME) - colormap = Attribute(doc=Band._DOC_COLORMAP) - class_labels = ListAttribute( - TypedAttribute(str), - doc="""list(str or None), optional: A list of labels. - - A list of labels where each element is a name for the class with the value at - that index. Elements can be null if there is no label at that value. - """, - ) - - -class GenericBand(Band): - """A generic kind of band not fitting any other type. - - For example mapping physical values like temperature or angles. - - Instantiating a generic band indicates that you want to create a *new* Descartes - Labs catalog generic band. If you instead want to retrieve an existing catalog - generic band use `Band.get() `, or if - you're not sure use `GenericBand.get_or_create() - `. You can also use - `Band.search() `. Also see the example - for :py:meth:`~descarteslabs.catalog.Band.save`. - - Parameters - ---------- - client : CatalogClient, optional - A `CatalogClient` instance to use for requests to the Descartes Labs catalog. - The :py:meth:`~descarteslabs.catalog.CatalogClient.get_default_client` will - be used if not set. - kwargs : dict, optional - With the exception of readonly attributes (`created`, `modified`) and with the - exception of properties (`ATTRIBUTES`, `is_modified`, and `state`), any - attribute listed below can also be used as a keyword argument. Also see - `~GenericBand.ATTRIBUTES`. - """ - - _derived_type = BandType.GENERIC.value - - physical_range = TupleAttribute( - min_length=2, - max_length=2, - coerce=True, - attribute_type=float, - doc=Band._DOC_PHYSICALRANGE, - ) - physical_range_unit = Attribute(doc="str, optional: Unit of the physical range.") - colormap_name = EnumAttribute(Colormap, doc=Band._DOC_COLORMAPNAME) - colormap = Attribute(doc=Band._DOC_COLORMAP) diff --git a/descarteslabs/core/catalog/blob.py b/descarteslabs/core/catalog/blob.py deleted file mode 100644 index b165a288..00000000 --- a/descarteslabs/core/catalog/blob.py +++ /dev/null @@ -1,1116 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 io - -from strenum import StrEnum - -from descarteslabs.exceptions import NotFoundError - -from ..client.services.service import ThirdPartyService -from ..common.collection import Collection -from ..common.property_filtering import Properties -from .attributes import ( - DocumentState, - EnumAttribute, - GeometryAttribute, - StorageState, - Timestamp, - TypedAttribute, - parse_iso_datetime, -) -from .blob_download import BlobDownload -from .catalog_base import ( - AuthCatalogObject, - CatalogClient, - check_deleted, - check_derived, - hybridmethod, - UnsavedObjectError, -) -from .search import AggregateDateField, GeoSearch, SummarySearchMixin -from .task import TaskStatus - -properties = Properties() - - -class StorageType(StrEnum): - """The storage type for a blob. - - Attributes - ---------- - COMPUTE : enum - Compute service job results. - DATA : enum - Arbitrary user-managed data. This type may be uploaded by users. - DYNCOMP : enum - Saved Dynamic Compute objects. This type may be uploaded by users. - LOGS : enum - Compute service job log output (text files). - """ - - COMPUTE = "compute" - DATA = "data" - DYNCOMP = "dyncomp" - LOGS = "logs" - - -class BlobSummaryResult(object): - """ - The readonly data returned by :py:meth:`SummaySearch.summary` or - :py:meth:`SummaySearch.summary_interval`. - - Attributes - ---------- - count : int - Number of blobs in the summary. - bytes : int - Total number of bytes of data across all blobs in the summary. - namespaces : list(str) - List of namespace IDs for the blobs included in the summary. - interval_start: datetime - For interval summaries only, a datetime representing the start of the interval period. - - """ - - def __init__( - self, count=None, bytes=None, namespaces=None, interval_start=None, **kwargs - ): - self.count = count - self.bytes = bytes - self.namespaces = namespaces - self.interval_start = ( - parse_iso_datetime(interval_start) if interval_start else None - ) - - def __repr__(self): - text = [ - "\nSummary for {} blobs:".format(self.count), - " - Total bytes: {:,}".format(self.bytes), - ] - if self.namespaces: - text.append(" - Namespaces: {}".format(", ".join(self.namespaces))) - if self.interval_start: - text.append(" - Interval start: {}".format(self.interval_start)) - return "\n".join(text) - - -class BlobSearch(SummarySearchMixin, GeoSearch): - # Be aware that the `|` characters below add whitespace. The first one is needed - # avoid the `Inheritance` section from appearing before the auto summary. - """A search request that iterates over its search results for blobs. - - The `BlobSearch` is identical to `Search` but with a couple of summary methods: - :py:meth:`summary` and :py:meth:`summary_interval`. - """ - - SummaryResult = BlobSummaryResult - DEFAULT_AGGREGATE_DATE_FIELD = AggregateDateField.CREATED - - -class Blob(AuthCatalogObject): - """A stored blob (arbitrary bytes) that can be searched and retrieved. - - Instantiating a blob indicates that you want to create a *new* Descartes Labs - storage blob. If you instead want to retrieve an existing blob use - `Blob.get() `. - You can also use `Blob.search() `. - Also see the example for :py:meth:`~descarteslabs.catalog.Blob.upload`. - - - Parameters - ---------- - client : CatalogClient, optional - A `CatalogClient` instance to use for requests to the Descartes Labs catalog. - The :py:meth:`~descarteslabs.catalog.CatalogClient.get_default_client` will - be used if not set. - kwargs : dict - With the exception of readonly attributes (`created`, `modified`) and with - the exception of properties (`ATTRIBUTES`, `is_modified`, and `state`), any - attribute listed below can also be used as a keyword argument. Also see - `~Blob.ATTRIBUTES`. - """ - - _doc_type = "storage" - _url = "/storage" - # _collection_type set below due to circular problems - _url_client = ThirdPartyService() - - # Blob Attributes - namespace = TypedAttribute( - str, - doc="""str: The namespace of this blob. - - All blobs are stored and indexed under a namespace. Namespaces are allowed - a restricted alphabet (``a-zA-Z0-9:._-``), and must begin with the user's - org name, or their unique user hash if the user has no org. The required - prefix is seperated from the rest of the namespace name (if any) by a ``:``. - If not provided, the namespace will default to the users org (if any) and - the unique user hash. The combined length of the ``namespace`` and the - ``name`` cannot exceed 979 bytes. - - *Searchable, sortable*. - """, - ) - name = TypedAttribute( - str, - doc="""str: The name of this blob. - - All blobs are stored and indexed by name. Names are allowed - a restricted alphabet (``a-zA-Z0-9:._/-``), but may not begin or end with a - ``/``. The combined length of the ``namespace`` and the ``name`` cannot exceed - 979 bytes. - - The ``/`` is intended to be used like a directory in a pathname to allow for - prefix search operations, but otherwise has no special meaning. - - *Searchable, sortable*. - """, - ) - storage_state = EnumAttribute( - StorageState, - doc="""str or StorageState: Storage state of the blob. - - The state is `~StorageState.AVAILABLE` if the data is available and can be - retrieved, `~StorageState.REMOTE` if the data is not currently available. - - *Filterable, sortable*. - """, - ) - storage_type = EnumAttribute( - StorageType, - doc="""str or StorageType: Storage type of the blob. - - `~StorageType.DATA` is managed by end users (e.g. via - :py:meth:`descarteslabs.catalog.Blob.upload`. - Other types are generated and managed by various components of the platform. - - *Filterable, sortable*. - """, - ) - description = TypedAttribute( - str, - doc="""str, optional: A description with further details on this blob. - - The description can be up to 80,000 characters and is used by - :py:meth:`Search.find_text`. - - *Searchable* - """, - ) - geometry = GeometryAttribute( - doc="""str or shapely.geometry.base.BaseGeometry, optional: Geometry representing the location for the blob. - - *Filterable* - - (use :py:meth:`BlobSearch.intersects - ` to search based on geometry) - """ - ) - expires = Timestamp( - doc="""str or datetime, optional: Timestamp when the blob should be expired and deleted. - - *Filterable, sortable*. - """ - ) - href = TypedAttribute( - str, - doc="""str, optional: Storage location for the blob. - - This attribute may not be set by the end user. - """, - ) - size_bytes = TypedAttribute( - int, - doc="""int, optional: Size of the blob in bytes. - - *Filterable, sortable*. - """, - ) - hash = TypedAttribute( - str, doc="""str, optional: Content hash (MD5) for the blob.""" - ) - - @classmethod - def namespace_id(cls, namespace_id, client=None): - """Generate a fully namespaced id. - - Parameters - ---------- - namespace_id : str or None - The unprefixed part of the id that you want prefixed. - client : CatalogClient, optional - A `CatalogClient` instance to use for requests to the Descartes Labs - catalog. The - :py:meth:`~descarteslabs.catalog.CatalogClient.get_default_client` will - be used if not set. - - Returns - ------- - str - The fully namespaced id. - - Example - ------- - >>> namespace = Blob.namespace_id("myproject") # doctest: +SKIP - 'myorg:myproject' # doctest: +SKIP - """ - if client is None: - client = CatalogClient.get_default_client() - org = client.auth.payload.get("org") - namespace = client.auth.namespace - - if not namespace_id: - if org: - return f"{org}:{namespace}" - else: - return namespace - elif org: - if namespace_id == org or namespace_id.startswith(org + ":"): - return namespace_id - else: - return f"{org}:{namespace_id}" - elif namespace_id == namespace or namespace_id.startswith(namespace + ":"): - return namespace_id - else: - return f"{namespace}:{namespace_id}" - - @classmethod - def get( - cls, - id=None, - storage_type=StorageType.DATA, - namespace=None, - name=None, - client=None, - request_params=None, - headers=None, - ): - """Get an existing Blob from the Descartes Labs catalog. - - If the Blob is found, it will be returned in the - `~descarteslabs.catalog.DocumentState.SAVED` state. Subsequent changes will - put the instance in the `~descarteslabs.catalog.DocumentState.MODIFIED` state, - and you can use :py:meth:`save` to commit those changes and update the Descartes - Labs catalog object. Also see the example for :py:meth:`save`. - - Exactly one of the ``id`` and ``name`` parameters must be specified. If ``name`` - is specified, it is used together with the ``storage_type`` and ``namespace`` - parameters to form the corresponding ``id``. - - Parameters - ---------- - id : str, optional - The id of the object you are requesting. Required unless ``name`` is supplied. - May not be specified if ``name`` is specified. - storage_type : StorageType, optional - The storage type of the Blob you wish to retrieve. Defaults to ``data``. Ignored - unless ``name`` is specified. - namespace : str, optional - The namespace of the Blob you wish to retrieve. Defaults to the user's org name - (if any) plus the unique user hash. Ignored unless ``name`` is specified. - name : str, optional - The name of the Blob you wish to retrieve. Required if ``id`` is not specified. - May not be specified if ``id`` is specified. - client : CatalogClient, optional - A `CatalogClient` instance to use for requests to the Descartes Labs - catalog. The - :py:meth:`~descarteslabs.catalog.CatalogClient.get_default_client` will - be used if not set. - - Returns - ------- - :py:class:`~descarteslabs.catalog.CatalogObject` or None - The object you requested, or ``None`` if an object with the given `id` - does not exist in the Descartes Labs catalog. - - Raises - ------ - ~descarteslabs.exceptions.ClientError or ~descarteslabs.exceptions.ServerError - :ref:`Spurious exception ` that can occur during a - network request. - """ - if (not id and not name) or (id and name): - raise TypeError("Must specify exactly one of id or name parameters") - if not id: - id = f"{storage_type}/{Blob.namespace_id(namespace)}/{name}" - return super(cls, Blob).get( - id, client=client, request_params=request_params, headers=headers - ) - - @classmethod - def get_or_create( - cls, - id=None, - storage_type=StorageType.DATA, - namespace=None, - name=None, - client=None, - **kwargs, - ): - """Get an existing object from the Descartes Labs catalog or create a new object. - - If the Descartes Labs catalog object is found, and the remainder of the - arguments do not differ from the values in the retrieved instance, it will be - returned in the `~descarteslabs.catalog.DocumentState.SAVED` state. - - If the Descartes Labs catalog object is found, and the remainder of the - arguments update one or more values in the instance, it will be returned in - the `~descarteslabs.catalog.DocumentState.MODIFIED` state. - - If the Descartes Labs catalog object is not found, it will be created and the - state will be `~descarteslabs.catalog.DocumentState.UNSAVED`. Also see the - example for :py:meth:`save`. - - Parameters - ---------- - id : str, optional - The id of the object you are requesting. Required unless ``name`` is supplied. - May not be specified if ``name`` is specified. - storage_type : StorageType, optional - The storage type of the Blob you wish to retrieve. Defaults to ``data``. Ignored - unless ``name`` is specified. - namespace : str, optional - The namespace of the Blob you wish to retrieve. Defaults to the user's org name - (if any) plus the unique user hash. Ignored unless ``name`` is specified. - name : str, optional - The name of the Blob you wish to retrieve. Required if ``id`` is not specified. - May not be specified if ``id`` is specified. - client : CatalogClient, optional - A `CatalogClient` instance to use for requests to the Descartes Labs - catalog. The - :py:meth:`~descarteslabs.catalog.CatalogClient.get_default_client` will - be used if not set. - kwargs : dict, optional - With the exception of readonly attributes (`created`, `modified`), any - attribute of a catalog object can be set as a keyword argument (Also see - `ATTRIBUTES`). - - Returns - ------- - :py:class:`~descarteslabs.catalog.CatalogObject` - The requested catalog object that was retrieved or created. - - """ - if (not id and not name) or (id and name): - raise TypeError("Must specify exactly one of id or name parameters") - if not id: - namespace = cls.namespace_id(namespace) - id = f"{storage_type}/{namespace}/{name}" - kwargs["storage_type"] = storage_type - kwargs["namespace"] = namespace - kwargs["name"] = name - - return super(cls, Blob).get_or_create(id, client=client, **kwargs) - - @classmethod - def search(cls, client=None, request_params=None, headers=None): - """A search query for all blobs. - - Return an `~descarteslabs.catalog.BlobSearch` instance for searching - blobs in the Descartes Labs catalog. This instance extends the - :py:class:`~descarteslabs.catalog.Search` class with the - :py:meth:`~descarteslabs.catalog.BlobSearch.summary` and - :py:meth:`~descarteslabs.catalog.BlobSearch.summary_interval` methods - which return summary statistics about the blobs that match the search query. - - Parameters - ---------- - client : :class:`CatalogClient`, optional - A `CatalogClient` instance to use for requests to the Descartes Labs - catalog. - - Returns - ------- - :class:`~descarteslabs.catalog.BlobSearch` - An instance of the `~descarteslabs.catalog.BlobSearch` class - - Example - ------- - >>> from descarteslabs.catalog import Blob - >>> search = Blob.search().limit(10) - >>> for result in search: # doctest: +SKIP - ... print(result.name) # doctest: +SKIP - - """ - return BlobSearch( - cls, client=client, request_params=request_params, headers=headers - ) - - @check_deleted - def upload(self, file): - """Uploads storage blob from a file. - - Uploads data from a file and creates the Blob. - - The Blob must be in the state `~descarteslabs.catalog.DocumentState.UNSAVED`. - The `storage_state`, `storage_type`, `namespace`, and the `name` attributes, - must all be set. If either the `size_bytes` and the `hash` attributes are set, - they must agree with the actual file to be uploaded, and will be validated - during the upload process. - - On return, the Blob object will be updated to reflect the full state of the - new blob. - - Parameters - ---------- - file : str or io.IOBase - File or files to be uploaded. Can be string with path to the file in the - local filesystem, or a file-like object (``io.IOBase``). If a file like - object and already open, must be binary mode and readable. Open file-like - objects remain open on return and must be closed by the caller. - - Returns - ------- - Blob - The uploaded instance. - - Raises - ------ - ValueError - If any improper arguments are supplied. - DeletedObjectError - If this blob was deleted. - """ - self.namespace = self.__class__.namespace_id(self.namespace) - if not self.name: - raise ValueError("name field required") - if not self.storage_state: - self.storage_state = StorageState.AVAILABLE - if not self.storage_type: - self.storage_type = StorageType.DATA - - if self.state != DocumentState.UNSAVED: - raise ValueError( - "Blob {} has been saved. Please use an unsaved blob for uploading".format( - self.id - ) - ) - - if isinstance(file, str): - file = io.open(file, "rb") - close = True - elif isinstance(file, io.IOBase): - close = file.closed - if close: - file = io.open(file.name, "rb") - elif not file.readable() or "b" not in file.mode: - raise ValueError("Invalid file is open but not readable or binary mode") - else: - raise ValueError("Invalid file value: must be string or IOBase") - - try: - return self._do_upload(file) - finally: - if close: - file.close() - - @check_deleted - def upload_data(self, data): - """Uploads storage blob from a bytes or str. - - Uploads data from a string or bytes and creates the Blob. - - The Blob must be in the state `~descarteslabs.catalog.DocumentState.UNSAVED`. - The `storage_state`, `storage_type`, `namespace`, and the `name` attributes, - must all be set. If either the `size_bytes` and the `hash` attributes are set, - they must agree with the actual data to be uploaded, and will be validated - during the upload process. - - On return, the Blob object will be updated to reflect the full state of the - new blob. - - Parameters - ---------- - data : str or bytes - Data to be uploaded. A str will be default encoded to bytes. - - Returns - ------- - Blob - The uploaded instance. - - Raises - ------ - ValueError - If any improper arguments are supplied. - DeletedObjectError - If this blob was deleted. - """ - self.namespace = self.__class__.namespace_id(self.namespace) - if not self.name: - raise ValueError("name field required") - if not self.storage_state: - self.storage_state = StorageState.AVAILABLE - if not self.storage_type: - self.storage_type = StorageType.DATA - - if self.state != DocumentState.UNSAVED: - raise ValueError( - "Blob {} has been saved. Please use an unsaved blob for uploading".format( - self.id - ) - ) - - if isinstance(data, str): - data = data.encode() - elif not isinstance(data, bytes): - raise ValueError("Invalid data value: must be string or bytes") - - return self._do_upload(data) - - # the upload implementation is broken out so it can be used from multiple methods - def _do_upload(self, src): - # import here for circular dependency - from .blob_upload import BlobUpload - - # Request an upload url - upload = BlobUpload(client=self._client, storage=self) - - upload.save() - - headers = {} - headers["content-type"] = "application/octet-stream" - if upload.storage.size_bytes: - headers["content-length"] = str(upload.storage.size_bytes) - - # This should work but it doesn't. The header must be the base64 - # encoding of the 16 binary MD5 checksum bytes. But the value - # that is is checked against by S3 is the hex-ified version of the - # 16 binary bytes. So even though they mean the same thing, - # they miscompare at S3 and the file upload fails. - # if upload.storage.hash: - # headers["content-md5"] = upload.storage.hash - - # do the upload - self._url_client.session.put(upload.resumable_url, data=src, headers=headers) - - # save the blob - upload.storage.save(request_params={"upload_signature": upload.signature}) - - # replenish our state, like reload but no need to go to server. - # this will effectively wipe all current state & caching. - self._initialize( - saved=True, - **upload.storage._attributes, - ) - - return self - - @check_deleted - def download(self, file, range=None): - """Downloads storage blob to a file. - - Downloads data from the blob to a file. - - The Blob must be in the state `~descarteslabs.catalog.DocumentState.SAVED`. - - Parameters - ---------- - file : str or io.IOBase - Where to write the downloaded blob. Can be string with path to the file in the - local filesystem, or an file opened for writing (``io.IOBase``). If a file like - object and already open, must be binary mode and writable. Open file-like - objects remain open on return and must be closed by the caller. - range : str or list, optional - Range(s) of blob to be downloaded. Can either be a string in the standard - HTTP Range header format (e.g. "bytes=0-99"), or a list or tuple containing - one or two integers (e.g. ``(0, 99)``), or a list or tuple of the same - (e.g. ``((0, 99), (200-299))``). A list or tuple of one integer implies - no upper bound; in this case the integer can be negative, indicating the - count back from the end of the blob. - - Returns - ------- - str - The name of the downloaded file. - - Raises - ------ - ValueError - If any improper arguments are supplied. - DeletedObjectError - If this blob was deleted. - """ - if self.state != DocumentState.SAVED: - raise ValueError("Blob {} has not been saved".format(self.id)) - - if isinstance(file, str): - file = io.open(file, "wb") - elif isinstance(file, io.IOBase): - close = file.closed - if close: - file = io.open(file.name, "wb") - elif not file.writable() or "b" not in file.mode: - raise ValueError("Invalid file is open but not writable or binary mode") - else: - raise ValueError("Invalid file value: must be string or IOBase") - - return self._do_download(dest=file, range=range) - - @check_deleted - def data(self, range=None): - """Downloads storage blob data. - - Downloads data from the blob and returns as a bytes object. - - The Blob must be in the state `~descarteslabs.catalog.DocumentState.SAVED`. - - Parameters - ---------- - range : str or list, optional - Range(s) of blob to be downloaded. Can either be a string in the standard - HTTP Range header format (e.g. "bytes=0-99"), or a list or tuple containing - one or two integers (e.g. ``(0, 99)``), or a list or tuple of the same - (e.g. ``((0, 99), (200-299))``). A list or tuple of one integer implies - no upper bound; in this case the integer can be negative, indicating the - count back from the end of the blob. - - Returns - ------- - bytes - The data retrieved from the Blob. - - Raises - ------ - ValueError - If any improper arguments are supplied. - DeletedObjectError - If this blob was deleted. - """ - if self.state != DocumentState.SAVED: - raise ValueError("Blob {} has not been saved".format(self.id)) - - return self._do_download(range=range) - - @check_deleted - def iter_data(self, chunk_size=None, range=None): - """Downloads storage blob data. - - Downloads data from the blob and returns as an iterator (generator) - which will yield the data (as a bytes) in chunks. This enables the - processing of very large files. - - The Blob must be in the state `~descarteslabs.catalog.DocumentState.SAVED`. - - Parameters - ---------- - chunk_size : int, optional - Size of chunks over which to iterate. Default is whatever size chunks - are received. - range : str or list, optional - Range(s) of blob to be downloaded. Can either be a string in the standard - HTTP Range header format (e.g. "bytes=0-99"), or a list or tuple containing - one or two integers (e.g. ``(0, 99)``), or a list or tuple of the same - (e.g. ``((0, 99), (200-299))``). A list or tuple of one integer implies - no upper bound; in this case the integer can be negative, indicating the - count back from the end of the blob. - - Returns - ------- - generator - An iterator over the blob data. - - Raises - ------ - ValueError - If any improper arguments are supplied. - DeletedObjectError - If this blob was deleted. - """ - if self.state != DocumentState.SAVED: - raise ValueError("Blob {} has not been saved".format(self.id)) - - def generator(response): - try: - yield from response.iter_content(chunk_size) - finally: - response.close() - - return self._do_download(dest=generator, range=range) - - @check_deleted - def iter_lines(self, decode_unicode=False, delimiter=None): - """Downloads storage blob data. - - Downloads data from the blob and returns as an iterator (generator) - which will yield the data as text lines. This enables the - processing of very large files. - - The Blob must be in the state `~descarteslabs.catalog.DocumentState.SAVED`. - The data within the blob must represent encoded text. - - .. note:: This method is not reentrant safe. - - Parameters - ---------- - decode_unicode : bool, optional - If true, then decode unicode in the incoming data and return - strings. Default is to return bytes. - delimiter : str or byte, optional - Delimiter for lines. Type depends on setting of `decode_unicode`. - Default is to use default line break sequence. - - Returns - ------- - generator - An iterator over the blob byte or text lines, depending on - value of `decode_unicode`. - - Raises - ------ - ValueError - If any improper arguments are supplied. - DeletedObjectError - If this blob was deleted. - """ - if self.state != DocumentState.SAVED: - raise ValueError("Blob {} has not been saved".format(self.id)) - - def generator(response): - if decode_unicode: - # response will always claim to be application/octet-stream - response.encoding = "utf-8" - try: - yield from response.iter_lines( - decode_unicode=decode_unicode, delimiter=delimiter - ) - finally: - response.close() - - return self._do_download(dest=generator) - - @classmethod - def get_data( - cls, - id=None, - storage_type=StorageType.DATA, - namespace=None, - name=None, - client=None, - range=None, - stream=False, - chunk_size=None, - ): - """Downloads storage blob data. - - Downloads data for a given blob id and returns as a bytes object. - - Parameters - ---------- - id : str, optional - The id of the object you are requesting. Required unless ``name`` is supplied. - May not be specified if ``name`` is specified. - storage_type : StorageType, optional - The storage type of the Blob you wish to retrieve. Defaults to ``data``. Ignored - unless ``name`` is specified. - namespace : str, optional - The namespace of the Blob you wish to retrieve. Defaults to the user's org name - (if any) plus the unique user hash. Ignored unless ``name`` is specified. - name : str, optional - The name of the Blob you wish to retrieve. Required if ``id`` is not specified. - May not be specified if ``id`` is specified. - client : Client, optional - Client instance. If not given, the default client will be used. - range : str or list, optional - Range(s) of blob to be downloaded. Can either be a string in the standard - HTTP Range header format (e.g. "bytes=0-99"), or a list or tuple containing - one or two integers (e.g. ``(0, 99)``), or a list or tuple of the same - (e.g. ``((0, 99), (200-299))``). A list or tuple of one integer implies - no upper bound; in this case the integer can be negative, indicating the - count back from the end of the blob. - stream : bool, optional - If True, return a generator that will yield the data in chunks. Defaults to False. - chunk_size : int, optional - If stream is True, the size of chunks over which to stream. Default is whatever - chunks are received on the wire. - - Returns - ------- - bytes or generator - The data retrieved from the Blob. If stream is True, returned as an iterator - (generator) which will yeild the data in chunks. - - Raises - ------ - ValueError - If any improper arguments are supplied. - NotFoundError - If the Blob does not exist. - DeletedObjectError - If this blob was deleted. - """ - if (not id and not name) or (id and name): - raise TypeError("Must specify exactly one of id or name parameters") - if not id: - id = f"{storage_type}/{cls.namespace_id(namespace)}/{name}" - - dest = None - if stream: - - def generator(response): - try: - yield from response.iter_content(chunk_size) - finally: - response.close() - - dest = generator - - return cls(id=id, client=client)._do_download(dest=dest, range=range) - - @classmethod - def delete_many( - cls, ids, raise_on_missing=False, wait_for_completion=False, client=None - ): - """Delete many blobs from the Descartes Labs catalog. - - Only those blobs that exist and are owned by the user will be deleted. - No errors will be raised for blobs that do not exist or are visible but - not owned by the user. If you need to know, compare the supplied list of - ids with the returned list of deleted ids. - - All blobs to be deleted must belong to the same purchase. - - Parameters - ---------- - ids : list(str) - A list of blob ids to delete. - raise_on_missing : bool, optional - If True, raise an exception if any of the blobs are not found, otherwise ignore - missing blobs. Defaults to False. - wait_for_completion : bool, optional - If True, wait for the deletion to complete before returning. Defaults to False. - client : CatalogClient, optional - A `CatalogClient` instance to use for requests to the Descartes Labs catalog. - The :py:meth:`~descarteslabs.catalog.CatalogClient.get_default_client` will - be used if not set. - - Returns - ------- - list(str) - A list of the ids of the blobs that were successfully deleted. - - Raises - ------ - ~descarteslabs.exceptions.ClientError or ~descarteslabs.exceptions.ServerError - :ref:`Spurious exception ` that can occur during a - network request. - """ - if client is None: - client = CatalogClient.get_default_client() - - task_status = BlobDeletionTaskStatus.create( - ids=ids, raise_on_missing=raise_on_missing, client=client - ) - - if wait_for_completion: - task_status.wait_for_completion() - - return task_status.ids - - def _do_download(self, dest=None, range=None): - download = BlobDownload.get(id=self.id, client=self._client) - - # BlobDownload.get() returns None if the blob does not exist - # raise a NotFoundError in this case - if not download: - raise NotFoundError("Blob {} does not exist".format(self.id)) - - headers = {} - if self.hash: - headers["if-match"] = self.hash - if range: - if isinstance(range, str): - range_str = range - elif isinstance(range, (list, tuple)) and all( - map(lambda x: isinstance(x, int), range) - ): - if len(range) == 1: - range_str = f"bytes={range[0]}" - elif len(range) == 2: - range_str = f"bytes={range[0]}-{range[1]}" - else: - raise ValueError("invalid range value") - else: - raise ValueError("invalid range value") - - headers["range"] = range_str - - r = self._url_client.session.get( - download.resumable_url, headers=headers, stream=True - ) - r.raise_for_status() - if callable(dest): - # generator will close response - return dest(r) - else: - try: - if dest is None: - return r.raw.read() - else: - for chunk in r.iter_content(1048576): - dest.write(chunk) - return dest.name - finally: - r.close() - - @hybridmethod - @check_derived - def delete(cls, id, client=None): - """Delete the catalog object with the given `id`. - - Parameters - ---------- - id : str - The id of the object to be deleted. - client : CatalogClient, optional - A `CatalogClient` instance to use for requests to the Descartes Labs - catalog. The - :py:meth:`~descarteslabs.catalog.CatalogClient.get_default_client` will - be used if not set. - - Returns - ------- - BlobDeletionTaskStatus - The status of the deletion task which can be used to wait for completion. ``None`` if the - object was not found. - - Raises - ------ - ConflictError - If the object has related objects (bands, images) that exist. - ~descarteslabs.exceptions.ClientError or ~descarteslabs.exceptions.ServerError - :ref:`Spurious exception ` that can occur during a - network request. - - Example - ------- - >>> Image.delete('my-image-id') # doctest: +SKIP - - There is also an instance ``delete`` method that can be used to delete a blob. - It accepts no parameters and also returns a ``BlobDeletionTaskStatus``. Once - deleted, you cannot use the blob and should release any references. - """ - if client is None: - client = CatalogClient.get_default_client() - - try: - return BlobDeletionTaskStatus.create( - ids=[id], raise_on_missing=True, client=client - ) - except NotFoundError: - return None - - @delete.instancemethod - @check_deleted - def delete(self): - """Delete this catalog object from the Descartes Labs catalog. - - Once deleted, you cannot use the catalog object and should release any - references. - - Returns - ------- - BlobDeletionTaskStatus - The status of the deletion task which can be used to wait for completion. - - Raises - ------ - DeletedObjectError - If this catalog object was already deleted. - UnsavedObjectError - If this catalog object is being deleted without having been saved. - ~descarteslabs.exceptions.ClientError or ~descarteslabs.exceptions.ServerError - :ref:`Spurious exception ` that can occur during a - network request. - """ - if self.state == DocumentState.UNSAVED: - raise UnsavedObjectError("You cannot delete an unsaved object.") - - task_status = BlobDeletionTaskStatus.create( - ids=[self.id], raise_on_missing=True, client=self._client - ) - self._deleted = True # non-200 will raise an exception - return task_status - - -class BlobCollection(Collection): - _item_type = Blob - - -# handle circular references -Blob._collection_type = BlobCollection - - -class BlobDeletionTaskStatus(TaskStatus): - """The asynchronous deletion task's status - - Attributes - ---------- - id : str - The id of the object for which this task is running. - status : TaskState - The state of the task as explained in `TaskState`. - start_datetime : datetime - The date and time at which the task started running. - duration_in_seconds : float - The duration of the task. - objects_deleted : int - The number of objects (a combination of bands or images) that were deleted. - errors : list - In case the status is ``FAILED`` this will contain a list of errors - that were encountered. In all other states this will not be set. - ids : list - The ids of the objects that were deleted. - """ - - _task_name = "delete task" - _url = "/storage/delete/{}" - - def __init__(self, objects_deleted=None, ids=None, **kwargs): - super(BlobDeletionTaskStatus, self).__init__(**kwargs) - self.objects_deleted = objects_deleted - self.ids = ids - - @classmethod - def create(cls, ids, raise_on_missing, client): - # TaskStatus objects are not catalog objects so we need to do this manually - response = client.session.post( - "/storage/delete", - json={ - "data": { - "attributes": { - "ids": ids, - "raise_on_missing": raise_on_missing, - }, - "type": "storage_delete", - } - }, - ) - - if response.status_code == 201: - data = response.json()["data"] - return BlobDeletionTaskStatus( - id=data["id"], _client=client, **data["attributes"] - ) - else: - return None - - def __repr__(self): - text = super(BlobDeletionTaskStatus, self).__repr__() - - if self.objects_deleted: - text += "\n - {:,} objects deleted".format(self.objects_deleted) - - return text diff --git a/descarteslabs/core/catalog/blob_download.py b/descarteslabs/core/catalog/blob_download.py deleted file mode 100644 index 07eec4bd..00000000 --- a/descarteslabs/core/catalog/blob_download.py +++ /dev/null @@ -1,33 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 .attributes import TypedAttribute -from .catalog_base import CatalogObjectBase - - -class BlobDownload(CatalogObjectBase): - """Internal class used to initiate a blob upload.""" - - _doc_type = "storage_download" - _url = "/storage/download" - _no_inherit = True - - resumable_url = TypedAttribute( - str, - readonly=True, - doc="""str: Download URL from which the client will transfer the file contents. - - This field is for internal use by the client only. - """, - ) diff --git a/descarteslabs/core/catalog/blob_upload.py b/descarteslabs/core/catalog/blob_upload.py deleted file mode 100644 index d7ce3829..00000000 --- a/descarteslabs/core/catalog/blob_upload.py +++ /dev/null @@ -1,56 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 .attributes import Attribute, TypedAttribute -from .blob import Blob -from .catalog_base import CatalogObjectBase, CatalogObjectReference - - -class BlobUpload(CatalogObjectBase): - """Internal class used to initiate a blob upload.""" - - _doc_type = "storage_upload" - _url = "/storage/upload" - _no_inherit = True - - storage_id = Attribute( - mutable=False, - serializable=False, - sticky=True, - readonly=True, - ) - storage = CatalogObjectReference( - Blob, - require_unsaved=True, - mutable=False, - serializable=True, - sticky=True, - doc="""~descarteslabs.catalog.Blob: Blob instance with all desired metadata fields.""", - ) - resumable_url = TypedAttribute( - str, - readonly=True, - doc="""str: Upload URL to which the client will transfer the file contents. - - This field is for internal use by the client only. - """, - ) - signature = TypedAttribute( - str, - readonly=True, - doc="""str: Signature for the upload operation. - - This field is for internal use by the client only. - """, - ) diff --git a/descarteslabs/core/catalog/catalog_base.py b/descarteslabs/core/catalog/catalog_base.py deleted file mode 100644 index 62b31090..00000000 --- a/descarteslabs/core/catalog/catalog_base.py +++ /dev/null @@ -1,1167 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 urllib.parse -from functools import wraps -from types import MethodType - -from descarteslabs.exceptions import NotFoundError - -from ..client.deprecation import deprecate -from ..common.collection import Collection -from .attributes import ( - AttributeEqualityMixin, - AttributeMeta, - AttributeValidationError, - CatalogObjectReference, - DocumentState, - ExtraPropertiesAttribute, - ListAttribute, - Timestamp, - TypedAttribute, -) -from .catalog_client import CatalogClient, HttpRequestMethod -from .search import Search - - -class DeletedObjectError(Exception): - """Indicates that an action cannot be performed. - - Raised when some action cannot be performed because the catalog object - has been deleted from the Descartes Labs catalog using the delete method - (e.g. :py:meth:`Product.delete`). - """ - - pass - - -class UnsavedObjectError(Exception): - """Indicate that an action cannot be performed. - - Raised when trying to delete an object that hasn't been saved. - """ - - pass - - -def check_deleted(f): - @wraps(f) - def wrapper(self, *args, **kwargs): - if self.state == DocumentState.DELETED: - raise DeletedObjectError("This catalog object has been deleted.") - try: - return f(self, *args, **kwargs) - except NotFoundError as e: - self._deleted = True - raise DeletedObjectError( - "{} instance with id {} has been deleted".format( - self.__class__.__name__, self.id - ) - ).with_traceback(e.__traceback__) from None - - return wrapper - - -def check_derived(f): - @wraps(f) - def wrapper(cls, *args, **kwargs): - if cls._url is None: - raise TypeError( - "This method is only available for a derived class of 'CatalogObject'" - ) - return f(cls, *args, **kwargs) - - return wrapper - - -def _new_abstract_class(cls, abstract_cls): - if cls is abstract_cls: - raise TypeError( - "You can only instantiate a derived class of '{}'".format( - abstract_cls.__name__ - ) - ) - - return super(abstract_cls, cls).__new__(cls) - - -# This lets us have a class method and an instance method with the same name, but -# different signatures and implementation. -# see https://stackoverflow.com/questions/28237955/same-name-for-classmethod-and-instancemethod -class hybridmethod: - def __init__(self, fclass, finstance=None, doc=None): - self.fclass = fclass - self.finstance = finstance - self.__doc__ = doc or fclass.__doc__ - # support use on abstract base classes - self.__isabstractmethod__ = bool(getattr(fclass, "__isabstractmethod__", False)) - - def classmethod(self, fclass): - return type(self)(fclass, self.finstance, None) - - def instancemethod(self, finstance): - return type(self)(self.fclass, finstance, self.__doc__) - - def __get__(self, instance, cls): - if instance is None or self.finstance is None: - # either bound to the class, or no instance method available - return self.fclass.__get__(cls, None) - return self.finstance.__get__(instance, cls) - - -class CatalogObjectMeta(AttributeMeta): - def __new__(cls, name, bases, attrs): - new_cls = super(CatalogObjectMeta, cls).__new__(cls, name, bases, attrs) - - if new_cls._doc_type: - new_cls._model_classes_by_type_and_derived_type[ - (new_cls._doc_type, new_cls._derived_type) - ] = new_cls - - return new_cls - - -class CatalogObjectBase(AttributeEqualityMixin, metaclass=CatalogObjectMeta): - """A base class for all representations of top level objects in the Catalog API.""" - - # The following can be overridden by subclasses to customize behavior: - - # JSONAPI type for this model (required) - _doc_type = None - - # Path added to the base URL for a list request of this model (required) - _url = None - - # List of related objects to include in read requests - _default_includes = [] - - # The derived type of this class - _derived_type = None - - # Attribute to use to determine the derived type of an instance - _derived_type_switch = None - - _model_classes_by_type_and_derived_type = {} - - # Type returned by collect() on the corresponding Search object - _collection_type = Collection - - id = TypedAttribute( - str, - mutable=False, - serializable=False, - doc="""str, immutable: A unique identifier for this object. - - Note that if you pass a string that does not begin with your Descartes Labs - user organization ID, it will be prepended to your `id` with a ``:`` as - separator. If you are not part of an organization, your user ID is used. Once - set, it cannot be changed. - """, - ) - created = Timestamp( - readonly=True, - doc="""datetime, readonly: The point in time this object was created. - - *Filterable, sortable*. - """, - ) - modified = Timestamp( - readonly=True, - doc="""datetime, readonly: The point in time this object was last modified. - - *Filterable, sortable*. - """, - ) - v1_properties = TypedAttribute( - dict, - mutable=False, - serializable=False, - readonly=True, - ) - - def __new__(cls, *args, **kwargs): - return _new_abstract_class(cls, CatalogObjectBase) - - def __init__(self, **kwargs): - self._client = kwargs.pop("client", None) or CatalogClient.get_default_client() - - self._attributes = {} - self._modified = set() - self._deleted = False - - self._initialize( - id=kwargs.pop("id", None), - saved=kwargs.pop("_saved", False), - relationships=kwargs.pop("_relationships", None), - related_objects=kwargs.pop("_related_objects", None), - **kwargs, - ) - - def __del__(self): - for attr_type in self._attribute_types.values(): - attr_type.__delete__(self, validate=False) - - def _clear_attributes(self): - self._mapping_attribute_instances = {} - self._clear_modified_attributes() - - # This only applies to top-level attributes - sticky_attributes = {} - for name, value in self._attributes.items(): - attribute_type = self._attribute_types.get(name) - if attribute_type._sticky: - sticky_attributes[name] = value - self._attributes = sticky_attributes - - def _initialize( - self, - id=None, - saved=False, - relationships=None, - related_objects=None, - deleted=False, - **kwargs, - ): - self._clear_attributes() - self._saved = saved - self._deleted = deleted - - # This is an immutable attribute; can only be set once - if id: - self.id = id - - for name, val in kwargs.items(): - # Only silently ignore unknown attributes if data came from service - attribute_definition = ( - self._attribute_types.get(name) - if saved - else self._get_attribute_type(name) - ) - if attribute_definition is not None: - attribute_definition.__set__(self, val, validate=not saved) - - for name, t in self._reference_attribute_types.items(): - id_value = kwargs.get(t.id_field) - if id_value is not None: - object_value = kwargs.get(name) - if object_value and object_value.id != id_value: - message = ( - "Conflicting related object reference: '{}' was '{}' " - "but '{}' was '{}'" - ).format(t.id_field, id_value, name, object_value.id) - raise AttributeValidationError(message) - - if related_objects: - related_object = related_objects.get( - (t.reference_class._doc_type, id_value) - ) - if related_object is not None: - t.__set__(self, related_object, validate=not saved) - - if saved: - self._clear_modified_attributes() - - def __repr__(self): - name = getattr(self, "name", None) - if name is None: - name = "" - elif isinstance(name, bytes): - name = name.decode() - - sections = [ - # Document type and ID - "{}: {}\n id: {}".format(self.__class__.__name__, name, self.id) - ] - # related objects and their ids - for name in sorted(self._reference_attribute_types): - t = self._reference_attribute_types[name] - # as a temporary hack for image upload, handle missing image_id field - sections.append(" {}: {}".format(name, getattr(self, t.id_field, None))) - - if self.created: - sections.append(" created: {:%c}".format(self.created)) - - if self.state == DocumentState.DELETED: - sections.append("* Deleted from the Descartes Labs catalog.") - elif self.state != DocumentState.SAVED: - sections.append( - "* Not up-to-date in the Descartes Labs catalog. Call `.save()` to save or update this record." - ) - - return "\n".join(sections) - - def __eq__(self, other): - if ( - not isinstance(other, self.__class__) - or self.id != other.id - or self.state != other.state - ): - return False - - return super(CatalogObjectBase, self).__eq__(other) - - def __setattr__(self, name, value): - if not (name.startswith("_") or isinstance(value, MethodType)): - # Make sure it's a proper attribute - self._get_attribute_type(name) - super(CatalogObjectBase, self).__setattr__(name, value) - - @property - def is_modified(self): - """bool: Whether any attributes were changed (see `state`). - - ``True`` if any of the attribute values changed since the last time this - catalog object was retrieved or saved. ``False`` otherwise. - - Note that assigning an identical value does not affect the state. - """ - return bool(self._modified) - - @classmethod - def _get_attribute_type(cls, name): - try: - return cls._attribute_types[name] - except KeyError: - raise AttributeError("{} has no attribute {}".format(cls.__name__, name)) - - @classmethod - def _get_model_class(cls, serialized_object): - class_type = serialized_object["type"] - klass = cls._model_classes_by_type_and_derived_type.get((class_type, None)) - - if klass._derived_type_switch: - derived_type = serialized_object["attributes"][klass._derived_type_switch] - klass = cls._model_classes_by_type_and_derived_type.get( - (class_type, derived_type) - ) - - return klass - - @classmethod - def _serialize_filter_attribute(cls, name, value): - """Serialize a single value for a filter. - - Allow the given value to be serialized using the serialization logic - of the given attribute. This method should only be used to serialize - a filter value. - - Parameters - ---------- - name : str - The name of the attribute used for serialization logic. - value : object - The value to be serialized. - - Returns - ------- - name : str - The name to use in the serialized filter - value : str - The serialized value - - Raises - ------ - AttributeValidationError - If the attribute is not serializable. - """ - attribute_type = cls._get_attribute_type(name) - - if isinstance(attribute_type, ListAttribute): - # The type is contained in the list - attribute_type = attribute_type._attribute_type - - if isinstance(attribute_type, CatalogObjectReference): - # This is a little tricky... If the value is an instance containing - # `id`, the name was already updated by the Expression to have `_id` - # appended to it, and the value will be converted to a string below. - # But if the value is a string, this hasn't happened yet and we need - # to update the name... - if value is None or isinstance(value, str): - return (attribute_type.id_field, value) - - return (name, attribute_type.serialize(value)) - - def _set_modified(self, attr_name, changed=True, validate=True): - # Verify it is allowed to to be set - attr = self._get_attribute_type(attr_name) - if validate: - if attr._readonly: - raise AttributeValidationError( - "Can't set '{}' because it is a readonly attribute".format( - attr_name - ) - ) - if not attr._mutable and attr_name in self._attributes: - raise AttributeValidationError( - "Can't set '{}' because it is an immutable attribute".format( - attr_name - ) - ) - - if changed: - self._modified.add(attr_name) - - def _serialize(self, attrs, jsonapi_format=False): - serialized = {} - for name in attrs: - value = self._attributes[name] - attribute_type = self._get_attribute_type(name) - if attribute_type._serializable: - serialized[name] = attribute_type.serialize( - value, jsonapi_format=jsonapi_format - ) - - return serialized - - @check_deleted - def update(self, ignore_errors=False, **kwargs): - """Update multiple attributes at once using the given keyword arguments. - - Parameters - ---------- - ignore_errors : bool, optional - ``False`` by default. When set to ``True``, it will suppress - `AttributeValidationError` and `AttributeError`. Any given attribute that - causes one of these two exceptions will be ignored, all other attributes - will be set to the given values. - - Raises - ------ - AttributeValidationError - If one or more of the attributes being updated are immutable. - AttributeError - If one or more of the attributes are not part of this catalog object. - DeletedObjectError - If this catalog object was deleted. - """ - original_values = dict(self._attributes) - original_modified = set(self._modified) - - for name, val in kwargs.items(): - try: - # A non-existent attribute will raise an AttributeError - attribute_definition = self._get_attribute_type(name) - - # A bad value will raise an AttributeValidationError - attribute_definition.__set__(self, val) - except (AttributeError, AttributeValidationError): - if ignore_errors: - pass - else: - self._attributes = original_values - self._modified = original_modified - raise - - def serialize(self, modified_only=False, jsonapi_format=False): - """Serialize the catalog object into json. - - Parameters - ---------- - modified_only : bool, optional - Whether only modified attributes should be serialized. ``False`` by - default. If set to ``True``, only those attributes that were modified since - the last time the catalog object was retrieved or saved will be included. - jsonapi_format : bool, optional - Whether to use the ``data`` element for catalog objects. ``False`` by - default. When set to ``False``, the serialized data will directly contain - the attributes of the catalog object. If set to ``True``, the serialized - data will follow the exact JSONAPI with a top-level ``data`` element which - contains ``id``, ``type``, and ``attributes``. The latter will contain - the attributes of the catalog object. - """ - keys = self._modified if modified_only else self._attributes.keys() - attributes = self._serialize(keys, jsonapi_format=jsonapi_format) - - if jsonapi_format: - return self._client.jsonapi_document(self._doc_type, attributes, self.id) - else: - return attributes - - def _clear_modified_attributes(self): - self._modified = set() - - @property - def state(self): - """DocumentState: The state of this catalog object.""" - if self._deleted: - return DocumentState.DELETED - - if self._saved is False: - return DocumentState.UNSAVED - elif self.is_modified: - return DocumentState.MODIFIED - else: - return DocumentState.SAVED - - @classmethod - def get(cls, id, client=None, request_params=None, headers=None): - """Get an existing object from the Descartes Labs catalog. - - If the Descartes Labs catalog object is found, it will be returned in the - `~descarteslabs.catalog.DocumentState.SAVED` state. Subsequent changes will - put the instance in the `~descarteslabs.catalog.DocumentState.MODIFIED` state, - and you can use :py:meth:`save` to commit those changes and update the Descartes - Labs catalog object. Also see the example for :py:meth:`save`. - - For bands, if you request a specific band type, for example - :meth:`SpectralBand.get`, you will only receive that type. Use :meth:`Band.get` - to receive any type. - - Parameters - ---------- - id : str - The id of the object you are requesting. - client : CatalogClient, optional - A `CatalogClient` instance to use for requests to the Descartes Labs - catalog. The - :py:meth:`~descarteslabs.catalog.CatalogClient.get_default_client` will - be used if not set. - - Returns - ------- - :py:class:`~descarteslabs.catalog.CatalogObject` or None - The object you requested, or ``None`` if an object with the given `id` - does not exist in the Descartes Labs catalog. - - Raises - ------ - ~descarteslabs.exceptions.ClientError or ~descarteslabs.exceptions.ServerError - :ref:`Spurious exception ` that can occur during a - network request. - """ - try: - data, related_objects = cls._send_data( - method=HttpRequestMethod.GET, - id=id, - client=client, - request_params=request_params, - headers=headers, - ) - except NotFoundError: - return None - - model_class = cls._get_model_class(data) - if not issubclass(model_class, cls): - return None - - return model_class( - id=data["id"], - client=client, - _saved=True, - _relationships=data.get("relationships"), - _related_objects=related_objects, - **data["attributes"], - ) - - @classmethod - def get_or_create( - cls, id, client=None, request_params=None, headers=None, **kwargs - ): - """Get an existing object from the Descartes Labs catalog or create a new object. - - If the Descartes Labs catalog object is found, and the remainder of the - arguments do not differ from the values in the retrieved instance, it will be - returned in the `~descarteslabs.catalog.DocumentState.SAVED` state. - - If the Descartes Labs catalog object is found, and the remainder of the - arguments update one or more values in the instance, it will be returned in - the `~descarteslabs.catalog.DocumentState.MODIFIED` state. - - If the Descartes Labs catalog object is not found, it will be created and the - state will be `~descarteslabs.catalog.DocumentState.UNSAVED`. Also see the - example for :py:meth:`save`. - - Parameters - ---------- - id : str - The id of the object you are requesting. - client : CatalogClient, optional - A `CatalogClient` instance to use for requests to the Descartes Labs - catalog. The - :py:meth:`~descarteslabs.catalog.CatalogClient.get_default_client` will - be used if not set. - kwargs : dict, optional - With the exception of readonly attributes (`created`, `modified`), any - attribute of a catalog object can be set as a keyword argument (Also see - `ATTRIBUTES`). - - Returns - ------- - :py:class:`~descarteslabs.catalog.CatalogObject` - The requested catalog object that was retrieved or created. - - """ - obj = cls.get(id, client=client, request_params=request_params, headers=headers) - - if obj is None: - obj = cls(id=id, client=client, **kwargs) - else: - obj.update(**kwargs) - - return obj - - @classmethod - def get_many( - cls, ids, ignore_missing=False, client=None, request_params=None, headers=None - ): - """Get existing objects from the Descartes Labs catalog. - - All returned Descartes Labs catalog objects will be in the - `~descarteslabs.catalog.DocumentState.SAVED` state. Also see :py:meth:`get`. - - For bands, if you request a specific band type, for example - :meth:`SpectralBand.get_many`, you will only receive that type. Use - :meth:`Band.get_many` to receive any type. - - Parameters - ---------- - ids : list(str) - A list of identifiers for the objects you are requesting. - ignore_missing : bool, optional - Whether to raise a `~descarteslabs.exceptions.NotFoundError` - exception if any of the requested objects are not found in the Descartes - Labs catalog. ``False`` by default which raises the exception. - client : CatalogClient, optional - A `CatalogClient` instance to use for requests to the Descartes Labs - catalog. The - :py:meth:`~descarteslabs.catalog.CatalogClient.get_default_client` will - be used if not set. - - Returns - ------- - list(:py:class:`~descarteslabs.catalog.CatalogObject`) - List of the objects you requested in the same order. - - Raises - ------ - NotFoundError - If any of the requested objects do not exist in the Descartes Labs catalog - and `ignore_missing` is ``False``. - ~descarteslabs.exceptions.ClientError or ~descarteslabs.exceptions.ServerError - :ref:`Spurious exception ` that can occur during a - network request. - """ - - if not isinstance(ids, list) or any(not isinstance(id_, str) for id_ in ids): - raise TypeError("ids must be a list of strings") - - id_filter = {"name": "id", "op": "eq", "val": ids} - - raw_objects, related_objects = cls._send_data( - method=HttpRequestMethod.PUT, - client=client, - json={"filter": json.dumps([id_filter], separators=(",", ":"))}, - request_params=request_params, - headers=headers, - ) - - if not ignore_missing: - received_ids = set(obj["id"] for obj in raw_objects) - missing_ids = set(ids) - received_ids - - if len(missing_ids) > 0: - raise NotFoundError( - "Objects not found for ids: {}".format(", ".join(missing_ids)) - ) - - objects = [ - model_class( - id=obj["id"], - client=client, - _saved=True, - _relationships=obj.get("relationships"), - _related_objects=related_objects, - **obj["attributes"], - ) - for obj in raw_objects - for model_class in (cls._get_model_class(obj),) - if issubclass(model_class, cls) - ] - - return objects - - @classmethod - @check_derived - def exists(cls, id, client=None, headers=None): - """Checks if an object exists in the Descartes Labs catalog. - - Parameters - ---------- - id : str - The id of the object. - client : CatalogClient, optional - A `CatalogClient` instance to use for requests to the Descartes Labs - catalog. The - :py:meth:`~descarteslabs.catalog.CatalogClient.get_default_client` will - be used if not set. - - Returns - ------- - bool - Returns ``True`` if the given ``id`` represents an existing object in - the Descartes Labs catalog and ``False`` if not. - - Raises - ------ - ~descarteslabs.exceptions.ClientError or ~descarteslabs.exceptions.ServerError - :ref:`Spurious exception ` that can occur during a - network request. - """ - client = client or CatalogClient.get_default_client() - r = None - try: - r = client.session.head(cls._url + "/" + id, headers=headers) - except NotFoundError: - return False - - return r and r.ok - - @classmethod - @check_derived - def search(cls, client=None, request_params=None, headers=None): - """A search query for all objects of the type this class represents. - - Parameters - ---------- - client : CatalogClient, optional - A `CatalogClient` instance to use for requests to the Descartes Labs - catalog. The - :py:meth:`~descarteslabs.catalog.CatalogClient.get_default_client` will - be used if not set. - - Returns - ------- - Search - An instance of the :py:class:`~descarteslabs.catalog.Search` - class. - - Example - ------- - >>> search = Product.search().limit(10) # doctest: +SKIP - >>> for result in search: # doctest: +SKIP - print(result.name) # doctest: +SKIP - """ - return Search( - cls, client=client, request_params=request_params, headers=headers - ) - - @check_deleted - @deprecate(renamed={"extra_attributes": "request_params"}) - def save(self, request_params=None, headers=None): - """Saves this object to the Descartes Labs catalog. - - If this instance was created using the constructor, it will be in the - `~descarteslabs.catalog.DocumentState.UNSAVED` state and is considered a new - Descartes Labs catalog object that must be created. If the catalog object - already exists in this case, this method will raise a - `~descarteslabs.exceptions.BadRequestError`. - - If this instance was retrieved using :py:meth:`get`, :py:meth:`get_or_create` - or any other way (for example as part of a :py:meth:`search`), and any of its - values were changed, it will be in the - `~descarteslabs.catalog.DocumentState.MODIFIED` state and the existing catalog - object will be updated. - - If this instance was retrieved using :py:meth:`get`, :py:meth:`get_or_create` - or any other way (for example as part of a :py:meth:`search`), and none of its - values were changed, it will be in the - `~descarteslabs.catalog.DocumentState.SAVED` state, and if no `request_params` - parameter is given, nothing will happen. - - Parameters - ---------- - request_params : dict, optional - A dictionary of attributes that should be sent to the catalog along with - attributes already set on this object. Empty by default. If not empty, - and the object is in the `~descarteslabs.catalog.DocumentState.SAVED` - state, it is updated in the Descartes Labs catalog even though no attributes - were modified. - headers : dict, optional - A dictionary of header keys and values to be sent with the request. - - Raises - ------ - ConflictError - If you're trying to create a new object and the object with given ``id`` - already exists in the Descartes Labs catalog. - BadRequestError - If any of the attribute values are invalid. - DeletedObjectError - If this catalog object was deleted. - ~descarteslabs.exceptions.ClientError or ~descarteslabs.exceptions.ServerError - :ref:`Spurious exception ` that can occur during a - network request. - """ - if self.state == DocumentState.SAVED and not request_params: - # Noop, already saved in the catalog - return - - if self.state == DocumentState.UNSAVED: - method = HttpRequestMethod.POST - json = self.serialize(modified_only=False, jsonapi_format=True) - else: - method = HttpRequestMethod.PATCH - json = self.serialize(modified_only=True, jsonapi_format=True) - - if request_params: - json["data"]["attributes"].update(request_params) - - data, related_objects = self._send_data( - method=method, id=self.id, json=json, client=self._client, headers=headers - ) - - self._initialize( - id=data["id"], - saved=True, - relationships=data.get("relationships"), - related_objects=related_objects, - **data["attributes"], - ) - - @check_deleted - def reload(self, request_params=None, headers=None): - """Reload all attributes from the Descartes Labs catalog. - - Refresh the state of this catalog object from the object in the Descartes Labs - catalog. This may be necessary if there are concurrent updates and the object - in the Descartes Labs catalog was updated from another client. The instance - state must be in the `~descarteslabs.catalog.DocumentState.SAVED` state. - - If you want to revert a modified object to its original one, you should use - :py:meth:`get` on the object class with the object's `id`. - - Raises - ------ - ValueError - If the catalog object is not in the ``SAVED`` state. - DeletedObjectError - If this catalog object was deleted. - ~descarteslabs.exceptions.ClientError or ~descarteslabs.exceptions.ServerError - :ref:`Spurious exception ` that can occur during a - network request. - """ - - if self.state != DocumentState.SAVED: - raise ValueError( - "{} instance with id {} has not been saved".format( - self.__class__.__name__, self.id - ) - ) - - data, related_objects = self._send_data( - method=HttpRequestMethod.GET, - id=self.id, - client=self._client, - request_params=request_params, - headers=headers, - ) - - # this will effectively wipe all current state & caching - self._initialize( - id=data["id"], - saved=True, - relationships=data.get("relationships"), - related_objects=related_objects, - **data["attributes"], - ) - - @hybridmethod - @check_derived - def delete(cls, id, client=None): - """Delete the catalog object with the given `id`. - - Parameters - ---------- - id : str - The id of the object to be deleted. - client : CatalogClient, optional - A `CatalogClient` instance to use for requests to the Descartes Labs - catalog. The - :py:meth:`~descarteslabs.catalog.CatalogClient.get_default_client` will - be used if not set. - - Returns - ------- - bool - ``True`` if this object was successfully deleted. ``False`` if the - object was not found. - - Raises - ------ - ConflictError - If the object has related objects (bands, images) that exist. - ~descarteslabs.exceptions.ClientError or ~descarteslabs.exceptions.ServerError - :ref:`Spurious exception ` that can occur during a - network request. - - Example - ------- - >>> Image.delete('my-image-id') # doctest: +SKIP - - There is also an instance ``delete`` method that can be used to delete an object. - It accepts no parameters and does not return anything. Once deleted, you cannot - use the catalog object and should release any references. - """ - if client is None: - client = CatalogClient.get_default_client() - - try: - client.session.delete(cls._url + "/" + id) - return True # non-200 will raise an exception - except NotFoundError: - return False - - @delete.instancemethod - @check_deleted - def delete(self): - """Delete this catalog object from the Descartes Labs catalog. - - Once deleted, you cannot use the catalog object and should release any - references. - - Raises - ------ - DeletedObjectError - If this catalog object was already deleted. - UnsavedObjectError - If this catalog object is being deleted without having been saved. - ~descarteslabs.exceptions.ClientError or ~descarteslabs.exceptions.ServerError - :ref:`Spurious exception ` that can occur during a - network request. - """ - if self.state == DocumentState.UNSAVED: - raise UnsavedObjectError("You cannot delete an unsaved object.") - - self._client.session.delete(self._url + "/" + self.id) - self._deleted = True # non-200 will raise an exception - - # This unused method must remain here to support unpickling any - # pickled objects generated prior to v3.2.0. - def _instance_delete(self): - """Obsolete, do not use""" - self.delete() - - @classmethod - @check_derived - def _send_data( - cls, method, id=None, json=None, client=None, request_params=None, headers=None - ): - client = client or CatalogClient.get_default_client() - session_method = getattr(client.session, method.lower()) - url = cls._url - - query_params = {} - if method not in (HttpRequestMethod.POST, HttpRequestMethod.PUT): - url += "/" + urllib.parse.quote(id) - if request_params: - query_params.update(**request_params) - elif request_params: - if json: - json = dict(**json, **request_params) - else: - json = dict(**request_params) - - if cls._default_includes: - query_params["include"] = ",".join(cls._default_includes) - - if query_params: - url += "?" + urllib.parse.urlencode(query_params) - - r = session_method(url, json=json, headers=headers).json() - data = r["data"] - related_objects = cls._load_related_objects(r, client) - - return data, related_objects - - @classmethod - def _load_related_objects(cls, response, client): - related_objects = {} - related_objects_serialized = response.get("included") - if related_objects_serialized: - for serialized in related_objects_serialized: - model_class = cls._get_model_class(serialized) - if model_class: - related = model_class( - id=serialized["id"], - client=client, - _saved=True, - **serialized["attributes"], - ) - related_objects[(serialized["type"], serialized["id"])] = related - - return related_objects - - -class CatalogObject(CatalogObjectBase): - """A base class for all representations of objects in the Descartes Labs catalog.""" - - extra_properties = ExtraPropertiesAttribute( - doc="""dict, optional: A dictionary of up to 50 key/value pairs. - - The keys of this dictionary must be strings, and the values of this dictionary - can be strings or numbers. This allows for more structured custom metadata - to be associated with objects. - """ - ) - tags = ListAttribute( - TypedAttribute(str), - doc="""list, optional: A list of up to 32 tags, each up to 1000 bytes long. - - The tags may support the classification and custom filtering of objects. - - *Filterable*. - """, - ) - - def __new__(cls, *args, **kwargs): - return _new_abstract_class(cls, CatalogObject) - - -class AuthCatalogObject(CatalogObject): - """A base class for all representations of objects in the Descartes Labs catalog - that support ACLs. - - .. _auth_note: - - Note - ---- - The `readers` and `writers` IDs must be prefixed with ``email:``, ``user:``, - ``group:`` or ``org:``. The `owners` IDs must be prefixed with ``org:`` or ``user:``. - Using ``org:`` as an owner will assign those privileges only to administrators - for that organization; using ``org:`` as a reader or writer assigns those - privileges to everyone in that organization. The `readers` and `writers` attributes - are only visible in full to an owner. If you are a reader or a writer those - attributes will only display the elements of those lists by which you are gaining - read or write access. - - Any user with owner privileges is able to read the object attributes and data, - modify the object attributes, and delete the object, including reading and modifying the - `owners`, `writers`, and `readers` attributes. - - Any user with writer privileges is able to read the object attributes and data, - modify the object attributes except for `owners`, `writers`, and `readers`. - A writer cannot delete the object. A writer can read the `owners` attribute but - can only read the elements of `writers` and `readers` by which they gain access - to the object. - - Any user with reader privileges is able to read the objects attributes and data. - A reader can read the `owners` attribute but can only read the elements of - `writers` and `readers` by which they gain access to the object. - - Also see :doc:`Sharing Resources `. - """ - - owners = ListAttribute( - TypedAttribute(str), - doc="""list(str), optional: User, group, or organization IDs that own this object. - - Defaults to [``user:current_user``, ``org:current_org``]. The owner can edit, - delete, and change access to this object. :ref:`See this note `. - - *Filterable*. - """, - ) - readers = ListAttribute( - TypedAttribute(str), - doc="""list(str), optional: User, email, group, or organization IDs that can read this object. - - Will be empty by default. This attribute is only available in full to the `owners` - of the object. :ref:`See this note `. - """, - ) - writers = ListAttribute( - TypedAttribute(str), - doc="""list(str), optional: User, group, or organization IDs that can edit this object. - - Writers will also have read permission. Writers will be empty by default. - See note below. This attribute is only available in full to the `owners` of the object. - :ref:`See this note `. - """, - ) - - def __new__(cls, *args, **kwargs): - return _new_abstract_class(cls, AuthCatalogObject) - - def user_is_owner(self, auth=None): - """Check if the authenticated user is an owner, and can - perform actions such as changing ACLs or deleting this object. - - Parameters - ---------- - auth : Auth, optional - The auth object to use for the check. If not provided, the default auth object - will be used. - - Returns - ------- - bool - True if the user is an owner of the object, False otherwise. - """ - if auth is None: - auth = self._client.auth - - return "internal:platform-admin" in auth.payload.get("groups", []) or bool( - set(self.owners) & auth.all_owner_acl_subjects_as_set - ) - - def user_can_write(self, auth=None): - """Check if the authenticated user is an owner or a writer and has permissions - to modify this object. - - Parameters - ---------- - auth : Auth, optional - The auth object to use for the check. If not provided, the default auth object - will be used. - - Returns - ------- - bool - True if the user can modify the object, False otherwise. - """ - if auth is None: - auth = self._client.auth - - return self.user_is_owner(auth) or bool( - set(self.writers) & auth.all_acl_subjects_as_set - ) - - def user_can_read(self, auth=None): - """Check if the authenticated user is an owner, a writer, or a reader - and has permissions to read this object. - - Note it is kind of silly to call this method unless a non-default auth - object is provided, because the default authorized user must have read - permission in order to even retrieve this object. - - Parameters - ---------- - auth : Auth, optional - The auth object to use for the check. If not provided, the default auth object - will be used. - - Returns - ------- - bool - True if the user can read the object, False otherwise. - """ - if auth is None: - auth = self._client.auth - - return ( - "internal:platform-ro" in auth.payload.get("groups", []) - or self.user_can_write(auth) - or bool(set(self.readers) & auth.all_acl_subjects_as_set) - ) diff --git a/descarteslabs/core/catalog/catalog_client.py b/descarteslabs/core/catalog/catalog_client.py deleted file mode 100644 index 52eb65d8..00000000 --- a/descarteslabs/core/catalog/catalog_client.py +++ /dev/null @@ -1,56 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 descarteslabs.auth import Auth -from descarteslabs.config import get_settings - -from ..client.services.service.service import HttpRequestMethod, JsonApiService -from ..common.http.service import DefaultClientMixin - -HttpRequestMethod = HttpRequestMethod - - -class CatalogClient(JsonApiService, DefaultClientMixin): - """ - The CatalogClient handles the HTTP communication with the Descartes Labs catalog. - It is almost sufficient to use the default client that is automatically retrieved - using `get_default_client`. However, if you want to adjust e.g. the retries, you - can create your own. - - Parameters - ---------- - url : str, optional - The URL to use when connecting to the Descartes Labs catalog. Only change - this if you are being asked to use a non-default Descartes Labs catalog. If - not set, then ``descarteslabs.config.get_settings().CATALOG_V2_URL`` will be used. - auth : Auth, optional - The authentication object used when connecting to the Descartes Labs catalog. - This is typically the default :class:`~descarteslabs.auth.Auth` object that uses - the cached authentication - token retrieved with the shell command "$ descarteslabs auth login". - retries : int, optional - The number of retries when there is a problem with the connection. Set this to - zero to disable retries. The default is 3 retries. - """ - - def __init__(self, url=None, auth=None, retries=None): - if auth is None: - auth = Auth.get_default_auth() - - if url is None: - url = get_settings().catalog_v2_url - - super(CatalogClient, self).__init__( - url, auth=auth, retries=retries, rewrite_errors=True - ) diff --git a/descarteslabs/core/catalog/cli/__init__.py b/descarteslabs/core/catalog/cli/__init__.py deleted file mode 100644 index cc32986e..00000000 --- a/descarteslabs/core/catalog/cli/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. - -# 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 .cli import cli - -__all__ = ["cli"] diff --git a/descarteslabs/core/catalog/cli/bands.py b/descarteslabs/core/catalog/cli/bands.py deleted file mode 100644 index 28e92be1..00000000 --- a/descarteslabs/core/catalog/cli/bands.py +++ /dev/null @@ -1,65 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. - -# 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 click - -from .. import BandType, Product, properties as p - -from .utils import serialize - - -@click.group() -def bands(): - """Band management.""" - pass - - -@bands.command() -@click.argument("product-id", type=str) -@click.option("--type", type=click.Choice([bt.value for bt in BandType])) -@click.option("--name", type=str) -@click.option("--tags", type=str, multiple=True) -@click.option("--limit", type=int) -@click.option( - "--output", - type=click.Choice(("id", "short", "json")), - default="short", - help="Output format", -) -def list(product_id, type, name, tags, limit, output): - """List bands.""" - product = Product.get(product_id) - if not product: - raise click.BadParameter(f"Product {product_id} not found") - search = product.bands() - if product_id is not None: - search = search.filter(p.product_id == product_id) - if type is not None: - search = search.filter(p.type == type) - if name is not None: - search = search.filter(p.name == name) - if tags: - search = search.filter(p.tags.any_of(tags)) - if limit: - search = search.limit(limit) - if output == "json": - click.echo(json.dumps([serialize(band) for band in search])) - else: - for band in search: - if output == "id": - click.echo(band.id) - else: - click.echo(band) diff --git a/descarteslabs/core/catalog/cli/blobs.py b/descarteslabs/core/catalog/cli/blobs.py deleted file mode 100644 index bfe379a5..00000000 --- a/descarteslabs/core/catalog/cli/blobs.py +++ /dev/null @@ -1,182 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. - -# 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 click - -from .. import Blob, StorageType, properties as p - -from .utils import serialize - - -@click.group() -def blobs(): - """Blob management.""" - pass - - -@blobs.command() -@click.option("--ids", type=str, multiple=True) -@click.option("--storage-type", type=click.Choice([st.value for st in StorageType])) -@click.option("--namespace", type=str) -@click.option("--prefix", type=str, help="Name prefix") -@click.option("--access-id", type=str, help="Administrative users only") -@click.option("--owners", type=str, multiple=True) -@click.option("--writers", type=str, multiple=True, help="Administrative users only") -@click.option("--readers", type=str, multiple=True, help="Administrative users only") -@click.option("--tags", type=str, multiple=True) -@click.option("--limit", type=int) -@click.option( - "--output", - type=click.Choice(("id", "short", "json")), - default="short", - help="Output format", -) -def list( - ids, - storage_type, - namespace, - prefix, - access_id, - owners, - writers, - readers, - tags, - limit, - output, -): - """List blobs.""" - search = Blob.search() - if ids: - search = search.filter(p.id.any_of(ids)) - if storage_type is not None: - search = search.filter(p.storage_type == storage_type) - if namespace is not None: - search = search.filter(p.namespace == namespace) - if prefix is not None: - search = search.filter(p.name.startswith(prefix)) - if access_id: - search = search.filter(p.access_id == access_id) - if owners: - search = search.filter(p.owners.any_of(owners)) - if writers: - search = search.filter(p.writers.any_of(writers)) - if readers: - search = search.filter(p.readers.any_of(readers)) - if tags: - search = search.filter(p.tags.any_of(tags)) - if limit: - search = search.limit(limit) - if output == "json": - click.echo(json.dumps([serialize(blob) for blob in search])) - else: - for blob in search: - if output == "id": - click.echo(blob.id) - else: - click.echo(blob) - - -@blobs.command() -@click.argument("id", type=str) -@click.argument("subject", type=str) -def add_owner(id, subject): - """Add an owner to a blob.""" - blob = Blob.get(id) - if not blob: - raise click.BadParameter(f"blob {id} not found") - if subject in blob.owners: - click.echo(f"{subject} is already an owner of {id}") - else: - blob.owners.append(subject) - blob.save() - click.echo(f"Added {subject} as an owner of {id}") - - -@blobs.command() -@click.argument("id", type=str) -@click.argument("subject", type=str) -def remove_owner(id, subject): - """Remove an owner from a blob.""" - blob = Blob.get(id) - if not blob: - raise click.BadParameter(f"blob {id} not found") - if subject not in blob.owners: - raise click.BadParameter(f"{subject} is not an owner of {id}") - blob.owners.remove(subject) - blob.save() - click.echo(f"Removed {subject} as an owner of {id}") - - -@blobs.command() -@click.argument("id", type=str) -@click.argument("subject", type=str) -def add_writer(id, subject): - """Add a writer to a blob.""" - blob = Blob.get(id) - if not blob: - raise click.BadParameter(f"blob {id} not found") - if subject in blob.writers: - click.echo(f"{subject} is already a writer of {id}") - else: - blob.writers.append(subject) - blob.save() - click.echo(f"Added {subject} as a writer of {id}") - - -@blobs.command() -@click.argument("id", type=str) -@click.argument("subject", type=str) -def remove_writer(id, subject): - """Remove a writer from a blob.""" - blob = Blob.get(id) - if not blob: - raise click.BadParameter(f"blob {id} not found") - if subject not in blob.writers: - raise click.BadParameter(f"{subject} is not a writer of {id}") - blob.writers.remove(subject) - blob.save() - click.echo(f"Removed {subject} as a writer of {id}") - - -@blobs.command() -@click.argument("id", type=str) -@click.argument("subject", type=str) -def add_reader(id, subject): - """Add a reader to a blob.""" - blob = Blob.get(id) - if not blob: - raise click.BadParameter(f"blob {id} not found") - if subject in blob.reader: - click.echo(f"{subject} is already a reader of {id}") - else: - blob.readers.append(subject) - blob.save() - click.echo(f"Added {subject} as a reader of {id}") - - -@blobs.command() -@click.argument("id", type=str) -@click.argument("subject", type=str) -def remove_reader(id, subject): - """Remove a reader from a blob.""" - blob = Blob.get(id) - if not blob: - raise click.BadParameter(f"blob {id} not found") - if subject not in blob.readers: - raise click.BadParameter(f"{subject} is not a reader of {id}") - blob.readers.remove(subject) - blob.save() - click.echo(f"Removed {subject} as a reader of {id}") diff --git a/descarteslabs/core/catalog/cli/cli.py b/descarteslabs/core/catalog/cli/cli.py deleted file mode 100644 index 6afffbad..00000000 --- a/descarteslabs/core/catalog/cli/cli.py +++ /dev/null @@ -1,29 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. - -# 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 click - -from .products import products -from .bands import bands -from .blobs import blobs - - -@click.group() -def cli(): - pass - - -cli.add_command(products) -cli.add_command(blobs) -cli.add_command(bands) diff --git a/descarteslabs/core/catalog/cli/products.py b/descarteslabs/core/catalog/cli/products.py deleted file mode 100644 index 37bccef0..00000000 --- a/descarteslabs/core/catalog/cli/products.py +++ /dev/null @@ -1,171 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. - -# 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 click - -from .. import Product, properties as p - -from .utils import serialize - - -@click.group() -def products(): - """Product management.""" - pass - - -@products.command() -@click.option("--ids", type=str, multiple=True) -@click.option("--core", "is_core", flag_value=True) -@click.option("--user", "is_core", flag_value=False) -@click.option("--all", "is_core", flag_value=None, type=bool) -@click.option("--product-tier", type=click.Choice(("standard", "premium"))) -@click.option("--access-id", type=str, help="Administrative users only") -@click.option("--owners", type=str, multiple=True) -@click.option("--writers", type=str, multiple=True, help="Administrative users only") -@click.option("--readers", type=str, multiple=True, help="Administrative users only") -@click.option("--tags", type=str, multiple=True) -@click.option("--limit", type=int) -@click.option( - "--output", - type=click.Choice(("id", "short", "json")), - default="short", - help="Output format", -) -def list( - ids, is_core, product_tier, access_id, owners, writers, readers, tags, limit, output -): - """List products.""" - search = Product.search() - if ids: - search = search.filter(p.id.any_of(ids)) - if is_core is not None: - search = search.filter(p.is_core == is_core) - if product_tier is not None: - search = search.filter(p.product_tier == product_tier) - if access_id: - search = search.filter(p.access_id == access_id) - if owners: - search = search.filter(p.owners.any_of(owners)) - if writers: - search = search.filter(p.writers.any_of(writers)) - if readers: - search = search.filter(p.readers.any_of(readers)) - if tags: - search = search.filter(p.tags.any_of(tags)) - if limit: - search = search.limit(limit) - if output == "json": - click.echo(json.dumps([serialize(product) for product in search])) - else: - for product in search: - if output == "id": - click.echo(product.id) - else: - click.echo(product) - - -@products.command() -@click.argument("id", type=str) -@click.argument("subject", type=str) -def add_owner(id, subject): - """Add an owner to a product.""" - product = Product.get(id) - if not product: - raise click.BadParameter(f"Product {id} not found") - if subject in product.owners: - click.echo(f"{subject} is already an owner of {id}") - else: - product.owners.append(subject) - product.save() - click.echo(f"Added {subject} as an owner of {id}") - - -@products.command() -@click.argument("id", type=str) -@click.argument("subject", type=str) -def remove_owner(id, subject): - """Remove an owner from a product.""" - product = Product.get(id) - if not product: - raise click.BadParameter(f"Product {id} not found") - if subject not in product.owners: - raise click.BadParameter(f"{subject} is not an owner of {id}") - product.owners.remove(subject) - product.save() - click.echo(f"Removed {subject} as an owner of {id}") - - -@products.command() -@click.argument("id", type=str) -@click.argument("subject", type=str) -def add_writer(id, subject): - """Add a writer to a product.""" - product = Product.get(id) - if not product: - raise click.BadParameter(f"Product {id} not found") - if subject in product.writers: - click.echo(f"{subject} is already an writer of {id}") - else: - product.writers.append(subject) - product.save() - click.echo(f"Added {subject} as an writer of {id}") - - -@products.command() -@click.argument("id", type=str) -@click.argument("subject", type=str) -def remove_writer(id, subject): - """Remove a writer from a product.""" - product = Product.get(id) - if not product: - raise click.BadParameter(f"Product {id} not found") - if subject not in product.writers: - raise click.BadParameter(f"{subject} is not a writer of {id}") - product.writers.remove(subject) - product.save() - click.echo(f"Removed {subject} as a writer of {id}") - - -@products.command() -@click.argument("id", type=str) -@click.argument("subject", type=str) -def add_reader(id, subject): - """Add a reader to a product.""" - product = Product.get(id) - if not product: - raise click.BadParameter(f"Product {id} not found") - if subject in product.reader: - click.echo(f"{subject} is already a reader of {id}") - else: - product.readers.append(subject) - product.save() - click.echo(f"Added {subject} as a reader of {id}") - - -@products.command() -@click.argument("id", type=str) -@click.argument("subject", type=str) -def remove_reader(id, subject): - """Remove a reader from a product.""" - product = Product.get(id) - if not product: - raise click.BadParameter(f"Product {id} not found") - if subject not in product.readers: - raise click.BadParameter(f"{subject} is not a reader of {id}") - product.readers.remove(subject) - product.save() - click.echo(f"Removed {subject} as a reader of {id}") diff --git a/descarteslabs/core/catalog/cli/tests/__init__.py b/descarteslabs/core/catalog/cli/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/descarteslabs/core/catalog/cli/tests/test_cli.py b/descarteslabs/core/catalog/cli/tests/test_cli.py deleted file mode 100644 index c45913da..00000000 --- a/descarteslabs/core/catalog/cli/tests/test_cli.py +++ /dev/null @@ -1,47 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 unittest - -import click.testing - -from ..cli import cli - - -class TestCli(unittest.TestCase): - def setUp(self): - self.runner = click.testing.CliRunner() - - def test_help(self): - result = self.runner.invoke(cli, []) - assert result.exit_code == 2 - assert result.output.startswith("Usage: ") - - # at present, I don't want to test individual commands, - # it'd be a huge pain to mock and would basically just be - # testing catalog itself. - def test_products(self): - result = self.runner.invoke(cli, ["products"]) - assert result.exit_code == 2 - assert result.output.startswith("Usage: ") - - def test_bands(self): - result = self.runner.invoke(cli, ["bands"]) - assert result.exit_code == 2 - assert result.output.startswith("Usage: ") - - def test_blobs(self): - result = self.runner.invoke(cli, ["blobs"]) - assert result.exit_code == 2 - assert result.output.startswith("Usage: ") diff --git a/descarteslabs/core/catalog/cli/utils.py b/descarteslabs/core/catalog/cli/utils.py deleted file mode 100644 index 1a7216bb..00000000 --- a/descarteslabs/core/catalog/cli/utils.py +++ /dev/null @@ -1,19 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. - -# 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. - - -def serialize(obj): - d = {"id": obj.id} - d.update(obj.serialize()) - return d diff --git a/descarteslabs/core/catalog/event_api_destination.py b/descarteslabs/core/catalog/event_api_destination.py deleted file mode 100644 index 58873072..00000000 --- a/descarteslabs/core/catalog/event_api_destination.py +++ /dev/null @@ -1,445 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 ..client.services.service import ThirdPartyService -from ..common.collection import Collection -from .attributes import ( - BooleanAttribute, - ListAttribute, - MappingAttribute, - TypedAttribute, -) -from .catalog_base import ( - AuthCatalogObject, - CatalogClient, -) -from .search import Search - - -class EventConnectionParameter(MappingAttribute): - """Parameter value for a connection. - - Attributes - ---------- - Key: str - The key for this parameter. - Value: str - The value for this parameter. - IsValueSecret: bool - True if the value should be stored as a secret. - """ - - Key = TypedAttribute( - str, - doc="""str: The key for this parameter.""", - ) - Value = TypedAttribute( - str, - doc="""str: The value for this parameter.""", - ) - IsValueSecret = TypedAttribute( - bool, - doc="""str: True if the value should be stored as a secret.""", - ) - - -class EventApiDestinationSearch(Search): - """A search request that iterates over its search results for event api destinations. - - The `EventApiDestinationSearch` is identical to `Search`. - """ - - pass - - -class EventApiDestination(AuthCatalogObject): - """An EventBridge API destination. - - - Parameters - ---------- - client : CatalogClient, optional - A `CatalogClient` instance to use for requests to the Descartes Labs catalog. - The :py:meth:`~descarteslabs.catalog.CatalogClient.get_default_client` will - be used if not set. - kwargs : dict - With the exception of readonly attributes (`created` and `modified`) - and with the exception of properties (`ATTRIBUTES`, `is_modified`, and `state`), - any attribute listed below can also be used as a keyword argument. Also see - `~EventApiDestination.ATTRIBUTES`. - """ - - _doc_type = "event_api_destination" - _url = "/event_api_destinations" - # _collection_type set below due to circular problems - _url_client = ThirdPartyService() - - # EventApiDestination Attributes - namespace = TypedAttribute( - str, - doc="""str: The namespace of this event api destination. - - All event api destinations are stored and indexed under a namespace. - Namespaces are allowed a restricted alphabet (``a-zA-Z0-9:._-``), - and must begin with the user's org name, or their unique user hash if - the user has no org. The required prefix is seperated from the rest of - the namespace name (if any) by a ``:``. If not provided, the namespace - will default to the users org (if any) and the unique user hash. - - *Searchable, sortable*. - """, - ) - name = TypedAttribute( - str, - doc="""str: The name of this event api destination. - - All event_api_destinations are stored and indexed by name. Names are allowed - a restricted alphabet (``a-zA-Z0-9_-``). - - *Searchable, sortable*. - """, - ) - description = TypedAttribute( - str, - doc="""str, optional: A description with further details on this event api destination. - - The description can be up to 80,000 characters and is used by - :py:meth:`Search.find_text`. - - *Searchable* - """, - ) - is_core = BooleanAttribute( - doc="""bool, optional: Whether this is a Descartes Labs catalog core event api destination. - - A core event api destination is an event api destination that is fully managed by Descartes Labs. By - default this value is ``False`` and you must have a special permission - (``internal:core:create``) to set it to ``True``. - - *Filterable, sortable*. - """ - ) - endpoint = TypedAttribute( - str, - doc="""str: The endpoint for this api destination. May contain `*` characters to be - replaced with path parameters from the rule target.""", - ) - method = TypedAttribute( - str, - doc="""str: The HTTP method for this api destination.""", - ) - invocation_rate = TypedAttribute( - int, - doc="""int: The maximum number of invocations per second for this api destination.""", - ) - arn = TypedAttribute( - str, - doc="""str: The ARN of the event api destination.""", - ) - connection_name = TypedAttribute( - str, - doc="""str: The name of the connection for this api destination.""", - ) - connection_description = TypedAttribute( - str, - doc="""str, optional: A description with further details on this event connection. - - The description can be up to 80,000 characters and is used by - :py:meth:`Search.find_text`. - - *Searchable* - """, - ) - connection_header_parameters = ListAttribute( - EventConnectionParameter, - doc="""list(EventConnectionParameter): A list of connection parameters for headers to be - sent on requests on the connection. - """, - ) - connection_query_string_parameters = ListAttribute( - EventConnectionParameter, - doc="""list(EventConnectionParameter): A list of connection parameters for query strings to be - sent on requests on the connection. - """, - ) - connection_body_parameters = ListAttribute( - EventConnectionParameter, - doc="""list(EventConnectionParameter): A list of connection parameters for request bodies to be - sent on requests on the connection. - """, - ) - connection_authorization_type = TypedAttribute( - str, - doc="""str: The authorization type for this api destination.""", - ) - # for connection_authorization_type == "API_KEY" - connection_api_key_name = TypedAttribute( - str, - doc="""str: The API_KEY header name.""", - ) - connection_api_key_value = TypedAttribute( - str, - doc="""str: The API_KEY header value.""", - ) - # for connection_authorization_type == "BASIC" - connection_basic_username = TypedAttribute( - str, - doc="""str: The BASIC username for this api destination.""", - ) - connection_basic_password = TypedAttribute( - str, - doc="""str: The BASIC password for this api destination.""", - ) - # for connection_authorization_type == "OAUTH_CLIENT_CREDENTIALS" - connection_oauth_endpoint = TypedAttribute( - str, - doc="""str: The OAUTH authorization endpoint for this connection.""", - ) - connection_oauth_method = TypedAttribute( - str, - doc="""str: The HTTP method for OAuth authorization for this connection.""", - ) - connection_oauth_client_id = TypedAttribute( - str, - doc="""str: The client ID for OAuth authorization for this connection.""", - ) - connection_oauth_client_secret = TypedAttribute( - str, - doc="""str: The client secret for OAuth authorization for this connection.""", - ) - connection_oauth_header_parameters = ListAttribute( - EventConnectionParameter, - doc="""list(EventConnectionParameter): A list of connection parameters for OAuth request - headers to be sent on OAuth requests on the connection. - """, - ) - connection_oauth_query_string_parameters = ListAttribute( - EventConnectionParameter, - doc="""list(EventConnectionParameter): A list of connection parameters for OAuth request - query strings to be sent on OAuth requests on the connection. - """, - ) - connection_oauth_body_parameters = ListAttribute( - EventConnectionParameter, - doc="""list(EventConnectionParameter): A list of connection parameters for OAuth request - body values to be sent on OAuth requests on the connection. - """, - ) - connection_arn = TypedAttribute( - str, - doc="""str: The ARN of the connection.""", - ) - - @classmethod - def namespace_id(cls, namespace_id, client=None): - """Generate a fully namespaced id. - - Parameters - ---------- - namespace_id : str or None - The unprefixed part of the id that you want prefixed. - client : CatalogClient, optional - A `CatalogClient` instance to use for requests to the Descartes Labs - catalog. The - :py:meth:`~descarteslabs.catalog.CatalogClient.get_default_client` will - be used if not set. - - Returns - ------- - str - The fully namespaced id. - - Example - ------- - >>> namespace = EventApiDestination.namespace_id("myproject") # doctest: +SKIP - 'myorg:myproject' # doctest: +SKIP - """ - if client is None: - client = CatalogClient.get_default_client() - org = client.auth.payload.get("org") - namespace = client.auth.namespace - - if not namespace_id: - if org: - return f"{org}:{namespace}" - else: - return namespace - elif org: - if namespace_id == org or namespace_id.startswith(org + ":"): - return namespace_id - else: - return f"{org}:{namespace_id}" - elif namespace_id == namespace or namespace_id.startswith(namespace + ":"): - return namespace_id - else: - return f"{namespace}:{namespace_id}" - - @classmethod - def get( - cls, - id=None, - namespace=None, - name=None, - client=None, - request_params=None, - headers=None, - ): - """Get an existing EventApiDestination from the Descartes Labs catalog. - - If the EventApiDestination is found, it will be returned in the - `~descarteslabs.catalog.DocumentState.SAVED` state. Subsequent changes will - put the instance in the `~descarteslabs.catalog.DocumentState.MODIFIED` state, - and you can use :py:meth:`save` to commit those changes and update the Descartes - Labs catalog object. Also see the example for :py:meth:`save`. - - Exactly one of the ``id`` and ``name`` parameters must be specified. If ``name`` - is specified, it is used together with the ``namespace`` - parameters to form the corresponding ``id``. - - Parameters - ---------- - id : str, optional - The id of the object you are requesting. Required unless ``name`` is supplied. - May not be specified if ``name`` is specified. - namespace : str, optional - The namespace of the EventApiDestination you wish to retrieve. Defaults to the user's org name - (if any) plus the unique user hash. Ignored unless ``name`` is specified. - name : str, optional - The name of the EventApiDestination you wish to retrieve. Required if ``id`` is not specified. - May not be specified if ``id`` is specified. - client : CatalogClient, optional - A `CatalogClient` instance to use for requests to the Descartes Labs - catalog. The - :py:meth:`~descarteslabs.catalog.CatalogClient.get_default_client` will - be used if not set. - - Returns - ------- - :py:class:`~descarteslabs.catalog.CatalogObject` or None - The object you requested, or ``None`` if an object with the given `id` - does not exist in the Descartes Labs catalog. - - Raises - ------ - ~descarteslabs.exceptions.ClientError or ~descarteslabs.exceptions.ServerError - :ref:`Spurious exception ` that can occur during a - network request. - """ - if (not id and not name) or (id and name): - raise TypeError("Must specify exactly one of id or name parameters") - if not id: - id = f"{cls.namespace_id(namespace)}:{name}" - return super(cls, EventApiDestination).get( - id, client=client, request_params=request_params, headers=headers - ) - - @classmethod - def get_or_create( - cls, - id=None, - namespace=None, - name=None, - client=None, - **kwargs, - ): - """Get an existing object from the Descartes Labs catalog or create a new object. - - If the Descartes Labs catalog object is found, and the remainder of the - arguments do not differ from the values in the retrieved instance, it will be - returned in the `~descarteslabs.catalog.DocumentState.SAVED` state. - - If the Descartes Labs catalog object is found, and the remainder of the - arguments update one or more values in the instance, it will be returned in - the `~descarteslabs.catalog.DocumentState.MODIFIED` state. - - If the Descartes Labs catalog object is not found, it will be created and the - state will be `~descarteslabs.catalog.DocumentState.UNSAVED`. Also see the - example for :py:meth:`save`. - - Parameters - ---------- - id : str, optional - The id of the object you are requesting. Required unless ``name`` is supplied. - May not be specified if ``name`` is specified. - namespace : str, optional - The namespace of the EventApiDestination you wish to retrieve. Defaults to the user's org name - (if any) plus the unique user hash. Ignored unless ``name`` is specified. - name : str, optional - The name of the EventApiDestination you wish to retrieve. Required if ``id`` is not specified. - May not be specified if ``id`` is specified. - client : CatalogClient, optional - A `CatalogClient` instance to use for requests to the Descartes Labs - catalog. The - :py:meth:`~descarteslabs.catalog.CatalogClient.get_default_client` will - be used if not set. - kwargs : dict, optional - With the exception of readonly attributes (`created`, `modified`), any - attribute of a catalog object can be set as a keyword argument (Also see - `ATTRIBUTES`). - - Returns - ------- - :py:class:`~descarteslabs.catalog.CatalogObject` - The requested catalog object that was retrieved or created. - - """ - if (not id and not name) or (id and name): - raise TypeError("Must specify exactly one of id or name parameters") - if not id: - namespace = cls.namespace_id(namespace) - id = f"{namespace}:{name}" - kwargs["namespace"] = namespace - kwargs["name"] = name - - return super(cls, EventApiDestination).get_or_create( - id, client=client, **kwargs - ) - - @classmethod - def search(cls, client=None, request_params=None, headers=None): - """A search query for all event api destinations. - - Return an `~descarteslabs.catalog.EventApiDestinationSearch` instance for searching - event api destinations in the Descartes Labs catalog. - - Parameters - ---------- - client : :class:`CatalogClient`, optional - A `CatalogClient` instance to use for requests to the Descartes Labs - catalog. - - Returns - ------- - :class:`~descarteslabs.catalog.EventApiDestinationSearch` - An instance of the `~descarteslabs.catalog.EventApiDestinationSearch` class - - Example - ------- - >>> from descarteslabs.catalog import EventApiDestination - >>> search = EventApiDestination.search().limit(10) - >>> for result in search: # doctest: +SKIP - ... print(result.name) # doctest: +SKIP - - """ - return EventApiDestinationSearch( - cls, client=client, request_params=request_params, headers=headers - ) - - -class EventApiDestinationCollection(Collection): - _item_type = EventApiDestination - - -# handle circular references -EventApiDestination._collection_type = EventApiDestinationCollection diff --git a/descarteslabs/core/catalog/event_rule.py b/descarteslabs/core/catalog/event_rule.py deleted file mode 100644 index 01adb3ff..00000000 --- a/descarteslabs/core/catalog/event_rule.py +++ /dev/null @@ -1,441 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 ..client.services.service import ThirdPartyService -from ..common.collection import Collection -from .attributes import ( - BooleanAttribute, - ListAttribute, - MappingAttribute, - StringDictAttribute, - TypedAttribute, -) -from .catalog_base import ( - AuthCatalogObject, - CatalogClient, -) -from .search import Search - - -class EventRuleTarget(MappingAttribute): - """Target for an EventRule. - - Attributes - ---------- - name: str - The name of this event target. - arn: str - The ARN of the event target. For example, the ARN of the API Destination. - role_arn: str, optional - The ARN of the role to assume when invoking the target. - input: str, optional - The input to the target. - input_path: str, optional - The path into the event to be mapped to the input to the target. - input_paths_map: dict, optional - Dictionary mapping named substition variables for the input template - to their path in the event. - input_template: str, optional - The template for the input to the target. Substitutions are defined by the input_paths_map. - See the AWS EventBridge documentation for more information. - ttl: int, optional - The time to live for the target. - retries: int, optional - The number of times to retry the target. - dead_letter_arn: str, optional - The ARN of the dead letter queue for the target. - path_parameter_values: list(str), optional - The path parameter values for the target URL. - header_parameters: dict, optional - Dictionary of headers and values to be sent to the target. - query_string_parameters: dict, optional - Dictionary of query parameters and values to be sent to the target. - event_api_destination_id: str - The id of the EventApiDestination for the target. - - Note that `input`, `input_path`, and `input_template` are mutually exclusive, and `input_paths_map` - may only be used together with `input_template`. - """ - - name = TypedAttribute( - str, - doc="""str: The name of this event target.""", - ) - arn = TypedAttribute( - str, - doc="""str: The ARN of the event target. For example, the ARN of the API Destination.""", - ) - role_arn = TypedAttribute( - str, - doc="""str, optional: The ARN of the role to assume when invoking the target.""", - ) - input = TypedAttribute( - str, - doc="""str, optional: The input to the target.""", - ) - input_path = TypedAttribute( - str, - doc="""str, optional: The path into the event to be mapped to the input to the target.""", - ) - input_paths_map = StringDictAttribute( - doc="""dict, optional: Dictionary mapping named substition variables for the input template - to their path in the event.""", - ) - input_template = TypedAttribute( - str, - doc="""str, optional: The template for the input to the target. - Substitutions are defined by the input_paths_map.""", - ) - ttl = TypedAttribute( - int, - doc="""int, optional: The time to live for the target.""", - ) - retries = TypedAttribute( - int, - doc="""int, optional: The number of times to retry the target.""", - ) - dead_letter_arn = TypedAttribute( - str, - doc="""str, optional: The ARN of the dead letter queue for the target.""", - ) - path_parameter_values = ListAttribute( - TypedAttribute(str), - doc="""list(str), optional: The path parameter values for the target URL.""", - ) - header_parameters = StringDictAttribute( - doc="""dict, optional: Dictionary of HTTP headers to send to the target.""", - ) - query_string_parameters = StringDictAttribute( - doc="""dict, optional: Dictionary of HTTP query parameters to send to the target.""", - ) - event_api_destination_id = TypedAttribute( - str, - doc="""str: The id of the EventApiDestination for the target.""", - ) - - -class EventRuleSearch(Search): - """A search request that iterates over its search results for event rules. - - The `EventRuleSearch` is identical to `Search`. - """ - - pass - - -class EventRule(AuthCatalogObject): - """An EventBridge rule to match event subscription targets. - - - Parameters - ---------- - client : CatalogClient, optional - A `CatalogClient` instance to use for requests to the Descartes Labs catalog. - The :py:meth:`~descarteslabs.catalog.CatalogClient.get_default_client` will - be used if not set. - kwargs : dict - With the exception of readonly attributes (`created` and `modified`) - and with the exception of properties (`ATTRIBUTES`, `is_modified`, and `state`), - any attribute listed below can also be used as a keyword argument. Also see - `~EventRule.ATTRIBUTES`. - """ - - _doc_type = "event_rule" - _url = "/event_rules" - # _collection_type set below due to circular problems - _url_client = ThirdPartyService() - - # EventRule Attributes - namespace = TypedAttribute( - str, - doc="""str: The namespace of this event rule. - - All event rules are stored and indexed under a namespace. - Namespaces are allowed a restricted alphabet (``a-zA-Z0-9:._-``), - and must begin with the user's org name, or their unique user hash if - the user has no org. The required prefix is seperated from the rest of - the namespace name (if any) by a ``:``. If not provided, the namespace - will default to the users org (if any) and the unique user hash. - - *Searchable, sortable*. - """, - ) - name = TypedAttribute( - str, - doc="""str: The name of this event rule. - - All event_rules are stored and indexed by name. Names are allowed - a restricted alphabet (``a-zA-Z0-9_-``). - - *Searchable, sortable*. - """, - ) - description = TypedAttribute( - str, - doc="""str, optional: A description with further details on this event rule. - - The description can be up to 80,000 characters and is used by - :py:meth:`Search.find_text`. - - *Searchable* - """, - ) - is_core = BooleanAttribute( - doc="""bool, optional: Whether this is a Descartes Labs catalog core event rule. - - A core event rule is an event rule that is fully managed by Descartes Labs. By - default this value is ``False`` and you must have a special permission - (``internal:core:create``) to set it to ``True``. - - *Filterable, sortable*. - """ - ) - event_pattern = TypedAttribute( - str, - doc="""str: The event pattern for this rule. - - The event pattern is a JSON object that describes the event that the rule will match. - """, - ) - targets = ListAttribute( - EventRuleTarget, - doc="""list(EventRuleTarget): A list of targets to be invoked when the rule matches - an event. - - At least one target is required. - """, - ) - enabled = BooleanAttribute( - doc="""bool, optional: True if the rule is enabled. Non-enabled rules are ignored - during the matching of events. - - *Filterable, sortable*. - """, - ) - event_bus_arn = TypedAttribute( - str, - doc="""str: The ARN of the event bus to which this rule belongs.""", - ) - role_arn = TypedAttribute( - str, - doc="""str, optional: The ARN of the role to assume when targeting another Event Bus. - - *Filterable, sortable*. - """, - ) - rule_arn = TypedAttribute( - str, - doc="""str: The ARN of the rule.""", - ) - - @classmethod - def namespace_id(cls, namespace_id, client=None): - """Generate a fully namespaced id. - - Parameters - ---------- - namespace_id : str or None - The unprefixed part of the id that you want prefixed. - client : CatalogClient, optional - A `CatalogClient` instance to use for requests to the Descartes Labs - catalog. The - :py:meth:`~descarteslabs.catalog.CatalogClient.get_default_client` will - be used if not set. - - Returns - ------- - str - The fully namespaced id. - - Example - ------- - >>> namespace = EventRule.namespace_id("myproject") # doctest: +SKIP - 'myorg:myproject' # doctest: +SKIP - """ - if client is None: - client = CatalogClient.get_default_client() - org = client.auth.payload.get("org") - namespace = client.auth.namespace - - if not namespace_id: - if org: - return f"{org}:{namespace}" - else: - return namespace - elif org: - if namespace_id == org or namespace_id.startswith(org + ":"): - return namespace_id - else: - return f"{org}:{namespace_id}" - elif namespace_id == namespace or namespace_id.startswith(namespace + ":"): - return namespace_id - else: - return f"{namespace}:{namespace_id}" - - @classmethod - def get( - cls, - id=None, - namespace=None, - name=None, - client=None, - request_params=None, - headers=None, - ): - """Get an existing EventRule from the Descartes Labs catalog. - - If the EventRule is found, it will be returned in the - `~descarteslabs.catalog.DocumentState.SAVED` state. Subsequent changes will - put the instance in the `~descarteslabs.catalog.DocumentState.MODIFIED` state, - and you can use :py:meth:`save` to commit those changes and update the Descartes - Labs catalog object. Also see the example for :py:meth:`save`. - - Exactly one of the ``id`` and ``name`` parameters must be specified. If ``name`` - is specified, it is used together with the ``namespace`` - parameters to form the corresponding ``id``. - - Parameters - ---------- - id : str, optional - The id of the object you are requesting. Required unless ``name`` is supplied. - May not be specified if ``name`` is specified. - namespace : str, optional - The namespace of the EventRule you wish to retrieve. Defaults to the user's org name - (if any) plus the unique user hash. Ignored unless ``name`` is specified. - name : str, optional - The name of the EventRule you wish to retrieve. Required if ``id`` is not specified. - May not be specified if ``id`` is specified. - client : CatalogClient, optional - A `CatalogClient` instance to use for requests to the Descartes Labs - catalog. The - :py:meth:`~descarteslabs.catalog.CatalogClient.get_default_client` will - be used if not set. - - Returns - ------- - :py:class:`~descarteslabs.catalog.CatalogObject` or None - The object you requested, or ``None`` if an object with the given `id` - does not exist in the Descartes Labs catalog. - - Raises - ------ - ~descarteslabs.exceptions.ClientError or ~descarteslabs.exceptions.ServerError - :ref:`Spurious exception ` that can occur during a - network request. - """ - if (not id and not name) or (id and name): - raise TypeError("Must specify exactly one of id or name parameters") - if not id: - id = f"{cls.namespace_id(namespace)}:{name}" - return super(cls, EventRule).get( - id, client=client, request_params=request_params, headers=headers - ) - - @classmethod - def get_or_create( - cls, - id=None, - namespace=None, - name=None, - client=None, - **kwargs, - ): - """Get an existing object from the Descartes Labs catalog or create a new object. - - If the Descartes Labs catalog object is found, and the remainder of the - arguments do not differ from the values in the retrieved instance, it will be - returned in the `~descarteslabs.catalog.DocumentState.SAVED` state. - - If the Descartes Labs catalog object is found, and the remainder of the - arguments update one or more values in the instance, it will be returned in - the `~descarteslabs.catalog.DocumentState.MODIFIED` state. - - If the Descartes Labs catalog object is not found, it will be created and the - state will be `~descarteslabs.catalog.DocumentState.UNSAVED`. Also see the - example for :py:meth:`save`. - - Parameters - ---------- - id : str, optional - The id of the object you are requesting. Required unless ``name`` is supplied. - May not be specified if ``name`` is specified. - namespace : str, optional - The namespace of the EventRule you wish to retrieve. Defaults to the user's org name - (if any) plus the unique user hash. Ignored unless ``name`` is specified. - name : str, optional - The name of the EventRule you wish to retrieve. Required if ``id`` is not specified. - May not be specified if ``id`` is specified. - client : CatalogClient, optional - A `CatalogClient` instance to use for requests to the Descartes Labs - catalog. The - :py:meth:`~descarteslabs.catalog.CatalogClient.get_default_client` will - be used if not set. - kwargs : dict, optional - With the exception of readonly attributes (`created`, `modified`), any - attribute of a catalog object can be set as a keyword argument (Also see - `ATTRIBUTES`). - - Returns - ------- - :py:class:`~descarteslabs.catalog.CatalogObject` - The requested catalog object that was retrieved or created. - - """ - if (not id and not name) or (id and name): - raise TypeError("Must specify exactly one of id or name parameters") - if not id: - namespace = cls.namespace_id(namespace) - id = f"{namespace}:{name}" - kwargs["namespace"] = namespace - kwargs["name"] = name - - return super(cls, EventRule).get_or_create(id, client=client, **kwargs) - - @classmethod - def search(cls, client=None, request_params=None, headers=None): - """A search query for all event rules. - - Return an `~descarteslabs.catalog.EventRuleSearch` instance for searching - event rules in the Descartes Labs catalog. - - Parameters - ---------- - client : :class:`CatalogClient`, optional - A `CatalogClient` instance to use for requests to the Descartes Labs - catalog. - - Returns - ------- - :class:`~descarteslabs.catalog.EventRuleSearch` - An instance of the `~descarteslabs.catalog.EventRuleSearch` class - - Example - ------- - >>> from descarteslabs.catalog import EventRule - >>> search = EventRule.search().limit(10) - >>> for result in search: # doctest: +SKIP - ... print(result.name) # doctest: +SKIP - - """ - return EventRuleSearch( - cls, client=client, request_params=request_params, headers=headers - ) - - -class EventRuleCollection(Collection): - _item_type = EventRule - - -# handle circular references -EventRule._collection_type = EventRuleCollection diff --git a/descarteslabs/core/catalog/event_schedule.py b/descarteslabs/core/catalog/event_schedule.py deleted file mode 100644 index 0b474332..00000000 --- a/descarteslabs/core/catalog/event_schedule.py +++ /dev/null @@ -1,361 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 ..client.services.service import ThirdPartyService -from ..common.collection import Collection -from .attributes import ( - BooleanAttribute, - Timestamp, - TypedAttribute, -) -from .catalog_base import ( - AuthCatalogObject, - CatalogClient, -) -from .search import Search - - -class EventScheduleSearch(Search): - """A search request that iterates over its search results for event schedules. - - The `EventScheduleSearch` is identical to `GeoSearch`. - """ - - pass - - -class EventSchedule(AuthCatalogObject): - """A Scheduled Event. - - - Parameters - ---------- - client : CatalogClient, optional - A `CatalogClient` instance to use for requests to the Descartes Labs catalog. - The :py:meth:`~descarteslabs.catalog.CatalogClient.get_default_client` will - be used if not set. - kwargs : dict - With the exception of readonly attributes (`created`, `modified`, and `owner`) - and with the exception of properties (`ATTRIBUTES`, `is_modified`, and `state`), - any attribute listed below can also be used as a keyword argument. Also see - `~EventSchedule.ATTRIBUTES`. - """ - - _doc_type = "event_schedule" - _url = "/event_schedules" - # _collection_type set below due to circular problems - _url_client = ThirdPartyService() - - # EventSchedule Attributes - namespace = TypedAttribute( - str, - doc="""str: The namespace of this event schedule. - - All event schedules are stored and indexed under a namespace. - Namespaces are allowed a restricted alphabet (``a-zA-Z0-9:._-``), - and must begin with the user's org name, or their unique user hash if - the user has no org. The required prefix is seperated from the rest of - the namespace name (if any) by a ``:``. If not provided, the namespace - will default to the users org (if any) and the unique user hash. - - *Searchable, sortable*. - """, - ) - name = TypedAttribute( - str, - doc="""str: The name of this event schedule. - - All event_schedules are stored and indexed by name. Names are allowed - a restricted alphabet (``a-zA-Z0-9_-``). - - *Searchable, sortable*. - """, - ) - description = TypedAttribute( - str, - doc="""str, optional: A description with further details on this event schedule. - - The description can be up to 80,000 characters and is used by - :py:meth:`Search.find_text`. - - *Searchable* - """, - ) - arn = TypedAttribute( - str, - doc="""str: The Amazon Resource Name (ARN) for this event schedule. - - The ARN is a unique identifier for this event schedule in the AWS ecosystem. - - *Searchable, sortable*. - """, - ) - schedule = TypedAttribute( - str, - doc="""str: The schedule expression for this event schedule. - - The schedule expression can be one of three forms. For a single event, - use the `at()` form. For an event which is triggered on a fixed interval, - use the `rate()` form. For a cron-type event which recurs, use the `cron()` - form. See the AWS EventBridge Scheduler documentation at - https://docs.aws.amazon.com/scheduler/latest/UserGuide/schedule-types.html - for the complete syntax of these expressions. - - *Searchable, sortable*. - """, - ) - schedule_timezone = TypedAttribute( - str, - doc="""str: The timezone for the schedule expression. Must be a valid timezone string - as defined by the IANA ZoneInfoiana database. - - *Searchable, sortable*. - """, - ) - start_datetime = Timestamp( - doc="""str or datetime, optional: Timestamp when the schedule should begin. - - *Filterable, sortable*. - """ - ) - end_datetime = Timestamp( - doc="""str or datetime, optional: Timestamp when the schedule should be expired and deleted. - - *Filterable, sortable*. - """ - ) - flexible_time_window = TypedAttribute( - int, - doc="""int, optional: The maximum amount of time in seconds that the event schedule - can be delayed. The event will be generated at a random time within this window, - beginning with the nominal scheduled time. - - *Filterable, sortable*. - """, - ) - enabled = BooleanAttribute( - doc="""bool, optional: True if the schedule is enabled. Non-enabled schedules are ignored - during the matching of events. - - *Filterable, sortable*. - """, - ) - expires = Timestamp( - doc="""str or datetime, readonly. Timestamp when the schedule will be expired and deleted. - Set automatically when the schedule is created or updated. - - *Filterable, sortable*. - """, - ) - - @classmethod - def namespace_id(cls, namespace_id, client=None): - """Generate a fully namespaced id. - - Parameters - ---------- - namespace_id : str or None - The unprefixed part of the id that you want prefixed. - client : CatalogClient, optional - A `CatalogClient` instance to use for requests to the Descartes Labs - catalog. The - :py:meth:`~descarteslabs.catalog.CatalogClient.get_default_client` will - be used if not set. - - Returns - ------- - str - The fully namespaced id. - - Example - ------- - >>> namespace = EventSchedule.namespace_id("myproject") # doctest: +SKIP - 'myorg:myproject' # doctest: +SKIP - """ - if client is None: - client = CatalogClient.get_default_client() - org = client.auth.payload.get("org") - namespace = client.auth.namespace - - if not namespace_id: - if org: - return f"{org}:{namespace}" - else: - return namespace - elif org: - if namespace_id == org or namespace_id.startswith(org + ":"): - return namespace_id - else: - return f"{org}:{namespace_id}" - elif namespace_id == namespace or namespace_id.startswith(namespace + ":"): - return namespace_id - else: - return f"{namespace}:{namespace_id}" - - @classmethod - def get( - cls, - id=None, - namespace=None, - name=None, - client=None, - request_params=None, - headers=None, - ): - """Get an existing EventSchedule from the Descartes Labs catalog. - - If the EventSchedule is found, it will be returned in the - `~descarteslabs.catalog.DocumentState.SAVED` state. Subsequent changes will - put the instance in the `~descarteslabs.catalog.DocumentState.MODIFIED` state, - and you can use :py:meth:`save` to commit those changes and update the Descartes - Labs catalog object. Also see the example for :py:meth:`save`. - - Exactly one of the ``id`` and ``name`` parameters must be specified. If ``name`` - is specified, it is used together with the ``namespace`` - parameters to form the corresponding ``id``. - - Parameters - ---------- - id : str, optional - The id of the object you are requesting. Required unless ``name`` is supplied. - May not be specified if ``name`` is specified. - namespace : str, optional - The namespace of the EventSchedule you wish to retrieve. Defaults to the user's org name - (if any) plus the unique user hash. Ignored unless ``name`` is specified. - name : str, optional - The name of the EventSchedule you wish to retrieve. Required if ``id`` is not specified. - May not be specified if ``id`` is specified. - client : CatalogClient, optional - A `CatalogClient` instance to use for requests to the Descartes Labs - catalog. The - :py:meth:`~descarteslabs.catalog.CatalogClient.get_default_client` will - be used if not set. - - Returns - ------- - :py:class:`~descarteslabs.catalog.CatalogObject` or None - The object you requested, or ``None`` if an object with the given `id` - does not exist in the Descartes Labs catalog. - - Raises - ------ - ~descarteslabs.exceptions.ClientError or ~descarteslabs.exceptions.ServerError - :ref:`Spurious exception ` that can occur during a - network request. - """ - if (not id and not name) or (id and name): - raise TypeError("Must specify exactly one of id or name parameters") - if not id: - id = f"{cls.namespace_id(namespace)}:{name}" - return super(cls, EventSchedule).get( - id, client=client, request_params=request_params, headers=headers - ) - - @classmethod - def get_or_create( - cls, - id=None, - namespace=None, - name=None, - client=None, - **kwargs, - ): - """Get an existing object from the Descartes Labs catalog or create a new object. - - If the Descartes Labs catalog object is found, and the remainder of the - arguments do not differ from the values in the retrieved instance, it will be - returned in the `~descarteslabs.catalog.DocumentState.SAVED` state. - - If the Descartes Labs catalog object is found, and the remainder of the - arguments update one or more values in the instance, it will be returned in - the `~descarteslabs.catalog.DocumentState.MODIFIED` state. - - If the Descartes Labs catalog object is not found, it will be created and the - state will be `~descarteslabs.catalog.DocumentState.UNSAVED`. Also see the - example for :py:meth:`save`. - - Parameters - ---------- - id : str, optional - The id of the object you are requesting. Required unless ``name`` is supplied. - May not be specified if ``name`` is specified. - namespace : str, optional - The namespace of the EventSchedule you wish to retrieve. Defaults to the user's org name - (if any) plus the unique user hash. Ignored unless ``name`` is specified. - name : str, optional - The name of the EventSchedule you wish to retrieve. Required if ``id`` is not specified. - May not be specified if ``id`` is specified. - client : CatalogClient, optional - A `CatalogClient` instance to use for requests to the Descartes Labs - catalog. The - :py:meth:`~descarteslabs.catalog.CatalogClient.get_default_client` will - be used if not set. - kwargs : dict, optional - With the exception of readonly attributes (`created`, `modified`), any - attribute of a catalog object can be set as a keyword argument (Also see - `ATTRIBUTES`). - - Returns - ------- - :py:class:`~descarteslabs.catalog.CatalogObject` - The requested catalog object that was retrieved or created. - - """ - if (not id and not name) or (id and name): - raise TypeError("Must specify exactly one of id or name parameters") - if not id: - namespace = cls.namespace_id(namespace) - id = f"{namespace}:{name}" - kwargs["namespace"] = namespace - kwargs["name"] = name - - return super(cls, EventSchedule).get_or_create(id, client=client, **kwargs) - - @classmethod - def search(cls, client=None, request_params=None, headers=None): - """A search query for all event schedules. - - Return an `~descarteslabs.catalog.EventScheduleSearch` instance for searching - event schedules in the Descartes Labs catalog. - - Parameters - ---------- - client : :class:`CatalogClient`, optional - A `CatalogClient` instance to use for requests to the Descartes Labs - catalog. - - Returns - ------- - :class:`~descarteslabs.catalog.EventScheduleSearch` - An instance of the `~descarteslabs.catalog.EventScheduleSearch` class - - Example - ------- - >>> from descarteslabs.catalog import EventSchedule - >>> search = EventSchedule.search().limit(10) - >>> for result in search: # doctest: +SKIP - ... print(result.name) # doctest: +SKIP - - """ - return EventScheduleSearch( - cls, client=client, request_params=request_params, headers=headers - ) - - -class EventScheduleCollection(Collection): - _item_type = EventSchedule - - -# handle circular references -EventSchedule._collection_type = EventScheduleCollection diff --git a/descarteslabs/core/catalog/event_subscription.py b/descarteslabs/core/catalog/event_subscription.py deleted file mode 100644 index b0964792..00000000 --- a/descarteslabs/core/catalog/event_subscription.py +++ /dev/null @@ -1,856 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 collections.abc import Mapping -import json -import functools -from typing import Dict, List - -from strenum import StrEnum - -from ..client.services.service import ThirdPartyService -from ..common.collection import Collection -from .attributes import ( - BooleanAttribute, - EnumAttribute, - ExpressionAttribute, - GeometryAttribute, - ListAttribute, - MappingAttribute, - Timestamp, - TypedAttribute, -) -from .catalog_base import ( - AuthCatalogObject, - CatalogClient, -) -from .search import GeoSearch - - -class EventType(StrEnum): - """The event type for a subscription. - - Also refered to as the detail type for an event. - - Attributes - ---------- - NEW_IMAGE : enum - A new image that has been uploaded to the Catalog. - UPDATE_IMAGE : enum - An existing image in the Catalog has been updated. - NEW_STORAGE : enum - A new storage blob that has been uploaded to the Catalog. - UPDATE_STORAGE : enum - An existing blob in the Catalog has been updated. - NEW_VECTOR : enum - A new vector feature that has been uploaded to the Vector service. - UPDATE_VECTOR : enum - An existing vector feature in the Vector service has been updated. - SCHEDULED : enum - A scheduled event. - COMPUTE_FUNCTION_COMPLETED : enum - A compute function has completed all jobs. - """ - - NEW_IMAGE = "new-image" - UPDATE_IMAGE = "update-image" - NEW_STORAGE = "new-storage" - UPDATE_STORAGE = "update-storage" - NEW_VECTOR = "new-vector" - UPDATE_VECTOR = "update-vector" - SCHEDULED = "scheduled" - COMPUTE_FUNCTION_COMPLETED = "compute-function-completed" - - -class EventSubscriptionTarget(MappingAttribute): - """Target for an EventSubscription. - - Attributes - ---------- - rule_id : str - The id of the EventRule for the target. - detail_template : str, optional - A Jinja2 template for the detail JSON for the target event. - If not provided, the detail will be the same as the event - which the subscription matches. The context for rendering - the template will include the event object and the subscription - to which this target belongs. Note that this template must - render to a valid JSON string: no trailing commas anywhere. - """ - - rule_id = TypedAttribute( - str, - doc="""str: The id of the EventRule for the target.""", - ) - detail_template = TypedAttribute( - str, - doc="""str, optional: A Jinja2 template for the detail JSON for the target event.""", - ) - - -class Placeholder: - """Placeholder class for EventSubscriptionComputeTarget. - - Can be used in an EventSubscriptionComputeTarget for any value element, - as a mechanism to pass Jinja2 template substitutions through to the resulting - detail template. - """ - - def __init__(self, text: str, unquoted=False, raw=False): - """Create a Placeholder object. - - By default when unquoted and raw are both False, the text will be rendered as a - string value with the text substituted from the event context. - For example, ``Placeholder("event.detail.id")`` will render as ``"some-id"``. - - If unquoted is True, the text will be rendered without enclosing quotes - (typically for a numeric value, JSON object or array). For example, - ``Placeholder("event.detail.geometry", unquoted=True)`` will render as - ``{"type": "Polygon", "coordinates": [[[0, 0,], [1, 0], [1, 1], [0, 1], [0, 0]]]}``. - - If raw is True, then the text will be rendered by Jinja2 as is, without - introducing any additional quotes or substitutions. Generally this is used - when you must explicitly pass through a Jinja2 template expression with - substitutions. - - In all cases, the final result after all substitions must be a fragment of - a valid JSON string. - - Parameters - ---------- - text : str - The text to be rendered into the resulting JSON detail template. How it is - handled depends on the `unquoted` and `raw` parameters. - unquoted: bool, optional - If False, the text will be rendered as a string value. If False, - then the text will be rendered without enclosing quotes. Defaults to False. - Ignored if `raw` is True. - raw : bool, optional - If True, the text will be rendered as is, without wrapping as a substitution - or a string. Defaults to False. - """ - if raw: - self.text = text - elif unquoted: - self.text = f"{{{{ {text} }}}}" - else: - self.text = f'"{{{{ {text} }}}}"' - - @classmethod - def json_serialize(cls, obj, placeholders=None): - if not isinstance(obj, cls): - raise TypeError( - f"Object of type {obj.__class__.__name__} is not JSON serializable" - ) - placeholders.append(obj.text) - return "__placeholder__" - - @classmethod - def substitute_placeholders(cls, text: str, placeholders): - for placeholder in placeholders: - text = text.replace('"__placeholder__"', placeholder, 1) - return text - - -class EventSubscriptionComputeTarget(EventSubscriptionTarget): - """An EventSubscriptionTarget tailored for a compute function. - - Supports the use of placeholders in the detail template to be substituted - from the matching event and subscription. - """ - - def __init__(self, _: str, *args, **kwargs): - """Create an EventSubscriptionTarget tailored for a compute function. - - Placeholder values can be used for any parameter value, which allows - for passing through Jinja2 template substitutions into the resulting - detail template which are otherwise not themselves JSON serializable. - - Parameters - ---------- - _ : str - The compute function id to be invoked. - args : Any, optional - Positional arguments to pass to the compute function. - kwargs : Any, optional - Keyword arguments to pass to the compute function. This includes - the special parameters `tags` and `environment` as used by the - compute function. - """ - super().__init__() - self.rule_id = "internal:compute-job-create" - self.detail_template = self._make_detail_template(_, *args, **kwargs) - - def _make_detail_template( - self, - _: str, - *args, - tags: List[str] = None, - environment: Dict[str, str] = None, - **kwargs, - ): - """Generate a template of a job invocation for use with a Catalog - EventSubscription to send events to the compute function. - - This call will return a JSON template string (with placeholders for - Jinja2 templating) that can be used to submit a job to the function - via an EventSubscription. - - Returns - ------- - str - The the detail template to use for the EventSubscription target. - - Parameters - ---------- - _ : str - The compute function id to be invoked. - args : Any, optional - Positional arguments to pass to the function. - tags : List[str], optional - A list of tags to apply to the Job. - environment : Dict[str, str], optional - Environment variables to be set in the environment of the running Job. - Will be merged with environment variables set on the Function, with - the Job environment variables taking precedence. - kwargs : Any, optional - Keyword arguments to pass to the function. - """ - body = { - "function_id": _, - "args": args or None, - "kwargs": kwargs or None, - "environment": environment or None, - "tags": tags or None, - } - placeholders = [] - return Placeholder.substitute_placeholders( - json.dumps( - {"body": {k: v for k, v in body.items() if v is not None}}, - default=functools.partial( - Placeholder.json_serialize, placeholders=placeholders - ), - ), - placeholders, - ) - - -class EventSubscriptionSqsTarget(EventSubscriptionTarget): - """An EventSubscriptionTarget tailored for an SQS queue. - - Supports the use of placeholders in the detail template to be substituted - from the matching event and subscription. - """ - - def __init__(self, _: str, *args, **kwargs): - """Create an EventSubscriptionTarget tailored for an SQS queue. - - Placeholder values can be used for any parameter value, which allows - for passing through Jinja2 template substitutions into the resulting - detail template which are otherwise not themselves JSON serializable. - - If no positional or keyword arguments are provided, then the message - defaults to being the event detail. - - Parameters - ---------- - _ : str - The SQS queue URL. - args : Placeholder or mapping type, optional - At most one positional argument which is either a Placeholder object - which will be rendered as a JSON object, or a mapping type which will - yield the same. If a value is provided, then no kwargs are permitted. - kwargs : Any, optional - Keyword parameters to pass in the message to the SQS queue. They - may include placeholders for Jinja2 templating. - """ - super().__init__() - self.rule_id = "internal:sqs-forwarder" - if len(args) > 1: - raise TypeError("At most one positional argument is allowed") - if args: - if kwargs: - raise TypeError( - "No keyword arguments allowed with a positional argument" - ) - if isinstance(args[0], Placeholder): - message = args[0] - elif isinstance(args[0], Mapping): - message = {**args[0]} - else: - raise ValueError( - "Positional argument must be a Placeholder or a mapping type" - ) - elif kwargs: - message = kwargs - else: - message = Placeholder("event.detail", unquoted=True) - - self.detail_template = self._make_detail_template(_, message) - - def _make_detail_template( - self, - _, - message, - ): - """Generate a template of an SQS queue message for use with a Catalog - EventSubscription to send events to the SQS queue. - - This call will return a JSON template string (with placeholders for - Jinja2 templating) that can be used to send a message to the SQS queue - via an EventSubscription. - - Returns - ------- - str - The the detail template to use for the EventSubscription target. - - Parameters - ---------- - _ : str - The SQS queue URL. - kwargs : Any, optional - Keyword parameters to compose into the message. - """ - placeholders = [] - return Placeholder.substitute_placeholders( - json.dumps( - {"message": message, "sqs_queue_url": _}, - default=functools.partial( - Placeholder.json_serialize, placeholders=placeholders - ), - ), - placeholders, - ) - - -class EventSubscriptionSearch(GeoSearch): - """A search request that iterates over its search results for event subscriptions. - - The `EventSubscriptionSearch` is identical to `GeoSearch`. - """ - - pass - - -class EventSubscription(AuthCatalogObject): - """A Subscription to receive event notifications. - - - Parameters - ---------- - client : CatalogClient, optional - A `CatalogClient` instance to use for requests to the Descartes Labs catalog. - The :py:meth:`~descarteslabs.catalog.CatalogClient.get_default_client` will - be used if not set. - kwargs : dict - With the exception of readonly attributes (`created`, `modified`, `owner`, and - `owner_role_arn`), and with the exception of properties (`ATTRIBUTES`, - `is_modified`, and `state`), any attribute listed below can also be used as a - keyword argument. Also see `~EventSubscription.ATTRIBUTES`. - """ - - _doc_type = "event_subscription" - _url = "/event_subscriptions" - # _collection_type set below due to circular problems - _url_client = ThirdPartyService() - - # EventSubscription Attributes - namespace = TypedAttribute( - str, - doc="""str: The namespace of this event subscription. - - All event subscriptions are stored and indexed under a namespace. - Namespaces are allowed a restricted alphabet (``a-zA-Z0-9:._-``), - and must begin with the user's org name, or their unique user hash if - the user has no org. The required prefix is seperated from the rest of - the namespace name (if any) by a ``:``. If not provided, the namespace - will default to the users org (if any) and the unique user hash. - - *Searchable, sortable*. - """, - ) - name = TypedAttribute( - str, - doc="""str: The name of this event subscription. - - All event_subscriptions are stored and indexed by name. Names are allowed - a restricted alphabet (``a-zA-Z0-9_-``). - - *Searchable, sortable*. - """, - ) - description = TypedAttribute( - str, - doc="""str, optional: A description with further details on this event subscription. - - The description can be up to 80,000 characters and is used by - :py:meth:`Search.find_text`. - - *Searchable* - """, - ) - geometry = GeometryAttribute( - doc="""str or shapely.geometry.base.BaseGeometry, optional: Geometry representing the AOI - for the subscription. - - *Filterable* - - (use :py:meth:`EventSubscriptionSearch.intersects - ` to search based on geometry) - """ - ) - expires = Timestamp( - doc="""str or datetime, optional: Timestamp when the subscription should be expired and deleted. - - *Filterable, sortable*. - """ - ) - owner = TypedAttribute( - str, - doc="""str, optional: The user who created the subscription, and for whom any subsequent actions - will be credentialed. The form is ``user:``. - - This attribute may not be set by the end user. - - *Filterable, sortable*. - """, - ) - owner_role_arn = TypedAttribute( - str, - doc="""str, readonlyl: The AWS IAM role associated with the owner for use in target invocation. - - This attribute may not be set by the end user. - - *Filterable, sortable*. - """, - ) - event_type = ListAttribute( - EnumAttribute(EventType), - doc="""list(str): Event detail types which this subscription will match. At least one event - detail type must be specified. - - *Filterable*. - """, - ) - event_source = ListAttribute( - TypedAttribute(str), - doc="""list(str), optional: Event sources which this subscription will match. - - *Filterable*. - """, - ) - event_namespace = ListAttribute( - TypedAttribute(str), - doc="""list(str): Event object namespaces which this subscription will match. - At least one event namespace must be specified. For image events, this is the product_id. - For storage events, this is the namespace of the blob. - - *Filterable*. - """, - ) - event_filters = ListAttribute( - ExpressionAttribute, - doc="""list(Expression), optional: A list of property filter expressions against the appropriate - event object type that must all be true for an event to be matched. - """, - ) - targets = ListAttribute( - EventSubscriptionTarget, - doc="""list(EventSubscriptionTarget): A list of targets to be invoked when the subscription matches - an event. - """, - ) - enabled = BooleanAttribute( - doc="""bool, optional: True if the subscription is enabled. Non-enabled subscriptions are ignored - during the matching of events. - - *Filterable, sortable*. - """, - ) - - @classmethod - def namespace_id(cls, namespace_id, client=None): - """Generate a fully namespaced id. - - Parameters - ---------- - namespace_id : str or None - The unprefixed part of the id that you want prefixed. - client : CatalogClient, optional - A `CatalogClient` instance to use for requests to the Descartes Labs - catalog. The - :py:meth:`~descarteslabs.catalog.CatalogClient.get_default_client` will - be used if not set. - - Returns - ------- - str - The fully namespaced id. - - Example - ------- - >>> namespace = EventSubscription.namespace_id("myproject") # doctest: +SKIP - 'myorg:myproject' # doctest: +SKIP - """ - if client is None: - client = CatalogClient.get_default_client() - org = client.auth.payload.get("org") - namespace = client.auth.namespace - - if not namespace_id: - if org: - return f"{org}:{namespace}" - else: - return namespace - elif org: - if namespace_id == org or namespace_id.startswith(org + ":"): - return namespace_id - else: - return f"{org}:{namespace_id}" - elif namespace_id == namespace or namespace_id.startswith(namespace + ":"): - return namespace_id - else: - return f"{namespace}:{namespace_id}" - - @classmethod - def get( - cls, - id=None, - namespace=None, - name=None, - client=None, - request_params=None, - headers=None, - ): - """Get an existing EventSubscription from the Descartes Labs catalog. - - If the EventSubscription is found, it will be returned in the - `~descarteslabs.catalog.DocumentState.SAVED` state. Subsequent changes will - put the instance in the `~descarteslabs.catalog.DocumentState.MODIFIED` state, - and you can use :py:meth:`save` to commit those changes and update the Descartes - Labs catalog object. Also see the example for :py:meth:`save`. - - Exactly one of the ``id`` and ``name`` parameters must be specified. If ``name`` - is specified, it is used together with the ``namespace`` - parameters to form the corresponding ``id``. - - Parameters - ---------- - id : str, optional - The id of the object you are requesting. Required unless ``name`` is supplied. - May not be specified if ``name`` is specified. - namespace : str, optional - The namespace of the EventSubscription you wish to retrieve. Defaults to the user's org name - (if any) plus the unique user hash. Ignored unless ``name`` is specified. - name : str, optional - The name of the EventSubscription you wish to retrieve. Required if ``id`` is not specified. - May not be specified if ``id`` is specified. - client : CatalogClient, optional - A `CatalogClient` instance to use for requests to the Descartes Labs - catalog. The - :py:meth:`~descarteslabs.catalog.CatalogClient.get_default_client` will - be used if not set. - - Returns - ------- - :py:class:`~descarteslabs.catalog.CatalogObject` or None - The object you requested, or ``None`` if an object with the given `id` - does not exist in the Descartes Labs catalog. - - Raises - ------ - ~descarteslabs.exceptions.ClientError or ~descarteslabs.exceptions.ServerError - :ref:`Spurious exception ` that can occur during a - network request. - """ - if (not id and not name) or (id and name): - raise TypeError("Must specify exactly one of id or name parameters") - if not id: - id = f"{cls.namespace_id(namespace)}:{name}" - return super(cls, EventSubscription).get( - id, client=client, request_params=request_params, headers=headers - ) - - @classmethod - def get_or_create( - cls, - id=None, - namespace=None, - name=None, - client=None, - **kwargs, - ): - """Get an existing object from the Descartes Labs catalog or create a new object. - - If the Descartes Labs catalog object is found, and the remainder of the - arguments do not differ from the values in the retrieved instance, it will be - returned in the `~descarteslabs.catalog.DocumentState.SAVED` state. - - If the Descartes Labs catalog object is found, and the remainder of the - arguments update one or more values in the instance, it will be returned in - the `~descarteslabs.catalog.DocumentState.MODIFIED` state. - - If the Descartes Labs catalog object is not found, it will be created and the - state will be `~descarteslabs.catalog.DocumentState.UNSAVED`. Also see the - example for :py:meth:`save`. - - Parameters - ---------- - id : str, optional - The id of the object you are requesting. Required unless ``name`` is supplied. - May not be specified if ``name`` is specified. - namespace : str, optional - The namespace of the EventSubscription you wish to retrieve. Defaults to the user's org name - (if any) plus the unique user hash. Ignored unless ``name`` is specified. - name : str, optional - The name of the EventSubscription you wish to retrieve. Required if ``id`` is not specified. - May not be specified if ``id`` is specified. - client : CatalogClient, optional - A `CatalogClient` instance to use for requests to the Descartes Labs - catalog. The - :py:meth:`~descarteslabs.catalog.CatalogClient.get_default_client` will - be used if not set. - kwargs : dict, optional - With the exception of readonly attributes (`created`, `modified`), any - attribute of a catalog object can be set as a keyword argument (Also see - `ATTRIBUTES`). - - Returns - ------- - :py:class:`~descarteslabs.catalog.CatalogObject` - The requested catalog object that was retrieved or created. - - """ - if (not id and not name) or (id and name): - raise TypeError("Must specify exactly one of id or name parameters") - if not id: - namespace = cls.namespace_id(namespace) - id = f"{namespace}:{name}" - kwargs["namespace"] = namespace - kwargs["name"] = name - - return super(cls, EventSubscription).get_or_create(id, client=client, **kwargs) - - @classmethod - def search(cls, client=None, request_params=None, headers=None): - """A search query for all event subscriptions. - - Return an `~descarteslabs.catalog.EventSubscriptionSearch` instance for searching - event subscriptions in the Descartes Labs catalog. - - Parameters - ---------- - client : :class:`CatalogClient`, optional - A `CatalogClient` instance to use for requests to the Descartes Labs - catalog. - - Returns - ------- - :class:`~descarteslabs.catalog.EventSubscriptionSearch` - An instance of the `~descarteslabs.catalog.EventSubscriptionSearch` class - - Example - ------- - >>> from descarteslabs.catalog import EventSubscription - >>> search = EventSubscription.search().limit(10) - >>> for result in search: # doctest: +SKIP - ... print(result.name) # doctest: +SKIP - - """ - return EventSubscriptionSearch( - cls, client=client, request_params=request_params, headers=headers - ) - - -class EventSubscriptionCollection(Collection): - _item_type = EventSubscription - - -# handle circular references -EventSubscription._collection_type = EventSubscriptionCollection - - -class NewImageEventSubscription(EventSubscription): - """A convenience class for creating an EventSubscription for a new image event. - - Creates an EventSubscription for a new image event. Based on the one or more - Product ids provided to the constructer, the subscription is configured - with the correct ``event_source``, ``event_type``, and ``event_namespace`` - attributes, so that they need not be provided explicitly (indeed if they are - explicitly provided, they will be overwritten). - """ - - _derived_type = "new_image_event_subscription" - - def __init__(self, *product_ids, **kwargs): - """Create an EventSubscription for a new image event. - - Parameters - ---------- - product_ids : str, as one or more positional arguments - The ids of one or more products to be subscribed, as separate positional arguments. - Plus any additional keyword arguments to pass to the EventSubscription constructor. - """ - if not product_ids: - raise TypeError( - "At least one Product id must be provided as a positional argument" - ) - if any(not isinstance(id, str) for id in product_ids): - raise TypeError("All Product ids must be strings") - - kwargs["event_source"] = ["catalog"] - kwargs["event_type"] = [EventType.NEW_IMAGE] - kwargs["event_namespace"] = product_ids - super().__init__(**kwargs) - - -class NewStorageEventSubscription(EventSubscription): - """A convenience class for creating an EventSubscription for a new storage event. - - Creates an EventSubscription for a new storage event. Based on the one or more - Blob namespaces provided to the constructer, the subscription is configured - with the correct ``event_source``, ``event_type``, and ``event_namespace`` - attributes, so that they need not be provided explicitly (indeed if they are - explicitly provided, they will be overwritten). - """ - - _derived_type = "new_storage_event_subscription" - - def __init__(self, *namespaces, **kwargs): - """Create an EventSubscription for a new storage event. - - Parameters - ---------- - namespaces : str, as one or more positional arguments - One or more storage namespaces to be subscribed, as separate positional arguments. - Plus any additional keyword arguments to pass to the EventSubscription constructor. - """ - if not namespaces: - raise TypeError( - "At least one storage namespace must be provided as a positional argument" - ) - if any(not isinstance(id, str) for id in namespaces): - raise TypeError("All Product ids must be strings") - - kwargs["event_source"] = ["catalog"] - kwargs["event_type"] = [EventType.NEW_STORAGE] - kwargs["event_namespace"] = namespaces - super().__init__(**kwargs) - - -class NewVectorEventSubscription(EventSubscription): - """A convenience class for creating an EventSubscription for a new storage event. - - Creates an EventSubscription for a new vector event. Based on the one or more - Vector product ids provided to the constructer, the subscription is configured - with the correct ``event_source``, ``event_type``, and ``event_namespace`` - attributes, so that they need not be provided explicitly (indeed if they are - explicitly provided, they will be overwritten). - """ - - _derived_type = "new_vector_event_subscription" - - def __init__(self, *product_ids, **kwargs): - """Create an EventSubscription for a new vector event. - - Parameters - ---------- - product_ids : str, as one or more positional arguments - The ids of one or more vector products to be subscribed, as separate positional arguments. - Plus any additional keyword arguments to pass to the EventSubscription constructor. - """ - if not product_ids: - raise TypeError( - "At least one product id must be provided as a positional argument" - ) - if any(not isinstance(id, str) for id in product_ids): - raise TypeError("All product ids must be strings") - - kwargs["event_source"] = ["vector"] - kwargs["event_type"] = [EventType.NEW_VECTOR] - kwargs["event_namespace"] = product_ids - super().__init__(**kwargs) - - -class ComputeFunctionCompletedEventSubscription(EventSubscription): - """A convenience class for creating an EventSubscription for a compute - function completion event. - - Creates an EventSubscription for a compute function completion event. - Based on the one or more Function ids provided to the constructer, - the subscription is configured with the correct ``event_source``, - ``event_type``, and ``event_namespace`` attributes, so that they - need not be provided explicitly (indeed if they are explicitly provided, - they will be overwritten). - """ - - _derived_type = "compute_function_completed_event_subscription" - - def __init__(self, *function_ids, **kwargs): - """Create an EventSubscription for a compute function completion event. - - Parameters - ---------- - function_ids : str, as one or more positional arguments - One or more Function ids or Function namespaces to be subscribed, - as separate positional arguments. A Function namespace will match - all functions in that namespace. - Plus any additional keyword arguments to pass to the EventSubscription constructor. - """ - if not function_ids: - raise TypeError( - "At least one function id or namespace must be provided as a positional argument" - ) - if any(not isinstance(id, str) for id in function_ids): - raise TypeError("All product ids must be strings") - - kwargs["event_source"] = ["compute"] - kwargs["event_type"] = [EventType.COMPUTE_FUNCTION_COMPLETED] - kwargs["event_namespace"] = function_ids - super().__init__(**kwargs) - - -class ScheduledEventSubscription(EventSubscription): - """A convenience class for creating an EventSubscription for a scheduled event. - - Creates an EventSubscription for a scheduled event. Based on the one or more - EventSchedule ids provided to the constructer, the subscription is configured - with the correct ``event_source``, ``event_type``, and ``event_namespace`` - attributes, so that they need not be provided explicitly (indeed if they are - explicitly provided, they will be overwritten). - """ - - _derived_type = "scheduled_event_subscription" - - def __init__(self, *event_schedule_ids, **kwargs): - """Create an EventSubscription for a scheduled event. - - Parameters - ---------- - event_schedule_ids : str, as one or more positional arguments - The ids of one or more scheduled event to be subscribed, as separate positional arguments. - Plus any additional keyword arguments to pass to the EventSubscription constructor. - """ - if not event_schedule_ids: - raise TypeError( - "At least one EventSchedule id must be provided as a positional argument" - ) - if any(not isinstance(id, str) for id in event_schedule_ids): - raise TypeError("All EventSchedule ids must be strings") - - kwargs["event_source"] = ["scheduler"] - kwargs["event_type"] = [EventType.SCHEDULED] - kwargs["event_namespace"] = event_schedule_ids - super().__init__(**kwargs) diff --git a/descarteslabs/core/catalog/helpers.py b/descarteslabs/core/catalog/helpers.py deleted file mode 100644 index b4b58036..00000000 --- a/descarteslabs/core/catalog/helpers.py +++ /dev/null @@ -1,161 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 cachetools -import os.path -import json - -from descarteslabs.exceptions import NotFoundError, BadRequestError -from ..client.services.raster import Raster -from ..common.property_filtering import Properties - -from .band import Band -from .image_types import DownloadFileFormat, ResampleAlgorithm - - -BANDS_BY_PRODUCT_CACHE = cachetools.TTLCache(maxsize=256, ttl=600) - - -@cachetools.cached(BANDS_BY_PRODUCT_CACHE, key=lambda p, c: p) -def cached_bands_by_product(product_id, client): - bands = { - band.name: band - for band in Band.search(client=client).filter( - Properties().product_id == product_id - ) - } - return bands - - -def bands_to_list(bands): - if isinstance(bands, str): - return bands.split(" ") - if not isinstance(bands, (list, tuple)): - raise TypeError( - f"Expected list or tuple of band names, instead got {type(bands)}" - ) - if len(bands) == 0: - raise ValueError("No bands specified to load") - return list(bands) - - -# map from file extensions to GDAL file format string -ext_to_format = { - DownloadFileFormat.TIF: "GTiff", - DownloadFileFormat.PNG: "PNG", - DownloadFileFormat.JPEG: "JPEG", -} - - -def is_path_like(dest): - return isinstance(dest, str) or ( - hasattr(os, "PathLike") and isinstance(dest, os.PathLike) - ) - - -def format_from_path(path): - _, ext = os.path.splitext(path) - return get_format(ext.lstrip(".")) - - -def get_format(ext): - try: - return ext_to_format[ext] - except KeyError: - raise ValueError( - "Unknown format '{}'. Possible values are {}.".format( - ext, ", ".join(ext_to_format) - ) - ) from None - - -# helper for shared code around creating downloads -def download( - inputs, - bands_list, - geocontext, - data_type, - dest, - format=DownloadFileFormat.TIF, - resampler=ResampleAlgorithm.NEAR, - processing_level=None, - scales=None, - nodata=None, - progress=None, -): - """ - Download inputs as an image file and save to file or path-like `dest`. - Code shared by Scene.download and SceneCollection.download_mosaic - """ - if dest is None: - if len(inputs) == 0: - raise ValueError("No inputs given to download") - bands_str = "-".join(bands_list) - if len(inputs) == 1: - # default filename for a single scene - dest = "{id}-{bands}.{ext}".format( - id=inputs[0], bands=bands_str, ext=format - ) - else: - # default filename for a mosaic - dest = "mosaic-{bands}.{ext}".format(bands=bands_str, ext=format) - - # Create any intermediate directories - if is_path_like(dest): - dirname = os.path.dirname(dest) - if dirname != "" and not os.path.exists(dirname): - os.makedirs(dirname) - - format = format_from_path(dest) - else: - format = get_format(format) - - raster_params = geocontext.raster_params - full_raster_args = dict( - inputs=inputs, - bands=bands_list, - scales=scales, - data_type=data_type, - resampler=resampler, - processing_level=processing_level, - output_format=format, - outfile_basename=os.path.splitext(dest)[0], - nodata=nodata, - progress=progress, - **raster_params, - ) - - try: - Raster.get_default_client().raster(**full_raster_args) - except NotFoundError: - if len(inputs) == 1: - msg = "'{}' does not exist in the Descartes catalog".format(inputs[0]) - else: - msg = "Some or all of these IDs don't exist in the Descartes catalog: {}".format( - inputs - ) - raise NotFoundError(msg) from None - except BadRequestError as e: - msg = ( - "Error with request:\n" - "{err}\n" - "For reference, Raster.raster was called with these arguments:\n" - "{args}" - ) - msg = msg.format(err=e, args=json.dumps(full_raster_args, indent=2)) - raise BadRequestError(msg) from None - except ValueError as e: - raise e - - return dest diff --git a/descarteslabs/core/catalog/image.py b/descarteslabs/core/catalog/image.py deleted file mode 100644 index 957aed40..00000000 --- a/descarteslabs/core/catalog/image.py +++ /dev/null @@ -1,1590 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 io -import json -import os.path -import warnings -from tempfile import NamedTemporaryFile - -try: - import collections.abc as abc -except ImportError: - import collections as abc - -from affine import Affine -import numpy as np - -from descarteslabs.exceptions import BadRequestError, NotFoundError - -from ..client.services.raster import Raster -from ..client.services.service import ThirdPartyService -from ..common.geo import AOI, GeoContext -from ..common.property_filtering import Properties -from ..common.shapely_support import geometry_like_to_shapely -from .attributes import ( - EnumAttribute, - File, - GeometryAttribute, - ListAttribute, - StorageState, - Timestamp, - TupleAttribute, - TypedAttribute, - parse_iso_datetime, -) -from .catalog_base import DocumentState, check_deleted -from .helpers import bands_to_list, cached_bands_by_product, download -from .image_types import DownloadFileFormat, ResampleAlgorithm -from .named_catalog_base import NamedCatalogObject -from .scaling import scaling_parameters -from .search import AggregateDateField, GeoSearch, SummarySearchMixin - -properties = Properties() - - -class ImageSummaryResult(object): - """ - The readonly data returned by :py:meth:`SummaySearch.summary` or - :py:meth:`SummaySearch.summary_interval`. - - Attributes - ---------- - count : int - Number of images in the summary. - bytes : int - Total number of bytes of data across all images in the summary. - products : list(str) - List of IDs for the products included in the summary. - interval_start: datetime - For interval summaries only, a datetime representing the start of the interval period. - - """ - - def __init__( - self, count=None, bytes=None, products=None, interval_start=None, **kwargs - ): - self.count = count - self.bytes = bytes - self.products = products - self.interval_start = ( - parse_iso_datetime(interval_start) if interval_start else None - ) - - def __repr__(self): - text = [ - "\nSummary for {} images:".format(self.count), - " - Total bytes: {:,}".format(self.bytes), - ] - if self.products: - text.append(" - Products: {}".format(", ".join(self.products))) - if self.interval_start: - text.append(" - Interval start: {}".format(self.interval_start)) - return "\n".join(text) - - -class ImageSearch(SummarySearchMixin, GeoSearch): - # Be aware that the `|` characters below add whitespace. The first one is needed - # avoid the `Inheritance` section from appearing before the auto summary. - """A search request that iterates over its search results for images. - - The `ImageSearch` is identical to `Search` but with a couple of summary methods: - :py:meth:`summary` and :py:meth:`summary_interval`. - """ - - SummaryResult = ImageSummaryResult - DEFAULT_AGGREGATE_DATE_FIELD = AggregateDateField.ACQUIRED - - def collect(self, geocontext=None, **kwargs): - """ - Execute the search query and return the collection of the appropriate type. - - Parameters - ---------- - geocontext : shapely.geometry.base.BaseGeometry, descarteslabs.common.geo.Geocontext, geojson-like, default None # noqa: E501 - AOI for the ImageCollection. - - Returns - ------- - ~descarteslabs.catalog.ImageCollection - ImageCollection of Images returned from the search. - - Raises - ------ - BadRequestError - If any of the query parameters or filters are invalid - ~descarteslabs.exceptions.ClientError or ~descarteslabs.exceptions.ServerError - :ref:`Spurious exception ` that can occur during a - network request. - """ - if geocontext is None: - geocontext = self._intersects - if geocontext is not None: - kwargs["geocontext"] = geocontext - - return super(ImageSearch, self).collect(**kwargs) - - -class Image(NamedCatalogObject): - """An image with raster data. - - Instantiating an image indicates that you want to create a *new* Descartes Labs - catalog image. If you instead want to retrieve an existing catalog image use - `Image.get() `, or if you're not sure use - `Image.get_or_create() <~descarteslabs.catalog.Image.get_or_create>`. You - can also use `Image.search() `. Also - see the example for :py:meth:`~descarteslabs.catalog.Image.save`. - - Parameters - ---------- - client : CatalogClient, optional - A `CatalogClient` instance to use for requests to the Descartes Labs catalog. - The :py:meth:`~descarteslabs.catalog.CatalogClient.get_default_client` will - be used if not set. - kwargs : dict - With the exception of readonly attributes (`created`, `modified`) and with the - exception of properties (`ATTRIBUTES`, `is_modified`, and `state`), any - attribute listed below can also be used as a keyword argument. Also see - `~Image.ATTRIBUTES`. - """ - - _doc_type = "image" - _url = "/images" - _default_includes = ["product"] - # _collection_type set below due to circular import problems - _upload_service = ThirdPartyService() - - # Geo referencing - geometry = GeometryAttribute( - doc="""str or shapely.geometry.base.BaseGeometry: Geometry representing the image coverage. - - *Filterable* - - (use :py:meth:`ImageSearch.intersects - ` to search based on geometry) - """ - ) - cs_code = TypedAttribute( - str, - doc="""str: The coordinate reference system used by the image as an EPSG or ESRI code. - - An example of a EPSG code is ``"EPSG:4326"``. One of `cs_code` and `projection` - is required. If both are set and disagree, `cs_code` takes precedence. - """, - ) - projection = TypedAttribute( - str, - doc="""str: The spatial reference system used by the image. - - The projection can be specified as either a proj.4 string or a a WKT string. - One of `cs_code` and `projection` is required. If both are set and disagree, - `cs_code` takes precedence. - """, - ) - geotrans = TupleAttribute( - min_length=6, - max_length=6, - coerce=True, - attribute_type=float, - doc="""tuple of six float elements, optional if `~StorageState.REMOTE`: GDAL-style geotransform matrix. - - A GDAL-style `geotransform matrix - `_ that - transforms pixel coordinates into the spatial reference system defined by the - `cs_code` or `projection` attributes. - """, - ) - x_pixels = TypedAttribute( - int, - doc="int, optional if `~StorageState.REMOTE`: X dimension of the image in pixels.", - ) - y_pixels = TypedAttribute( - int, - doc="int, optional if `~StorageState.REMOTE`: Y dimension of the image in pixels.", - ) - - # Time dimensions - acquired = Timestamp( - doc="""str or datetime: Timestamp when the image was captured by its sensor or created. - - *Filterable, sortable*. - """ - ) - acquired_end = Timestamp( - doc="""str or datetime, optional: Timestamp when the image capture by its sensor was completed. - - *Filterable, sortable*. - """ - ) - published = Timestamp( - doc="""str or datetime, optional: Timestamp when the data provider published this image. - - *Filterable, sortable*. - """ - ) - - # Stored files - storage_state = EnumAttribute( - StorageState, - doc="""str or StorageState: Storage state of the image. - - The state is `~StorageState.AVAILABLE` if the data is available and can be - rastered, `~StorageState.REMOTE` if the data is not currently available. - - *Filterable, sortable*. - """, - ) - files = ListAttribute( - File, doc="list(File): The list of files holding data for this image." - ) - - # Image properties - area = TypedAttribute( - float, - coerce=True, - doc="""float, optional: Surface area the image covers. - - *Filterable, sortable*. - """, - ) - azimuth_angle = TypedAttribute( - float, - coerce=True, - doc="""float, optional: Sensor azimuth angle in degrees. - - *Filterable, sortable*. - """, - ) - bits_per_pixel = ListAttribute( - TypedAttribute(float, coerce=True), - doc="list(float), optional: Average bits of data per pixel per band.", - ) - bright_fraction = TypedAttribute( - float, - coerce=True, - doc="""float, optional: Fraction of the image that has reflectance greater than .4 in the blue band. - - *Filterable, sortable*. - """, - ) - brightness_temperature_k1_k2 = ListAttribute( - ListAttribute(TypedAttribute(float, coerce=True)), - doc="""list(list(float), optional: radiance to brightness temperature - conversion coefficients. - - Outer list indexed by ``Band.vendor_order``, inner lists are ``[k1, k2]`` or - empty if not applicable. - """, - ) - c6s_dlsr = ListAttribute( - ListAttribute(TypedAttribute(float, coerce=True)), - doc="list(list(float), optional: DLSR conversion coefficients.", - ) - cloud_fraction = TypedAttribute( - float, - coerce=True, - doc="""float, optional: Fraction of pixels which are obscured by clouds. - - *Filterable, sortable*. - """, - ) - confidence_dlsr = TypedAttribute( - float, - coerce=True, - doc="""float, optional: Confidence value for DLSR coefficients. - - *Filterable, sortable*. - """, - ) - alt_cloud_fraction = TypedAttribute( - float, - coerce=True, - doc="""float, optional: Fraction of pixels which are obscured by clouds. - - This is as per an alternative algorithm. See the product documentation in the - `Descartes Labs catalog `_ for more information. - - *Filterable, sortable*. - """, - ) - processing_pipeline_id = TypedAttribute( - str, - doc="""str, optional: Identifier for the pipeline that processed this image from raw data. - - *Filterable, sortable*. - """, - ) - fill_fraction = TypedAttribute( - float, - coerce=True, - doc="""float, optional: Fraction of this image which has data. - - *Filterable, sortable*. - """, - ) - incidence_angle = TypedAttribute( - float, - coerce=True, - doc="""float, optional: Sensor incidence angle in degrees. - - *Filterable, sortable*. - """, - ) - radiance_gain_bias = ListAttribute( - ListAttribute(TypedAttribute(float, coerce=True)), - doc="""list(list(float), optional: radiance conversion gain and bias. - - Outer list indexed by ``Band.vendor_order``, inner lists are ``[gain, bias]`` or - empty if not applicable. - """, - ) - reflectance_gain_bias = ListAttribute( - ListAttribute(TypedAttribute(float, coerce=True)), - doc="""list(list(float), optional: reflectance conversion gain and bias. - - Outer list indexed by ``Band.vendor_order``, inner lists are ``[gain, bias]`` or - empty if not applicable. - """, - ) - reflectance_scale = ListAttribute( - TypedAttribute(float, coerce=True), - doc="list(float), optional: Scale factors converting TOA radiances to TOA reflectances.", - ) - roll_angle = TypedAttribute( - float, - coerce=True, - doc="""float, optional: Applicable only to Landsat 8, roll angle in degrees. - - *Filterable, sortable*. - """, - ) - solar_azimuth_angle = TypedAttribute( - float, - coerce=True, - doc="""float, optional: Solar azimuth angle at capture time. - - *Filterable, sortable*. - """, - ) - solar_elevation_angle = TypedAttribute( - float, - coerce=True, - doc="""float, optional: Solar elevation angle at capture time. - - *Filterable, sortable*. - """, - ) - temperature_gain_bias = ListAttribute( - ListAttribute(TypedAttribute(float, coerce=True)), - doc="""list(list(float), optional: surface temperature conversion coefficients. - - Outer list indexed by ``Band.vendor_order``, inner lists are ``[gain, bias]`` or - empty if not applicable. - """, - ) - view_angle = TypedAttribute( - float, - coerce=True, - doc="""float, optional: Sensor view angle in degrees. - - *Filterable, sortable*. - """, - ) - satellite_id = TypedAttribute( - str, - doc="""str, optional: Id of the capturing satellite/sensor among a constellation of many satellites. - - *Filterable, sortable*. - """, - ) - - # Provider info - provider_id = TypedAttribute( - str, - doc="""str, optional: Id that uniquely ties this image to an entity as understood by the data provider. - - *Filterable, sortable*. - """, - ) - provider_url = TypedAttribute( - str, - doc="str, optional: An external (http) URL that has more details about the image", - ) - preview_url = TypedAttribute( - str, - doc="""str, optional: An external (http) URL to a preview image. - - This image could be inlined in a UI to show a preview for the image. - """, - ) - preview_file = TypedAttribute( - str, - doc="""str, optional: A GCS URL with a georeferenced image. - - Use a GCS URL (``gs://...```) with appropriate access permissions. This - referenced image can be used to raster the image in a preview context, generally - low resolution. It should be a 3-band (RBG) or a 4-band (RGBA) image suitable - for visual preview. (It's not expected to conform to the bands of the - products.) - """, - ) - - SUPPORTED_DATATYPES = ( - "uint8", - "int16", - "uint16", - "int32", - "uint32", - "float32", - "float64", - ) - - def __init__(self, **kwargs): - super(Image, self).__init__(**kwargs) - self._geocontext = None - - @property - def geocontext(self): - """ - `~descarteslabs.common.geo.AOI`: A geocontext for loading this Image's original, unwarped data. - - These defaults are used: - - * resolution: resolution determined from the Image's ``geotrans`` - * crs: native CRS of the Image (often, a UTM CRS) - * bounds: bounds determined from the Image's ``geotrans``, ``x_pixels`` and ``y_pixels`` - * bounds_crs: native CRS of the Image - * align_pixels: False, to prevent interpolation snapping pixels to a new grid - * geometry: None - - .. note:: - - Using this :class:`~descarteslabs.common.geo.GeoContext` will only - return original, unwarped data if the Image is axis-aligned ("north-up") - within the CRS. If its ``geotrans`` applies a rotation, a warning will be raised. - In that case, use `Raster.ndarray` or `Raster.raster` to retrieve - original data. (The :class:`~descarteslabs.common.geo.GeoContext` - paradigm requires bounds for consistency, which are inherently axis-aligned.) - """ - if self._geocontext is None: - shape = None - bounds = None - bounds_crs = None - crs = self.cs_code or self.projection - resolution = None - - geotrans = self.geotrans - if geotrans is not None: - geotrans = Affine.from_gdal(*geotrans) - if not geotrans.is_rectilinear: - # NOTE: this may still be an insufficient check for some CRSs, i.e. polar stereographic? - warnings.warn( - "The GeoContext will *not* return this Image's original data, " - "since it's rotated compared to the grid of the CRS. " - "The array will be 'north-up', with the data rotated within it, " - "and extra empty pixels padded around the side(s). " - "To get the original, unrotated data, you must use the Raster API: " - "`descarteslabs.client.services.raster.Raster.ndarray(image.id, ...)`." - ) - - if self.x_pixels is not None and self.y_pixels is not None: - # prefer to raster by image shape, to best preserve original data. - # upper-left, upper-right, lower-left, lower-right in pixel coordinates - pixel_corners = [ - (0, 0), - (self.x_pixels, 0), - (0, self.y_pixels), - (self.x_pixels, self.y_pixels), - ] - geo_corners = [geotrans * corner for corner in pixel_corners] - xs, ys = zip(*geo_corners) - bounds = (min(xs), min(ys), max(xs), max(ys)) - bounds_crs = crs - shape = (self.y_pixels, self.x_pixels) - else: - x_res, y_res = geotrans._scaling - if x_res != y_res: - # if pixels aren't square (unlikely), we won't just pick a resolution, - # the user has to figure that out. - raise ValueError( - "Image has no size and non-square pixels, so resolution is ambiguous. " - "You must manually define a geocontext for this image." - ) - resolution = x_res - - self._geocontext = AOI( - geometry=self.geometry, - resolution=resolution, - bounds=bounds, - bounds_crs=bounds_crs, - crs=crs, - shape=shape, - align_pixels=False, - ) - - return self._geocontext - - @property - def __geo_interface__(self): - return self.geocontext.__geo_interface__ - - # convenience property - @property - def date(self): - return self.acquired - - @classmethod - def search(cls, client=None, request_params=None, headers=None): - """A search query for all images. - - Return an `~descarteslabs.catalog.ImageSearch` instance for searching - images in the Descartes Labs catalog. This instance extends the - :py:class:`~descarteslabs.catalog.Search` class with the - :py:meth:`~descarteslabs.catalog.ImageSearch.summary` and - :py:meth:`~descarteslabs.catalog.ImageSearch.summary_interval` methods - which return summary statistics about the images that match the search query. - - Parameters - ---------- - client : :class:`CatalogClient`, optional - A `CatalogClient` instance to use for requests to the Descartes Labs - catalog. - - Returns - ------- - :class:`~descarteslabs.catalog.ImageSearch` - An instance of the `~descarteslabs.catalog.ImageSearch` class - - Example - ------- - >>> from descarteslabs.catalog import Image - >>> search = Image.search().limit(10) - >>> for result in search: # doctest: +SKIP - ... print(result.name) # doctest: +SKIP - - """ - return ImageSearch( - cls, client=client, request_params=request_params, headers=headers - ) - - @check_deleted - def upload(self, files, upload_options=None, overwrite=False): - """Uploads imagery from a file (or files). - - Uploads imagery from a file (or files) in GeoTIFF or JP2 format to be ingested - as an Image. - - The Image must be in the state `~descarteslabs.catalog.DocumentState.UNSAVED`. - The `product` or `product_id` attribute, the `name` attribute, and the - `acquired` attribute must all be set. If either the `cs_code` or `projection` - attributes is set (deprecated), it must agree with the projection defined in the file, - otherwise an upload error will occur during processing. - - Parameters - ---------- - files : str or io.IOBase or iterable of same - File or files to be uploaded. Can be string with path to the file in the - local filesystem, or an opened file (``io.IOBase``), or an iterable of - either of these when multiple files make up the image. - upload_options : `~descarteslabs.catalog.ImageUploadOptions`, optional - Control of the upload process. - overwrite : bool, optional - If True, then permit overwriting of an existing image with the same id - in the catalog. Defaults to False. Note that in all cases, the image - object must have a state of `~descarteslabs.catalog.DocumentState.UNSAVED`. - USE WITH CAUTION: This can cause data cache inconsistencies in the platform, - and should only be used for infrequent needs to update the image file - contents. You can expect inconsistencies to endure for a period afterwards. - - Returns - ------- - :py:class:`~descarteslabs.catalog.ImageUpload` - An `~descarteslabs.catalog.ImageUpload` instance which can - be used to check the status or wait on the asynchronous upload process to - complete. - - Raises - ------ - ValueError - If any improper arguments are supplied. - DeletedObjectError - If this image was deleted. - """ - from .image_upload import ImageUploadOptions, ImageUploadType - - if not self.id: - raise ValueError("id field required") - if not self.acquired: - raise ValueError("acquired field required") - if self.cs_code or self.projection: - warnings.warn("cs_code and projection fields not permitted", FutureWarning) - # raise ValueError("cs_code and projection fields not permitted") - - if self.state != DocumentState.UNSAVED: - raise ValueError( - "Image {} has been saved. Please use an unsaved image for uploading".format( - self.id - ) - ) - - if not overwrite and Image.exists(self.id, self._client): - raise ValueError( - "Image {} already exists in the catalog. Please either use a new image id or overwrite=True".format( - self.id - ) - ) - - if self.product.state != DocumentState.SAVED: - raise ValueError( - "Product {} has not been saved. Please save before uploading images".format( - self.product_id - ) - ) - - # convert file to a list, validating and extracting file names - if isinstance(files, str) or isinstance(files, io.IOBase): - files = [files] - elif not isinstance(files, abc.Iterable): - raise ValueError( - "Invalid files value: must be string, IOBase, or iterable of the same" - ) - filenames = [] - for f in files: - if isinstance(f, str): - filenames.append(f) - elif isinstance(f, io.IOBase): - filenames.append(f.name) - else: - raise ValueError( - "Invalid files value: must be string, IOBase, or iterable of the same" - ) - if not filenames: - raise ValueError("Invalid files value has zero length") - - if not upload_options: - upload_options = ImageUploadOptions() - - upload_options.upload_type = ImageUploadType.FILE - upload_options.image_files = filenames - - return self._do_upload(files, upload_options) - - @check_deleted - def upload_ndarray( - self, - ndarray, - upload_options=None, - raster_meta=None, - overviews=None, - overview_resampler=None, - overwrite=False, - ): - """Uploads imagery from an ndarray to be ingested as an Image. - - The Image must be in the state `~descarteslabs.catalog.DocumentState.UNSAVED`. - The `product` or `product_id` attribute, the `name` attribute, and the - `acquired` attribute must all be set. Either (but not both) the `cs_code` - or `projection` attributes must be set, or the `raster_meta` parameter must be provided. - Similarly, either the `geotrans` attribute must be set or `raster_meta` must be provided. - - Note that one of the spatial reference attributes (`cs_code` or - `projection`), or the `geotrans` attribute can be - specified explicitly in the image, or the `raster_meta` parameter can be - specified. Likewise, `overviews` and `overview_resampler` can be - specified explicitly, or via the `upload_options` parameter. - - - Parameters - ---------- - ndarray : np.array, Iterable(np.array) - A numpy array or list of numpy arrays with image data, either with 2 - dimensions of shape ``(x, y)`` for a single band or with 3 dimensions of - shape ``(band, x, y)`` for any number of bands. If providing a 3d array - the first dimension must index the bands. The ``dtype`` of the array must - also be one of the following: - [``uint8``, ``int8``, ``uint16``, ``int16``, ``uint32``, ``int32``, - ``float32``, ``float64``] - upload_options : :py:class:`~descarteslabs.catalog.ImageUploadOptions`, optional - Control of the upload process. - raster_meta : dict, optional - Metadata returned from the :meth:`Raster.ndarray() - ` request which - generated the initial data for the `ndarray` being uploaded. Specifying - `geotrans` and one of the spatial reference attributes (`cs_code` or - `projection`) is unnecessary in this case but will take precedence over - the value in `raster_meta`. - overviews : list(int), optional - Overview resolution magnification factors e.g. [2, 4] would make two - overviews at 2x and 4x the native resolution. Maximum number of overviews - allowed is 16. Can also be set in the `upload_options` parameter. - overview_resampler : `ResampleAlgorithm`, optional - Resampler algorithm to use when building overviews. Controls how pixels - are combined to make lower res pixels in overviews. Can also be set in - the `upload_options` parameter. - overwrite : bool, optional - If True, then permit overwriting of an existing image with the same id - in the catalog. Defaults to False. Note that in all cases, the image - object must have a state of `~descarteslabs.catalog.DocumentState.UNSAVED`. - USE WITH CAUTION: This can cause data cache inconsistencies in the platform, - and should only be used for infrequent needs to update the image file - contents. You can expect inconsistencies to endure for a period afterwards. - - Raises - ------ - ValueError - If any improper arguments are supplied. - DeletedObjectError - If this image was deleted. - - Returns - ------- - :py:class:`~descarteslabs.catalog.ImageUpload` - An `~descarteslabs.catalog.ImageUpload` instance which can - be used to check the status or wait on the asynchronous upload process to - complete. - """ - from .image_upload import ImageUploadOptions, ImageUploadType - - if not self.id: - raise ValueError("id field required") - if not self.acquired: - raise ValueError("acquired field required") - if self.cs_code and self.projection: - warnings.warn( - "Only one of cs_code and projection fields permitted", - FutureWarning, - ) - # raise ValueError("only one of cs_code and projection fields permitted") - - if self.state != DocumentState.UNSAVED: - raise ValueError( - "Image {} has been saved. Please use an unsaved image for uploading".format( - self.id - ) - ) - - if not overwrite and Image.exists(self.id, self._client): - raise ValueError( - "Image {} already exists in the catalog. Please either use a new image id or overwrite=True".format( - self.id - ) - ) - - if self.product.state != DocumentState.SAVED: - raise ValueError( - "Product {} has not been saved. Please save before uploading images".format( - self.product_id - ) - ) - - if isinstance(ndarray, (np.ndarray, np.generic)): - ndarray = [ndarray] - elif not isinstance(ndarray, abc.Iterable): - raise ValueError( - "The array must be an instance of ndarray or an Iterable of ndarrays" - "such as a list." - ) - - # validate the shape of each ndarray - # modify image data to shift axes to what ingest expects - for idx, image_data in enumerate(ndarray): - if not isinstance(image_data, (np.ndarray, np.generic)): - raise ValueError(f"The item at index {idx} is not an ndarray") - - if len(image_data.shape) not in (2, 3): - raise ValueError( - "The array must have 2 dimensions (shape '(x, y)') or 3 dimensions with the band " - "axis in the first dimension (shape '(band, x, y)'). The given array has shape " - "'{}' instead.".format(image_data.shape) - ) - - if image_data.dtype.name not in self.SUPPORTED_DATATYPES: - raise ValueError( - "The array has an unsupported data type {}. Only the following data types are supported: {}".format( - image_data.dtype.name, ",".join(self.SUPPORTED_DATATYPES) - ) - ) - - if len(image_data.shape) == 3: - scale_factor = 5 - scaled_band_dim = image_data.shape[0] * scale_factor - - if ( - scaled_band_dim > image_data.shape[1] - or scaled_band_dim > image_data.shape[2] - ): - warnings.warn( - "The shape '{}' of the given 3d-array looks like it might not have the band " - "axis as the first dimension. Verify that your array conforms to the shape " - "'(band, x, y)'".format(image_data.shape) - ) - # v1 ingest expects (X,Y,bands) - ndarray[idx] = np.moveaxis(image_data, 0, -1) - - # default to raster_meta fields if not explicitly provided - if raster_meta: - if not self.geotrans: - self.geotrans = raster_meta.get("geoTransform") - if not self.cs_code and not self.projection: - # doesn't yet exist! - self.projection = raster_meta.get("coordinateSystem", {}).get("proj4") - - if not self.geotrans: - raise ValueError("geotrans field or raster_meta parameter is required") - if not self.cs_code and not self.projection: - raise ValueError( - "cs_code or projection field is required if " - + "raster_meta parameter is not given" - ) - - if not upload_options: - upload_options = ImageUploadOptions() - upload_options.upload_type = ImageUploadType.NDARRAY - if overviews: - upload_options.overviews = overviews - if overview_resampler: - upload_options.overview_resampler = overview_resampler - - # write all the ndarrays to files so that _do_upload can read them - files = [] - upload_size = 0 - - try: - for image_data in ndarray: - upload_size += image_data.nbytes - tmp = NamedTemporaryFile(delete=False) - files.append(tmp) - np.save(tmp, image_data, allow_pickle=False) - # From tempfile docs: - # Whether the name can be used to open the file a second time, - # while the named temporary file is still open, varies across - # platforms (it can be so used on Unix; it cannot on Windows - # NT or later) - # We close the underlying file object so _do_upload can open - # the path again in a cross platform compatible way. - # Cleanup is manual in the finally block. - tmp.close() - - file_names = [f.name for f in files] - upload_options.upload_size = upload_size - upload_options.image_files = file_names - - return self._do_upload(file_names, upload_options) - finally: - for file in files: - try: - os.unlink(file.name) - except OSError: - pass - - def image_uploads(self): - """A search query for all uploads for this image created by this user. - - Returns - ------- - :py:class:`~descarteslabs.catalog.Search` - A :py:class:`~descarteslabs.catalog.Search` instance configured to - find all uploads for this image. - """ - from .image_upload import ImageUpload - - return ImageUpload.search(client=self._client).filter( - (properties.product_id == self.product_id) - & (properties.image_id == self.id) - ) - - # the upload implementation is broken out so it can be used from multiple methods - def _do_upload(self, files, upload_options): - from .image_upload import ImageUpload, ImageUploadStatus - - upload = ImageUpload( - client=self._client, image=self, image_upload_options=upload_options - ) - - upload.save() - - headers = {"content-type": "application/octet-stream"} - - for file, upload_url in zip(files, upload.resumable_urls): - if isinstance(file, io.IOBase): - if "b" not in file.mode: - file.close() - file = io.open(file.name, "rb") - f = file - else: - f = io.open(file, "rb") - - try: - self._upload_service.session.put(upload_url, data=f, headers=headers) - finally: - f.close() - - upload.status = ImageUploadStatus.PENDING - upload.save() - - return upload - - def coverage(self, geom): - """ - The fraction of a geometry-like object covered by this Image's geometry. - - Parameters - ---------- - geom : GeoJSON-like dict, :class:`~descarteslabs.common.geo.geocontext.GeoContext`, or object with __geo_interface__ - Geometry to which to compare this Image's geometry - - Returns - ------- - coverage: float - The fraction of ``geom``'s area that overlaps with this Image, - between 0 and 1. - - Example - ------- - >>> import descarteslabs as dl - >>> image = dl.catalog.Image.get("landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1") # doctest: +SKIP - >>> image.coverage(image.geometry.buffer(1)) # doctest: +SKIP - 0.258370644415335 - """ # noqa: E501 - - if isinstance(geom, GeoContext): - shape = geom.geometry - else: - shape = geometry_like_to_shapely(geom) - - intersection = shape.intersection(self.geometry) - return intersection.area / shape.area - - def ndarray( - self, - bands, - geocontext=None, - crs=None, - resolution=None, - all_touched=None, - mask_nodata=True, - mask_alpha=None, - bands_axis=0, - raster_info=False, - resampler=ResampleAlgorithm.NEAR, - processing_level=None, - scaling=None, - data_type=None, - progress=None, - ): - """ - Load bands from this image as an ndarray, optionally masking invalid data. - - If the selected bands have different data types the resulting ndarray - has the most general of those data types. This table defines which data types - can be cast to which more general data types: - - * ``Byte`` to: ``UInt16``, ``UInt32``, ``Int16``, ``Int32``, ``Float32``, ``Float64`` - * ``UInt16`` to: ``UInt32``, ``Int32``, ``Float32``, ``Float64`` - * ``UInt32`` to: ``Float64`` - * ``Int16`` to: ``Int32``, ``Float32``, ``Float64`` - * ``Int32`` to: ``Float32``, ``Float64`` - * ``Float32`` to: ``Float64`` - * ``Float64`` to: No possible casts - - Parameters - ---------- - bands : str or Sequence[str] - Band names to load. Can be a single string of band names - separated by spaces (``"red green blue"``), - or a sequence of band names (``["red", "green", "blue"]``). - Names must be keys in ``self.properties.bands``. - If the alpha band is requested, it must be last in the list - to reduce rasterization errors. - geocontext : :class:`~descarteslabs.common.geo.geocontext.GeoContext`, default None - A :class:`~descarteslabs.common.geo.geocontext.GeoContext` to use when loading this Image. - If ``None`` then the default geocontext of the image will be used. - crs : str, default None - if not None, update the gecontext with this value to set the output CRS. - resolution : float, default None - if not None, update the geocontext with this value to set the output resolution - in the units native to the specified or defaulted output CRS. - all_touched : float, default None - if not None, update the geocontext with this value to control rastering behavior. - mask_nodata : bool, default True - Whether to mask out values in each band that equal - that band's ``nodata`` sentinel value. - mask_alpha : bool or str or None, default None - Whether to mask pixels in all bands where the alpha band of the image is 0. - Provide a string to use an alternate band name for masking. - If the alpha band is available and ``mask_alpha`` is None, ``mask_alpha`` - is set to True. If not, mask_alpha is set to False. - bands_axis : int, default 0 - Axis along which bands should be located in the returned array. - If 0, the array will have shape ``(band, y, x)``, if -1, - it will have shape ``(y, x, band)``. - - It's usually easier to work with bands as the outermost axis, - but when working with large arrays, or with many arrays concatenated - together, NumPy operations aggregating each xy point across bands - can be slightly faster with bands as the innermost axis. - raster_info : bool, default False - Whether to also return a dict of information about the rasterization - of the image, including the coordinate system WKT and geotransform matrix. - Generally only useful if you plan to upload data derived - from this image back to the Descartes Labs catalog, or use it with GDAL. - resampler : `ResampleAlgorithm`, default `ResampleAlgorithm.NEAR` - Algorithm used to interpolate pixel values when scaling and transforming - the image to its new resolution or CRS. - processing_level : str, optional - How the processing level of the underlying data should be adjusted. Possible - values depend on the product and bands in use. Legacy products support - ``toa`` (top of atmosphere) and in some cases ``surface``. Consult the - available ``processing_levels`` in the product bands to understand what - is available. - scaling : None, str, list, dict - Band scaling specification. Please see :meth:`scaling_parameters` for a full - description of this parameter. - data_type : None, str - Output data type. Please see :meth:`scaling_parameters` for a full - description of this parameter. - progress : None, bool - Controls display of a progress bar. - - Returns - ------- - arr : ndarray - Returned array's shape will be ``(band, y, x)`` if bands_axis is 0, - ``(y, x, band)`` if bands_axis is -1. - If ``mask_nodata`` or ``mask_alpha`` is True, arr will be a masked array. - The data type ("dtype") of the array is the most general of the data - types among the bands being rastered. - raster_info : dict - If ``raster_info=True``, a raster information dict is also returned. - - Example - ------- - >>> import descarteslabs as dl - >>> image = dl.catalog.Image.get("landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1") # doctest: +SKIP - >>> arr = image.ndarray("red green blue", resolution=120.) # doctest: +SKIP - >>> type(arr) # doctest: +SKIP - - >>> arr.shape # doctest: +SKIP - (3, 1995, 1962) - >>> red_band = arr[0] # doctest: +SKIP - - Raises - ------ - ValueError - If requested bands are unavailable. - If band names are not given or are invalid. - If the requested bands have incompatible dtypes. - NotFoundError - If a Image's ID cannot be found in the Descartes Labs catalog - BadRequestError - If the Descartes Labs Platform is given invalid parameters - """ - if geocontext is None: - # Lose the image's geometry (which is only used as a cutline), - # as it might cause some unexpected clipping when rasterizing, due - # to imperfect simplified geometries used when native image CRS is not WGS84. - geocontext = self.geocontext.assign(geometry=None) - if crs is not None or resolution is not None: - try: - params = {} - if crs is not None: - params["crs"] = crs - if resolution is not None: - params["resolution"] = resolution - geocontext = geocontext.assign(**params) - except TypeError: - raise ValueError( - f"{type(geocontext)} geocontext does not support modifying crs or resolution" - ) from None - if all_touched is not None: - geocontext = geocontext.assign(all_touched=all_touched) - - return self._ndarray( - bands, - geocontext, - mask_nodata=mask_nodata, - mask_alpha=mask_alpha, - bands_axis=bands_axis, - raster_info=raster_info, - resampler=resampler, - processing_level=processing_level, - scaling=scaling, - data_type=data_type, - progress=progress, - ) - - # the ndarray implementation is broken out so it can be used directly from ImageCollection - def _ndarray( - self, - bands, - geocontext, - mask_nodata=True, - mask_alpha=None, - bands_axis=0, - raster_info=False, - resampler=ResampleAlgorithm.NEAR, - processing_level=None, - scaling=None, - data_type=None, - progress=None, - ): - if not (-3 < bands_axis < 3): - raise ValueError( - "Invalid bands_axis; axis {} would not exist in a 3D array".format( - bands_axis - ) - ) - - bands = bands_to_list(bands) - product_bands = cached_bands_by_product(self.product_id, self._client) - - scales, data_type = scaling_parameters( - product_bands, bands, processing_level, scaling, data_type - ) - - mask_nodata = bool(mask_nodata) - - alpha_band_name = "alpha" - if isinstance(mask_alpha, str): - alpha_band_name = mask_alpha - mask_alpha = True - elif mask_alpha is None: - # if user does not set mask_alpha, only attempt to mask_alpha if - # alpha band is exists in the image. - mask_alpha = alpha_band_name in product_bands - elif type(mask_alpha) is not bool: - raise ValueError("'mask_alpha' must be None, a band name, or a bool.") - - drop_alpha = False - if mask_alpha: - if alpha_band_name not in product_bands: - raise ValueError( - "Cannot mask alpha: no {} band for the product '{}'. " - "Try setting 'mask_alpha=False'.".format( - alpha_band_name, self.product_id - ) - ) - try: - alpha_i = bands.index(alpha_band_name) - except ValueError: - bands.append(alpha_band_name) - drop_alpha = True - else: - if alpha_i != len(bands) - 1: - raise ValueError( - "Alpha must be the last band in order to reduce rasterization errors" - ) - - raster_params = geocontext.raster_params - full_raster_args = dict( - inputs=[self.id], - order="gdal", - bands=bands, - scales=scales, - data_type=data_type, - resampler=resampler, - processing_level=processing_level, - masked=mask_nodata or mask_alpha, - mask_nodata=mask_nodata, - mask_alpha=mask_alpha, - drop_alpha=drop_alpha, - progress=progress, - **raster_params, - ) - - try: - arr, info = Raster.get_default_client().ndarray(**full_raster_args) - - except NotFoundError: - raise NotFoundError( - "'{}' does not exist in the Descartes Labs catalog".format(self.id) - ) from None - except BadRequestError as e: - msg = ( - "Error with request:\n" - "{err}\n" - "For reference, Raster.ndarray was called with these arguments:\n" - "{args}" - ) - msg = msg.format(err=e, args=json.dumps(full_raster_args, indent=2)) - raise BadRequestError(msg) from None - - if len(arr.shape) == 2: - # if only 1 band requested, still return a 3d array - arr = arr[np.newaxis] - - if bands_axis != 0: - arr = np.moveaxis(arr, 0, bands_axis) - if raster_info: - return arr, info - else: - return arr - - def download( - self, - bands, - geocontext=None, - crs=None, - resolution=None, - all_touched=None, - dest=None, - format=DownloadFileFormat.TIF, - resampler=ResampleAlgorithm.NEAR, - processing_level=None, - scaling=None, - data_type=None, - nodata=None, - progress=None, - ): - """ - Save bands from this image as a GeoTIFF, PNG, or JPEG, writing to a path. - - Parameters - ---------- - bands : str or Sequence[str] - Band names to load. Can be a single string of band names - separated by spaces (``"red green blue"``), - or a sequence of band names (``["red", "green", "blue"]``). - Names must be keys in ``self.properties.bands``. - geocontext : :class:`~descarteslabs.common.geo.geocontext.GeoContext`, default None - A :class:`~descarteslabs.common.geo.geocontext.GeoContext` to use when loading this image. - If ``None`` then use the default context for the image. - crs : str, default None - if not None, update the gecontext with this value to set the output CRS. - resolution : float, default None - if not None, update the geocontext with this value to set the output resolution - in the units native to the specified or defaulted output CRS. - all_touched : float, default None - if not None, update the geocontext with this value to control rastering behavior. - dest : str or path-like object, default None - Where to write the image file. - - * If None (default), it's written to an image file of the given ``format`` - in the current directory, named by the image's ID and requested bands, - like ``"sentinel-2:L1C:2018-08-10_10TGK_68_S2A_v1-red-green-blue.tif"`` - * If a string or path-like object, it's written to that path. - - Any file already existing at that path will be overwritten. - - Any intermediate directories will be created if they don't exist. - - Note that path-like objects (such as pathlib.Path) are only supported - in Python 3.6 or later. - format : `DownloadFileFormat`, default `DownloadFileFormat.TIF` - Output file format to use - If a str or path-like object is given as ``dest``, ``format`` is ignored - and determined from the extension on the path (one of ".tif", ".png", or ".jpg"). - resampler : `ResampleAlgorithm`, default `ResampleAlgorithm.NEAR` - Algorithm used to interpolate pixel values when scaling and transforming - the image to its new resolution or SRS. - processing_level : str, optional - How the processing level of the underlying data should be adjusted. Possible - values depend on the product and bands in use. Legacy products support - ``toa`` (top of atmosphere) and in some cases ``surface``. Consult the - available ``processing_levels`` in the product bands to understand what - is available. - scaling : None, str, list, dict - Band scaling specification. Please see :meth:`scaling_parameters` for a full - description of this parameter. - data_type : None, str - Output data type. Please see :meth:`scaling_parameters` for a full - description of this parameter. - nodata : None, number - NODATA value for a geotiff file. Will be assigned to any masked pixels. - progress : None, bool - Controls display of a progress bar. - - Returns - ------- - path : str or None - If ``dest`` is None or a path, the path where the image file was written is returned. - If ``dest`` is file-like, nothing is returned. - - Example - ------- - >>> import descarteslabs as dl - >>> image = dl.catalog.Image.get("landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1") # doctest: +SKIP - >>> image.download("red green blue", resolution=120.) # doctest: +SKIP - "landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1_red-green-blue.tif" - >>> import os - >>> os.listdir(".") # doctest: +SKIP - ["landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1_red-green-blue.tif"] - >>> image.download( - ... "nir swir1", - ... "rasters/{geocontext.resolution}-{image_id}.jpg".format(geocontext=image.geocontext, image_id=image.id) - ... ) # doctest: +SKIP - "rasters/15-landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1.tif" - - Raises - ------ - ValueError - If requested bands are unavailable. - If band names are not given or are invalid. - If the requested bands have incompatible dtypes. - If ``format`` is invalid, or the path has an invalid extension. - NotFoundError - If a image's ID cannot be found in the Descartes Labs catalog - BadRequestError - If the Descartes Labs Platform is given invalid parameters - """ - if geocontext is None: - # Lose the image's geometry (which is only used as a cutline), - # as it might cause some unexpected clipping when rasterizing, due - # to imperfect simplified geometries used when native image CRS is not WGS84. - geocontext = self.geocontext.assign(geometry=None) - if crs is not None or resolution is not None: - try: - params = {} - if crs is not None: - params["crs"] = crs - if resolution is not None: - params["resolution"] = resolution - geocontext = geocontext.assign(**params) - except TypeError: - raise ValueError( - f"{type(geocontext)} geocontext does not support modifying crs or resolution" - ) from None - if all_touched is not None: - geocontext = geocontext.assign(all_touched=all_touched) - - return self._download( - bands, - geocontext, - dest=dest, - format=format, - resampler=resampler, - processing_level=processing_level, - scaling=scaling, - data_type=data_type, - nodata=nodata, - progress=progress, - ) - - # the download implementation is broken out so it can be used directly from ImageCollection - def _download( - self, - bands, - geocontext, - dest=None, - format=DownloadFileFormat.TIF, - resampler=ResampleAlgorithm.NEAR, - processing_level=None, - scaling=None, - data_type=None, - nodata=None, - progress=None, - ): - bands = bands_to_list(bands) - scales, data_type = scaling_parameters( - cached_bands_by_product(self.product_id, self._client), - bands, - processing_level, - scaling, - data_type, - ) - - return download( - inputs=[self.id], - bands_list=bands, - geocontext=geocontext, - data_type=data_type, - dest=dest, - format=format, - resampler=resampler, - processing_level=processing_level, - scales=scales, - nodata=nodata, - progress=progress, - ) - - def scaling_parameters( - self, bands, processing_level=None, scaling=None, data_type=None - ): - """ - Computes fully defaulted scaling parameters and output data_type - from provided specifications. - - This method makes accessible the scales and data_type parameters - which will be generated and passed to the Raster API by methods - such as :meth:`ndarray` and :meth:`download`. It is provided - as a convenience to the user to aid in understanding how the - ``scaling`` and ``data_type`` parameters will be handled by - those methods. It would not usually be used in a normal workflow. - - Parameters - ---------- - bands : list - List of bands to be scaled. - processing_level : str, optional - How the processing level of the underlying data should be adjusted. Possible - values depend on the product and bands in use. Legacy products support - ``toa`` (top of atmosphere) and in some cases ``surface``. Consult the - available ``processing_levels`` in the product bands to understand what - is available. - scaling : None or str or list or dict, default None - Supplied scaling specification, see below. - data_type : None or str, default None - Result data type desired, as a standard data type string (e.g. - ``"Byte"``, ``"Uint16"``, or ``"Float64"``). If not specified, - will be deduced from the ``scaling`` specification. Typically - this is left unset and the appropriate type will be determined - automatically. - - Returns - ------- - scales : list(tuple) - The fully specified scaling parameter, compatible with the - :class:`~descarteslabs.client.services.raster.Raster` API and the - output data type. - data_type : str - The result data type as a standard GDAL type string. - - Raises - ------ - ValueError - If any invalid or incompatible value is passed to any of the - three parameters. - - - Scaling is determined on a band-by-band basis, incorporating the user - provided specification, the output data_type, and properties for the - band, such as the band type, the band data type, and the - ``default_range``, ``data_range``, and ``physical_range`` properties. - Ultimately the scaling for each band will be resolved to either - ``None`` or a tuple of numeric values of length 0, 2, or 4, as - accepted by the Raster API. The result is a list (with length equal - to the number of bands) of one of these values, or may be a None - value which is just a shorthand equivalent for a list of None values. - - A ``None`` indicates that no scaling should be performed. - - A 0-tuple ``()`` indicates that the band data should be automatically - scaled from the minimum and maximum values present in the image data - to the display range 0-255. - - A 2-tuple ``(input-min, input-max)`` indicates that the band data - should be scaled from the specified input range to the display - range of 0-255. - - A 4-tuple ``(input-min, input-max, output-min, output-max)`` - indicates that the band data should be scaled from the input range - to the output range. - - In all cases, the scaling will be performed as a multiply and add, - and the resulting values are only clipped as necessary to fit in - the output data type. As such, if the input and output ranges are - the same, it is effectively a no-op equivalent to ``None``. - - The support for scaling parameters in the Catalog API includes - the concept of an automated scaling mode. The four supported modes - are as follows. - - ``"raw"``: - Equivalent to a ``None``, the data should not be scaled. - ``"auto"``: - Equivalent to a 0-tuple, the data should be scaled by - the Raster service so that the actual range of data in the - input is scaled up to the full display range (0-255). It - is not possible to determine the bounds of this input range - in the client as the actual band data is not accessible. - ``"display"``: - The data should be scaled from any specified input bounds, - defaulting to the ``default_range`` property for the band, - to the output range, defaulting to 0-255. - ``"physical"``: - The data should be scaled from the input range, defaulting - to the ``data_range`` property for the band, to the output - range, defaulting to the ``physical_range`` property for - the band. - - The mode may be explicitly specified, or it may be determined - implicitly from other characteristics such as the length - and contents of the tuples for each band, or from the output - data_type if this is explicitly specified (e.g. ``"Byte"`` - implies display mode, ``"Float64"`` implies physical mode). - - If it is not possible to infer the mode, and a mode is required - in order to fully determine the results of this method, an - error will be raised. It is also an error to explicitly - specify more than one mode, with several exceptions: auto - and display mode are compatible, while a raw display mode - for a band which is of type "mask" or type "class" does - not conflict with any other mode specification. - - Normally the ``data_type`` parameter is not provided by the - user, and is instead determined from the mode as follows. - - ``"raw"``: - The data type that best matches the data types of all - the bands, preserving the precision and range of the - original data. - ``"auto"`` and ``"display"``: - ``"Byte"`` - ``"physical"``: - ``"Float64"`` - - The ``scaling`` parameter passed to this method can be any - of the following: - - None: - No scaling for all bands. Equivalent to ``[None, ...]``. - str: - Any of the four supported automatic modes as - described above. - list or Iterable: - A list or similar iterable must contain a number of - elements equal to the number of bands specified. Each - element must either be a None, a 0-, 2-, or 4-tuple, or - one of the above four automatic mode strings. The - elements of each tuple must either be a numeric value - or a string containing a valid numerical string followed - by a "%" character. The latter will be interpreted as a - percentage of the appropriate range (e.g. ``default_range``, - ``data_range``, or ``physical_range``) according to the mode. - For example, a tuple of ``("25%", "75%")`` with a - ``default_range`` of ``[0, 4000]`` will yield ``(1000, 3000)``. - dict or Mapping: - A dictionary or similar mapping with keys corresponding to - band names and values as accepted as elements for each band - as with a list described above. Each band name is used to - lookup a value in the mapping. If none is found, and the - band is not of type "mask" or "class", then the special - key ``"default_"`` is looked up in the mapping if it exists. - Otherwise a value of ``None`` will be used for the band. - This is strictly a convenience for constructing a list of - scale values, one for each band, but can be useful if a - single general-purpose mapping is defined for all possible - or relevant bands and then reused across many calls to the - different methods in the Catalog API which accept a ``scaling`` - parameter. - - See Also - -------- - :doc:`Catalog Guide ` : This contains many examples of the use of - the ``scaling`` and ``data_type`` parameters. - """ - bands = bands_to_list(bands) - return scaling_parameters( - cached_bands_by_product(self.product_id, self._client), - bands, - processing_level, - scaling, - data_type, - ) - - -# Deal with circular import problem -from .image_collection import ImageCollection # noqa: E402 - -Image._collection_type = ImageCollection diff --git a/descarteslabs/core/catalog/image_collection.py b/descarteslabs/core/catalog/image_collection.py deleted file mode 100644 index 7d1bc504..00000000 --- a/descarteslabs/core/catalog/image_collection.py +++ /dev/null @@ -1,1146 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 collections -import concurrent.futures -import json -import os - -import numpy as np - -from descarteslabs.exceptions import NotFoundError, BadRequestError - -from ..common.collection import Collection -from ..common.geo import GeoContext, AOI -from ..client.services.raster import Raster - -from .attributes import ResolutionUnit -from .image_types import ResampleAlgorithm, DownloadFileFormat -from .helpers import bands_to_list, cached_bands_by_product, download, is_path_like -from .scaling import multiproduct_scaling_parameters, append_alpha_scaling - - -class ImageCollection(Collection): - """ - Holds Images, with methods for loading their data. - - As a subclass of `Collection`, the `filter`, `map`, and `groupby` - methods and `each` property simplify inspection and subselection of - contained Images. - - `stack` and `mosaic` rasterize all contained images into an ndarray - using the a :class:`~descarteslabs.common.geo.geocontext.GeoContext`. - """ - - # _item_type set below due to circular imports - - def __init__(self, iterable=None, geocontext=None): - super(ImageCollection, self).__init__(iterable) - - # try to form a default context - if geocontext is not None: - if not isinstance(geocontext, GeoContext): - geocontext = AOI(geometry=geocontext) - - if len(self) > 0 and isinstance(geocontext, AOI): - # possibly update from contained images - if geocontext.crs is None: - crs = collections.Counter( - i.cs_code or i.projection for i in self._list - ).most_common(1)[0][0] - geocontext = geocontext.assign(crs=crs) - - if geocontext.resolution is None and geocontext.shape is None: - product_bands = self._product_bands() - - # The resolution must be in the same units as the CRS. However, - # we don't have any means here to determine the units of the CRS. - # Instead the best we can do is trust the band resolution definitions. - resolution = None - - # gather up all the resolutions for all the bands - resolutions = [ - band.resolution - for product_id in product_bands - for band in product_bands[product_id].values() - if band.resolution is not None and band.resolution.value - ] - - if resolutions: - # determine whether degrees or meters is more common - unit_counter = collections.Counter( - resolution.unit - for resolution in resolutions - if resolution.unit is not None - ) - if len(unit_counter) > 0: - unit = unit_counter.most_common(1)[0][0] - else: - unit = ResolutionUnit.METERS - - # define factors to convert to most common unit - if unit == ResolutionUnit.DEGREES: - factors = { - ResolutionUnit.METERS: 1 / 111111, - ResolutionUnit.DEGREES: 1, - } - else: - factors = { - ResolutionUnit.METERS: 1, - ResolutionUnit.DEGREES: 111111, - } - - # find the minimum of all values - values = ( - resolution.value - * factors[resolution.unit or ResolutionUnit.METERS] - for resolution in resolutions - ) - resolution = min(values) - - geocontext = geocontext.assign(resolution=resolution) - - self._geocontext = geocontext - - @property - def _client(self): - # pick a client, any client. Sure hope they're all the same - return self._list[0]._client - - @property - def geocontext(self): - return self._geocontext - - def filter_coverage(self, geom, minimum_coverage=1): - """ - Include only images overlapping with ``geom`` by some fraction. - - See `Image.coverage ` - for getting coverage information for an image. - - Parameters - ---------- - geom : GeoJSON-like dict, :class:`~descarteslabs.common.geo.geocontext.GeoContext`, or object with __geo_interface__ # noqa: E501 - Geometry to which to compare each image's geometry. - minimum_coverage : float - Only include images that cover ``geom`` by at least this fraction. - - Returns - ------- - images : ImageCollection - - Example - ------- - >>> import descarteslabs as dl - >>> aoi_geometry = { - ... 'type': 'Polygon', - ... 'coordinates': [[[-95, 42],[-93, 42],[-93, 40],[-95, 41],[-95, 42]]]} - >>> product = dl.catalog.Product.get("landsat:LC08:PRE:TOAR") # doctest: +SKIP - >>> images = product.images().intersects(aoi_geometry).limit(20).collect() # doctest: +SKIP - >>> filtered_images = images.filter_coverage(images.geocontext, 0.01) # doctest: +SKIP - >>> assert len(filtered_images) < len(images) # doctest: +SKIP - """ - - return self.filter(lambda i: i.coverage(geom) >= minimum_coverage) - - def stack( - self, - bands, - geocontext=None, - crs=None, - resolution=None, - all_touched=None, - flatten=None, - mask_nodata=True, - mask_alpha=None, - bands_axis=1, - raster_info=False, - resampler=ResampleAlgorithm.NEAR, - processing_level=None, - scaling=None, - data_type=None, - progress=None, - max_workers=None, - ): - """ - Load bands from all images and stack them into a 4D ndarray, - optionally masking invalid data. - - If the selected bands and images have different data types the resulting - ndarray has the most general of those data types. See - `Image.ndarray() ` for details - on data type conversions. - - Parameters - ---------- - bands : str or Sequence[str] - Band names to load. Can be a single string of band names - separated by spaces (``"red green blue"``), - or a sequence of band names (``["red", "green", "blue"]``). - If the alpha band is requested, it must be last in the list - to reduce rasterization errors. - geocontext : :class:`~descarteslabs.common.geo.geocontext.GeoContext`, default None - A :class:`~descarteslabs.common.geo.geocontext.GeoContext` to use when loading each image. - If ``None`` then the default context of the collection will be used. If - this is ``None``, a ValueError is raised. - crs : str, default None - if not None, update the gecontext with this value to set the output CRS. - resolution : float, default None - if not None, update the geocontext with this value to set the output resolution - in the units native to the specified or defaulted output CRS. - all_touched : float, default None - if not None, update the geocontext with this value to control rastering behavior. - flatten : str, Sequence[str], callable, or Sequence[callable], default None - "Flatten" groups of images in the stack into a single layer by mosaicking - each group (such as images from the same day), then stacking the mosaics. - - ``flatten`` takes the same predicates as `Collection.groupby`, such as - ``"properties.date"`` to mosaic images acquired at the exact same timestamp, - or ``["properties.date.year", "properties.date.month", "properties.date.day"]`` - to combine images captured on the same day (but not necessarily the same time). - - This is especially useful when ``geocontext`` straddles an image boundary - and contains one image captured right after another. Instead of having - each as a separate layer in the stack, you might want them combined. - - Note that indicies in the returned ndarray will no longer correspond to - indicies in this ImageCollection, since multiple images may be combined into - one layer in the stack. You can call ``groupby`` on this ImageCollection - with the same parameters to iterate through groups of images in equivalent - order to the returned ndarray. - - Additionally, the order of images in the ndarray will change: - they'll be sorted by the parameters to ``flatten``. - mask_nodata : bool, default True - Whether to mask out values in each band of each image that equal - that band's ``nodata`` sentinel value. - mask_alpha : bool or str or None, default None - Whether to mask pixels in all bands where the alpha band of all images is 0. - Provide a string to use an alternate band name for masking. - If the alpha band is available for all images in the collection and - ``mask_alpha`` is None, ``mask_alpha`` is set to True. If not, - mask_alpha is set to False. - bands_axis : int, default 1 - Axis along which bands should be located. - If 1, the array will have shape ``(image, band, y, x)``, if -1, - it will have shape ``(image, y, x, band)``, etc. - A bands_axis of 0 is currently unsupported. - raster_info : bool, default False - Whether to also return a list of dicts about the rasterization of - each image, including the coordinate system WKT and geotransform matrix. - Generally only useful if you plan to upload data derived from this - image back to the Descartes Labs catalog, or use it with GDAL. - resampler : `ResampleAlgorithm`, default `ResampleAlgorithm.NEAR` - Algorithm used to interpolate pixel values when scaling and transforming - each image to its new resolution or SRS. - processing_level : str, optional - How the processing level of the underlying data should be adjusted. Possible - values depend on the product and bands in use. Legacy products support - ``toa`` (top of atmosphere) and in some cases ``surface``. Consult the - available ``processing_levels`` in the product bands to understand what - is available. - scaling : None, str, list, dict - Band scaling specification. Please see :meth:`scaling_parameters` for a full - description of this parameter. - data_type : None, str - Output data type. Please see :meth:`scaling_parameters` for a full - description of this parameter. - progress : None, bool - Controls display of a progress bar. - max_workers : int, default None - Maximum number of threads to use to parallelize individual ndarray - calls to each image. - If None, it defaults to the number of processors on the machine, - multiplied by 5. - Note that unnecessary threads *won't* be created if ``max_workers`` - is greater than the number of images in the ImageCollection. - - Returns - ------- - arr : ndarray - Returned array's shape is ``(image, band, y, x)`` if bands_axis is 1, - or ``(image, y, x, band)`` if bands_axis is -1. - If ``mask_nodata`` or ``mask_alpha`` is True, arr will be a masked array. - The data type ("dtype") of the array is the most general of the data - types among the images being rastered. - raster_info : List[dict] - If ``raster_info=True``, a list of raster information dicts for each image - is also returned - - Raises - ------ - ValueError - If requested bands are unavailable, or band names are not given - or are invalid. - If the context is None and no default context for the ImageCollection - is defined, or if not all required parameters are specified in the - :class:`~descarteslabs.common.geo.geocontext.GeoContext`. - If the ImageCollection is empty. - NotFoundError - If a Image's ID cannot be found in the Descartes Labs catalog - BadRequestError - If the Descartes Labs Platform is given unrecognized parameters - """ - if len(self) == 0: - raise ValueError("This ImageCollection is empty") - - if geocontext is None: - geocontext = self.geocontext - if geocontext is None: - raise ValueError( - "No geocontext supplied, and no default geocontext is defined for this ImageCollection" - ) - if crs is not None or resolution is not None: - try: - params = {} - if crs is not None: - params["crs"] = crs - if resolution is not None: - params["resolution"] = resolution - geocontext = geocontext.assign(**params) - except TypeError: - raise ValueError( - f"{type(geocontext)} geocontext does not support modifying crs or resolution" - ) from None - if all_touched is not None: - geocontext = geocontext.assign(all_touched=all_touched) - - kwargs = dict( - mask_nodata=mask_nodata, - mask_alpha=mask_alpha, - bands_axis=bands_axis, - raster_info=raster_info, - resampler=resampler, - processing_level=processing_level, - progress=progress, - ) - - if bands_axis == 0 or bands_axis == -4: - raise NotImplementedError( - "bands_axis of 0 is currently unsupported for `ImageCollection.stack`. " - "If you require this shape, try ``np.moveaxis(my_stack, 1, 0)`` on the returned ndarray." - ) - elif bands_axis > 0: - kwargs["bands_axis"] = ( - bands_axis - 1 - ) # the bands axis for each component ndarray call in the stack - - if flatten is not None: - if isinstance(flatten, str) or not hasattr(flatten, "__len__"): - flatten = [flatten] - images = [ - ic if len(ic) > 1 else ic[0] for group, ic in self.groupby(*flatten) - ] - else: - images = self - - full_stack = None - mask = None - if raster_info: - raster_infos = [None] * len(images) - - bands = bands_to_list(bands) - product_bands = self._product_bands() - (bands, scaling, mask_alpha, pop_alpha) = self._mask_alpha_if_applicable( - product_bands, bands, mask_alpha=mask_alpha, scaling=scaling - ) - scales, data_type = multiproduct_scaling_parameters( - product_bands, bands, processing_level, scaling, data_type - ) - - if pop_alpha: - bands.pop(-1) - if scales: - scales.pop(-1) - - kwargs["scaling"] = scales - kwargs["data_type"] = data_type - - def threaded_ndarrays(): - def data_loader(image_or_imagecollection, bands, geocontext, **kwargs): - if isinstance(image_or_imagecollection, self.__class__): - return lambda: image_or_imagecollection.mosaic( - bands, geocontext, **kwargs - ) - else: - return lambda: image_or_imagecollection._ndarray( - bands, geocontext, **kwargs - ) - - with concurrent.futures.ThreadPoolExecutor( - max_workers=max_workers - ) as executor: - future_ndarrays = {} - for i, image_or_imagecollection in enumerate(images): - future_ndarray = executor.submit( - data_loader( - image_or_imagecollection, bands, geocontext, **kwargs - ) - ) - future_ndarrays[future_ndarray] = i - for future in concurrent.futures.as_completed(future_ndarrays): - i = future_ndarrays[future] - result = future.result() - yield i, result - - for i, arr in threaded_ndarrays(): - if raster_info: - arr, raster_meta = arr - raster_infos[i] = raster_meta - - if full_stack is None: - stack_shape = (len(images),) + arr.shape - full_stack = np.empty(stack_shape, dtype=arr.dtype) - if isinstance(arr, np.ma.MaskedArray): - mask = np.empty(stack_shape, dtype=bool) - - if isinstance(arr, np.ma.MaskedArray): - full_stack[i] = arr.data - mask[i] = arr.mask - else: - full_stack[i] = arr - - if mask is not None: - full_stack = np.ma.MaskedArray(full_stack, mask, copy=False) - if raster_info: - return full_stack, raster_infos - else: - return full_stack - - def mosaic( - self, - bands, - geocontext=None, - crs=None, - resolution=None, - all_touched=None, - mask_nodata=True, - mask_alpha=None, - bands_axis=0, - resampler=ResampleAlgorithm.NEAR, - processing_level=None, - scaling=None, - data_type=None, - progress=None, - raster_info=False, - ): - """ - Load bands from all images, combining them into a single 3D ndarray - and optionally masking invalid data. - - Where multiple images overlap, only data from the image that comes last - in the ImageCollection is used. - - If the selected bands and images have different data types the resulting - ndarray has the most general of those data types. See - `Image.ndarray() ` for details - on data type conversions. - - Parameters - ---------- - bands : str or Sequence[str] - Band names to load. Can be a single string of band names - separated by spaces (``"red green blue"``), - or a sequence of band names (``["red", "green", "blue"]``). - If the alpha band is requested, it must be last in the list - to reduce rasterization errors. - geocontext : :class:`~descarteslabs.common.geo.geocontext.GeoContext`, default None - A :class:`~descarteslabs.common.geo.geocontext.GeoContext` to use when loading each image. - If ``None`` then the default context of the collection will be used. If - this is ``None``, a ValueError is raised. - crs : str, default None - if not None, update the gecontext with this value to set the output CRS. - resolution : float, default None - if not None, update the geocontext with this value to set the output resolution - in the units native to the specified or defaulted output CRS. - all_touched : float, default None - if not None, update the geocontext with this value to control rastering behavior. - mask_nodata : bool, default True - Whether to mask out values in each band that equal - that band's ``nodata`` sentinel value. - mask_alpha : bool or str or None, default None - Whether to mask pixels in all bands where the alpha band of all images is 0. - Provide a string to use an alternate band name for masking. - If the alpha band is available for all images in the collection and - ``mask_alpha`` is None, ``mask_alpha`` is set to True. If not, - mask_alpha is set to False. - bands_axis : int, default 0 - Axis along which bands should be located in the returned array. - If 0, the array will have shape ``(band, y, x)``, - if -1, it will have shape ``(y, x, band)``. - - It's usually easier to work with bands as the outermost axis, - but when working with large arrays, or with many arrays concatenated - together, NumPy operations aggregating each xy point across bands - can be slightly faster with bands as the innermost axis. - raster_info : bool, default False - Whether to also return a dict of information about the rasterization - of the images, including the coordinate system WKT and geotransform matrix. - Generally only useful if you plan to upload data derived - from this image back to the Descartes Labs catalog, or use it with GDAL. - resampler : `ResampleAlgorithm`, default `ResampleAlgorithm.NEAR` - Algorithm used to interpolate pixel values when scaling and transforming - the image to its new resolution or SRS. - processing_level : str, optional - How the processing level of the underlying data should be adjusted. Possible - values depend on the product and bands in use. Legacy products support - ``toa`` (top of atmosphere) and in some cases ``surface``. Consult the - available ``processing_levels`` in the product bands to understand what - is available. - scaling : None, str, list, dict - Band scaling specification. Please see :meth:`scaling_parameters` for a full - description of this parameter. - data_type : None, str - Output data type. Please see :meth:`scaling_parameters` for a full - description of this parameter. - progress : None, bool - Controls display of a progress bar. - - - Returns - ------- - arr : ndarray - Returned array's shape will be ``(band, y, x)`` if ``bands_axis`` - is 0, and ``(y, x, band)`` if ``bands_axis`` is -1. - If ``mask_nodata`` or ``mask_alpha`` is True, arr will be a masked array. - The data type ("dtype") of the array is the most general of the data - types among the images being rastered. - raster_info : dict - If ``raster_info=True``, a raster information dict is also returned. - - Raises - ------ - ValueError - If requested bands are unavailable, or band names are not given - or are invalid. - If not all required parameters are specified in the - :class:`~descarteslabs.common.geo.geocontext.GeoContext`. - If the ImageCollection is empty. - NotFoundError - If a Image's ID cannot be found in the Descartes Labs catalog - BadRequestError - If the Descartes Labs Platform is given unrecognized parameters - """ - if len(self) == 0: - raise ValueError("This ImageCollection is empty") - - if geocontext is None: - geocontext = self.geocontext - if geocontext is None: - raise ValueError( - "No geocontext supplied, and no default geocontext is defined for this ImageCollection" - ) - if crs is not None or resolution is not None: - try: - params = {} - if crs is not None: - params["crs"] = crs - if resolution is not None: - params["resolution"] = resolution - geocontext = geocontext.assign(**params) - except TypeError: - raise ValueError( - f"{type(geocontext)} geocontext does not support modifying crs or resolution" - ) from None - if all_touched is not None: - geocontext = geocontext.assign(all_touched=all_touched) - - if not (-3 < bands_axis < 3): - raise ValueError( - "Invalid bands_axis; axis {} would not exist in a 3D array".format( - bands_axis - ) - ) - - bands = bands_to_list(bands) - product_bands = self._product_bands() - (bands, scaling, mask_alpha, drop_alpha) = self._mask_alpha_if_applicable( - product_bands, bands, mask_alpha=mask_alpha, scaling=scaling - ) - scales, data_type = multiproduct_scaling_parameters( - product_bands, bands, processing_level, scaling, data_type - ) - - raster_params = geocontext.raster_params - full_raster_args = dict( - inputs=[image.id for image in self], - order="gdal", - bands=bands, - scales=scales, - data_type=data_type, - resampler=resampler, - processing_level=processing_level, - mask_nodata=bool(mask_nodata), - mask_alpha=mask_alpha, - drop_alpha=drop_alpha, - masked=mask_nodata or mask_alpha, - progress=progress, - **raster_params, - ) - try: - arr, info = Raster.get_default_client().ndarray(**full_raster_args) - except NotFoundError: - raise NotFoundError( - "Some or all of these IDs don't exist in the Descartes Labs catalog: {}".format( - full_raster_args["inputs"] - ) - ) - except BadRequestError as e: - msg = ( - "Error with request:\n" - "{err}\n" - "For reference, Raster.ndarray was called with these arguments:\n" - "{args}" - ) - msg = msg.format(err=e, args=json.dumps(full_raster_args, indent=2)) - raise BadRequestError(msg) from None - - if len(arr.shape) == 2: - # if only 1 band requested, still return a 3d array - arr = arr[np.newaxis] - - if bands_axis != 0: - arr = np.moveaxis(arr, 0, bands_axis) - if raster_info: - return arr, info - else: - return arr - - def download( - self, - bands, - geocontext=None, - crs=None, - resolution=None, - all_touched=None, - dest=None, - format=DownloadFileFormat.TIF, - resampler=ResampleAlgorithm.NEAR, - processing_level=None, - scaling=None, - data_type=None, - progress=None, - max_workers=None, - ): - """ - Download images as image files in parallel. - - Parameters - ---------- - bands : str or Sequence[str] - Band names to load. Can be a single string of band names - separated by spaces (``"red green blue"``), - or a sequence of band names (``["red", "green", "blue"]``). - geocontext : :class:`~descarteslabs.common.geo.geocontext.GeoContext`, default None - A :class:`~descarteslabs.common.geo.geocontext.GeoContext` to use when loading each image. - If ``None`` then the default context of the collection will be used. If - this is ``None``, a ValueError is raised. - crs : str, default None - if not None, update the gecontext with this value to set the output CRS. - resolution : float, default None - if not None, update the geocontext with this value to set the output resolution - in the units native to the specified or defaulted output CRS. - all_touched : float, default None - if not None, update the geocontext with this value to control rastering behavior. - dest : str, path-like, or sequence of str or path-like, default None - Directory or sequence of paths to which to write the image files. - - If ``None``, the current directory is used. - - If a directory, files within it will be named by - their image IDs and the bands requested, like - ``"sentinel-2:L1C:2018-08-10_10TGK_68_S2A_v1-red-green-blue.tif"``. - - If a sequence of paths of the same length as the ImageCollection is given, - each Image will be written to the corresponding path. This lets you use your - own naming scheme, or even write images to multiple directories. - - Any intermediate paths are created if they do not exist, - for both a single directory and a sequence of paths. - format : `DownloadFileFormat`, default `DownloadFileFormat.TIF` - Output file format to use. - If ``dest`` is a sequence of paths, ``format`` is ignored - and determined by the extension on each path. - resampler : `ResampleAlgorithm`, default `ResampleAlgorithm.NEAR` - Algorithm used to interpolate pixel values when scaling and transforming - the image to its new resolution or SRS. - processing_level : str, optional - How the processing level of the underlying data should be adjusted. Possible - values depend on the product and bands in use. Legacy products support - ``toa`` (top of atmosphere) and in some cases ``surface``. Consult the - available ``processing_levels`` in the product bands to understand what - is available. - scaling : None, str, list, dict - Band scaling specification. Please see :meth:`scaling_parameters` for a full - description of this parameter. - data_type : None, str - Output data type. Please see :meth:`scaling_parameters` for a full - description of this parameter. - progress : None, bool - Controls display of a progress bar. - max_workers : int, default None - Maximum number of threads to use to parallelize individual ``download`` - calls to each Image. - If None, it defaults to the number of processors on the machine, - multiplied by 5. - Note that unnecessary threads *won't* be created if ``max_workers`` - is greater than the number of Images in the ImageCollection. - - Returns - ------- - paths : Sequence[str] - A list of all the paths where files were written. - - Example - ------- - >>> import descarteslabs as dl - >>> tile = dl.common.geo.DLTile.from_key("256:0:75.0:15:-5:230") # doctest: +SKIP - >>> product = dl.catalog.Product.get("landsat:LC08:PRE:TOAR") # doctest: +SKIP - >>> images = product.images().intersects(tile).limit(5).collect() # doctest: +SKIP - >>> images.download("red green blue", tile, "rasters") # doctest: +SKIP - ["rasters/landsat:LC08:PRE:TOAR:meta_LC80260322013108_v1-red-green-blue.tif", - "rasters/landsat:LC08:PRE:TOAR:meta_LC80260322013124_v1-red-green-blue.tif", - "rasters/landsat:LC08:PRE:TOAR:meta_LC80260322013140_v1-red-green-blue.tif", - "rasters/landsat:LC08:PRE:TOAR:meta_LC80260322013156_v1-red-green-blue.tif", - "rasters/landsat:LC08:PRE:TOAR:meta_LC80260322013172_v1-red-green-blue.tif"] - >>> # use explicit paths for a custom naming scheme: - >>> paths = [ - ... "{tile.key}/l8-{image.date:%Y-%m-%d-%H:%m}.tif".format(tile=tile, image=image) - ... for image in images - ... ] # doctest: +SKIP - >>> images.download("nir red", tile, paths) # doctest: +SKIP - ["256:0:75.0:15:-5:230/l8-2013-04-18-16:04.tif", - "256:0:75.0:15:-5:230/l8-2013-05-04-16:05.tif", - "256:0:75.0:15:-5:230/l8-2013-05-20-16:05.tif", - "256:0:75.0:15:-5:230/l8-2013-06-05-16:06.tif", - "256:0:75.0:15:-5:230/l8-2013-06-21-16:06.tif"] - - Raises - ------ - RuntimeError - If the paths given are not all unique. - If there is an error generating default filenames. - ValueError - If requested bands are unavailable, or band names are not given - or are invalid. - If not all required parameters are specified in the - :class:`~descarteslabs.common.geo.geocontext.GeoContext`. - If the ImageCollection is empty. - If ``dest`` is a sequence not equal in length to the ImageCollection. - If ``format`` is invalid, or a path has an invalid extension. - TypeError - If ``dest`` is not a string or a sequence type. - NotFoundError - If a Image's ID cannot be found in the Descartes Labs catalog - BadRequestError - If the Descartes Labs Platform is given unrecognized parameters - """ - if len(self) == 0: - raise ValueError("This ImageCollection is empty") - - if geocontext is None: - geocontext = self.geocontext - if geocontext is None: - raise ValueError( - "No geocontext supplied, and no default geocontext is defined for this ImageCollection" - ) - if crs is not None or resolution is not None: - try: - params = {} - if crs is not None: - params["crs"] = crs - if resolution is not None: - params["resolution"] = resolution - geocontext = geocontext.assign(**params) - except TypeError: - raise ValueError( - f"{type(geocontext)} geocontext does not support modifying crs or resolution" - ) from None - if all_touched is not None: - geocontext = geocontext.assign(all_touched=all_touched) - - if dest is None: - dest = "." - - bands = bands_to_list(bands) - scales, data_type = multiproduct_scaling_parameters( - self._product_bands(), bands, processing_level, scaling, data_type - ) - - if is_path_like(dest): - default_pattern = "{image.id}-{bands}.{ext}" - bands_str = "-".join(bands) - try: - dest = [ - os.path.join( - dest, - default_pattern.format( - image=image, bands=bands_str, ext=format - ), - ) - for image in self - ] - except Exception as e: - raise RuntimeError( - "Error while generating default filenames:\n{}\n" - "This is likely due to missing or unexpected data " - "in Images in this ImageCollection.".format(e) - ) from None - - try: - if len(dest) != len(self): - raise ValueError( - "`dest` contains {} items, but the ImageCollection contains {}".format( - len(dest), len(self) - ) - ) - except TypeError: - raise TypeError( - "`dest` should be a sequence of strings or path-like objects; " - "instead found type {}, which has no length".format(type(dest)) - ) from None - - # check for duplicate paths to prevent the confusing situation where - # multiple rasters overwrite the same filename - unique = set() - for path in dest: - if path in unique: - raise RuntimeError( - "Paths must be unique, but '{}' occurs multiple times".format(path) - ) - else: - unique.add(path) - - download_args = dict( - resampler=resampler, - processing_level=processing_level, - scaling=scales, - data_type=data_type, - progress=progress, - ) - with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: - futures = { - executor.submit( - image._download, bands, geocontext, dest=path, **download_args - ): path - for image, path in zip(self, dest) - } - exceptions = [] - for future in concurrent.futures.as_completed(futures): - try: - future.result() - except Exception as ex: - exceptions.append((futures[future], ex)) - if exceptions: - raise RuntimeError( - "One or more downloads failed: {}".format(exceptions) - ) - return dest - - def download_mosaic( - self, - bands, - geocontext=None, - crs=None, - resolution=None, - all_touched=None, - dest=None, - format=DownloadFileFormat.TIF, - resampler=ResampleAlgorithm.NEAR, - processing_level=None, - scaling=None, - data_type=None, - mask_alpha=None, - nodata=None, - progress=None, - ): - """ - Download all images as a single image file. - Where multiple images overlap, only data from the image that comes last - in the ImageCollection is used. - - Parameters - ---------- - bands : str or Sequence[str] - Band names to load. Can be a single string of band names - separated by spaces (``"red green blue"``), - or a sequence of band names (``["red", "green", "blue"]``). - geocontext : :class:`~descarteslabs.common.geo.geocontext.GeoContext`, default None - A :class:`~descarteslabs.common.geo.geocontext.GeoContext` to use when loading each image. - If ``None`` then the default context of the collection will be used. If - this is ``None``, a ValueError is raised. - crs : str, default None - if not None, update the gecontext with this value to set the output CRS. - resolution : float, default None - if not None, update the geocontext with this value to set the output resolution - in the units native to the specified or defaulted output CRS. - all_touched : float, default None - if not None, update the geocontext with this value to control rastering behavior. - dest : str or path-like object, default None - Where to write the image file. - - * If None (default), it's written to an image file of the given ``format`` - in the current directory, named by the requested bands, - like ``"mosaic-red-green-blue.tif"`` - * If a string or path-like object, it's written to that path. - - Any file already existing at that path will be overwritten. - - Any intermediate directories will be created if they don't exist. - - Note that path-like objects (such as pathlib.Path) are only supported - in Python 3.6 or later. - format : `DownloadFileFormat`, default `DownloadFileFormat.TIF` - Output file format to use. - If a str or path-like object is given as ``dest``, ``format`` is ignored - and determined from the extension on the path (one of ".tif", ".png", or ".jpg"). - resampler : `ResampleAlgorithm`, default `ResampleAlgorithm.NEAR` - Algorithm used to interpolate pixel values when scaling and transforming - the image to its new resolution or SRS. - processing_level : str, optional - How the processing level of the underlying data should be adjusted. Possible - values depend on the product and bands in use. Legacy products support - ``toa`` (top of atmosphere) and in some cases ``surface``. Consult the - available ``processing_levels`` in the product bands to understand what - is available. - scaling : None, str, list, dict - Band scaling specification. Please see :meth:`scaling_parameters` for a full - description of this parameter. - data_type : None, str - Output data type. Please see :meth:`scaling_parameters` for a full - description of this parameter. - mask_alpha : bool or str or None, default None - Whether to mask pixels in all bands where the alpha band of all images is 0. - Provide a string to use an alternate band name for masking. - If the alpha band is available for all images in the collection and - ``mask_alpha`` is None, ``mask_alpha`` is set to True. If not, - mask_alpha is set to False. - nodata : None, number - NODATA value for a geotiff file. Will be assigned to any masked pixels. - progress : None, bool - Controls display of a progress bar. - - Returns - ------- - path : str or None - If ``dest`` is a path or None, the path where the image file was written is returned. - If ``dest`` is file-like, nothing is returned. - - Example - ------- - >>> import descarteslabs as dl - >>> tile = dl.common.geo.DLTile.from_key("256:0:75.0:15:-5:230") # doctest: +SKIP - >>> product = dl.catalog.Product.get("landsat:LC08:PRE:TOAR") # doctest: +SKIP - >>> images = product.images().intersects(tile).limit(5).collect() # doctest: +SKIP - >>> images.download_mosaic("nir red", mask_alpha=False) # doctest: +SKIP - 'mosaic-nir-red.tif' - >>> images.download_mosaic("red green blue", dest="mosaics/{}.png".format(tile.key)) # doctest: +SKIP - 'mosaics/256:0:75.0:15:-5:230.tif' - - - Raises - ------ - ValueError - If requested bands are unavailable, or band names are not given - or are invalid. - If not all required parameters are specified in the - :class:`~descarteslabs.common.geo.geocontext.GeoContext`. - If the ImageCollection is empty. - If ``format`` is invalid, or the path has an invalid extension. - NotFoundError - If a Image's ID cannot be found in the Descartes Labs catalog - BadRequestError - If the Descartes Labs Platform is given unrecognized parameters - """ - if len(self) == 0: - raise ValueError("This ImageCollection is empty") - - if geocontext is None: - geocontext = self.geocontext - if geocontext is None: - raise ValueError( - "No geocontext supplied, and no default geocontext is defined for this ImageCollection" - ) - if crs is not None or resolution is not None: - try: - params = {} - if crs is not None: - params["crs"] = crs - if resolution is not None: - params["resolution"] = resolution - geocontext = geocontext.assign(**params) - except TypeError: - raise ValueError( - f"{type(geocontext)} geocontext does not support modifying crs or resolution" - ) from None - if all_touched is not None: - geocontext = geocontext.assign(all_touched=all_touched) - - bands = bands_to_list(bands) - product_bands = self._product_bands() - (bands, scaling, mask_alpha, drop_alpha) = self._mask_alpha_if_applicable( - product_bands, bands, mask_alpha=mask_alpha, scaling=scaling - ) - scales, data_type = multiproduct_scaling_parameters( - product_bands, bands, processing_level, scaling, data_type - ) - - return download( - inputs=self.each.id.collect(list), - bands_list=bands, - geocontext=geocontext, - scales=scales, - data_type=data_type, - dest=dest, - format=format, - resampler=resampler, - processing_level=processing_level, - nodata=nodata, - progress=progress, - ) - - def scaling_parameters( - self, bands, processing_level=None, scaling=None, data_type=None - ): - """ - Computes fully defaulted scaling parameters and output data_type - from provided specifications. - - This method is provided as a convenience to the user to help with - understanding how ``scaling`` and ``data_type`` parameters passed - to other methods on this class (e.g. :meth:`stack` or :meth:`mosaic`) - will be interpreted. It would not usually be used in a normal - workflow. - - A image collection may contain images from more than one product, - introducing the possibility that the band properties for a band - of a given name may differ from product to product. This method - works in a similar fashion to the - :meth:`Image.scaling_parameters ` - method, but it additionally ensures that the resulting scale - elements are compatible across the multiple products. If there - is an incompatibility, an appropriate ValueError will be raised. - - Parameters - ---------- - bands : list(str) - List of bands to be scaled. - processing_level : str, optional - How the processing level of the underlying data should be adjusted. Possible - values depend on the product and bands in use. Legacy products support - ``toa`` (top of atmosphere) and in some cases ``surface``. Consult the - available ``processing_levels`` in the product bands to understand what - is available. - scaling : None or str or list or dict - Band scaling specification. See - :meth:`Image.scaling_parameters ` - for a full description of this parameter. - data_type : None or str - Result data type desired, as a standard data type string (e.g. - ``"Byte"``, ``"Uint16"``, or ``"Float64"``). If not specified, - will be deduced from the ``scaling`` specification. See - :meth:`Image.scaling_parameters ` - for a full description of this parameter. - - Returns - ------- - scales : list(tuple) - The fully specified scaling parameter, compatible with the - :class:`~descarteslabs.client.services.raster.Raster` API and the - output data type. - data_type : str - The result data type as a standard GDAL type string. - - Raises - ------ - ValueError - If any invalid or incompatible value is passed to any of the - three parameters. - - See Also - -------- - :doc:`Catalog Guide ` : This contains many examples of the use of - the ``scaling`` and ``data_type`` parameters. - """ - bands = bands_to_list(bands) - return multiproduct_scaling_parameters( - self._product_bands(), bands, processing_level, scaling, data_type - ) - - def __repr__(self): - parts = [ - "ImageCollection of {} image{}".format( - len(self), "" if len(self) == 1 else "s" - ) - ] - try: - first = min(self.each.date) - last = max(self.each.date) - dates = " * Dates: {:%b %d, %Y} to {:%b %d, %Y}".format(first, last) - parts.append(dates) - except Exception: - pass - - try: - products = self.each.product_id.combine(collections.Counter) - if len(products) > 0: - products = ", ".join("{}: {}".format(k, v) for k, v in products.items()) - products = " * Products: {}".format(products) - parts.append(products) - except Exception: - pass - - return "\n".join(parts) - - def _product_bands(self): - product_ids = set(map(lambda i: i.product_id, self._list)) - return { - product_id: cached_bands_by_product(product_id, self._client) - for product_id in product_ids - } - - def _mask_alpha_if_applicable( - self, product_bands, bands, mask_alpha=None, scaling=None - ): - alpha_band_name = "alpha" - if isinstance(mask_alpha, str): - alpha_band_name = mask_alpha - mask_alpha = True - elif mask_alpha is None: - mask_alpha = all( - map(lambda b: alpha_band_name in b, product_bands.values()) - ) - elif type(mask_alpha) is not bool: - raise ValueError("'mask_alpha' must be None, a band name, or a bool.") - - drop_alpha = False - if mask_alpha: - try: - alpha_i = bands.index(alpha_band_name) - except ValueError: - bands.append(alpha_band_name) - drop_alpha = True - scaling = append_alpha_scaling(scaling) - else: - if alpha_i != len(bands) - 1: - raise ValueError( - "Alpha must be the last band in order to reduce rasterization errors" - ) - return (bands, scaling, mask_alpha, drop_alpha) - - -# deal with circular import problem -from .image import Image # noqa: E402 - -ImageCollection._item_type = Image diff --git a/descarteslabs/core/catalog/image_types.py b/descarteslabs/core/catalog/image_types.py deleted file mode 100644 index bdc66673..00000000 --- a/descarteslabs/core/catalog/image_types.py +++ /dev/null @@ -1,76 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 strenum import StrEnum - - -class ResampleAlgorithm(StrEnum): - """Valid GDAL resampler algorithms for rastering. - - Attributes - ---------- - NEAR : enum - Nearest neighbor. - BILINEAR : enum - Bilinear. - CUBIC : enum - Cubic. - CUBICSPLINE : enum - Cubic spline. - AVERAGE : enum - Average. - MODE : enum - Mode. - MAX : enum - Max. - MIN : enum - Min. - MED : enum - Median. - Q1 : enum - Q1. - Q3 : enum - Q3. - """ - - NEAR = "near" - BILINEAR = "bilinear" - CUBIC = "cubic" - CUBICSPLINE = "cubicspline" - LANCZOS = "lanczos" - AVERAGE = "average" - MODE = "mode" - MAX = "max" - MIN = "min" - MEDIAN = "med" - Q1 = "q1" - Q3 = "q3" - - -class DownloadFileFormat(StrEnum): - """Supported download file formats. - - Attributes - ---------- - JPEG : enum - JPEG encoded GeoTIFF format. - PNG : enum - PNG format. - TIF : enum - GeoTIFF format. - """ - - JPEG = "jpg" - PNG = "png" - TIF = "tif" diff --git a/descarteslabs/core/catalog/image_upload.py b/descarteslabs/core/catalog/image_upload.py deleted file mode 100644 index 914866cd..00000000 --- a/descarteslabs/core/catalog/image_upload.py +++ /dev/null @@ -1,566 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 concurrent.futures import TimeoutError -import itertools -import requests.exceptions -import time -import urllib3.exceptions -import warnings - -from strenum import StrEnum - -from descarteslabs.exceptions import ServerError - -from .catalog_base import CatalogObjectBase, check_deleted, check_derived, hybridmethod -from .attributes import ( - Attribute, - CatalogObjectReference, - Timestamp, - EnumAttribute, - MappingAttribute, - ListAttribute, - TypedAttribute, -) -from .image import Image -from .search import Search - - -class ImageUploadType(StrEnum): - """The type of upload data. - - Attributes - ---------- - NDARRAY : enum - An multidimensional, homogeneous array of fixed-size items representing one - or more images. - FILE : enum - A file on disk containing one or more images. - """ - - NDARRAY = "ndarray" - FILE = "file" - - -class OverviewResampler(StrEnum): - """Allowed overview resampler algorithms. - - Attributes - ---------- - NEAREST : enum - Applies a nearest neighbour (simple sampling) resampler - AVERAGE : enum - Computes the average of all non-NODATA contributing pixels. - GAUSS : enum - Applies a Gaussian kernel before computing the overview, which can lead to - better results than simple averaging in e.g case of sharp edges with high - contrast or noisy patterns. - CUBIC : enum - Applies a cubic convolution kernel. - CUBICSPLINE : enum - Applies a B-Spline convolution kernel. - LANCZOS : enum - Applies a Lanczos windowed sinc convolution kernel. - AVERAGE_MP : enum - Averages complex data in mag/phase space. - AVERAGE_MAGPHASE : enum - average_magphase - MODE : enum - Selects the value which appears most often of all the sampled points. - """ - - NEAREST = "nearest" - AVERAGE = "average" - GAUSS = "gauss" - CUBIC = "cubic" - CUBICSPLINE = "cubicspline" - LANCZOS = "lanczos" - AVERAGE_MP = "average_mp" - AVERAGE_MAGPHASE = "average_magphase" - MODE = "mode" - - -class ImageUploadStatus(StrEnum): - """The status of the image upload operation. - - Attributes - ---------- - TRANSFERRING : enum - Upload has been initiated and file(s) are being transfered from - the client to the platform. - PENDING : enum - The files were transfered to the platform, and are waiting for processing to begin. - RUNNING : enum - The processing step is currently running. - SUCCESS : enum - The upload processing completed successfully and the new image is available. - FAILURE : enum - The upload failed; error information is available. - CANCELED : enum - The upload was canceled by the user prior to completion. - """ - - TRANSFERRING = "transferring" - PENDING = "pending" - RUNNING = "running" - SUCCESS = "success" - FAILURE = "failure" - CANCELED = "canceled" - - -class ImageUploadEventType(StrEnum): - """The type of the image upload event. - - Attributes - ---------- - QUEUE : enum - The transfer of the file(s) was completed, and the upload processing - request has been issued. - CANCEL : enum - The user has requested that the upload be canceled. If processing is - already underway, it will continue. - RUN : enum - The processing step is starting. - COMPLETE : enum - All processing has completed. The upload status will reflect - success, failure, or cancellation. - ERROR : enum - An error has been detected, but the operation may continue or be - retried. - TIMEOUT : enum - The upload operation has timed out, and will be retried. - LOG : enum - The event contains logging output. - USAGE : enum - The event contains process resource usage information. - """ - - QUEUE = "queue" - CANCEL = "cancel" - RUN = "run" - COMPLETE = "complete" - ERROR = "error" - TIMEOUT = "timeout" - LOG = "log" - USAGE = "usage" - - -class ImageUploadEventSeverity(StrEnum): - """The severity of an image upload event. - - The severity values duplicate the standard python logging package - level names and have the same meaning. - - Attributes - ---------- - CRITICAL : enum - Critical (error) event. - ERROR : enum - Error event. - WARNING : enum - Warning event. - INFO : enum - Informational event. - DEBUG : enum - Debug event. - """ - - CRITICAL = "CRITICAL" - ERROR = "ERROR" - WARNING = "WARNING" - INFO = "INFO" - DEBUG = "DEBUG" - - -class ImageUploadOptions(MappingAttribute): - """Control of the upload process. - - Attributes - ---------- - upload_type : str or ImageUploadType - Type of upload job, see `ImageUploadType`. - image_files : list(str) - File basenames of the uploaded files. - overviews : list(int) - Overview generation control, only used when `upload_type` is - `ImageUploadType.NDARRAY`. - overview_resampler : str or OverviewResampler - Overview resampler method, only used when `upload_type` is - `ImageUploadType.NDARRAY`. - upload_size : int - When `upload_type` is `ImageUploadType.NDARRAY`, - the total size of the array in bytes. - """ - - upload_type = EnumAttribute(ImageUploadType) - image_files = ListAttribute(Attribute) - overviews = ListAttribute(Attribute) - overview_resampler = EnumAttribute(OverviewResampler) - upload_size = Attribute() - # worker_tag is for development and testing and should not be used by ordinary - # clients, and as such is not documented above. - worker_tag = Attribute() - - -class ImageUploadEvent(MappingAttribute): - """Image upload event data. - - During the sequence of steps in the life-cycle of an upload, events are recorded - at each change in upload status and as responsibility for the upload passes between - different subsystems (referred to here as "components"). While the `ImageUpload` - object provides the current status of the upload and the time at which that - status was reached, the events associated with an upload record the circumstances - for each of the changes in the upload status as they occurred. - - A typical upload, once complete, will have four events with the following - `event_type`: - - * `ImageUploadEventType.QUEUE` - * `ImageUploadEventType.RUN` - * `ImageUploadEventType.USAGE` - * `ImageUploadEventType.COMPLETE` - - """ - - _doc_type = "image_upload_event" - - id = Attribute(readonly=True, doc="str: Unique id for the event.") - event_datetime = Timestamp( - readonly=True, doc="datetime: The time at which the event occurred." - ) - component = Attribute( - readonly=True, - doc="""str: The component which generated the event. - - The value of this field depends on the internal details of how images are - uploaded, but is useful to support personnel for understanding where a failure - may have occurred. - """, - ) - component_id = Attribute( - readonly=True, - doc="""str: The unique identifier for the component instance which generated the event. - - This identifier is useful to support personnel for tracking down any errors - which may have occurred. - """, - ) - event_type = EnumAttribute( - ImageUploadEventType, - readonly=True, - doc="ImageUploadEventType: The type of the event.", - ) - severity = EnumAttribute( - ImageUploadEventSeverity, - readonly=True, - doc="ImageUploadEventSeverity: The severity of the event.", - ) - message = Attribute( - readonly=True, doc="str: Any message associated with the event." - ) - - -class ImageUpload(CatalogObjectBase): - """The status object returned when you upload an image using - :py:meth:`~descarteslabs.catalog.Image.upload` or - :py:meth:`~descarteslabs.catalog.Image.upload_ndarray`. - """ - - _POLLING_INTERVALS = [1, 1, 1, 1, 1, 5, 10, 10, 30, 60] - _TERMINAL_STATES = ( - ImageUploadStatus.SUCCESS, - ImageUploadStatus.FAILURE, - ImageUploadStatus.CANCELED, - ) - - _upload_model_classes = {ImageUploadEvent._doc_type: ImageUploadEvent} - - _doc_type = "image_upload" - _url = "/uploads_v2" - INCLUDE_EVENTS = "events" - _default_includes = [INCLUDE_EVENTS] - _no_inherit = True - - id = TypedAttribute( - str, - mutable=False, - serializable=False, - doc="str: Globally unique identifier for the upload.", - ) - product_id = TypedAttribute( - attribute_type=str, - mutable=False, - doc="""str: Product id of the product for this imagery. - - The product id for the `~descarteslabs.catalog.Product` to which this imagery - will be uploaded. - - *Filterable, sortable*. - """, - ) - image_id = TypedAttribute( - str, - mutable=False, - doc="""str: Image id of the image for this imagery. - - The image id for the `~descarteslabs.catalog.Image` to which this imagery will - be uploaded. This is identical to `image`.id. - - *Filterable*. - """, - ) - image = CatalogObjectReference( - Image, - require_unsaved=True, - mutable=False, - serializable=True, - sticky=True, - doc="""~descarteslabs.catalog.Image: Image instance with all desired metadata fields. - - Note that any values will override those determined from the image files - themselves. - """, - ) - image_upload_options = ImageUploadOptions( - sticky=True, - mutable=False, - doc="ImageUploadOptions: Control of the upload process.", - ) - user = Attribute( - readonly=True, - doc="""str: The User ID of the user requesting the upload. - - *Filterable, sortable*. - """, - ) - resumable_urls = ListAttribute( - Attribute, - readonly=True, - doc="""list(str): Upload URLs to which the client will transfer the file contents. - - This field is for internal use by the client only. - """, - ) - status = EnumAttribute( - ImageUploadStatus, - doc="""str or ImageUploadStatus: Current job status. - - To retrieve the latest status, use :py:meth:`reload`. - - *Filterable, sortable*. - """, - ) - events = ListAttribute( - ImageUploadEvent, - readonly=True, - doc="list(ImageUploadEvent): List of events pertaining to the upload process.", - ) - - def _initialize( - self, - id=None, - saved=False, - relationships=None, - related_objects=None, - deleted=False, - **kwargs - ): - # CatalogObjectBase only supports many to one, we need the other direction - if relationships and related_objects: - for name, relationship in relationships.items(): - # we depend on our attribute name (e.g. "events") being the same as the upstream - value = [] - for related in relationship["data"]: - value.append(related_objects.get((related["type"], related["id"]))) - kwargs[name] = value - - super(ImageUpload, self)._initialize( - id=id, saved=saved, deleted=deleted, **kwargs - ) - - @classmethod - def search(cls, client=None, includes=True): - """A search query for all uploads. - - Return an `Search` instance for searching image uploads. - - Parameters - ---------- - includes : bool - Controls the inclusion of events. If True, includes these objects. - If False, no events are included. Defaults to True. - client : :class:`CatalogClient`, optional - A `CatalogClient` instance to use for requests to the Descartes Labs - catalog. - - Returns - ------- - :class:`~descarteslabs.catalog.search.Search` - An instance of the `Search` class - - Example - ------- - >>> from descarteslabs.catalog import ( - ... ImageUpload, - ... ImageUploadStatus, - ... properties as p, - ... ) - >>> search = ImageUpload.search().filter(p.status == ImageUploadStatus.FAILURE) - >>> for result in search: # doctest: +SKIP - ... print(result) # doctest: +SKIP - - """ - return Search(cls, client=client, includes=includes) - - def wait_for_completion(self, timeout=None, warn_transient_errors=True): - """Wait for the upload to complete. - - Parameters - ---------- - timeout : int, optional - If specified, will wait up to specified number of seconds and will raise - a `concurrent.futures.TimeoutError` if the upload has not completed. - warn_transient_errors : bool, optional, default True - Any transient errors while periodically checking upload status are suppressed. - If True, those errors will be printed as warnings. - - Raises - ------ - concurrent.futures.TimeoutError - If the specified timeout elapses and the upload has not completed. - """ - if self.status in self._TERMINAL_STATES: - return - - if timeout: - timeout = time.time() + timeout - intervals = itertools.chain( - self._POLLING_INTERVALS, itertools.repeat(self._POLLING_INTERVALS[-1]) - ) - while True: - try: - self.reload() - except ( - ServerError, - urllib3.exceptions.MaxRetryError, - requests.exceptions.RetryError, - urllib3.exceptions.TimeoutError, - ) as e: - # If a reload fails, just try again on the next interval - if warn_transient_errors: - warnings.warn( - "In wait_for_completion: error fetching status for ImageUpload {!r}; " - "will retry: {}".format(self.id, e) - ) - if self.status in self._TERMINAL_STATES: - return - interval = next(intervals) - if timeout: - t = timeout - time.time() - if t <= 0: - raise TimeoutError() - t = min(t, interval) - else: - t = interval - time.sleep(t) - - def reload(self): - """Reload all attributes from the Descartes Labs catalog. - - Refresh the state of this upload object. The instance - state must be in the `~descarteslabs.catalog.DocumentState.SAVED` state. - If the status changes to ``ImageUploadStatus.SUCCESS`` then the `image` - instance is also reloaded so that it contains the full state of the newly - loaded image. - - Raises - ------ - NotFoundError - If the object no longer exists. - ValueError - If the catalog object is not in the ``SAVED`` state. - DeletedObjectError - If this catalog object was deleted. - """ - oldstatus = self.status - super(ImageUpload, self).reload() - if self.status == ImageUploadStatus.SUCCESS and oldstatus != self.status: - # image is not in a saved state, so doctor it up - self.image._saved = True - self.image._clear_modified_attributes() - self.image.reload() - - @check_deleted - def cancel(self): - """Cancel the upload if it is not yet completed. - - Note that if the upload process is already running, it - cannot be canceled unless a retryable error occurs. - - Raises - ------ - NotFoundError - If the object no longer exists. - ValueError - If the catalog object is not in the ``SAVED`` state. - DeletedObjectError - If this catalog object was deleted. - ConflictError - If the upload has a current status which does not allow it to be canceled. - """ - self.status = ImageUploadStatus.CANCELED - self.save() - - @classmethod - def _load_related_objects(cls, response, client): - """ - The relationships of the ImageUpload are not first-class CatalogObjects, - so we need slightly different handling here. - """ - related_objects = {} - related_objects_serialized = response.get("included") - if related_objects_serialized: - for serialized in related_objects_serialized: - model_class = cls._upload_model_classes[serialized["type"]] - if model_class: - related = model_class( - validate=False, id=serialized["id"], **serialized["attributes"] - ) - related_objects[(serialized["type"], serialized["id"])] = related - - return related_objects - - @hybridmethod - @check_derived - def delete(cls, id, client=None): - """You cannot delete an ImageUpload. - - Raises - ------ - NotImplementedError - This method is not supported for ImageUploads. - """ - raise NotImplementedError("Deleting ImageUploads is not permitted") - - @delete.instancemethod - @check_deleted - def delete(self): - """You cannot delete an ImageUpload. - - Raises - ------ - NotImplementedError - This method is not supported for ImageUploads. - """ - raise NotImplementedError("Deleting ImageUploads is not permitted") diff --git a/descarteslabs/core/catalog/named_catalog_base.py b/descarteslabs/core/catalog/named_catalog_base.py deleted file mode 100644 index e1049c1e..00000000 --- a/descarteslabs/core/catalog/named_catalog_base.py +++ /dev/null @@ -1,222 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 re - -from .attributes import AttributeValidationError, CatalogObjectReference, TypedAttribute -from .catalog_base import CatalogObject, _new_abstract_class -from .product import Product - - -class NamedIdAttribute(TypedAttribute): - """str, immutable: An optional unique identifier for this object. - - The identifier for a named catalog object is the concatenation of the `product_id` - and `name`, separated by a colon. It will be generated from the `product_id` and - the `name` if not provided. Otherwise, the `name` and `product_id` are extracted - from the `id`. A :py:exc:`AttributeValidationError` will be raised if it conflicts - with an existing `product_id` and/or `name`. - """ - - # Verifies that the id is a concatenation of product id and name - # It will compare the `id` against the `name` and `product_id` attributes if - # they are set. It will set the `name` and `product_id` atttributes otherwise. - - def __init__(self): - super(NamedIdAttribute, self).__init__(str, mutable=False, serializable=False) - - def __set__(self, obj, value, validate=True): - last_colon = value.rfind(":") - - if last_colon == -1: - raise AttributeValidationError( - "The id must be a concatenation of a product id and a name, " - "separated by a colon, not '{}'".format(value) - ) - - # Only update if it differs - if value != obj.id: - super(NamedIdAttribute, self).__set__(obj, value, validate=validate) - - # Some older images have colons in their names, so for existing data being - # loaded from the service we can't make the assumption that we can recover - # the name from the id. - if not obj._saved: - product_id = value[:last_colon] - name = value[last_colon + 1 :] - # Only update if it differs - if product_id != obj.product_id: - obj._get_attribute_type("product_id").__set__( - obj, product_id, validate=validate - ) - if name != obj.name: - obj._get_attribute_type("name").__set__(obj, name, validate=validate) - - -class NameAttribute(TypedAttribute): - """str, immutable: The name of the catalog object. - - The name of a named catalog object is unique within a product and object type - (images and bands). The name can contain alphanumeric characters, ``-``, ``_``, - and ``.`` up to 2000 characters. If the `id` contains a name, it will be used - instead. Once set, it cannot be changed. - - *Sortable*. - """ - - # Sets the id if the `product_id` is already set.""" - - def __init__(self): - super(NameAttribute, self).__init__(str, mutable=False) - - def __set__(self, obj, value, validate=True): - # Only update if it differs - if value != obj.name: - super(NameAttribute, self).__set__(obj, value, validate=validate) - - if value is not None and obj.id is None and obj.product_id: - id_ = "{}:{}".format(obj.product_id, value) - # Only update if it differs - if id_ != obj.id: - obj._get_attribute_type("id").__set__(obj, id_, validate=validate) - - -class ProductIdAttribute(TypedAttribute): - """str, immutable: The id of the product this catalog object belongs to. - - If the `id` contains a product id, it will be used instead. Once set, it cannot - be changed. - - *Filterable, sortable*. - """ - - # Sets the id if the `name` is already set.""" - - def __init__(self): - super(ProductIdAttribute, self).__init__(str, mutable=False) - - def __set__(self, obj, value, validate=True): - # Only update if it differs - if value != obj.product_id: - super(ProductIdAttribute, self).__set__(obj, value, validate=validate) - - if value is not None and obj.id is None and obj.name: - id_ = "{}:{}".format(value, obj.name) - # Only update if it differs - if id_ != obj.id: - obj._get_attribute_type("id").__set__(obj, id_, validate=validate) - - -class NamedCatalogObject(CatalogObject): - """A catalog object with a required `name` and `product_id` instead of `id`. - - A catalog object without a required `id` but instead a required `name` and `product` - or `product_id`. The `id` is generated from the `product_id` and the `name` - (`product_id`:`name`). If the `id` is provided, it will be used to extract the - `name` and `product_id`, if available. - - Parameters - ---------- - client : CatalogClient, optional - A `CatalogClient` instance to use for requests to the Descartes Labs catalog. - The :py:meth:`~descarteslabs.catalog.CatalogClient.get_default_client` will - be used if not set. - kwargs : dict, optional - With the exception of readonly attributes (`created`, `modified`) and with the - exception of properties (`ATTRIBUTES`, `is_modified`, and `state`), any - attribute listed below can also be used as a keyword argument. - - Example - ------- - Any combination that will yield the image name and the product id will work, but - the preferred way is using the `name` and `product`: - - >>> from descarteslabs.catalog import Product, Image - >>> product_id = "some_org:some_product_name" - >>> product = Product.get(product_id) # doctest: +SKIP - >>> image_name = "some_image_name" - >>> # Preferred - >>> product = Product(id=product_id) - >>> image = Image(name=image_name, product=product) # doctest: +SKIP - >>> # Also possible... - >>> image_id = "{}:{}".format(product.id, image_name) - >>> image = Image(id=image_id) - """ - - _invalid_sequence_pattern_for_name = re.compile(r"[^a-zA-Z0-9_.-]+") - - id = NamedIdAttribute() - name = NameAttribute() - product_id = ProductIdAttribute() - product = CatalogObjectReference( - Product, - mutable=False, - sticky=True, - doc=""" - Product, immutable: The product instance this catalog object belongs to. - - If given, it is used to retrieve the `product_id`. - - *Filterable*. - """, - ) - - def __new__(cls, *args, **kwargs): - return _new_abstract_class(cls, NamedCatalogObject) - - def __init__(self, **kwargs): - product_id = kwargs.get("product_id") - if product_id is None: - product = kwargs.get("product") - if product is not None: - kwargs["product_id"] = product.id - - super(NamedCatalogObject, self).__init__(**kwargs) - - @classmethod - def make_valid_name(cls, name): - """Replace invalid characters in the given name and return a valid name. - - Replace any sequence of invalid characters in a string with a single `_` - character to create a valid `~Image.name` for `Band` or `Image`. Since the - Band and Image names have a limited character set, this method will replace - any sequence of characters outside that character set with a single ``_`` - character. The returned string is a safe name to use for a `Band` or `Image`. - The given string is unchanged. - - Note that it is possible that two unique invalid names may turn into duplicate - valid names if the uniqueness is located in the same sequence of invalid - characters. - - Parameters - ---------- - name : str - A `~Image.name` for a `Band` or `Image` that may contain invalid characters. - - Returns - ------- - str - A `~Image.name` for a `Band` or `Image` that does not contain any invalid - characters. - - Example - ------- - >>> from descarteslabs.catalog import SpectralBand, Band - >>> name = "This is ań @#$^*% ïñvalid name!!!!" - >>> band = SpectralBand() - >>> band.name = Band.make_valid_name(name) - >>> band.name - 'This_is_a_valid_name_' - """ - return cls._invalid_sequence_pattern_for_name.sub("_", name) diff --git a/descarteslabs/core/catalog/product.py b/descarteslabs/core/catalog/product.py deleted file mode 100644 index df8ad76f..00000000 --- a/descarteslabs/core/catalog/product.py +++ /dev/null @@ -1,466 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 ..common.collection import Collection -from ..common.property_filtering import Properties -from .attributes import ( - BooleanAttribute, - ListAttribute, - Resolution, - Timestamp, - TypedAttribute, -) -from .catalog_base import ( - AuthCatalogObject, - CatalogClient, - check_deleted, -) -from .task import TaskStatus - - -properties = Properties() - - -class Product(AuthCatalogObject): - """A raster product that connects band information to imagery. - - Instantiating a product indicates that you want to create a *new* Descartes Labs - catalog product. If you instead want to retrieve an existing catalog product use - `Product.get() `, or if you're not sure - use `Product.get_or_create() `. - You can also use `Product.search() `. - Also see the example for :py:meth:`~descarteslabs.catalog.Product.save`. - - - Parameters - ---------- - client : CatalogClient, optional - A `CatalogClient` instance to use for requests to the Descartes Labs catalog. - The :py:meth:`~descarteslabs.catalog.CatalogClient.get_default_client` will - be used if not set. - kwargs : dict - With the exception of readonly attributes (`created`, `modified`, - `resolution_min`, and `resolution_max`) and with the exception of properties - (`ATTRIBUTES`, `is_modified`, and `state`), any attribute listed below can - also be used as a keyword argument. Also see - `~Product.ATTRIBUTES`. - """ - - _doc_type = "product" - _url = "/products" - # _collection_type set below due to circular problems - - # Product Attributes - name = TypedAttribute( - str, - doc="""str: The name of this product. - - This should not be confused with a band name or image name. Unlike the band - name and image name, this name is not unique and purely for display purposes - and is used by :py:meth:`Search.find_text`. It can contain a string with up - to 2000 arbitrary characters. - - *Searchable, sortable*. - """, - ) - description = TypedAttribute( - str, - doc="""str, optional: A description with further details on this product. - - The description can be up to 80,000 characters and is used by - :py:meth:`Search.find_text`. - - *Searchable* - """, - ) - is_core = BooleanAttribute( - doc="""bool, optional: Whether this is a Descartes Labs catalog core product. - - A core product is a product that is fully supported by Descartes Labs. By - default this value is ``False`` and you must have a special permission - (``internal:core:create``) to set it to ``True``. - - *Filterable, sortable*. - """ - ) - revisit_period_minutes_min = TypedAttribute( - float, - coerce=True, - doc="""float, optional: Minimum length of the time interval between observations. - - The minimum length of the time interval between observations of any given area - in minutes. - - *Filterable, sortable*. - """, - ) - revisit_period_minutes_max = TypedAttribute( - float, - coerce=True, - doc="""float, optional: Maximum length of the time interval between observations. - - The maximum length of the time interval between observations of any given area - in minutes. - - *Filterable, sortable*. - """, - ) - start_datetime = Timestamp( - doc="""str or datetime, optional: The beginning of the mission for this product. - - *Filterable, sortable*. - """ - ) - end_datetime = Timestamp( - doc="""str or datetime, optional: The end of the mission for this product. - - *Filterable, sortable*. - """ - ) - resolution_min = Resolution( - readonly=True, - doc="""Resolution, readonly: Minimum resolution of the bands for this product. - - If applying a filter with a plain unitless number the value is assumed to be - in meters. - - *Filterable, sortable*. - """, - ) - resolution_max = Resolution( - readonly=True, - doc="""Resolution, readonly: Maximum resolution of the bands for this product. - - If applying a filter with a plain unitless number the value is assumed to be - in meters. - - *Filterable, sortable*. - """, - ) - default_display_bands = ListAttribute( - TypedAttribute(str), - doc="""list(str) or iterable: Which bands to use for RGBA display. - - This field defines the default bands that are used for display purposes. There are - four supported formats: ``["greyscale-or-class"]``, ``["greyscale-or-class", "alpha"]``, - ``["red", "green", "blue"]``, and ``["red", "green", "blue", "alpha"]``. - """, - ) - image_index_name = TypedAttribute( - str, - doc="""str: The name of the image index for this product. - - This is an internal field, accessible to privileged users only. - - *Filterable, sortable*. - """, - ) - product_tier = TypedAttribute( - str, - doc="""str: Product tier for this product. - - This field can be set by privileged users only. - - *Filterable, sortable*. - """, - ) - - def named_id(self, name): - """Return the ~descarteslabs.catalog.NamedCatalogObject.id` for the given named catalog object. - - Parameters - ---------- - name : str - The name of the catalog object within this product, see - :py:attr:`~descarteslabs.catalog.NamedCatalogObject.name`. - - Returns - ------- - str - The named catalog object id within this product. - """ - return "{}:{}".format(self.id, name) - - @check_deleted - def get_band(self, name, client=None, request_params=None, headers=None): - """Retrieve the request band associated with this product by name. - - Parameters - ---------- - name : str - The name of the band to retrieve. - client : CatalogClient, optional - A `CatalogClient` instance to use for requests to the Descartes Labs - catalog. The - :py:meth:`~descarteslabs.catalog.CatalogClient.get_default_client` will - be used if not set. - - Returns - ------- - Band or None - A derived class of `Band` that represents the requested band object if - found, ``None`` if not found. - - """ - from .band import Band - - return Band.get( - self.named_id(name), - client=client, - request_params=request_params, - headers=headers, - ) - - @check_deleted - def get_image(self, name, client=None, request_params=None, headers=None): - """Retrieve the request image associated with this product by name. - - Parameters - ---------- - name : str - The name of the image to retrieve. - client : CatalogClient, optional - A `CatalogClient` instance to use for requests to the Descartes Labs - catalog. The - :py:meth:`~descarteslabs.catalog.CatalogClient.get_default_client` will - be used if not set. - - Returns - ------- - ~descarteslabs.catalog.Image or None - The requested image if found, or ``None`` if not found. - - """ - from .image import Image - - return Image.get( - self.named_id(name), - client=client, - request_params=request_params, - headers=headers, - ) - - @check_deleted - def delete_related_objects(self): - """Delete all related bands and images for this product. - - Starts an asynchronous operation that deletes all bands and images associated - with this product. If the product has a large number of associated images, this - operation could take several minutes, or even hours. - - Returns - ------- - DeletionTaskStatus - Returns :py:class:`DeletionTaskStatus` if deletion task was successfully - started and ``None`` if there were no related objects to delete. - - - Raises - ------ - ConflictError - If a deletion process is already in progress. - DeletedObjectError - If this product was deleted. - ~descarteslabs.exceptions.ClientError or ~descarteslabs.exceptions.ServerError - :ref:`Spurious exception ` that can occur during a - network request. - """ - r = self._client.session.post( - "/products/{}/delete_related_objects".format(self.id), - json={"data": {"type": "product_delete_task"}}, - ) - if r.status_code == 201: - response = r.json() - return DeletionTaskStatus( - id=self.id, _client=self._client, **response["data"]["attributes"] - ) - else: - return None - - @check_deleted - def get_delete_status(self): - """Fetches the status of a deletion task. - - Fetches the status of a deletion task started using - :py:meth:`delete_related_objects`. - - Returns - ------- - DeletionTaskStatus - - Raises - ------ - DeletedObjectError - If this product was deleted. - ~descarteslabs.exceptions.ClientError or ~descarteslabs.exceptions.ServerError - :ref:`Spurious exception ` that can occur during a - network request. - """ - r = self._client.session.get( - "/products/{}/delete_related_objects".format(self.id) - ) - response = r.json() - return DeletionTaskStatus( - id=self.id, _client=self._client, **response["data"]["attributes"] - ) - - @check_deleted - def bands(self, request_params=None, headers=None): - """A search query for all bands for this product, sorted by default band - ``sort_order``. - - Returns - ------- - :py:class:`~descarteslabs.catalog.Search` - A :py:class:`~descarteslabs.catalog.Search` instance configured to - find all bands for this product. - - Raises - ------ - DeletedObjectError - If this product was deleted. - - """ - from .band import Band - - return ( - Band.search( - client=self._client, request_params=request_params, headers=headers - ) - .filter(properties.product_id == self.id) - .sort("sort_order") - ) - - @check_deleted - def images(self, request_params=None, headers=None): - """A search query for all images in this product. - - Returns - ------- - :py:class:`~descarteslabs.catalog.Search` - A :py:class:`~descarteslabs.catalog.Search` instance configured to - find all images in this product. - - Raises - ------ - DeletedObjectError - If this product was deleted. - - """ - from .image import Image - - return Image.search( - client=self._client, request_params=request_params, headers=headers - ).filter(properties.product_id == self.id) - - @check_deleted - def image_uploads(self): - """A search query for all uploads in this product created by this user. - - Returns - ------- - :py:class:`~descarteslabs.catalog.Search` - A :py:class:`~descarteslabs.catalog.Search` instance configured to - find all uploads in this product. - - Raises - ------ - DeletedObjectError - If this product was deleted. - - """ - from .image_upload import ImageUpload - - return ImageUpload.search(client=self._client).filter( - properties.product_id == self.id - ) - - @classmethod - def namespace_id(cls, id_, client=None): - """Generate a fully namespaced id. - - Parameters - ---------- - id_ : str - The unprefixed part of the id that you want prefixed. - client : CatalogClient, optional - A `CatalogClient` instance to use for requests to the Descartes Labs - catalog. The - :py:meth:`~descarteslabs.catalog.CatalogClient.get_default_client` will - be used if not set. - - Returns - ------- - str - The fully namespaced id. - - Example - ------- - >>> product_id = Product.namespace_id("my-product") - """ - if client is None: - client = CatalogClient.get_default_client() - org = client.auth.payload.get("org") - if org is None: - org = client.auth.namespace # defaults to the user namespace - - prefix = "{}:".format(org) - if id_.startswith(prefix): - return id_ - - return "{}{}".format(prefix, id_) - - -class ProductCollection(Collection): - _item_type = Product - - -# handle circular references -Product._collection_type = ProductCollection - - -class DeletionTaskStatus(TaskStatus): - """The asynchronous deletion task's status - - Attributes - ---------- - id : str - The id of the object for which this task is running. - status : TaskState - The state of the task as explained in `TaskState`. - start_datetime : datetime - The date and time at which the task started running. - duration_in_seconds : float - The duration of the task. - objects_deleted : int - The number of objects (a combination of bands or images) that were deleted. - errors: list - In case the status is ``FAILED`` this will contain a list of errors - that were encountered. In all other states this will not be set. - """ - - _task_name = "delete task" - _url = "/products/{}/delete_related_objects" - - def __init__(self, objects_deleted=None, **kwargs): - super(DeletionTaskStatus, self).__init__(**kwargs) - self.objects_deleted = objects_deleted - - def __repr__(self): - text = super(DeletionTaskStatus, self).__repr__() - - if self.objects_deleted: - text += "\n - {:,} objects deleted".format(self.objects_deleted) - - return text diff --git a/descarteslabs/core/catalog/scaling.py b/descarteslabs/core/catalog/scaling.py deleted file mode 100644 index 6413cda9..00000000 --- a/descarteslabs/core/catalog/scaling.py +++ /dev/null @@ -1,725 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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. - -try: - from collections import abc -except ImportError: - import collections as abc - -from strenum import StrEnum - -from .band import BandType - -# supported data types. -# values must be ordered from smallest to largest, and -# (arbitrarily) unsigned before signed, integer before float -valid_data_types = ("Byte", "UInt16", "Int16", "UInt32", "Int32", "Float32", "Float64") - -# supported upcasts for each data type -valid_data_type_casts = { - "Byte": ("UInt16", "Int16", "UInt32", "Int32", "Float32", "Float64"), - "UInt16": ("UInt32", "Int32", "Float32", "Float64"), - "Int16": ("Int32", "Float32", "Float64"), - "UInt32": ("Float64"), - "Int32": ("Float32", "Float64"), - "Float32": ("Float64"), - "Float64": (), -} - - -# min/max ranges for supported data types -data_type_ranges = { - "Byte": [0, 255], - "UInt16": [0, 65535], - "Int16": [-32768, 32767], - "UInt32": [0, 4294967295], - "Int32": [-2147483648, 2147483647], - # for floats, this is our default output range, - # and does not imply the range a float can contain. - "Float32": [0.0, 1.0], - "Float64": [0.0, 1.0], -} - - -class ScalingMode(StrEnum): - RAW = "raw" - DISPLAY = "display" - AUTO = "auto" - PHYSICAL = "physical" - - -class BandScale(object): - # An explanation for the `mode` and `mode_is_implied` properties of BandScales: - # - # A non-implied mode (`mode_is_implied=False`) is when the user has explicitly - # stated a mode, e.g. "display", "physical", etc. If two bands specify different - # incompatible modes, this is an error on the part of the user (however "auto" - # and "display" are fungible). Thus you see `check_mode` raising an error. - # - # An implied mode (`mode_is_implied=True` is when the user has not explicitly - # stated a mode, and instead we are inferring it from the tuple. This is guesswork, - # and if two different bands imply two incompatible modes, it is not an error but - # rather a case in which we cannot determine a mode. - # - # Both are used in order to determine which possible input range to use (`display_range` or - # `data_range`), which possible output range to use (range of the data type, `physical_range`, - # or [0, 255]), and how to default the output data type. AN explicit mode is stronger, and - # the implied mode is never consulted unless there is no explicit mode. - - def __init__(self, name, properties, mode, mode_is_implied): - """ - Construct a BandScale instance from band properties and scale specification. - - Will raise ValueError for any bad input. - """ - self.name = name - self._properties = properties - self.mode = mode - self.mode_is_implied = mode_is_implied - - def __getattr__(self, attr): - if attr in self._properties: - return self._properties[attr] - raise AttributeError( - "{} object has no '{}' attribute".format(self.__class__.__name__, attr) - ) - - def output_range(self): - raise NotImplementedError - - def get_scale(self, mode, data_type): - raise NotImplementedError - - -class NoBandScale(BandScale): - def __init__(self, name, properties, mode=None): - super(NoBandScale, self).__init__(name, properties, mode, True) - - def output_range(self): - return self.data_range - - def get_scale(self, mode, data_type): - return None - - -class AutomaticBandScale(BandScale): - def __init__(self, name, properties, mode): - super(AutomaticBandScale, self).__init__(name, properties, mode, False) - - def output_range(self): - if self.mode == ScalingMode.RAW: - return self.data_range - elif self.mode == ScalingMode.PHYSICAL: - return self.physical_range - else: - return [0, 255] - - def get_scale(self, mode, data_type): - ofloat = data_type.startswith("Float") - if self.mode == ScalingMode.RAW: - return None - elif self.mode == ScalingMode.AUTO: - # this is handled by GDAL itself - return () - elif self.mode == ScalingMode.DISPLAY: - # 255.99 from GDAL - return tuple(self.display_range) + (0, 255.99 if ofloat else 255) - elif self.mode == ScalingMode.PHYSICAL: - # avoid common no-op - if self.data_range == self.physical_range: - return None - else: - return tuple(self.data_range + self.physical_range) - else: - # shouldn't get here but be explicit - return None - - -class TupleBandScale(BandScale): - def __init__(self, name, properties, value): - is_pct = [] - is_float = [] - for t in value: - if isinstance(t, str): - if not t.endswith("%"): - raise ValueError( - "Invalid scaling tuple value '{}' for band '{}' is not a percentage string".format( - t, name - ) - ) - try: - float(t[:-1]) - except ValueError: - raise ValueError( - "Invalid scaling tuple value '{}' for band '{}' is not a percentage string".format( - t, name - ) - ) - is_pct.append(True) - is_float.append(False) - elif isinstance(t, int): - is_pct.append(False) - is_float.append(True) - elif isinstance(t, float): - is_pct.append(False) - is_float.append(True) - else: - raise ValueError( - "Invalid scaling value {} for band '{}' is not a number".format( - t, name - ) - ) - if len(value) == 0: - mode = ScalingMode.AUTO - elif len(value) == 2: - mode = ScalingMode.DISPLAY - elif is_float[2] or is_float[3]: - mode = ScalingMode.PHYSICAL - elif (not is_pct[2] and value[2] < 0) or (not is_pct[3] and value[3] > 255): - mode = ScalingMode.RAW - else: - mode = None - super(TupleBandScale, self).__init__(name, properties, mode, True) - self._tuple = value - self._is_pct = is_pct - - def __eq__(self, other): - return super(TupleBandScale, self).__eq__(other) and self._tuple == other._tuple - - def output_range(self): - if len(self._tuple) == 4: - return [ - None if self._is_pct[2] else self._tuple[2], - None if self._is_pct[3] else self._tuple[3], - ] - else: - return [0, 255] - - def get_scale(self, mode, data_type): - ifloat = self.data_type.startswith("Float") - ofloat = data_type.startswith("Float") - if len(self._tuple) == 0: - # GDAL handles this - return () - else: - # generate default ranges - if mode == ScalingMode.RAW: - irange = data_type_ranges[self.data_type] - # not sure about this; GDAL always uses 0, 255 - orange = data_type_ranges[data_type] - elif mode == ScalingMode.PHYSICAL: - irange = self.data_range - orange = self.physical_range - else: - irange = self.display_range - orange = [0.0, 255.99] # from GDAL, works for integer also - if len(self._tuple) == 2: - scale = (self._tuple[0], self._tuple[1], orange[0], orange[1]) - else: - scale = self._tuple - # apply any percentage calculations across the tuple - return tuple( - map( - calc_pct, - scale, - (irange, irange, orange, orange), - (ifloat, ifloat, ofloat, ofloat), - ) - ) - - -def make_band_scale(name, properties, value): - """ - Create a BandScale instance appropriate for the scale value. - - Raises ValueError on invalid input. - """ - if value is None: - return NoBandScale(name, properties) - elif isinstance(value, str): - try: - mode = ScalingMode(value) - except ValueError: - raise ValueError( - "Invalid scaling mode '{}' for band '{}'".format(value, name) - ) - if properties["band_type"] in (BandType.MASK, BandType.CLASS): - # do not scale these automatically, and make mode weak default - return NoBandScale(name, properties, mode) - else: - return AutomaticBandScale(name, properties, mode) - elif isinstance(value, (list, tuple)): - if len(value) not in (0, 2, 4): - raise ValueError( - "Invalid scaling tuple {} for band '{}'".format(value, name) - ) - return TupleBandScale(name, properties, tuple(value)) - - -def parse_scaling(properties, bands, scaling): - """ - Parse the scaling parameter, returning a list of BandScale instances. - - Properties should be a dictionary by band name of band properties. - - Will raise ValueError for any invalid input. - """ - scales = [] - # handle four types of values permitted for scaling parameter - if scaling is None: - # no scaling - scales = None - elif isinstance(scaling, str): - # automatic mode for all - for band in bands: - scales.append(make_band_scale(band, properties[band], scaling)) - elif isinstance(scaling, abc.Mapping): - # dictionary like mapping for looking up bands and band types - for band in bands: - # None is an allowed value, so don't use get() - if band in scaling: - bscale = scaling[band] - else: - if properties[band]["band_type"] in (BandType.MASK, BandType.CLASS): - bscale = None - else: - bscale = scaling.get("default_", None) - scales.append(make_band_scale(band, properties[band], bscale)) - elif isinstance(scaling, abc.Iterable): - # list, tuple, etc. - for i, bscale in enumerate(scaling): - if i >= len(bands): - raise ValueError("Invalid scaling value has more elements than bands") - band = bands[i] - scales.append(make_band_scale(band, properties[band], bscale)) - if i + 1 < len(bands): - raise ValueError("Invalid scaling value has fewer elements than bands") - else: - raise ValueError( - "Invalid scaling value {} has unsupported type".format(scaling) - ) - - return scales - - -def append_alpha_scaling(scaling): - """ - If the scaling parameter is an iterable (list), add a - None on the end to match an alpha band which has been - added to the end of the band list by the caller. - """ - if ( - scaling is None - or isinstance(scaling, str) - or isinstance(scaling, abc.Mapping) - or not isinstance(scaling, abc.Iterable) - ): - return scaling - return list(scaling) + [None] - - -def common_data_type(data_types): - """ - Return the common (GDAL) data type that all of the given data types can be cast to, or - None if there is no common one. - """ - if len(data_types) == 0: - return None - elif len(data_types) == 1: - if data_types[0] not in valid_data_types: - raise ValueError("Invalid data type '{}'".format(data_types[0])) - return data_types[0] - else: - dtype1 = common_data_type(data_types[0:-1]) - if dtype1 is None: - return None - types1 = valid_data_type_casts[dtype1] - dtype2 = data_types[-1] - if dtype2 not in valid_data_types: - raise ValueError("Invalid data type '{}'".format(dtype2)) - types2 = valid_data_type_casts[dtype2] - dtype = None - - if dtype1 == dtype2 or dtype1 in types2: - dtype = dtype1 - elif dtype2 in types1: - dtype = dtype2 - else: - for dt in types2: - if dt in types1: - dtype = dt - break - - return dtype - - -def data_type_from_range(min, max, is_float): - """ - Return the GDAL data type which can represent the provided min and max values. - """ - # short circuit float since range is 0-1 - if ( - is_float - or (min is not None and min < data_type_ranges["Int32"][0]) - or (max is not None and max > data_type_ranges["Int32"][1]) - ): - return "Float64" - for dt in valid_data_types: - rmin, rmax = data_type_ranges[dt] - if (min is None or min >= rmin) and (max is not None and max <= rmax): - return dt - return "Float64" - - -def resolve_processing_level(processing_level, processing_levels, depth=0): - """ - Resolve a processing_level through any aliases to the processing steps. - - Returns list of processing steps, or None if not found. - - Raises ValueError on bad processing levels definitions, although this - is really a problem with the metadata, not anything here. - """ - if depth >= 10: - # infinite loop, problem with band definitions - raise ValueError("Processing levels contains infinite loop") - if not processing_level: - processing_level = "default" - result = processing_levels.get(processing_level) - - if result is None: - if depth > 0: - # dangling alias, problem with band definitions - raise ValueError( - f"Processing levels contains dangling alias {processing_level}" - ) - # unknown but not an error - return None - - if isinstance(result, str): - # alias - return resolve_processing_level(result, processing_levels, depth=depth + 1) - - # a real processing level definition - return result - - -def properties_for_band(name, band, processing_level): - """ - Gather up relevant properties for the band, applying processing level and defaults. - """ - band_type = band.type - - # defaults on band base properties are real legacy - data_type = band.data_type or "UInt16" - data_range = band.data_range or [0, 10000] - # these are not required, some band types don't have them - display_range = None - try: - display_range = band.display_range - except AttributeError: - pass - if not display_range: - display_range = data_range - physical_range = None - try: - physical_range = band.physical_range - except AttributeError: - pass - if not physical_range: - physical_range = data_range - - processing_levels = getattr(band, "processing_levels", None) - if not processing_levels: - # not an error for legacy or mask and class bands - if ( - processing_level - and processing_level not in ("default", "toa", "surface") - and band_type not in (BandType.MASK, BandType.CLASS) - ): - raise ValueError( - f"Unknown processing_level value {processing_level} for band {name}" - ) - else: - processing_level_steps = resolve_processing_level( - processing_level, processing_levels - ) - if processing_level_steps: - step = processing_level_steps[-1] - # processing levels are always Float64 by default, except "dlsr" is special - # and always uses the underlying raw band's definitions - if step.function != "dlsr": - data_type = step.data_type or "Float64" - # this is a somewhat arbitrary default (good for reflectance) - data_range = step.data_range or data_type_ranges.get(data_type) - display_range = step.display_range or data_range - physical_range = step.physical_range or data_range - return { - "name": name, - "band_type": band_type, - "data_type": data_type, - "data_range": data_range, - "display_range": display_range, - "physical_range": physical_range, - } - - -def calc_pct(value, bounds, is_float): - """ - Helper function to calculate a scaling tuple value from a percentage. - """ - if isinstance(value, str): - value = float(value[:-1]) * (bounds[1] - bounds[0]) / 100.0 + bounds[0] - return value if is_float else int(value) - - -def check_modes(scales, implied=False): - """ - Checks for and returns any combined mode settings. - - If implied=False, checks for explicit modes and errors on conflict. - Otherwise checks for implicit modes and returns None on conflict. - - Raises a ValueError if there is any conflict. - """ - mode = None - for bscale in scales: - if bscale.mode is not None and bscale.mode_is_implied == implied: - if mode is None: - mode = bscale.mode - elif bscale.mode != mode: - if bscale.mode in (ScalingMode.AUTO, ScalingMode.DISPLAY) and mode in ( - ScalingMode.AUTO, - ScalingMode.DISPLAY, - ): # noqa - mode = ScalingMode.DISPLAY - elif implied: - # cannot determine implied mode on conflict - return None - else: - raise ValueError( - "Conflicting modes in scaling: '{}' and '{}'".format( - mode.value, bscale.mode.value - ) - ) - return mode - - -def check_implied_data_type(scales): - """ - Using any supplied output ranges, finds an output data type which - can hold everything. - - Returns None only when all scales are 4-tuples with percentage - values for the output range. - """ - # this is a "transpose" from a list or ranges to a list of mins and maxes - ranges = list(zip(*[bscale.output_range() for bscale in scales])) - # filter out None values - min_list = list(filter(lambda x: x is not None, ranges[0])) - max_list = list(filter(lambda x: x is not None, ranges[1])) - is_float = any(map(lambda x: isinstance(x, float), min_list + max_list)) - output_min = min(min_list) if min_list else None - output_max = max(max_list) if max_list else None - if output_min is None and output_max is None: - return None - return data_type_from_range(output_min, output_max, is_float) - - -def scaling_parameters(properties, bands, processing_level, scaling, data_type): - """ - Determine GDAL-style band scaling parameters. - - properties is the "bands" dictionary from a product. - - returns scales, data_type where scales is either a None or a list of tuples and Nones, - and data_type is the target GDAL data type. - """ - # validate bands and resolve properties - band_properties = {} - for band in bands: - if band not in properties: - message = "Invalid bands: band '{}' is not available".format(band) - raise ValueError(message) - band_properties[band] = properties_for_band( - band, properties[band], processing_level - ) - - # validate data_type - if data_type is not None and data_type not in valid_data_types: - raise ValueError(f"Invalid data_type value {data_type}") - - # handle this common case quickly - if scaling is None: - if data_type is None: - data_type = common_data_type( - [band_properties[band]["data_type"] for band in bands] - ) - return scaling, data_type - - # get list of BandScales, validates scaling possibly throwing ValueError - scales = parse_scaling(band_properties, bands, scaling) - - # at this point everything is validated, except possible conflicts between - # specifications for individual bands. - - # check any explicit modes, will raise error on conflict - mode = check_modes(scales) - - # time to do some guesswork from tuples - if mode is None and data_type is None: - mode = check_modes(scales, implied=True) - if mode is None: - data_type = check_implied_data_type(scales) - if data_type is None: - # we have nothing to go on! - raise ValueError( - "Invalid scaling parameters, cannot determine output data type or mode" - ) - - # at least one of mode or data_type is not None - # default one from the other if needed - if mode is None: - if data_type == "Byte": - mode = ScalingMode.DISPLAY - elif data_type == "Float32" or data_type == "Float64": - mode = ScalingMode.PHYSICAL - else: - mode = ScalingMode.RAW - elif data_type is None: - if mode == ScalingMode.RAW: - data_type = common_data_type( - [band_properties[band]["data_type"] for band in bands] - ) - elif mode == ScalingMode.PHYSICAL: - data_type = "Float64" - else: - data_type = "Byte" - - # now take a pass to determine complete scaling for each band - scales = [bscale.get_scale(mode, data_type) for bscale in scales] - - # simplify no scaling - if all([s is None for s in scales]): - scales = None - - return scales, data_type - - -def multiproduct_scaling_parameters( - properties, bands, processing_level, scaling, data_type -): - """ - Determine GDAL-style band scaling parameters. - - properties is the dictionary keyed by the product id with values of the "bands" dictionary from that product. - bands must already be validated. - - returns scales, data_type where scales is either a None or a list of tuples and Nones, - and data_type is the target GDAL data type. - """ - # validate bands - product_band_properties = {} - for band in bands: - for product in properties: - if band not in properties[product]: - message = ( - "Invalid bands: band '{}' is not available in product '{}'".format( - band, product - ) - ) - raise ValueError(message) - product_band_properties.setdefault(product, {})[band] = properties_for_band( - band, properties[product][band], processing_level - ) - - # validate data_type - if data_type is not None and data_type not in valid_data_types: - raise ValueError("Invalid data_type value {}") - - # handle this common case quickly - if scaling is None: - if data_type is None: - data_type = common_data_type( - [ - product_band_properties[product][band]["data_type"] - for product in product_band_properties - for band in product_band_properties[product] - ] - ) - return scaling, data_type - - # loop over all products and bands, get list of BandScales, validates scaling possibly throwing ValueError - scales = [] - for product in product_band_properties: - scales.extend(parse_scaling(product_band_properties[product], bands, scaling)) - - # at this point everything is validated, except possible conflicts between - # specifications for individual bands. This will be checked below. - - # check any explicit modes, will raise error on conflict - mode = check_modes(scales) - - # time to do some guesswork from tuples - if mode is None and data_type is None: - mode = check_modes(scales, implied=True) - if mode is None: - data_type = check_implied_data_type(scales) - if data_type is None: - # we have nothing to go on! - raise ValueError( - "Invalid scaling parameters, cannot determine output data type or mode" - ) - - # at least one of mode or data_type is not None - # default one from the other if needed - if mode is None: - if data_type == "Byte": - mode = ScalingMode.DISPLAY - elif data_type == "Float32" or data_type == "Float64": - mode = ScalingMode.PHYSICAL - else: - mode = ScalingMode.RAW - elif data_type is None: - if mode == ScalingMode.RAW: - data_type = common_data_type( - [ - product_band_properties[product][band]["data_type"] - for product in product_band_properties - for band in product_band_properties[product] - ] - ) - elif mode == ScalingMode.PHYSICAL: - data_type = "Float64" - else: - data_type = "Byte" - - # now take a pass to determine complete scaling for each product*band - scales = [bscale.get_scale(mode, data_type) for bscale in scales] - - # verify the resulting scale parameters for each band is the same across - # all products - products = [product for product in product_band_properties] - for i in range(1, len(product_band_properties)): - for j in range(len(bands)): - if scales[i * len(bands) + j] != scales[j]: - raise ValueError( - "Invalid scaling incompatible bands for band '{}' in products '{}' and '{}'".format( - bands[i], products[0], products[i] - ) - ) - - scales = scales[0 : len(bands)] - - return scales, data_type diff --git a/descarteslabs/core/catalog/search.py b/descarteslabs/core/catalog/search.py deleted file mode 100644 index 8e15f03b..00000000 --- a/descarteslabs/core/catalog/search.py +++ /dev/null @@ -1,653 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 collections.abc import Mapping -import copy -import json -from urllib.parse import parse_qs, urlencode, urlparse, urlunparse -import warnings - -from strenum import StrEnum - -from .catalog_client import CatalogClient -from ..common.property_filtering.filtering import AndExpression -from ..common.property_filtering.filtering import Expression # noqa: F401 - -from .attributes import serialize_datetime - - -class Search(object): - """A search request that iterates over its search results. - - You can narrow your search by using the following methods on the search object: - - * :py:meth:`limit` - * :py:meth:`filter` - * :py:meth:`find_text` - - Each method on a search instance returns a narrowed-down search object. You obtain - a search instance using the search() method on a catalog object class, for example - `Product.search() `, `Band.search() - ` or `Image.search() `. - - You must use the `Search` object as an ``iterator`` to get the results. This will - execute the search query and return a generator for iterating through the returned - results. This might raise a `~descarteslabs.exceptions.BadRequestError` - if any of the query parameters or filters are invalid. - - Example - ------- - >>> from descarteslabs.catalog import Product, Search, properties as p - >>> search = Search(Product).filter(p.start_datetime >= "2012-01-01") - >>> list(search) # doctest: +SKIP - """ - - def __init__( - self, - model, - client=None, - url=None, - includes=True, - request_params=None, - headers=None, - ): - self._url = url or model._url - self._model_cls = model - self._request_params = {} - if request_params: - self._request_params.update(request_params) - self._headers = {} - if headers: - self._headers.update(headers) - - self._filter_properties = None - self._client = client or CatalogClient.get_default_client() - self._limit = None - self._use_includes = includes - - def limit(self, limit): - """Limit the number of search results returned by the search execution. - - Successive calls to `limit` will overwrite the previous limit parameter. - - Parameters - ---------- - limit : int - The maximum number of records to return. - - Returns - ------- - Search - """ - s = copy.deepcopy(self) - s._limit = limit - - return s - - def sort(self, field, ascending=True): - """Sort the returned results by the given field. - - Multiple sort fields are not supported, so - successive calls to `sort` will overwrite the previous sort parameter. - - Parameters - ---------- - field : str - The name of the field to sort by - ascending : bool - Sorts results in ascending order if True and descending order if False. - - Returns - ------- - Search - - Example - ------- - >>> from descarteslabs.catalog import Product, Search - >>> search = Search(Product).sort("created", ascending=False) - >>> list(search) # doctest: +SKIP - - """ - s = copy.deepcopy(self) - s._request_params["sort"] = ("-" if not ascending else "") + field - - return s - - def filter(self, properties): - """Filter results by the values of various fields. - - Successive calls to `filter` will add the new filter(s) using the - ``and`` Boolean operator (``&``). - - Parameters - ---------- - properties : Expression - Expression used to filter objects in the search by their properties, built - from :class:`properties - `. - You can construct filter expressions using the ``==``, ``!=``, ``<``, - ``>``, ``<=`` and ``>=`` operators as well as the - :meth:`~descarteslabs.common.property_filtering.filtering.Property.in_` - or - :meth:`~descarteslabs.common.property_filtering.filtering.Property.any_of` - method. You cannot use the boolean keywords ``and`` and ``or`` because - of Python language limitations; instead combine filter expressions using - ``&`` (boolean "and") and ``|`` (boolean "or"). Filters using - :meth:`~descarteslabs.common.property_filtering.filtering.Property.like` - are not supported. - - Returns - ------- - Search - A new :py:class:`~descarteslabs.catalog.Search` instance with the - new filter(s) applied (using ``and`` if there were existing filters) - - Raises - ------ - ValueError - If the properties filter provided is not supported. - - Example - ------- - >>> from descarteslabs.catalog import Product, Search, properties as p - >>> search = Search(Product).filter( - ... (p.resolution_min < 60) & (p.start_datetime > "2000-01-01") - ... ) - >>> list(search) # doctest: +SKIP - """ - s = copy.deepcopy(self) - if s._filter_properties is None: - s._filter_properties = properties - else: - s._filter_properties = s._filter_properties & properties - return s - - def _serialize_filters(self): - filters = [] - - if self._filter_properties: - serialized = self._filter_properties.jsonapi_serialize(self._model_cls) - # Flatten top-level "and" expressions since they are fairly common, e.g. - # if you call filter() multiple times. - if type(self._filter_properties) is AndExpression: - for f in serialized["and"]: - filters.append(f) - else: - filters.append(serialized) - - return filters - - def find_text(self, text): - """Full-text search for a string in the name or description of an item. - - Not all attributes support full-text search; the product name - (`Product.name `) - and product and band description - (`Product.description `, - `Band.description `) - support full-text search. Successive calls - to `find_text` override the previous find_text parameter. - - Parameters - ---------- - text : str - A string you want to perform a full-text search for. - - Returns - ------- - Search - A new instance of the :py:class:`~descarteslabs.catalog.Search` - class that includes the text query. - """ - s = copy.deepcopy(self) - s._request_params["text"] = text - return s - - def _to_request(self): - s = copy.deepcopy(self) - - if self._limit is not None: - s._request_params["limit"] = self._limit - - filters = s._serialize_filters() - self._require_product_ids(filters) - if filters: - # urlencode encodes spaces in the json object which create an invalid filter value when - # the server tries to parse it, so we have to remove spaces prior to encoding. - s._request_params["filter"] = json.dumps(filters, separators=(",", ":")) - - if self._use_includes and self._model_cls._default_includes: - s._request_params["include"] = ",".join(self._model_cls._default_includes) - - url = s._url - continuation = s._request_params.pop("continuation", None) - if continuation: - url_parts = list(urlparse(url)) - query_params = parse_qs(url_parts[4]) - query_params["continuation"] = continuation - url_parts[4] = urlencode(query_params, doseq=True) - url = urlunparse(url_parts) - - return url, s._request_params - - def _require_product_ids(self, filters): - if hasattr(self._model_cls, "product_id"): - if filters: - for filter in filters: - # will be either a simple product_id eq filter, - # or an "or" of all of the same. - if "or" in filter: - ors = filter["or"] - if ors and all( - map( - lambda x: isinstance(x, Mapping) - and x.get("name") == "product_id" - and x.get("op") == "eq", - ors, - ) - ): - return - elif ( - isinstance(filter, Mapping) - and filter.get("name") == "product_id" - and filter.get("op") == "eq" - ): - return - raise ValueError( - f"{self._model_cls.__name__} search requires filtering by product_id" - ) - - def count(self): - """Fetch the number of documents that match the search. - - Note that this may not be an exact count if searching within a geometry. - - Returns - ------- - int - Number of matching records - - Raises - ------ - BadRequestError - If any of the query parameters or filters are invalid - ~descarteslabs.exceptions.ClientError or ~descarteslabs.exceptions.ServerError - :ref:`Spurious exception ` that can occur during a - network request. - - Example - ------- - >>> from descarteslabs.catalog import Band, Search, properties as p - >>> search = Search(Band).filter(p.type=="spectral") - >>> count = search.count() # doctest: +SKIP - """ - - # modify query to return 0 results, and just get the object count - s = self.limit(0) - url, params = s._to_request() - r = self._client.session.put(url, json=params, headers=s._headers) - response = r.json() - return response["meta"]["count"] - - def collect(self, **kwargs): - """ - Execute the search query and return the appropriate collection. - - Returns - ------- - ~descarteslabs.common.collection.Collection - Collection of objects that match the type of document beng searched. - - Raises - ------ - BadRequestError - If any of the query parameters or filters are invalid - ~descarteslabs.exceptions.ClientError or ~descarteslabs.exceptions.ServerError - :ref:`Spurious exception ` that can occur during a - network request. - """ - return self._model_cls._collection_type(self, **kwargs) - - def __iter__(self): - """ - Execute the search query and get a generator for iterating through the returned results - - Returns - ------- - generator - Generator of objects that match the type of document being searched. Empty if no - matching documents found. If per_item_continuations was set to True in the - request_params, then the generator will return a tuple of the object and the - continuation token for that object. - - Raises - ------ - BadRequestError - If any of the query parameters or filters are invalid - ~descarteslabs.exceptions.ClientError or ~descarteslabs.exceptions.ServerError - :ref:`Spurious exception ` that can occur during a - network request. - - Example - ------- - >>> from descarteslabs.catalog import Product, Search, properties as p - >>> search = Search(Product).filter(p.tags == "test") - >>> list(search) # doctest: +SKIP - - """ - url_next, params = self._to_request() - per_item_continuations = ( - str(params.get("per_item_continuations", False)).lower() == "true" - ) - while url_next is not None: - r = self._client.session.put(url_next, json=params, headers=self._headers) - response = r.json() - if not response["data"]: - break - - related_objects = self._model_cls._load_related_objects( - response, self._client - ) - - if per_item_continuations: - continuations = response["meta"]["per_item_continuations"] - for i, doc in enumerate(response["data"]): - model_class = self._model_cls._get_model_class(doc) - model_obj = model_class( - id=doc["id"], - client=self._client, - _saved=True, - _relationships=doc.get("relationships"), - _related_objects=related_objects, - **doc["attributes"], - ) - if per_item_continuations: - yield (model_obj, continuations[i]) - else: - yield model_obj - - next_link = response["links"].get("next") - if next_link is not None: - # The WrappedSession always prepends the base url, so we need to trim it from - # this URL. - if not next_link.startswith(self._client.base_url): - warnings.warn( - "Continuation URL '{}' does not match expected base URL '{}'".format( - next_link, self._client.base_url - ) - ) - url_next = next_link[len(self._client.base_url) :] - else: - url_next = None - - def __deepcopy__(self, memo): - cls = self.__class__ - result = cls.__new__(cls) - memo[id(self)] = result - for k, v in self.__dict__.items(): - if k in ["_client"]: - setattr(result, k, v) - else: - setattr(result, k, copy.deepcopy(v, memo)) - return result - - -class Interval(StrEnum): - """An interval for the :py:meth:`ImageSearch.summary_interval` method. - - Attributes - ---------- - YEAR : enum - Aggregate on a yearly basis - QUARTER : enum - Aggregate on a quarterly basis - MONTH : enum - Aggregate on a monthly basis - WEEK : enum - Aggregate on a weekly basis - DAY : enum - Aggregate on a daily basis - HOUR : enum - Aggregate on a hourly basis - MINUTE : enum - Aggregate per minute - """ - - YEAR = "year" - QUARTER = "quarter" - MONTH = "month" - WEEK = "week" - DAY = "day" - HOUR = "hour" - MINUTE = "minute" - - -class AggregateDateField(StrEnum): - """A date field to use for aggragation for the :py:meth:`ImageSearch.summary_interval` method. - - - Attributes - ---------- - ACQUIRED : enum - Aggregate on the `Image.acquired` field. - CREATED : enum - Aggregate on the `Image.created` field. - MODIFIED : enum - Aggregate on the `Image.modified` field. - PUBLISHED : enum - Aggregate on the `Image.published` field. - """ - - ACQUIRED = "acquired" - CREATED = "created" - MODIFIED = "modified" - PUBLISHED = "published" - - -class GeoSearch(Search): - """A search request that supports an :py:meth:`intersects` method for searching - geometries.""" - - def __init__( - self, - model, - client=None, - url=None, - includes=True, - request_params=None, - headers=None, - ): - super(GeoSearch, self).__init__( - model, client, url, includes, request_params=request_params, headers=headers - ) - self._intersects = None - self._intersects_none = False - - def intersects(self, geometry, match_null_geometry=False): - """Filter images or blobs to those that intersect the given geometry. - - Successive calls to `intersects` override the previous intersection - geometry. - - Parameters - ---------- - geometry : shapely.geometry.base.BaseGeometry, ~descarteslabs.common.geo.GeoContext, geojson-like Geometry that found images must intersect. - match_null_geometry : bool, optional (default False) Also match images or blobs with no geometry. - - Returns - ------- - Search - A new instance of the :py:class:`~descarteslabs.catalog.GeoSearch` - class that includes geometry filter. - """ # noqa: E501 - s = copy.deepcopy(self) - _, value = self._model_cls._serialize_filter_attribute("geometry", geometry) - s._request_params["intersects"] = json.dumps( - value, - separators=(",", ":"), - ) - - if match_null_geometry: - s._request_params["intersects_none"] = True - else: - s._request_params.pop("intersects_none", None) - - s._intersects = copy.deepcopy(geometry) - s._intersects_none = match_null_geometry - return s - - -class SummarySearchMixin(Search): - # Be aware that the `|` characters below add whitespace. The first one is needed - # avoid the `Inheritance` section from appearing before the auto summary. - """A search request that add support for summary methods. - - The `SummarySearch` is identical to `Search` but with a couple of summary methods: - :py:meth:`summary` and :py:meth:`summary_interval`. - """ - - _unsupported_summary_params = ["sort"] - # must be set in derived class - SummaryResult = None - DEFAULT_AGGREGATE_DATE_FIELD = None - - def _summary_request(self): - # don't modify existing search params - params = copy.deepcopy(self._request_params) - - for p in self._unsupported_summary_params: - params.pop(p, None) - - filters = self._serialize_filters() - if filters: - # urlencode encodes spaces in the json object which create an invalid filter value when - # the server tries to parse it, so we have to remove spaces prior to encoding. - params["filter"] = json.dumps(filters, separators=(",", ":")) - - return params - - def summary(self): - """Get summary statistics about the current `Search` query. - - Returns - ------- - SummaryResult - The summary statistics as a `SummaryResult` object. - - Raises - ------ - ~descarteslabs.exceptions.ClientError or ~descarteslabs.exceptions.ServerError - :ref:`Spurious exception ` that can occur during a - network request. - - Example - ------- - >>> from descarteslabs.catalog import Image, properties as p - >>> search = Image.search().filter( - ... p.product_id=="landsat:LC08:01:RT:TOAR" - ... ) - >>> s = search.summary() # doctest: +SKIP - >>> print(s.count, s.bytes) # doctest: +SKIP - """ - - s = copy.deepcopy(self) - summary_url = s._url + "/summary/all" - - r = self._client.session.put(summary_url, json=self._summary_request()) - response = r.json() - - return self.SummaryResult(**response["data"]["attributes"]) - - def summary_interval( - self, - aggregate_date_field=None, - interval="year", - start_datetime=None, - end_datetime=None, - ): - """Get summary statistics by specified datetime intervals about the current `ImageSearch` query. - - Parameters - ---------- - - aggregate_date_field : str or AggregateDateField, optional - The date field to use for aggregating summary results over time. Valid - inputs are `~AggregateDateField.ACQUIRED`, `~AggregateDateField.CREATED`, - `~AggregateDateField.MODIFIED`, `~AggregateDateField.PUBLISHED`. The - default is `~AggregateDateField.ACQUIRED`. Field must be defined for - the class. - interval : str or Interval, optional - The time interval to use for aggregating summary results. Valid inputs - are `~Interval.YEAR`, `~Interval.QUARTER`, `~Interval.MONTH`, - `~Interval.WEEK`, `~Interval.DAY`, `~Interval.HOUR`, `~Interval.MINUTE`. - The default is `~Interval.YEAR`. - start_datetime : str or datetime, optional - Beginning of the date range over which to summarize data in ISO format. - The default is least recent date found in the search result based on the - `aggregate_date_field`. The start_datetime is included in the result. To - set it as unbounded, use the value ``0``. - end_datetime : str or datetime, optional - End of the date range over which to summarize data in ISO format. The - default is most recent date found in the search result based on the - `aggregate_date_field`. The end_datetime is included in the result. To - set it as unbounded, use the value ``0``. - - Returns - ------- - list(SummaryResult) - The summary statistics for each interval, as a list of `SummaryResult` - objects. - - Raises - ------ - ~descarteslabs.exceptions.ClientError or ~descarteslabs.exceptions.ServerError - :ref:`Spurious exception ` that can occur during a - network request. - - Example - ------- - >>> from descarteslabs.catalog import Image, AggregateDateField, Interval, properties - >>> search = ( - ... Image.search() - ... .filter(properties.product_id == "landsat:LC08:01:RT:TOAR") - ... ) - >>> interval_results = search.summary_interval( - ... aggregate_date_field=AggregateDateField.ACQUIRED, interval=Interval.MONTH - ... ) # doctest: +SKIP - >>> print([(i.interval_start, i.count) for i in interval_results]) # doctest: +SKIP - """ - s = copy.deepcopy(self) - summary_url = "{}/summary/{}/{}".format( - s._url, aggregate_date_field or self.DEFAULT_AGGREGATE_DATE_FIELD, interval - ) - - # The service will calculate start/end if not given - if start_datetime is not None: - if start_datetime: - s._request_params["_start"] = serialize_datetime(start_datetime) - else: - s._request_params["_start"] = "" # Unbounded - - if end_datetime is not None: - if end_datetime: - s._request_params["_end"] = serialize_datetime(end_datetime) - else: - s._request_params["_end"] = "" # Unbounded - - r = self._client.session.put( - summary_url, json=s._summary_request(), headers=s._headers - ) - response = r.json() - - return [self.SummaryResult(**d["attributes"]) for d in response["data"]] diff --git a/descarteslabs/core/catalog/task.py b/descarteslabs/core/catalog/task.py deleted file mode 100644 index c6cff2ae..00000000 --- a/descarteslabs/core/catalog/task.py +++ /dev/null @@ -1,141 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 time -from concurrent.futures import TimeoutError - -from strenum import StrEnum - -from .catalog_base import _new_abstract_class -from .catalog_client import CatalogClient - - -class TaskState(StrEnum): - """The state of a task. - - Attributes - ---------- - NEVERRAN : enum - The operation was never invoked. - RUNNING : enum - The operation is in progress. - SUCCEEDED : enum - The operation was successfully completed. - FAILED : enum - The operation resulted in a failure and may not have been completed. - """ - - NEVERRAN = "NONE" # The operation was never started - RUNNING = "RUNNING" - SUCCEEDED = "SUCCESS" - FAILED = "FAILURE" - - -class TaskStatus(object): - """A base class for the status of asynchronous jobs.""" - - _TERMINAL_STATES = [TaskState.NEVERRAN, TaskState.SUCCEEDED, TaskState.FAILED] - _POLLING_INTERVAL = 60 - - # The following 2 attributes must be set correctly in any derived class - _task_name = "task" # The name of the task as shown in __repr__() - _url = "{}" # The url for getting the status of the task with the `id` passed in - - def __new__(cls, *args, **kwargs): - return _new_abstract_class(cls, TaskStatus) - - def __init__( - self, - id=None, - status=None, - start_datetime=None, - duration_in_seconds=None, - errors=None, - _client=None, - **kwargs - ): - self.id = id - self.start_datetime = start_datetime - self.duration_in_seconds = duration_in_seconds - self.errors = errors - self._client = _client or CatalogClient.get_default_client() - - try: - self.status = TaskState(status) - except ValueError: - pass - - def __repr__(self): - status = self.status.value if self.status else "UNKNOWN" - text = ["{} {} status: {}".format(self.id, self._task_name, status)] - if self.start_datetime: - text.append(" - started: {}".format(self.start_datetime)) - - if self.duration_in_seconds: - text.append(" - took {:,.4f} seconds".format(self.duration_in_seconds)) - - if self.errors: - text.append(" - {} errors reported:".format(len(self.errors))) - for e in self.errors: - text.append(" - {}".format(e)) - return "\n".join(text) - - def reload(self): - """Update the task information. - - Raises - ------ - ~descarteslabs.exceptions.ClientError or ~descarteslabs.exceptions.ServerError - :ref:`Spurious exception ` that can occur during a - network request. - """ - r = self._client.session.get(self._url.format(self.id)) - response = r.json() - new_values = response["data"]["attributes"] - - self.status = TaskState(new_values.pop("status")) - for key, value in new_values.items(): - setattr(self, key, value) - - def wait_for_completion(self, timeout=None): - """Wait for the task to complete. - - Parameters - ---------- - timeout : int, optional - If specified, will wait up to specified number of seconds and will raise - a :py:exc:`concurrent.futures.TimeoutError` if the task has not completed. - - Raises - ------ - :py:exc:`concurrent.futures.TimeoutError` - If the specified timeout elapses and the task has not completed - """ - if self.status in self._TERMINAL_STATES: - return - - if timeout: - timeout = time.time() + timeout - while True: - self.reload() - if self.status in self._TERMINAL_STATES: - return - if timeout: - t = timeout - time.time() - if t <= 0: - raise TimeoutError() - t = min(t, self._POLLING_INTERVAL) - else: - t = self._POLLING_INTERVAL - time.sleep(t) diff --git a/descarteslabs/core/catalog/tests/__init__.py b/descarteslabs/core/catalog/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/descarteslabs/core/catalog/tests/base.py b/descarteslabs/core/catalog/tests/base.py deleted file mode 100644 index 23726ad5..00000000 --- a/descarteslabs/core/catalog/tests/base.py +++ /dev/null @@ -1,79 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 base64 -import json -import re -import time -import unittest - -import responses -from descarteslabs.auth import Auth - -from ..catalog_client import CatalogClient - - -class ClientTestCase(unittest.TestCase): - not_found_json = { - "errors": [ - { - "detail": "Object not found: foo", - "status": "404", - "title": "Object not found", - } - ], - "jsonapi": {"version": "1.0"}, - } - - def setUp(self): - payload = ( - base64.b64encode( - json.dumps( - { - "aud": "client-id", - "exp": time.time() + 3600, - "sub": "some|user", - "org": "some-org", - } - ).encode() - ) - .decode() - .strip("=") - ) - public_token = f"header.{payload}.signature" - - self.url = "https://example.com/catalog/v2" - self.client = CatalogClient( - url=self.url, auth=Auth(jwt_token=public_token, token_info_path=None) - ) - self.match_url = re.compile(self.url) - - def mock_response(self, method, json, status=200, **kwargs): - responses.add(method, self.match_url, json=json, status=status, **kwargs) - - def get_request(self, index): - r = responses.calls[index].request - if r.body is None: - r.body = "" - elif isinstance(r.body, bytes): - r.body = r.body.decode() - return r - - def get_request_body(self, index): - body = responses.calls[index].request.body - if body is None: - body = "" - elif isinstance(body, bytes): - body = body.decode() - return json.loads(body) diff --git a/descarteslabs/core/catalog/tests/mock_data.py b/descarteslabs/core/catalog/tests/mock_data.py deleted file mode 100644 index 0b59db51..00000000 --- a/descarteslabs/core/catalog/tests/mock_data.py +++ /dev/null @@ -1,1015 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 datetime import datetime -import json -import numpy as np -import shapely.geometry - -from ...catalog import * - -# flake8: noqa: E501 - -# this file contains mock data for the Metadata and Raster services, extracted from the production -# services, which is shared across multiple tests in this directory. - -IMAGES = { - "landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1": Image( - acquired=datetime.fromisoformat("2016-07-06T16:59:42.753476+00:00"), - cs_code="EPSG:32615", - product_id="landsat:LC08:PRE:TOAR", - bits_per_pixel=[0.836, 1.767, 0.804], - id="landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1", - cloud_fraction=0.5646, - solar_azimuth_angle=131.36710631, - bright_fraction=0.2848, - files=[ - File( - hash="5b12fa74275aee3234428fc996429256", - href="gs://descartes-l8/2016-07-06_027031_L8_432.jp2", - size_bytes=49721086, - ), - File( - hash="efb979aeda1b2fbd58fd689f84540165", - href="gs://descartes-l8/2016-07-06_027031_L8_567_19a.jp2", - size_bytes=43577223, - ), - ], - area=35619.4, - alt_cloud_fraction=0.3264, - fill_fraction=0.6319, - x_pixels=15696, - y_pixels=15960, - reflectance_scale=[ - 0.1781, - 0.1746, - 0.1907, - 0.2252, - 0.3711, - 1.4732, - 4.5285, - 0.903, - 0.1999, - ], - solar_elevation_angle=64.12277058, - confidence_dlsr=1.0, - name="meta_LC80270312016188_v1", - roll_angle=-0.001, - provider_id="LC80270312016188LGN00.tar.bz", - geometry=shapely.geometry.shape( - { - "type": "Polygon", - "coordinates": [ - [ - [-95.2989209, 42.7999878], - [-93.1167728, 42.3858464], - [-93.7138666, 40.703737], - [-95.8364984, 41.1150618], - [-95.2989209, 42.7999878], - ] - ], - } - ), - created=datetime.utcfromtimestamp(1468251918), - published=datetime.fromisoformat("2016-07-06T23:11:30+00:00"), - satellite_id="LANDSAT_8", - geotrans=[258292.5, 15.0, 0.0, 4743307.5, 0.0, -15.0], - _saved=True, - ), - "landsat:LC08:PRE:TOAR:meta_LC80260322016197_v1": Image( - acquired=datetime.fromisoformat("2016-07-15T16:53:59.495435+00:00"), - cs_code="EPSG:32615", - product_id="landsat:LC08:PRE:TOAR", - bits_per_pixel=[1.022, 2.61, 0.804], - id="landsat:LC08:PRE:TOAR:meta_LC80260322016197_v1", - cloud_fraction=0.1705, - solar_azimuth_angle=129.79642888, - bright_fraction=0.0571, - files=[ - File( - hash="c80038509ca5572ecdba473bc3931fab", - href="gs://descartes-l8/2016-07-15_026032_L8_432.jp2", - size_bytes=60751671, - ), - File( - hash="92b10252278663b0b2d438bfa6c6b494", - href="gs://descartes-l8/2016-07-15_026032_L8_567_19a.jp2", - size_bytes=56534564, - ), - ], - area=35599.3, - alt_cloud_fraction=0.0947, - fill_fraction=0.6439, - x_pixels=15536, - y_pixels=15816, - reflectance_scale=[ - 0.1786, - 0.1751, - 0.1913, - 0.2258, - 0.3721, - 1.4773, - 4.5414, - 0.9055, - 0.2005, - ], - solar_elevation_angle=63.72682179, - confidence_dlsr=1.0, - name="meta_LC80260322016197_v1", - roll_angle=-0.001, - provider_id="LC80260322016197LGN00.tar.bz", - geometry=shapely.geometry.shape( - { - "type": "Polygon", - "coordinates": [ - [ - [-94.2036617, 41.3717716], - [-92.0686956, 40.9629603], - [-92.6448116, 39.2784859], - [-94.724166, 39.6850062], - [-94.2036617, 41.3717716], - ] - ], - } - ), - created=datetime.utcfromtimestamp(1469372319), - published=datetime.fromisoformat("2016-07-22T04:49:40+00:00"), - satellite_id="LANDSAT_8", - geotrans=[348592.5, 15, 0, 4582807.5, 0, -15], - _saved=True, - ), - "modis:mod11a2:006:meta_MOD11A2.A2017305.h09v05.006.2017314042814_v1": Image( - acquired=datetime.fromisoformat("2017-11-01T00:00:00+00:00"), - area=1236433958410.1, - bits_per_pixel=[16.24028611111111, 2.332976111111111], - files=[ - File( - hash="dcab9cdeace57fa4a275e51892e1d95f", - href="gs://dl-satin_modis-mod11a2-006_r/modis:mod11a2:006/MOD11A2.A2017305.h09v05.006.2017314042814.UInt16.tif", - size_bytes=5846503, - ), - File( - hash="6c026381b1e03c6b2ddbdda96a4358c0", - href="gs://dl-satin_modis-mod11a2-006_r/modis:mod11a2:006/MOD11A2.A2017305.h09v05.006.2017314042814.Byte.tif", - size_bytes=4199357, - ), - ], - fill_fraction=0.9842958333333334, - geometry=shapely.geometry.shape( - { - "coordinates": [ - [ - [-117.48665603990703, 39.999999999999154], - [-104.4325831465788, 39.999999999999154], - [-102.94076527145069, 38.99999999999963], - [-102.22229259882958, 38.49999999999986], - [-100.83779312081948, 37.500000000000334], - [-99.52020554825341, 36.5000000000008], - [-97.66196710091624, 35.00000000000151], - [-96.49743588031265, 34.00000000000198], - [-94.85512379473369, 32.500000000002686], - [-93.82621572912193, 31.50000000000316], - [-92.37604307034178, 30.000000000003865], - [-103.92304845413969, 30.000000000003865], - [-105.55449269526743, 31.50000000000316], - [-106.71201426908073, 32.500000000002686], - [-108.55961536535716, 34.00000000000198], - [-109.86971298853625, 35.00000000000151], - [-111.24611797498575, 36.00000000000104], - [-111.96023124179068, 36.5000000000008], - [-113.4425172609276, 37.500000000000334], - [-115.00007917368902, 38.49999999999986], - [-115.8083609303878, 38.99999999999963], - [-117.48665603990703, 39.999999999999154], - ] - ], - "type": "Polygon", - } - ), - geotrans=[ - -10007554.677899, - 926.6254331391661, - 0.0, - 4447802.079066, - 0.0, - -926.6254331383334, - ], - id="modis:mod11a2:006:meta_MOD11A2.A2017305.h09v05.006.2017314042814_v1", - provider_id="MOD11A2.A2017305.h09v05.006.2017314042814", - name="meta_MOD11A2.A2017305.h09v05.006.2017314042814_v1", - modified=datetime.fromisoformat("2018-11-12T03:25:33.871326+00:00"), - created=datetime.fromisoformat("2018-11-12T03:25:31+00:00"), - product_id="modis:mod11a2:006", - projection="+proj=sinu +lon_0=0 +x_0=0 +y_0=0 +a=6371007.181 +b=6371007.181 +units=m +no_defs ", - published=datetime.fromisoformat("2017-11-10T00:00:00+00:00"), - x_pixels=1200, - y_pixels=1200, - satellite_id="modis", - _saved=True, - ), - "modis:mod11a2:006:meta_MOD11A2.A2000049.h08v05.006.2015058135046_v1": Image( - acquired=datetime.fromisoformat("2000-02-18T00:00:00+00:00"), - area=1236433958407.88, - bits_per_pixel=[5.416830555555555, 0.8889755555555555], - files=[ - File( - hash="19ab952b0ee2997a26cc854b0b1225ba", - href="gs://dl-satin_modis-mod11a2-006_r/modis:mod11a2:006/MOD11A2.A2000049.h08v05.006.2015058135046.UInt16.tif", - size_bytes=1950059, - ), - File( - hash="7cdcc274be7bd6b0a206949cc8978ced", - href="gs://dl-satin_modis-mod11a2-006_r/modis:mod11a2:006/MOD11A2.A2000049.h08v05.006.2015058135046.Byte.tif", - size_bytes=1600156, - ), - ], - fill_fraction=0.5317729166666667, - geometry=shapely.geometry.shape( - { - "coordinates": [ - [ - [-130.5407289332235, 39.999999999999154], - [-117.48665603990703, 39.999999999999154], - [-115.8083609303878, 38.99999999999963], - [-115.00007917368902, 38.49999999999986], - [-113.4425172609276, 37.500000000000334], - [-111.96023124179068, 36.5000000000008], - [-111.24611797498575, 36.00000000000104], - [-109.86971298853625, 35.00000000000151], - [-108.55961536535716, 34.00000000000198], - [-106.71201426908073, 32.500000000002686], - [-105.55449269526743, 31.50000000000316], - [-103.92304845413969, 30.000000000003865], - [-115.47005383792722, 30.000000000003865], - [-117.28276966140238, 31.50000000000316], - [-118.56890474341712, 32.500000000002686], - [-119.92049433351035, 33.50000000000222], - [-120.6217948503908, 34.00000000000198], - [-122.0774588761453, 35.00000000000151], - [-123.606797749978, 36.00000000000104], - [-124.40025693531675, 36.5000000000008], - [-126.04724140102435, 37.500000000000334], - [-127.77786574853697, 38.49999999999986], - [-128.67595658931336, 38.99999999999963], - [-130.5407289332235, 39.999999999999154], - ] - ], - "type": "Polygon", - } - ), - geotrans=[ - -11119505.197665, - 926.6254331383342, - 0.0, - 4447802.079066, - 0.0, - -926.6254331383334, - ], - id="modis:mod11a2:006:meta_MOD11A2.A2000049.h08v05.006.2015058135046_v1", - provider_id="MOD11A2.A2000049.h08v05.006.2015058135046", - name="meta_MOD11A2.A2000049.h08v05.006.2015058135046_v1", - modified=datetime.fromisoformat("2018-11-15T21:12:31.874628+00:00"), - created=datetime.fromisoformat("2018-11-15T21:12:30+00:00"), - product_id="modis:mod11a2:006", - projection="+proj=sinu +lon_0=0 +x_0=0 +y_0=0 +a=6371007.181 +b=6371007.181 +units=m +no_defs ", - published=datetime.fromisoformat("2015-02-27T00:00:00+00:00"), - x_pixels=1200, - y_pixels=1200, - satellite_id="modis", - storage_state=StorageState.AVAILABLE, - _saved=True, - ), -} - - -def _image_get(id): - return IMAGES[id] - - -BANDS_BY_PRODUCT = { - "landsat:LC08:PRE:TOAR": { - "qa_cloud": ClassBand( - product_id="landsat:LC08:PRE:TOAR", - description="Cloud Classification", - tags=["class", "cloud", "30m", "landsat"], - data_type="UInt16", - vendor_band_name="qa_cloud", - band_index=3, - id="landsat:LC08:PRE:TOAR:qa_cloud", - name="qa_cloud", - file_index=1, - resolution=Resolution(value=30, unit=ResolutionUnit.METERS), - data_range=[0, 3], - jpx_layer_index=1, - display_range=[0, 3], - _saved=True, - ), - "tirs1": SpectralBand( - description="Thermal infrared TIRS 1", - tags=["spectral", "thermal", "tirs1", "100m", "landsat"], - data_type="UInt16", - jpx_layer_index=3, - vendor_band_name_vendor="B10", - product_id="landsat:LC08:PRE:TOAR", - sort_order=10, - vendor_order=10, - physical_range=[-32, 64], - band_index=2, - id="landsat:LC08:PRE:TOAR:tirs1", - name="tirs1", - file_index=1, - resolution=Resolution(value=100, unit=ResolutionUnit.METERS), - data_range=[0, 16383], - wavelength_nm_fwhm=600, - wavelength_nm_min=10600, - wavelength_nm_max=11200, - display_range=[0, 16383], - _saved=True, - ), - "qa_water": ClassBand( - product_id="landsat:LC08:PRE:TOAR", - description="Water Classification", - tags=["class", "water", "30m", "landsat"], - data_type="UInt16", - vendor_band_name="qa_water", - band_index=0, - id="landsat:LC08:PRE:TOAR:qa_water", - name="qa_water", - file_index=1, - resolution=Resolution(value=30, unit=ResolutionUnit.METERS), - data_range=[0, 3], - jpx_layer_index=1, - display_range=[0, 3], - _saved=True, - ), - "alpha": MaskBand( - product_id="landsat:LC08:PRE:TOAR", - description="Alpha (valid data)", - tags=["mask", "alpha", "15m", "landsat"], - data_type="UInt16", - resolution=Resolution(value=15, unit=ResolutionUnit.METERS), - band_index=0, - id="landsat:LC08:PRE:TOAR:alpha", - name="alpha", - file_index=0, - display_range=[0, 1], - data_range=[0, 1], - jpx_layer_index=1, - _saved=True, - ), - "nir": SpectralBand( - wavelength_nm_max=878.85, - data_type="UInt16", - vendor_band_name="B5", - id="landsat:LC08:PRE:TOAR:nir", - file_index=1, - wavelength_nm_center=864.7, - jpx_layer_index=2, - product_id="landsat:LC08:PRE:TOAR", - description="Near Infrared", - tags=["spectral", "nir", "near-infrared", "30m", "landsat"], - wavelength_nm_min=850.5500000000001, - physical_range=[0.0, 1.0], - band_index=0, - vendor_order=5, - sort_order=5, - name="nir", - display_range=[0, 10000], - data_range=[0, 10000], - wavelength_nm_fwhm=28.3, - resolution=Resolution(value=30, unit=ResolutionUnit.METERS), - _saved=True, - ), - "cirrus": SpectralBand( - wavelength_max=1375.0, - data_type="UInt16", - vendor_band_name="B9", - id="landsat:LC08:PRE:TOAR:cirrus", - file_index=1, - wavelength_nm_center=1370, - jpx_layer_index=3, - product_id="landsat:LC08:PRE:TOAR", - description="Cirrus", - tags=["spectral", "cirrus", "30m", "landsat"], - wavelength_nm_min=1365.0, - physical_range=[0.0, 1.0], - band_index=1, - vendor_order=9, - sort_order=9, - name="cirrus", - display_range=[0, 10000], - data_range=[0, 10000], - wavelength_nm_fwhm=10, - resolution=Resolution(value=30, unit=ResolutionUnit.METERS), - _saved=True, - ), - "swir1": SpectralBand( - wavelength_nm_max=1651.25, - data_type="UInt16", - vendor_band_name="B6", - id="landsat:LC08:PRE:TOAR:swir1", - file_index=1, - wavelength_nm_center=1608.9, - jpx_layer_index=2, - product_id="landsat:LC08:PRE:TOAR", - description="Short wave infrared 1", - tags=["spectral", "swir", "swir1", "30m", "landsat"], - wavelength_nm_min=1566.5500000000002, - physical_range=[0.0, 1.0], - band_index=1, - vendor_order=6, - sort_order=6, - name="swir1", - display_range=[0, 10000], - data_range=[0, 10000], - wavelength_nm_fwhm=84.7, - resolution=Resolution(value=30, unit=ResolutionUnit.METERS), - _saved=True, - ), - "swir2": SpectralBand( - wavelength_nm_max=2294.0499999999997, - data_type="UInt16", - vendor_band_name="B7", - id="landsat:LC08:PRE:TOAR:swir2", - file_index=1, - wavelength_nm_center=2200.7, - jpx_layer_index=2, - product_id="landsat:LC08:PRE:TOAR", - description="Short wave infrared 2", - tags=["spectral", "swir", "swir2", "30m", "landsat"], - wavelength_nm_min=2107.35, - physical_range=[0.0, 1.0], - band_index=2, - vendor_order=7, - sort_order=7, - name="swir2", - display_range=[0, 10000], - data_range=[0, 10000], - wavelength_nm_fwhm=186.7, - resolution=Resolution(value=30, unit=ResolutionUnit.METERS), - _saved=True, - ), - "qa_cirrus": ClassBand( - product_id="landsat:LC08:PRE:TOAR", - description="Cirrus Classification", - tags=["class", "cirrus", "30m", "landsat"], - data_type="UInt16", - vendor_band_name="qa_cirrus", - band_index=2, - id="landsat:LC08:PRE:TOAR:qa_cirrus", - name="qa_cirrus", - file_index=1, - resolution=Resolution(value=30, unit=ResolutionUnit.METERS), - data_range=[0, 3], - jpx_layer_index=1, - display_range=[0, 3], - _saved=True, - ), - "blue": SpectralBand( - wavelength_nm_max=512.0, - data_type="UInt16", - vendor_band_name="B2", - id="landsat:LC08:PRE:TOAR:blue", - file_index=0, - wavelength_nm_center=482, - jpx_layer_index=0, - product_id="landsat:LC08:PRE:TOAR", - description="Blue, Pansharpened", - tags=["spectral", "blue", "15m", "landsat"], - wavelength_nm_min=452.0, - physical_range=[0.0, 1.0], - band_index=2, - vendor_order=2, - sort_order=2, - name="blue", - display_range=[0, 4000], - data_range=[0, 10000], - wavelength_nm_fwhm=60, - resolution=Resolution(value=15, unit=ResolutionUnit.METERS), - _saved=True, - ), - "bright-mask": MaskBand( - product_id="landsat:LC08:PRE:TOAR", - description="Bright Mask (blue > 20% reflective)", - tags=["mask", "bright", "30m", "landsat"], - data_type="UInt16", - resolution=Resolution(value=30, unit=ResolutionUnit.METERS), - band_index=2, - id="landsat:LC08:PRE:TOAR:bright-mask", - name="bright-mask", - file_index=1, - display_range=[0, 1], - data_range=[0, 1], - jpx_layer_index=0, - _saved=True, - ), - "green": SpectralBand( - wavelength_nm_max=590.05, - data_type="UInt16", - vendor_band_name="B3", - id="landsat:LC08:PRE:TOAR:green", - file_index=0, - wavelength_nm_center=561.4, - jpx_layer_index=0, - product_id="landsat:LC08:PRE:TOAR", - description="Green, Pansharpened", - tags=["spectral", "green", "15m", "landsat"], - wavelength_nm_min=532.75, - physical_range=[0.0, 1.0], - band_index=1, - vendor_order=3, - sort_order=3, - name="green", - display_range=[0, 4000], - data_range=[0, 10000], - wavelength_nm_fwhm=57.3, - resolution=Resolution(value=15, unit=ResolutionUnit.METERS), - _saved=True, - ), - "qa_snow": ClassBand( - product_id="landsat:LC08:PRE:TOAR", - description="Snow Classification", - tags=["class", "snow", "30m", "landsat"], - data_type="UInt16", - vendor_band_name="qa_snow", - band_index=1, - id="landsat:LC08:PRE:TOAR:qa_snow", - name="qa_snow", - file_index=1, - resolution=Resolution(value=30, unit=ResolutionUnit.METERS), - data_range=[0, 3], - jpx_layer_index=1, - display_range=[0, 3], - _saved=True, - ), - "red": SpectralBand( - wavelength_nm_max=673.35, - data_type="UInt16", - vendor_band_name="B4", - id="landsat:LC08:PRE:TOAR:red", - file_index=0, - wavelength_nm_center=654.6, - jpx_layer_index=0, - product_id="landsat:LC08:PRE:TOAR", - description="Red, Pansharpened", - tags=["spectral", "red", "15m", "landsat"], - wavelength_nm_min=635.85, - physical_range=[0.0, 1.0], - band_index=0, - vendor_order=4, - sort_order=4, - name="red", - display_range=[0, 4000], - data_range=[0, 10000], - wavelength_nm_fwhm=37.5, - resolution=Resolution(value=15, unit=ResolutionUnit.METERS), - _saved=True, - ), - "cloud-mask": MaskBand( - product_id="landsat:LC08:PRE:TOAR", - description="Binary Cloud Mask", - tags=["mask", "cloud", "30m", "landsat"], - data_type="UInt16", - resolution=Resolution(value=30, unit=ResolutionUnit.METERS), - band_index=1, - id="landsat:LC08:PRE:TOAR:cloud-mask", - name="cloud-mask", - file_index=1, - display_range=[0, 1], - data_range=[0, 1], - jpx_layer_index=0, - _saved=True, - ), - "coastal-aerosol": SpectralBand( - wavelength_nm_max=451.0, - data_type="UInt16", - vendor_band_name="B1", - id="landsat:LC08:PRE:TOAR:coastal-aerosol", - name="coastal-aerosol", - file_index=1, - wavelength_nm_center=443, - jpx_layer_index=3, - product_id="landsat:LC08:PRE:TOAR", - description="Coastal Aerosol", - tags=["spectral", "aerosol", "coastal", "30m", "landsat"], - wavelength_nm_min=435.0, - physical_range=[0.0, 1.0], - band_index=0, - vendor_order=1, - sort_order=1, - display_range=[0, 10000], - data_range=[0, 10000], - wavelength_nm_fwhm=16, - resolution=Resolution(value=30, unit=ResolutionUnit.METERS), - _saved=True, - ), - }, - "modis:mod11a2:006": { - "Clear_sky_days": SpectralBand( - data_range=[0, 255], - display_range=[1, 255], - description="Day clear-sky coverage", - data_type="Byte", - id="modis:mod11a2:006:Clear_sky_days", - jpx_layer_index=0, - name="Clear_sky_days", - vendor_band_name="Clear_sky_days", - nodata=0, - physical_range=[0.0, 255.0], - product_id="modis:mod11a2:006", - resolution=Resolution(value=1000, unit=ResolutionUnit.METERS), - band_index=8, - file_index=1, - vendor_order=11, - sort_order=11, - _saved=True, - ), - "Clear_sky_nights": SpectralBand( - data_range=[0, 255], - display_range=[1, 255], - description="Night clear-sky coverage", - data_type="Byte", - id="modis:mod11a2:006:Clear_sky_nights", - jpx_layer_index=0, - name="Clear_sky_nights", - vendor_band_name="Clear_sky_nights", - nodata=0, - physical_range=[0.0, 255.0], - product_id="modis:mod11a2:006", - resolution=Resolution(value=1000, unit=ResolutionUnit.METERS), - band_index=9, - file_index=1, - vendor_order=12, - sort_order=12, - _saved=True, - ), - "Day_view_angl": SpectralBand( - data_range=[0, 255], - display_range=[0, 130], - description="View zenith angle of day observation", - data_type="Byte", - id="modis:mod11a2:006:Day_view_angl", - jpx_layer_index=0, - name="Day_view_angl", - vendor_band_name="Day_view_angl", - nodata=255, - physical_range=[-65.0, 190.0], - product_id="modis:mod11a2:006", - resolution=Resolution(value=1000, unit=ResolutionUnit.METERS), - band_index=2, - file_index=1, - vendor_order=4, - sort_order=4, - _saved=True, - ), - "Day_view_time": SpectralBand( - data_range=[0, 255], - display_range=[0, 240], - description="Local time of day observation", - data_type="Byte", - id="modis:mod11a2:006:Day_view_time", - jpx_layer_index=0, - name="Day_view_time", - vendor_band_name="Day_view_time", - nodata=255, - physical_range=[0.0, 25.5], - product_id="modis:mod11a2:006", - resolution=Resolution(value=1000, unit=ResolutionUnit.METERS), - band_index=1, - file_index=1, - vendor_order=3, - sort_order=3, - _saved=True, - ), - "Emis_31": SpectralBand( - data_range=[0, 255], - display_range=[1, 255], - description="Band 31 emissivity", - data_type="Byte", - id="modis:mod11a2:006:Emis_31", - jpx_layer_index=0, - name="Emis_31", - vendor_band_name="Emis_31", - nodata=255, - physical_range=[0.49, 1.0], - product_id="modis:mod11a2:006", - resolution=Resolution(value=1000, unit=ResolutionUnit.METERS), - band_index=6, - file_index=1, - vendor_order=9, - sort_order=9, - _saved=True, - ), - "Emis_32": SpectralBand( - data_range=[0, 255], - display_range=[1, 255], - description="Band 32 emissivity", - data_type="Byte", - id="modis:mod11a2:006:Emis_32", - jpx_layer_index=0, - name="Emis_32", - vendor_band_name="Emis_32", - nodata=255, - physical_range=[0.49, 1.0], - product_id="modis:mod11a2:006", - resolution=Resolution(value=1000, unit=ResolutionUnit.METERS), - band_index=7, - file_index=1, - vendor_order=9, - sort_order=9, - _saved=True, - ), - "LST_Day_1km": SpectralBand( - data_range=[0, 65535], - display_range=[7500, 65535], - description="Daytime Land Surface Temperature", - data_type="UInt16", - id="modis:mod11a2:006:LST_Day_1km", - jpx_layer_index=0, - name="LST_Day_1km", - vendor_band_name="LST_Day_1km", - nodata=0, - physical_range=[0.0, 1310.7], - product_id="modis:mod11a2:006", - resolution=Resolution(value=1000, unit=ResolutionUnit.METERS), - band_index=0, - file_index=0, - vendor_order=1, - sort_order=1, - _saved=True, - ), - "LST_Night_1km": SpectralBand( - data_range=[0, 65535], - display_range=[7500, 65535], - description="Nighttime Land Surface Temperature", - data_type="UInt16", - id="modis:mod11a2:006:LST_Night_1km", - jpx_layer_index=0, - name="LST_Night_1km", - vendor_band_name="LST_Day_1km", - nodata=0, - physical_range=[0.0, 1310.7], - product_id="modis:mod11a2:006", - resolution=Resolution(value=1000, unit=ResolutionUnit.METERS), - band_index=2, - file_index=0, - vendor_order=5, - sort_order=5, - _saved=True, - ), - "Night_view_angl": SpectralBand( - data_range=[0, 255], - display_range=[0, 130], - description="View zenith angle of night observation", - data_type="Byte", - id="modis:mod11a2:006:Night_view_angl", - jpx_layer_index=0, - name="Night_view_angl", - vendor_band_name="Night_view_angl", - nodata=255, - physical_range=[-65.0, 190.0], - product_id="modis:mod11a2:006", - resolution=Resolution(value=1000, unit=ResolutionUnit.METERS), - band_index=5, - file_index=1, - vendor_order=8, - sort_order=8, - _saved=True, - ), - "Night_view_time": SpectralBand( - data_range=[0, 255], - display_range=[0, 240], - description="Local time of night observation", - data_type="Byte", - id="modis:mod11a2:006:Night_view_time", - jpx_layer_index=0, - name="Night_view_time", - vendor_band_name="Night_view_time", - nodata=255, - physical_range=[0.0, 25.5], - product_id="modis:mod11a2:006", - resolution=Resolution(value=1000, unit=ResolutionUnit.METERS), - band_index=4, - file_index=1, - vendor_order=7, - sort_order=7, - _saved=True, - ), - "QC_Day": ClassBand( - data_range=[0, 255], - default_range=[0, 255], - description="Daytime LST Quality Indicators", - data_type="Byte", - id="modis:mod11a2:006:QC_Day", - jpx_layer_index=0, - name="QC_Day", - vendor_band_name="QC_Day", - physical_range=[0.0, 255.0], - product_id="modis:mod11a2:006", - resolution=Resolution(value=1000, unit=ResolutionUnit.METERS), - band_index=0, - file_index=1, - vendor_order=2, - sort_order=2, - _saved=True, - ), - "QC_Night": ClassBand( - data_range=[0, 255], - display_range=[0, 255], - description="Nighttime LST Quality Indicators", - data_type="Byte", - id="modis:mod11a2:006:QC_Night", - jpx_layer_index=0, - name="QC_Night", - vendor_band_name="QC_Night", - physical_range=[0.0, 255.0], - product_id="modis:mod11a2:006", - resolution=Resolution(value=1000, unit=ResolutionUnit.METERS), - band_index=3, - file_index=1, - vendor_order=6, - sort_order=6, - _saved=True, - ), - }, -} - - -def _cached_bands_by_product(product, _client): - return BANDS_BY_PRODUCT[product] - - -alpha = np.ones((122, 120), dtype="uint16") -alpha[2, 2] = 0 - -alpha1000 = np.ones((239, 235), dtype="uint16") -alpha1000[2, 2] = 0 - - -RASTER = { - '{"bands": ["nir", "alpha"], "data_type": "UInt16", "inputs": ["landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1"], "resolution": 600}': ( - np.stack([np.zeros((122, 120), dtype="uint16"), alpha]), - '{"files": [], "cornerCoordinates": {"upperRight": [456219.441, 4580160.511], "lowerLeft": [384219.441, 4506960.511], "lowerRight": [456219.441, 4506960.511], "upperLeft": [384219.441, 4580160.511], "center": [420219.441, 4543560.511]}, "wgs84Extent": {"type": "Polygon", "coordinates": [[[-94.3843159, 41.3646333], [-94.3705723, 40.7054251], [-93.5183204, 40.7123983], [-93.5235196, 41.3717692], [-94.3843159, 41.3646333]]]}, "driverShortName": "MEM", "driverLongName": "In Memory Raster", "bands": [{"description": {"default_range": [0, 10000], "wavelength_max": 878.85, "data_unit": "TOAR", "wavelength_center": 864.7, "color": "Gray", "dtype": "UInt16", "name_vendor": "B5", "id": "landsat:LC08:PRE:TOAR:nir", "nbits": 14, "wavelength_unit": "nm", "wavelength_min": 850.55, "processing_level": "TOAR", "product": "landsat:LC08:PRE:TOAR", "data_unit_description": "Top of atmosphere reflectance", "description": "Near Infrared", "tags": ["spectral", "nir", "near-infrared", "30m", "landsat"], "resolution_unit": "m", "data_description": "TOAR, 0-10000 is 0 - 100% reflective", "physical_range": [0.0, 1.0], "name_common": "nir", "vendor_order": 5, "name": "nir", "type": "spectral", "data_range": [0, 10000], "wavelength_fwhm": 28.3, "nodata": null, "resolution": 30}, "band": 1, "colorInterpretation": "Gray", "type": "UInt16", "block": [120, 1], "metadata": {"": {"NBITS": "14"}}}, {"description": {"product": "landsat:LC08:PRE:TOAR", "nbits": 1, "description": "Alpha (valid data)", "data_description": "0: nodata, 1: valid data", "tags": ["mask", "alpha", "15m", "landsat"], "color": "Alpha", "dtype": "UInt16", "data_range": [0, 1], "resolution": 15, "resolution_unit": "m", "data_unit_description": "unitless", "name_common": "alpha", "type": "mask", "nodata": null, "default_range": [0, 1], "id": "landsat:LC08:PRE:TOAR:alpha", "name": "alpha"}, "band": 2, "colorInterpretation": "Alpha", "type": "UInt16", "block": [120, 1], "metadata": {"": {"NBITS": "1"}}}], "coordinateSystem": {"wkt": "PROJCS[\\"WGS 84 / UTM zone 15N\\",\\n GEOGCS[\\"WGS 84\\",\\n DATUM[\\"WGS_1984\\",\\n SPHEROID[\\"WGS 84\\",6378137,298.257223563,\\n AUTHORITY[\\"EPSG\\",\\"7030\\"]],\\n AUTHORITY[\\"EPSG\\",\\"6326\\"]],\\n PRIMEM[\\"Greenwich\\",0,\\n AUTHORITY[\\"EPSG\\",\\"8901\\"]],\\n UNIT[\\"degree\\",0.0174532925199433,\\n AUTHORITY[\\"EPSG\\",\\"9122\\"]],\\n AUTHORITY[\\"EPSG\\",\\"4326\\"]],\\n PROJECTION[\\"Transverse_Mercator\\"],\\n PARAMETER[\\"latitude_of_origin\\",0],\\n PARAMETER[\\"central_meridian\\",-93],\\n PARAMETER[\\"scale_factor\\",0.9996],\\n PARAMETER[\\"false_easting\\",500000],\\n PARAMETER[\\"false_northing\\",0],\\n UNIT[\\"metre\\",1,\\n AUTHORITY[\\"EPSG\\",\\"9001\\"]],\\n AXIS[\\"Easting\\",EAST],\\n AXIS[\\"Northing\\",NORTH],\\n AUTHORITY[\\"EPSG\\",\\"32615\\"]]"}, "geoTransform": [384219.440777, 600.0, 0.0, 4580160.51059, 0.0, -600.0], "size": [120, 122], "metadata": {"": {"id": "landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1", "Corder": "RPCL"}}}', - ), # noqa - '{"bands": ["nir", "alpha"], "data_type": "Int32", "inputs": ["landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1"], "resolution": 600}': ( - np.stack([np.zeros((122, 120), dtype="int32"), alpha]), - '{"files": [], "cornerCoordinates": {"upperRight": [456219.441, 4580160.511], "lowerLeft": [384219.441, 4506960.511], "lowerRight": [456219.441, 4506960.511], "upperLeft": [384219.441, 4580160.511], "center": [420219.441, 4543560.511]}, "wgs84Extent": {"type": "Polygon", "coordinates": [[[-94.3843159, 41.3646333], [-94.3705723, 40.7054251], [-93.5183204, 40.7123983], [-93.5235196, 41.3717692], [-94.3843159, 41.3646333]]]}, "driverShortName": "MEM", "driverLongName": "In Memory Raster", "bands": [{"description": {"default_range": [0, 10000], "wavelength_max": 878.85, "data_unit": "TOAR", "wavelength_center": 864.7, "color": "Gray", "dtype": "UInt16", "name_vendor": "B5", "id": "landsat:LC08:PRE:TOAR:nir", "nbits": 14, "wavelength_unit": "nm", "wavelength_min": 850.55, "processing_level": "TOAR", "product": "landsat:LC08:PRE:TOAR", "data_unit_description": "Top of atmosphere reflectance", "description": "Near Infrared", "tags": ["spectral", "nir", "near-infrared", "30m", "landsat"], "resolution_unit": "m", "data_description": "TOAR, 0-10000 is 0 - 100% reflective", "physical_range": [0.0, 1.0], "name_common": "nir", "vendor_order": 5, "name": "nir", "type": "spectral", "data_range": [0, 10000], "wavelength_fwhm": 28.3, "nodata": null, "resolution": 30}, "band": 1, "colorInterpretation": "Gray", "type": "UInt16", "block": [120, 1], "metadata": {"": {"NBITS": "14"}}}, {"description": {"product": "landsat:LC08:PRE:TOAR", "nbits": 1, "description": "Alpha (valid data)", "data_description": "0: nodata, 1: valid data", "tags": ["mask", "alpha", "15m", "landsat"], "color": "Alpha", "dtype": "UInt16", "data_range": [0, 1], "resolution": 15, "resolution_unit": "m", "data_unit_description": "unitless", "name_common": "alpha", "type": "mask", "nodata": null, "default_range": [0, 1], "id": "landsat:LC08:PRE:TOAR:alpha", "name": "alpha"}, "band": 2, "colorInterpretation": "Alpha", "type": "UInt16", "block": [120, 1], "metadata": {"": {"NBITS": "1"}}}], "coordinateSystem": {"wkt": "PROJCS[\\"WGS 84 / UTM zone 15N\\",\\n GEOGCS[\\"WGS 84\\",\\n DATUM[\\"WGS_1984\\",\\n SPHEROID[\\"WGS 84\\",6378137,298.257223563,\\n AUTHORITY[\\"EPSG\\",\\"7030\\"]],\\n AUTHORITY[\\"EPSG\\",\\"6326\\"]],\\n PRIMEM[\\"Greenwich\\",0,\\n AUTHORITY[\\"EPSG\\",\\"8901\\"]],\\n UNIT[\\"degree\\",0.0174532925199433,\\n AUTHORITY[\\"EPSG\\",\\"9122\\"]],\\n AUTHORITY[\\"EPSG\\",\\"4326\\"]],\\n PROJECTION[\\"Transverse_Mercator\\"],\\n PARAMETER[\\"latitude_of_origin\\",0],\\n PARAMETER[\\"central_meridian\\",-93],\\n PARAMETER[\\"scale_factor\\",0.9996],\\n PARAMETER[\\"false_easting\\",500000],\\n PARAMETER[\\"false_northing\\",0],\\n UNIT[\\"metre\\",1,\\n AUTHORITY[\\"EPSG\\",\\"9001\\"]],\\n AXIS[\\"Easting\\",EAST],\\n AXIS[\\"Northing\\",NORTH],\\n AUTHORITY[\\"EPSG\\",\\"32615\\"]]"}, "geoTransform": [384219.440777, 600.0, 0.0, 4580160.51059, 0.0, -600.0], "size": [120, 122], "metadata": {"": {"id": "landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1", "Corder": "RPCL"}}}', - ), # noqa - '{"bands": ["nir", "alpha"], "data_type": "UInt16", "inputs": ["landsat:LC08:PRE:TOAR:meta_LC80260322016197_v1"], "resolution": 600}': ( - np.stack([np.zeros((122, 120), dtype="uint16"), alpha]), - '{"files": [], "cornerCoordinates": {"upperRight": [456219.441, 4580160.511], "lowerLeft": [384219.441, 4506960.511], "lowerRight": [456219.441, 4506960.511], "upperLeft": [384219.441, 4580160.511], "center": [420219.441, 4543560.511]}, "wgs84Extent": {"type": "Polygon", "coordinates": [[[-94.3843159, 41.3646333], [-94.3705723, 40.7054251], [-93.5183204, 40.7123983], [-93.5235196, 41.3717692], [-94.3843159, 41.3646333]]]}, "driverShortName": "MEM", "driverLongName": "In Memory Raster", "bands": [{"description": {"default_range": [0, 10000], "wavelength_max": 878.85, "data_unit": "TOAR", "wavelength_center": 864.7, "color": "Gray", "dtype": "UInt16", "name_vendor": "B5", "id": "landsat:LC08:PRE:TOAR:nir", "nbits": 14, "wavelength_unit": "nm", "wavelength_min": 850.55, "processing_level": "TOAR", "product": "landsat:LC08:PRE:TOAR", "data_unit_description": "Top of atmosphere reflectance", "description": "Near Infrared", "tags": ["spectral", "nir", "near-infrared", "30m", "landsat"], "resolution_unit": "m", "data_description": "TOAR, 0-10000 is 0 - 100% reflective", "physical_range": [0.0, 1.0], "name_common": "nir", "vendor_order": 5, "name": "nir", "type": "spectral", "data_range": [0, 10000], "wavelength_fwhm": 28.3, "nodata": null, "resolution": 30}, "band": 1, "colorInterpretation": "Gray", "type": "UInt16", "block": [120, 1], "metadata": {"": {"NBITS": "14"}}}, {"description": {"product": "landsat:LC08:PRE:TOAR", "nbits": 1, "description": "Alpha (valid data)", "data_description": "0: nodata, 1: valid data", "tags": ["mask", "alpha", "15m", "landsat"], "color": "Alpha", "dtype": "UInt16", "data_range": [0, 1], "resolution": 15, "resolution_unit": "m", "data_unit_description": "unitless", "name_common": "alpha", "type": "mask", "nodata": null, "default_range": [0, 1], "id": "landsat:LC08:PRE:TOAR:alpha", "name": "alpha"}, "band": 2, "colorInterpretation": "Alpha", "type": "UInt16", "block": [120, 1], "metadata": {"": {"NBITS": "1"}}}], "coordinateSystem": {"wkt": "PROJCS[\\"WGS 84 / UTM zone 15N\\",\\n GEOGCS[\\"WGS 84\\",\\n DATUM[\\"WGS_1984\\",\\n SPHEROID[\\"WGS 84\\",6378137,298.257223563,\\n AUTHORITY[\\"EPSG\\",\\"7030\\"]],\\n AUTHORITY[\\"EPSG\\",\\"6326\\"]],\\n PRIMEM[\\"Greenwich\\",0,\\n AUTHORITY[\\"EPSG\\",\\"8901\\"]],\\n UNIT[\\"degree\\",0.0174532925199433,\\n AUTHORITY[\\"EPSG\\",\\"9122\\"]],\\n AUTHORITY[\\"EPSG\\",\\"4326\\"]],\\n PROJECTION[\\"Transverse_Mercator\\"],\\n PARAMETER[\\"latitude_of_origin\\",0],\\n PARAMETER[\\"central_meridian\\",-93],\\n PARAMETER[\\"scale_factor\\",0.9996],\\n PARAMETER[\\"false_easting\\",500000],\\n PARAMETER[\\"false_northing\\",0],\\n UNIT[\\"metre\\",1,\\n AUTHORITY[\\"EPSG\\",\\"9001\\"]],\\n AXIS[\\"Easting\\",EAST],\\n AXIS[\\"Northing\\",NORTH],\\n AUTHORITY[\\"EPSG\\",\\"32615\\"]]"}, "geoTransform": [384219.440777, 600.0, 0.0, 4580160.51059, 0.0, -600.0], "size": [120, 122], "metadata": {"": {"id": "landsat:LC08:PRE:TOAR:meta_LC80260322016197_v1", "Corder": "RPCL"}}}', - ), # noqa - '{"bands": ["nir", "alpha"], "data_type": "Int32", "inputs": ["landsat:LC08:PRE:TOAR:meta_LC80260322016197_v1"], "resolution": 600}': ( - np.stack([np.zeros((122, 120), dtype="int32"), alpha]), - '{"files": [], "cornerCoordinates": {"upperRight": [456219.441, 4580160.511], "lowerLeft": [384219.441, 4506960.511], "lowerRight": [456219.441, 4506960.511], "upperLeft": [384219.441, 4580160.511], "center": [420219.441, 4543560.511]}, "wgs84Extent": {"type": "Polygon", "coordinates": [[[-94.3843159, 41.3646333], [-94.3705723, 40.7054251], [-93.5183204, 40.7123983], [-93.5235196, 41.3717692], [-94.3843159, 41.3646333]]]}, "driverShortName": "MEM", "driverLongName": "In Memory Raster", "bands": [{"description": {"default_range": [0, 10000], "wavelength_max": 878.85, "data_unit": "TOAR", "wavelength_center": 864.7, "color": "Gray", "dtype": "UInt16", "name_vendor": "B5", "id": "landsat:LC08:PRE:TOAR:nir", "nbits": 14, "wavelength_unit": "nm", "wavelength_min": 850.55, "processing_level": "TOAR", "product": "landsat:LC08:PRE:TOAR", "data_unit_description": "Top of atmosphere reflectance", "description": "Near Infrared", "tags": ["spectral", "nir", "near-infrared", "30m", "landsat"], "resolution_unit": "m", "data_description": "TOAR, 0-10000 is 0 - 100% reflective", "physical_range": [0.0, 1.0], "name_common": "nir", "vendor_order": 5, "name": "nir", "type": "spectral", "data_range": [0, 10000], "wavelength_fwhm": 28.3, "nodata": null, "resolution": 30}, "band": 1, "colorInterpretation": "Gray", "type": "UInt16", "block": [120, 1], "metadata": {"": {"NBITS": "14"}}}, {"description": {"product": "landsat:LC08:PRE:TOAR", "nbits": 1, "description": "Alpha (valid data)", "data_description": "0: nodata, 1: valid data", "tags": ["mask", "alpha", "15m", "landsat"], "color": "Alpha", "dtype": "UInt16", "data_range": [0, 1], "resolution": 15, "resolution_unit": "m", "data_unit_description": "unitless", "name_common": "alpha", "type": "mask", "nodata": null, "default_range": [0, 1], "id": "landsat:LC08:PRE:TOAR:alpha", "name": "alpha"}, "band": 2, "colorInterpretation": "Alpha", "type": "UInt16", "block": [120, 1], "metadata": {"": {"NBITS": "1"}}}], "coordinateSystem": {"wkt": "PROJCS[\\"WGS 84 / UTM zone 15N\\",\\n GEOGCS[\\"WGS 84\\",\\n DATUM[\\"WGS_1984\\",\\n SPHEROID[\\"WGS 84\\",6378137,298.257223563,\\n AUTHORITY[\\"EPSG\\",\\"7030\\"]],\\n AUTHORITY[\\"EPSG\\",\\"6326\\"]],\\n PRIMEM[\\"Greenwich\\",0,\\n AUTHORITY[\\"EPSG\\",\\"8901\\"]],\\n UNIT[\\"degree\\",0.0174532925199433,\\n AUTHORITY[\\"EPSG\\",\\"9122\\"]],\\n AUTHORITY[\\"EPSG\\",\\"4326\\"]],\\n PROJECTION[\\"Transverse_Mercator\\"],\\n PARAMETER[\\"latitude_of_origin\\",0],\\n PARAMETER[\\"central_meridian\\",-93],\\n PARAMETER[\\"scale_factor\\",0.9996],\\n PARAMETER[\\"false_easting\\",500000],\\n PARAMETER[\\"false_northing\\",0],\\n UNIT[\\"metre\\",1,\\n AUTHORITY[\\"EPSG\\",\\"9001\\"]],\\n AXIS[\\"Easting\\",EAST],\\n AXIS[\\"Northing\\",NORTH],\\n AUTHORITY[\\"EPSG\\",\\"32615\\"]]"}, "geoTransform": [384219.440777, 600.0, 0.0, 4580160.51059, 0.0, -600.0], "size": [120, 122], "metadata": {"": {"id": "landsat:LC08:PRE:TOAR:meta_LC80260322016197_v1", "Corder": "RPCL"}}}', - ), # noqa - '{"bands": ["nir", "red", "alpha"], "data_type": "UInt16", "inputs": ["landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1"], "resolution": 600}': ( - np.stack( - [ - np.zeros((122, 120), dtype="uint16"), - np.zeros((122, 120), dtype="uint16"), - alpha, - ] - ), - '{"files": [], "cornerCoordinates": {"upperRight": [456219.441, 4580160.511], "lowerLeft": [384219.441, 4506960.511], "lowerRight": [456219.441, 4506960.511], "upperLeft": [384219.441, 4580160.511], "center": [420219.441, 4543560.511]}, "wgs84Extent": {"type": "Polygon", "coordinates": [[[-94.3843159, 41.3646333], [-94.3705723, 40.7054251], [-93.5183204, 40.7123983], [-93.5235196, 41.3717692], [-94.3843159, 41.3646333]]]}, "driverShortName": "MEM", "driverLongName": "In Memory Raster", "bands": [{"description": {"default_range": [0, 10000], "wavelength_max": 878.85, "data_unit": "TOAR", "wavelength_center": 864.7, "color": "Gray", "dtype": "UInt16", "name_vendor": "B5", "id": "landsat:LC08:PRE:TOAR:nir", "nbits": 14, "wavelength_unit": "nm", "wavelength_min": 850.55, "processing_level": "TOAR", "product": "landsat:LC08:PRE:TOAR", "data_unit_description": "Top of atmosphere reflectance", "description": "Near Infrared", "tags": ["spectral", "nir", "near-infrared", "30m", "landsat"], "resolution_unit": "m", "data_description": "TOAR, 0-10000 is 0 - 100% reflective", "physical_range": [0.0, 1.0], "name_common": "nir", "vendor_order": 5, "name": "nir", "type": "spectral", "data_range": [0, 10000], "wavelength_fwhm": 28.3, "nodata": null, "resolution": 30}, "band": 1, "colorInterpretation": "Gray", "type": "UInt16", "block": [120, 1], "metadata": {"": {"NBITS": "14"}}}, {"description": {"default_range": [0, 4000], "wavelength_max": 673.35, "data_unit": "TOAR", "wavelength_center": 654.6, "color": "Red", "dtype": "UInt16", "name_vendor": "B4", "id": "landsat:LC08:PRE:TOAR:red", "nbits": 14, "wavelength_unit": "nm", "wavelength_min": 635.85, "processing_level": "TOAR", "product": "landsat:LC08:PRE:TOAR", "data_unit_description": "Top of atmosphere reflectance", "description": "Red, Pansharpened", "tags": ["spectral", "red", "15m", "landsat"], "resolution_unit": "m", "data_description": "TOAR, 0-10000 is 0 - 100% reflective", "physical_range": [0.0, 1.0], "name_common": "red", "vendor_order": 4, "name": "red", "type": "spectral", "data_range": [0, 10000], "wavelength_fwhm": 37.5, "nodata": null, "resolution": 15}, "band": 2, "colorInterpretation": "Red", "type": "UInt16", "block": [120, 1], "metadata": {"": {"NBITS": "14"}}}, {"description": {"product": "landsat:LC08:PRE:TOAR", "nbits": 1, "description": "Alpha (valid data)", "data_description": "0: nodata, 1: valid data", "tags": ["mask", "alpha", "15m", "landsat"], "color": "Alpha", "dtype": "UInt16", "data_range": [0, 1], "resolution": 15, "resolution_unit": "m", "data_unit_description": "unitless", "name_common": "alpha", "type": "mask", "nodata": null, "default_range": [0, 1], "id": "landsat:LC08:PRE:TOAR:alpha", "name": "alpha"}, "band": 3, "colorInterpretation": "Alpha", "type": "UInt16", "block": [120, 1], "metadata": {"": {"NBITS": "1"}}}], "coordinateSystem": {"wkt": "PROJCS[\\"WGS 84 / UTM zone 15N\\",\\n GEOGCS[\\"WGS 84\\",\\n DATUM[\\"WGS_1984\\",\\n SPHEROID[\\"WGS 84\\",6378137,298.257223563,\\n AUTHORITY[\\"EPSG\\",\\"7030\\"]],\\n AUTHORITY[\\"EPSG\\",\\"6326\\"]],\\n PRIMEM[\\"Greenwich\\",0,\\n AUTHORITY[\\"EPSG\\",\\"8901\\"]],\\n UNIT[\\"degree\\",0.0174532925199433,\\n AUTHORITY[\\"EPSG\\",\\"9122\\"]],\\n AUTHORITY[\\"EPSG\\",\\"4326\\"]],\\n PROJECTION[\\"Transverse_Mercator\\"],\\n PARAMETER[\\"latitude_of_origin\\",0],\\n PARAMETER[\\"central_meridian\\",-93],\\n PARAMETER[\\"scale_factor\\",0.9996],\\n PARAMETER[\\"false_easting\\",500000],\\n PARAMETER[\\"false_northing\\",0],\\n UNIT[\\"metre\\",1,\\n AUTHORITY[\\"EPSG\\",\\"9001\\"]],\\n AXIS[\\"Easting\\",EAST],\\n AXIS[\\"Northing\\",NORTH],\\n AUTHORITY[\\"EPSG\\",\\"32615\\"]]"}, "geoTransform": [384219.440777, 600.0, 0.0, 4580160.51059, 0.0, -600.0], "size": [120, 122], "metadata": {"": {"id": "landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1", "Corder": "RPCL"}}}', - ), # noqa - '{"bands": ["nir", "red", "alpha"], "data_type": "UInt16", "inputs": ["landsat:LC08:PRE:TOAR:meta_LC80260322016197_v1"], "resolution": 600}': ( - np.stack( - [ - np.zeros((122, 120), dtype="uint16"), - np.zeros((122, 120), dtype="uint16"), - alpha, - ] - ), - '{"files": [], "cornerCoordinates": {"upperRight": [456219.441, 4580160.511], "lowerLeft": [384219.441, 4506960.511], "lowerRight": [456219.441, 4506960.511], "upperLeft": [384219.441, 4580160.511], "center": [420219.441, 4543560.511]}, "wgs84Extent": {"type": "Polygon", "coordinates": [[[-94.3843159, 41.3646333], [-94.3705723, 40.7054251], [-93.5183204, 40.7123983], [-93.5235196, 41.3717692], [-94.3843159, 41.3646333]]]}, "driverShortName": "MEM", "driverLongName": "In Memory Raster", "bands": [{"description": {"default_range": [0, 10000], "wavelength_max": 878.85, "data_unit": "TOAR", "wavelength_center": 864.7, "color": "Gray", "dtype": "UInt16", "name_vendor": "B5", "id": "landsat:LC08:PRE:TOAR:nir", "nbits": 14, "wavelength_unit": "nm", "wavelength_min": 850.55, "processing_level": "TOAR", "product": "landsat:LC08:PRE:TOAR", "data_unit_description": "Top of atmosphere reflectance", "description": "Near Infrared", "tags": ["spectral", "nir", "near-infrared", "30m", "landsat"], "resolution_unit": "m", "data_description": "TOAR, 0-10000 is 0 - 100% reflective", "physical_range": [0.0, 1.0], "name_common": "nir", "vendor_order": 5, "name": "nir", "type": "spectral", "data_range": [0, 10000], "wavelength_fwhm": 28.3, "nodata": null, "resolution": 30}, "band": 1, "colorInterpretation": "Gray", "type": "UInt16", "block": [120, 1], "metadata": {"": {"NBITS": "14"}}}, {"description": {"default_range": [0, 4000], "wavelength_max": 673.35, "data_unit": "TOAR", "wavelength_center": 654.6, "color": "Red", "dtype": "UInt16", "name_vendor": "B4", "id": "landsat:LC08:PRE:TOAR:red", "nbits": 14, "wavelength_unit": "nm", "wavelength_min": 635.85, "processing_level": "TOAR", "product": "landsat:LC08:PRE:TOAR", "data_unit_description": "Top of atmosphere reflectance", "description": "Red, Pansharpened", "tags": ["spectral", "red", "15m", "landsat"], "resolution_unit": "m", "data_description": "TOAR, 0-10000 is 0 - 100% reflective", "physical_range": [0.0, 1.0], "name_common": "red", "vendor_order": 4, "name": "red", "type": "spectral", "data_range": [0, 10000], "wavelength_fwhm": 37.5, "nodata": null, "resolution": 15}, "band": 2, "colorInterpretation": "Red", "type": "UInt16", "block": [120, 1], "metadata": {"": {"NBITS": "14"}}}, {"description": {"product": "landsat:LC08:PRE:TOAR", "nbits": 1, "description": "Alpha (valid data)", "data_description": "0: nodata, 1: valid data", "tags": ["mask", "alpha", "15m", "landsat"], "color": "Alpha", "dtype": "UInt16", "data_range": [0, 1], "resolution": 15, "resolution_unit": "m", "data_unit_description": "unitless", "name_common": "alpha", "type": "mask", "nodata": null, "default_range": [0, 1], "id": "landsat:LC08:PRE:TOAR:alpha", "name": "alpha"}, "band": 3, "colorInterpretation": "Alpha", "type": "UInt16", "block": [120, 1], "metadata": {"": {"NBITS": "1"}}}], "coordinateSystem": {"wkt": "PROJCS[\\"WGS 84 / UTM zone 15N\\",\\n GEOGCS[\\"WGS 84\\",\\n DATUM[\\"WGS_1984\\",\\n SPHEROID[\\"WGS 84\\",6378137,298.257223563,\\n AUTHORITY[\\"EPSG\\",\\"7030\\"]],\\n AUTHORITY[\\"EPSG\\",\\"6326\\"]],\\n PRIMEM[\\"Greenwich\\",0,\\n AUTHORITY[\\"EPSG\\",\\"8901\\"]],\\n UNIT[\\"degree\\",0.0174532925199433,\\n AUTHORITY[\\"EPSG\\",\\"9122\\"]],\\n AUTHORITY[\\"EPSG\\",\\"4326\\"]],\\n PROJECTION[\\"Transverse_Mercator\\"],\\n PARAMETER[\\"latitude_of_origin\\",0],\\n PARAMETER[\\"central_meridian\\",-93],\\n PARAMETER[\\"scale_factor\\",0.9996],\\n PARAMETER[\\"false_easting\\",500000],\\n PARAMETER[\\"false_northing\\",0],\\n UNIT[\\"metre\\",1,\\n AUTHORITY[\\"EPSG\\",\\"9001\\"]],\\n AXIS[\\"Easting\\",EAST],\\n AXIS[\\"Northing\\",NORTH],\\n AUTHORITY[\\"EPSG\\",\\"32615\\"]]"}, "geoTransform": [384219.440777, 600.0, 0.0, 4580160.51059, 0.0, -600.0], "size": [120, 122], "metadata": {"": {"id": "landsat:LC08:PRE:TOAR:meta_LC80260322016197_v1", "Corder": "RPCL"}}}', - ), # noqa - '{"bands": ["nir"], "data_type": "UInt16", "inputs": ["landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1"], "resolution": 600}': ( - np.zeros((122, 120), dtype="uint16"), - '{"files": [], "cornerCoordinates": {"upperRight": [456219.441, 4580160.511], "lowerLeft": [384219.441, 4506960.511], "lowerRight": [456219.441, 4506960.511], "upperLeft": [384219.441, 4580160.511], "center": [420219.441, 4543560.511]}, "wgs84Extent": {"type": "Polygon", "coordinates": [[[-94.3843159, 41.3646333], [-94.3705723, 40.7054251], [-93.5183204, 40.7123983], [-93.5235196, 41.3717692], [-94.3843159, 41.3646333]]]}, "driverShortName": "MEM", "driverLongName": "In Memory Raster", "bands": [{"band": 1, "description": {"wavelength_max": 878.85, "data_unit_description": "Top of atmosphere reflectance", "data_unit": "TOAR", "description": "Near Infrared", "tags": ["spectral", "nir", "near-infrared", "30m", "landsat"], "color": "Gray", "dtype": "UInt16", "wavelength_min": 850.55, "name_vendor": "B5", "product": "landsat:LC08:PRE:TOAR", "data_description": "TOAR, 0-10000 is 0 - 100% reflective", "physical_range": [0.0, 1.0], "name_common": "nir", "id": "landsat:LC08:PRE:TOAR:nir", "vendor_order": 5, "nbits": 14, "type": "spectral", "name": "nir", "wavelength_center": 864.7, "data_range": [0, 10000], "resolution_unit": "m", "wavelength_unit": "nm", "resolution": 30, "wavelength_fwhm": 28.3, "nodata": null, "default_range": [0, 10000], "processing_level": "TOAR"}, "colorInterpretation": "Gray", "type": "UInt16", "block": [120, 1], "metadata": {"": {"NBITS": "14"}}}], "coordinateSystem": {"wkt": "PROJCS[\\"WGS 84 / UTM zone 15N\\",\\n GEOGCS[\\"WGS 84\\",\\n DATUM[\\"WGS_1984\\",\\n SPHEROID[\\"WGS 84\\",6378137,298.257223563,\\n AUTHORITY[\\"EPSG\\",\\"7030\\"]],\\n AUTHORITY[\\"EPSG\\",\\"6326\\"]],\\n PRIMEM[\\"Greenwich\\",0,\\n AUTHORITY[\\"EPSG\\",\\"8901\\"]],\\n UNIT[\\"degree\\",0.0174532925199433,\\n AUTHORITY[\\"EPSG\\",\\"9122\\"]],\\n AUTHORITY[\\"EPSG\\",\\"4326\\"]],\\n PROJECTION[\\"Transverse_Mercator\\"],\\n PARAMETER[\\"latitude_of_origin\\",0],\\n PARAMETER[\\"central_meridian\\",-93],\\n PARAMETER[\\"scale_factor\\",0.9996],\\n PARAMETER[\\"false_easting\\",500000],\\n PARAMETER[\\"false_northing\\",0],\\n UNIT[\\"metre\\",1,\\n AUTHORITY[\\"EPSG\\",\\"9001\\"]],\\n AXIS[\\"Easting\\",EAST],\\n AXIS[\\"Northing\\",NORTH],\\n AUTHORITY[\\"EPSG\\",\\"32615\\"]]"}, "geoTransform": [384219.440777, 600.0, 0.0, 4580160.51059, 0.0, -600.0], "metadata": {"": {"id": "landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1", "Corder": "RPCL"}}, "size": [120, 122]}', - ), # noqa - '{"bands": ["nir"], "data_type": "UInt16", "inputs": ["landsat:LC08:PRE:TOAR:meta_LC80260322016197_v1"], "resolution": 600}': ( - np.zeros((122, 120), dtype="uint16"), - '{"files": [], "cornerCoordinates": {"upperRight": [456219.441, 4580160.511], "lowerLeft": [384219.441, 4506960.511], "lowerRight": [456219.441, 4506960.511], "upperLeft": [384219.441, 4580160.511], "center": [420219.441, 4543560.511]}, "wgs84Extent": {"type": "Polygon", "coordinates": [[[-94.3843159, 41.3646333], [-94.3705723, 40.7054251], [-93.5183204, 40.7123983], [-93.5235196, 41.3717692], [-94.3843159, 41.3646333]]]}, "driverShortName": "MEM", "driverLongName": "In Memory Raster", "bands": [{"band": 1, "description": {"wavelength_max": 878.85, "data_unit_description": "Top of atmosphere reflectance", "data_unit": "TOAR", "description": "Near Infrared", "tags": ["spectral", "nir", "near-infrared", "30m", "landsat"], "color": "Gray", "dtype": "UInt16", "wavelength_min": 850.55, "name_vendor": "B5", "product": "landsat:LC08:PRE:TOAR", "data_description": "TOAR, 0-10000 is 0 - 100% reflective", "physical_range": [0.0, 1.0], "name_common": "nir", "id": "landsat:LC08:PRE:TOAR:nir", "vendor_order": 5, "nbits": 14, "type": "spectral", "name": "nir", "wavelength_center": 864.7, "data_range": [0, 10000], "resolution_unit": "m", "wavelength_unit": "nm", "resolution": 30, "wavelength_fwhm": 28.3, "nodata": null, "default_range": [0, 10000], "processing_level": "TOAR"}, "colorInterpretation": "Gray", "type": "UInt16", "block": [120, 1], "metadata": {"": {"NBITS": "14"}}}], "coordinateSystem": {"wkt": "PROJCS[\\"WGS 84 / UTM zone 15N\\",\\n GEOGCS[\\"WGS 84\\",\\n DATUM[\\"WGS_1984\\",\\n SPHEROID[\\"WGS 84\\",6378137,298.257223563,\\n AUTHORITY[\\"EPSG\\",\\"7030\\"]],\\n AUTHORITY[\\"EPSG\\",\\"6326\\"]],\\n PRIMEM[\\"Greenwich\\",0,\\n AUTHORITY[\\"EPSG\\",\\"8901\\"]],\\n UNIT[\\"degree\\",0.0174532925199433,\\n AUTHORITY[\\"EPSG\\",\\"9122\\"]],\\n AUTHORITY[\\"EPSG\\",\\"4326\\"]],\\n PROJECTION[\\"Transverse_Mercator\\"],\\n PARAMETER[\\"latitude_of_origin\\",0],\\n PARAMETER[\\"central_meridian\\",-93],\\n PARAMETER[\\"scale_factor\\",0.9996],\\n PARAMETER[\\"false_easting\\",500000],\\n PARAMETER[\\"false_northing\\",0],\\n UNIT[\\"metre\\",1,\\n AUTHORITY[\\"EPSG\\",\\"9001\\"]],\\n AXIS[\\"Easting\\",EAST],\\n AXIS[\\"Northing\\",NORTH],\\n AUTHORITY[\\"EPSG\\",\\"32615\\"]]"}, "geoTransform": [384219.440777, 600.0, 0.0, 4580160.51059, 0.0, -600.0], "metadata": {"": {"id": "landsat:LC08:PRE:TOAR:meta_LC80260322016197_v1", "Corder": "RPCL"}}, "size": [120, 122]}', - ), # noqa - '{"bands": ["nir", "alpha"], "data_type": "UInt16", "inputs": ["landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1", "landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1"], "resolution": 600}': ( - np.stack([np.zeros((122, 120), dtype="uint16"), alpha]), - '{"files": [], "cornerCoordinates": {"upperRight": [456219.441, 4580160.511], "lowerLeft": [384219.441, 4506960.511], "lowerRight": [456219.441, 4506960.511], "upperLeft": [384219.441, 4580160.511], "center": [420219.441, 4543560.511]}, "wgs84Extent": {"type": "Polygon", "coordinates": [[[-94.3843159, 41.3646333], [-94.3705723, 40.7054251], [-93.5183204, 40.7123983], [-93.5235196, 41.3717692], [-94.3843159, 41.3646333]]]}, "driverShortName": "MEM", "driverLongName": "In Memory Raster", "bands": [{"description": {"default_range": [0, 10000], "wavelength_max": 878.85, "data_unit": "TOAR", "wavelength_center": 864.7, "color": "Gray", "dtype": "UInt16", "name_vendor": "B5", "id": "landsat:LC08:PRE:TOAR:nir", "nbits": 14, "wavelength_unit": "nm", "wavelength_min": 850.55, "processing_level": "TOAR", "product": "landsat:LC08:PRE:TOAR", "data_unit_description": "Top of atmosphere reflectance", "description": "Near Infrared", "tags": ["spectral", "nir", "near-infrared", "30m", "landsat"], "resolution_unit": "m", "data_description": "TOAR, 0-10000 is 0 - 100% reflective", "physical_range": [0.0, 1.0], "name_common": "nir", "vendor_order": 5, "name": "nir", "type": "spectral", "data_range": [0, 10000], "wavelength_fwhm": 28.3, "nodata": null, "resolution": 30}, "band": 1, "colorInterpretation": "Gray", "type": "UInt16", "block": [120, 1], "metadata": {"": {"NBITS": "14"}}}, {"description": {"product": "landsat:LC08:PRE:TOAR", "nbits": 1, "description": "Alpha (valid data)", "data_description": "0: nodata, 1: valid data", "tags": ["mask", "alpha", "15m", "landsat"], "color": "Alpha", "dtype": "UInt16", "data_range": [0, 1], "resolution": 15, "resolution_unit": "m", "data_unit_description": "unitless", "name_common": "alpha", "type": "mask", "nodata": null, "default_range": [0, 1], "id": "landsat:LC08:PRE:TOAR:alpha", "name": "alpha"}, "band": 2, "colorInterpretation": "Alpha", "type": "UInt16", "block": [120, 1], "metadata": {"": {"NBITS": "1"}}}], "coordinateSystem": {"wkt": "PROJCS[\\"WGS 84 / UTM zone 15N\\",\\n GEOGCS[\\"WGS 84\\",\\n DATUM[\\"WGS_1984\\",\\n SPHEROID[\\"WGS 84\\",6378137,298.257223563,\\n AUTHORITY[\\"EPSG\\",\\"7030\\"]],\\n AUTHORITY[\\"EPSG\\",\\"6326\\"]],\\n PRIMEM[\\"Greenwich\\",0,\\n AUTHORITY[\\"EPSG\\",\\"8901\\"]],\\n UNIT[\\"degree\\",0.0174532925199433,\\n AUTHORITY[\\"EPSG\\",\\"9122\\"]],\\n AUTHORITY[\\"EPSG\\",\\"4326\\"]],\\n PROJECTION[\\"Transverse_Mercator\\"],\\n PARAMETER[\\"latitude_of_origin\\",0],\\n PARAMETER[\\"central_meridian\\",-93],\\n PARAMETER[\\"scale_factor\\",0.9996],\\n PARAMETER[\\"false_easting\\",500000],\\n PARAMETER[\\"false_northing\\",0],\\n UNIT[\\"metre\\",1,\\n AUTHORITY[\\"EPSG\\",\\"9001\\"]],\\n AXIS[\\"Easting\\",EAST],\\n AXIS[\\"Northing\\",NORTH],\\n AUTHORITY[\\"EPSG\\",\\"32615\\"]]"}, "geoTransform": [384219.440777, 600.0, 0.0, 4580160.51059, 0.0, -600.0], "size": [120, 122], "metadata": {"": {"id": "landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1", "Corder": "RPCL"}}}', - ), # noqa - '{"bands": ["nir", "alpha"], "data_type": "UInt16", "inputs": ["landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1", "landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1", "landsat:LC08:PRE:TOAR:meta_LC80260322016197_v1"], "resolution": 600}': ( - np.stack([np.zeros((122, 120), dtype="uint16"), alpha]), - '{"files": [], "cornerCoordinates": {"upperRight": [456219.441, 4580160.511], "lowerLeft": [384219.441, 4506960.511], "lowerRight": [456219.441, 4506960.511], "upperLeft": [384219.441, 4580160.511], "center": [420219.441, 4543560.511]}, "wgs84Extent": {"type": "Polygon", "coordinates": [[[-94.3843159, 41.3646333], [-94.3705723, 40.7054251], [-93.5183204, 40.7123983], [-93.5235196, 41.3717692], [-94.3843159, 41.3646333]]]}, "driverShortName": "MEM", "driverLongName": "In Memory Raster", "bands": [{"description": {"default_range": [0, 10000], "wavelength_max": 878.85, "data_unit": "TOAR", "wavelength_center": 864.7, "color": "Gray", "dtype": "UInt16", "name_vendor": "B5", "id": "landsat:LC08:PRE:TOAR:nir", "nbits": 14, "wavelength_unit": "nm", "wavelength_min": 850.55, "processing_level": "TOAR", "product": "landsat:LC08:PRE:TOAR", "data_unit_description": "Top of atmosphere reflectance", "description": "Near Infrared", "tags": ["spectral", "nir", "near-infrared", "30m", "landsat"], "resolution_unit": "m", "data_description": "TOAR, 0-10000 is 0 - 100% reflective", "physical_range": [0.0, 1.0], "name_common": "nir", "vendor_order": 5, "name": "nir", "type": "spectral", "data_range": [0, 10000], "wavelength_fwhm": 28.3, "nodata": null, "resolution": 30}, "band": 1, "colorInterpretation": "Gray", "type": "UInt16", "block": [120, 1], "metadata": {"": {"NBITS": "14"}}}, {"description": {"product": "landsat:LC08:PRE:TOAR", "nbits": 1, "description": "Alpha (valid data)", "data_description": "0: nodata, 1: valid data", "tags": ["mask", "alpha", "15m", "landsat"], "color": "Alpha", "dtype": "UInt16", "data_range": [0, 1], "resolution": 15, "resolution_unit": "m", "data_unit_description": "unitless", "name_common": "alpha", "type": "mask", "nodata": null, "default_range": [0, 1], "id": "landsat:LC08:PRE:TOAR:alpha", "name": "alpha"}, "band": 2, "colorInterpretation": "Alpha", "type": "UInt16", "block": [120, 1], "metadata": {"": {"NBITS": "1"}}}], "coordinateSystem": {"wkt": "PROJCS[\\"WGS 84 / UTM zone 15N\\",\\n GEOGCS[\\"WGS 84\\",\\n DATUM[\\"WGS_1984\\",\\n SPHEROID[\\"WGS 84\\",6378137,298.257223563,\\n AUTHORITY[\\"EPSG\\",\\"7030\\"]],\\n AUTHORITY[\\"EPSG\\",\\"6326\\"]],\\n PRIMEM[\\"Greenwich\\",0,\\n AUTHORITY[\\"EPSG\\",\\"8901\\"]],\\n UNIT[\\"degree\\",0.0174532925199433,\\n AUTHORITY[\\"EPSG\\",\\"9122\\"]],\\n AUTHORITY[\\"EPSG\\",\\"4326\\"]],\\n PROJECTION[\\"Transverse_Mercator\\"],\\n PARAMETER[\\"latitude_of_origin\\",0],\\n PARAMETER[\\"central_meridian\\",-93],\\n PARAMETER[\\"scale_factor\\",0.9996],\\n PARAMETER[\\"false_easting\\",500000],\\n PARAMETER[\\"false_northing\\",0],\\n UNIT[\\"metre\\",1,\\n AUTHORITY[\\"EPSG\\",\\"9001\\"]],\\n AXIS[\\"Easting\\",EAST],\\n AXIS[\\"Northing\\",NORTH],\\n AUTHORITY[\\"EPSG\\",\\"32615\\"]]"}, "geoTransform": [384219.440777, 600.0, 0.0, 4580160.51059, 0.0, -600.0], "size": [120, 122], "metadata": {"": {"id": "*", "Corder": "RPCL"}}}', - ), # noqa - '{"bands": ["nir", "alpha"], "data_type": "UInt16", "inputs": ["landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1", "landsat:LC08:PRE:TOAR:meta_LC80260322016197_v1"], "resolution": 600}': ( - np.stack([np.zeros((122, 120), dtype="uint16"), alpha]), - '{"files": [], "cornerCoordinates": {"upperRight": [456219.441, 4580160.511], "lowerLeft": [384219.441, 4506960.511], "lowerRight": [456219.441, 4506960.511], "upperLeft": [384219.441, 4580160.511], "center": [420219.441, 4543560.511]}, "wgs84Extent": {"type": "Polygon", "coordinates": [[[-94.3843159, 41.3646333], [-94.3705723, 40.7054251], [-93.5183204, 40.7123983], [-93.5235196, 41.3717692], [-94.3843159, 41.3646333]]]}, "driverShortName": "MEM", "driverLongName": "In Memory Raster", "bands": [{"description": {"default_range": [0, 10000], "wavelength_max": 878.85, "data_unit": "TOAR", "wavelength_center": 864.7, "color": "Gray", "dtype": "UInt16", "name_vendor": "B5", "id": "landsat:LC08:PRE:TOAR:nir", "nbits": 14, "wavelength_unit": "nm", "wavelength_min": 850.55, "processing_level": "TOAR", "product": "landsat:LC08:PRE:TOAR", "data_unit_description": "Top of atmosphere reflectance", "description": "Near Infrared", "tags": ["spectral", "nir", "near-infrared", "30m", "landsat"], "resolution_unit": "m", "data_description": "TOAR, 0-10000 is 0 - 100% reflective", "physical_range": [0.0, 1.0], "name_common": "nir", "vendor_order": 5, "name": "nir", "type": "spectral", "data_range": [0, 10000], "wavelength_fwhm": 28.3, "nodata": null, "resolution": 30}, "band": 1, "colorInterpretation": "Gray", "type": "UInt16", "block": [120, 1], "metadata": {"": {"NBITS": "14"}}}, {"description": {"product": "landsat:LC08:PRE:TOAR", "nbits": 1, "description": "Alpha (valid data)", "data_description": "0: nodata, 1: valid data", "tags": ["mask", "alpha", "15m", "landsat"], "color": "Alpha", "dtype": "UInt16", "data_range": [0, 1], "resolution": 15, "resolution_unit": "m", "data_unit_description": "unitless", "name_common": "alpha", "type": "mask", "nodata": null, "default_range": [0, 1], "id": "landsat:LC08:PRE:TOAR:alpha", "name": "alpha"}, "band": 2, "colorInterpretation": "Alpha", "type": "UInt16", "block": [120, 1], "metadata": {"": {"NBITS": "1"}}}], "coordinateSystem": {"wkt": "PROJCS[\\"WGS 84 / UTM zone 15N\\",\\n GEOGCS[\\"WGS 84\\",\\n DATUM[\\"WGS_1984\\",\\n SPHEROID[\\"WGS 84\\",6378137,298.257223563,\\n AUTHORITY[\\"EPSG\\",\\"7030\\"]],\\n AUTHORITY[\\"EPSG\\",\\"6326\\"]],\\n PRIMEM[\\"Greenwich\\",0,\\n AUTHORITY[\\"EPSG\\",\\"8901\\"]],\\n UNIT[\\"degree\\",0.0174532925199433,\\n AUTHORITY[\\"EPSG\\",\\"9122\\"]],\\n AUTHORITY[\\"EPSG\\",\\"4326\\"]],\\n PROJECTION[\\"Transverse_Mercator\\"],\\n PARAMETER[\\"latitude_of_origin\\",0],\\n PARAMETER[\\"central_meridian\\",-93],\\n PARAMETER[\\"scale_factor\\",0.9996],\\n PARAMETER[\\"false_easting\\",500000],\\n PARAMETER[\\"false_northing\\",0],\\n UNIT[\\"metre\\",1,\\n AUTHORITY[\\"EPSG\\",\\"9001\\"]],\\n AXIS[\\"Easting\\",EAST],\\n AXIS[\\"Northing\\",NORTH],\\n AUTHORITY[\\"EPSG\\",\\"32615\\"]]"}, "geoTransform": [384219.440777, 600.0, 0.0, 4580160.51059, 0.0, -600.0], "size": [120, 122], "metadata": {"": {"id": "*", "Corder": "RPCL"}}}', - ), # noqa - '{"bands": ["nir", "alpha"], "data_type": "Int32", "inputs": ["landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1", "landsat:LC08:PRE:TOAR:meta_LC80260322016197_v1"], "resolution": 600}': ( - np.stack([np.zeros((122, 120), dtype="int32"), alpha]), - '{"files": [], "cornerCoordinates": {"upperRight": [456219.441, 4580160.511], "lowerLeft": [384219.441, 4506960.511], "lowerRight": [456219.441, 4506960.511], "upperLeft": [384219.441, 4580160.511], "center": [420219.441, 4543560.511]}, "wgs84Extent": {"type": "Polygon", "coordinates": [[[-94.3843159, 41.3646333], [-94.3705723, 40.7054251], [-93.5183204, 40.7123983], [-93.5235196, 41.3717692], [-94.3843159, 41.3646333]]]}, "driverShortName": "MEM", "driverLongName": "In Memory Raster", "bands": [{"description": {"default_range": [0, 10000], "wavelength_max": 878.85, "data_unit": "TOAR", "wavelength_center": 864.7, "color": "Gray", "dtype": "UInt16", "name_vendor": "B5", "id": "landsat:LC08:PRE:TOAR:nir", "nbits": 14, "wavelength_unit": "nm", "wavelength_min": 850.55, "processing_level": "TOAR", "product": "landsat:LC08:PRE:TOAR", "data_unit_description": "Top of atmosphere reflectance", "description": "Near Infrared", "tags": ["spectral", "nir", "near-infrared", "30m", "landsat"], "resolution_unit": "m", "data_description": "TOAR, 0-10000 is 0 - 100% reflective", "physical_range": [0.0, 1.0], "name_common": "nir", "vendor_order": 5, "name": "nir", "type": "spectral", "data_range": [0, 10000], "wavelength_fwhm": 28.3, "nodata": null, "resolution": 30}, "band": 1, "colorInterpretation": "Gray", "type": "UInt16", "block": [120, 1], "metadata": {"": {"NBITS": "14"}}}, {"description": {"product": "landsat:LC08:PRE:TOAR", "nbits": 1, "description": "Alpha (valid data)", "data_description": "0: nodata, 1: valid data", "tags": ["mask", "alpha", "15m", "landsat"], "color": "Alpha", "dtype": "UInt16", "data_range": [0, 1], "resolution": 15, "resolution_unit": "m", "data_unit_description": "unitless", "name_common": "alpha", "type": "mask", "nodata": null, "default_range": [0, 1], "id": "landsat:LC08:PRE:TOAR:alpha", "name": "alpha"}, "band": 2, "colorInterpretation": "Alpha", "type": "UInt16", "block": [120, 1], "metadata": {"": {"NBITS": "1"}}}], "coordinateSystem": {"wkt": "PROJCS[\\"WGS 84 / UTM zone 15N\\",\\n GEOGCS[\\"WGS 84\\",\\n DATUM[\\"WGS_1984\\",\\n SPHEROID[\\"WGS 84\\",6378137,298.257223563,\\n AUTHORITY[\\"EPSG\\",\\"7030\\"]],\\n AUTHORITY[\\"EPSG\\",\\"6326\\"]],\\n PRIMEM[\\"Greenwich\\",0,\\n AUTHORITY[\\"EPSG\\",\\"8901\\"]],\\n UNIT[\\"degree\\",0.0174532925199433,\\n AUTHORITY[\\"EPSG\\",\\"9122\\"]],\\n AUTHORITY[\\"EPSG\\",\\"4326\\"]],\\n PROJECTION[\\"Transverse_Mercator\\"],\\n PARAMETER[\\"latitude_of_origin\\",0],\\n PARAMETER[\\"central_meridian\\",-93],\\n PARAMETER[\\"scale_factor\\",0.9996],\\n PARAMETER[\\"false_easting\\",500000],\\n PARAMETER[\\"false_northing\\",0],\\n UNIT[\\"metre\\",1,\\n AUTHORITY[\\"EPSG\\",\\"9001\\"]],\\n AXIS[\\"Easting\\",EAST],\\n AXIS[\\"Northing\\",NORTH],\\n AUTHORITY[\\"EPSG\\",\\"32615\\"]]"}, "geoTransform": [384219.440777, 600.0, 0.0, 4580160.51059, 0.0, -600.0], "size": [120, 122], "metadata": {"": {"id": "*", "Corder": "RPCL"}}}', - ), # noqa - '{"bands": ["nir", "red", "alpha"], "data_type": "UInt16", "inputs": ["landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1", "landsat:LC08:PRE:TOAR:meta_LC80260322016197_v1"], "resolution": 600}': ( - np.stack( - [ - np.zeros((122, 120), dtype="uint16"), - np.zeros((122, 120), dtype="uint16"), - alpha, - ] - ), - '{"files": [], "cornerCoordinates": {"upperRight": [456219.441, 4580160.511], "lowerLeft": [384219.441, 4506960.511], "lowerRight": [456219.441, 4506960.511], "upperLeft": [384219.441, 4580160.511], "center": [420219.441, 4543560.511]}, "wgs84Extent": {"type": "Polygon", "coordinates": [[[-94.3843159, 41.3646333], [-94.3705723, 40.7054251], [-93.5183204, 40.7123983], [-93.5235196, 41.3717692], [-94.3843159, 41.3646333]]]}, "driverShortName": "MEM", "driverLongName": "In Memory Raster", "bands": [{"description": {"default_range": [0, 10000], "wavelength_max": 878.85, "data_unit": "TOAR", "wavelength_center": 864.7, "color": "Gray", "dtype": "UInt16", "name_vendor": "B5", "id": "landsat:LC08:PRE:TOAR:nir", "nbits": 14, "wavelength_unit": "nm", "wavelength_min": 850.55, "processing_level": "TOAR", "product": "landsat:LC08:PRE:TOAR", "data_unit_description": "Top of atmosphere reflectance", "description": "Near Infrared", "tags": ["spectral", "nir", "near-infrared", "30m", "landsat"], "resolution_unit": "m", "data_description": "TOAR, 0-10000 is 0 - 100% reflective", "physical_range": [0.0, 1.0], "name_common": "nir", "vendor_order": 5, "name": "nir", "type": "spectral", "data_range": [0, 10000], "wavelength_fwhm": 28.3, "nodata": null, "resolution": 30}, "band": 1, "colorInterpretation": "Gray", "type": "UInt16", "block": [120, 1], "metadata": {"": {"NBITS": "14"}}}, {"description": {"default_range": [0, 4000], "wavelength_max": 673.35, "data_unit": "TOAR", "wavelength_center": 654.6, "color": "Red", "dtype": "UInt16", "name_vendor": "B4", "id": "landsat:LC08:PRE:TOAR:red", "nbits": 14, "wavelength_unit": "nm", "wavelength_min": 635.85, "processing_level": "TOAR", "product": "landsat:LC08:PRE:TOAR", "data_unit_description": "Top of atmosphere reflectance", "description": "Red, Pansharpened", "tags": ["spectral", "red", "15m", "landsat"], "resolution_unit": "m", "data_description": "TOAR, 0-10000 is 0 - 100% reflective", "physical_range": [0.0, 1.0], "name_common": "red", "vendor_order": 4, "name": "red", "type": "spectral", "data_range": [0, 10000], "wavelength_fwhm": 37.5, "nodata": null, "resolution": 15}, "band": 2, "colorInterpretation": "Red", "type": "UInt16", "block": [120, 1], "metadata": {"": {"NBITS": "14"}}}, {"description": {"product": "landsat:LC08:PRE:TOAR", "nbits": 1, "description": "Alpha (valid data)", "data_description": "0: nodata, 1: valid data", "tags": ["mask", "alpha", "15m", "landsat"], "color": "Alpha", "dtype": "UInt16", "data_range": [0, 1], "resolution": 15, "resolution_unit": "m", "data_unit_description": "unitless", "name_common": "alpha", "type": "mask", "nodata": null, "default_range": [0, 1], "id": "landsat:LC08:PRE:TOAR:alpha", "name": "alpha"}, "band": 3, "colorInterpretation": "Alpha", "type": "UInt16", "block": [120, 1], "metadata": {"": {"NBITS": "1"}}}], "coordinateSystem": {"wkt": "PROJCS[\\"WGS 84 / UTM zone 15N\\",\\n GEOGCS[\\"WGS 84\\",\\n DATUM[\\"WGS_1984\\",\\n SPHEROID[\\"WGS 84\\",6378137,298.257223563,\\n AUTHORITY[\\"EPSG\\",\\"7030\\"]],\\n AUTHORITY[\\"EPSG\\",\\"6326\\"]],\\n PRIMEM[\\"Greenwich\\",0,\\n AUTHORITY[\\"EPSG\\",\\"8901\\"]],\\n UNIT[\\"degree\\",0.0174532925199433,\\n AUTHORITY[\\"EPSG\\",\\"9122\\"]],\\n AUTHORITY[\\"EPSG\\",\\"4326\\"]],\\n PROJECTION[\\"Transverse_Mercator\\"],\\n PARAMETER[\\"latitude_of_origin\\",0],\\n PARAMETER[\\"central_meridian\\",-93],\\n PARAMETER[\\"scale_factor\\",0.9996],\\n PARAMETER[\\"false_easting\\",500000],\\n PARAMETER[\\"false_northing\\",0],\\n UNIT[\\"metre\\",1,\\n AUTHORITY[\\"EPSG\\",\\"9001\\"]],\\n AXIS[\\"Easting\\",EAST],\\n AXIS[\\"Northing\\",NORTH],\\n AUTHORITY[\\"EPSG\\",\\"32615\\"]]"}, "geoTransform": [384219.440777, 600.0, 0.0, 4580160.51059, 0.0, -600.0], "size": [120, 122], "metadata": {"": {"id": "*", "Corder": "RPCL"}}}', - ), # noqa - '{"bands": ["nir", "red"], "data_type": "UInt16", "inputs": ["landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1", "landsat:LC08:PRE:TOAR:meta_LC80260322016197_v1"], "resolution": 600}': ( - np.stack( - [np.zeros((122, 120), dtype="uint16"), np.zeros((122, 120), dtype="uint16")] - ), - '{"files": [], "cornerCoordinates": {"upperRight": [456219.441, 4580160.511], "lowerLeft": [384219.441, 4506960.511], "lowerRight": [456219.441, 4506960.511], "upperLeft": [384219.441, 4580160.511], "center": [420219.441, 4543560.511]}, "wgs84Extent": {"type": "Polygon", "coordinates": [[[-94.3843159, 41.3646333], [-94.3705723, 40.7054251], [-93.5183204, 40.7123983], [-93.5235196, 41.3717692], [-94.3843159, 41.3646333]]]}, "driverShortName": "MEM", "driverLongName": "In Memory Raster", "bands": [{"description": {"default_range": [0, 10000], "wavelength_max": 878.85, "data_unit": "TOAR", "wavelength_center": 864.7, "color": "Gray", "dtype": "UInt16", "name_vendor": "B5", "id": "landsat:LC08:PRE:TOAR:nir", "nbits": 14, "wavelength_unit": "nm", "wavelength_min": 850.55, "processing_level": "TOAR", "product": "landsat:LC08:PRE:TOAR", "data_unit_description": "Top of atmosphere reflectance", "description": "Near Infrared", "tags": ["spectral", "nir", "near-infrared", "30m", "landsat"], "resolution_unit": "m", "data_description": "TOAR, 0-10000 is 0 - 100% reflective", "physical_range": [0.0, 1.0], "name_common": "nir", "vendor_order": 5, "name": "nir", "type": "spectral", "data_range": [0, 10000], "wavelength_fwhm": 28.3, "nodata": null, "resolution": 30}, "band": 1, "colorInterpretation": "Gray", "type": "UInt16", "block": [120, 1], "metadata": {"": {"NBITS": "14"}}}, {"description": {"default_range": [0, 4000], "wavelength_max": 673.35, "data_unit": "TOAR", "wavelength_center": 654.6, "color": "Red", "dtype": "UInt16", "name_vendor": "B4", "id": "landsat:LC08:PRE:TOAR:red", "nbits": 14, "wavelength_unit": "nm", "wavelength_min": 635.85, "processing_level": "TOAR", "product": "landsat:LC08:PRE:TOAR", "data_unit_description": "Top of atmosphere reflectance", "description": "Red, Pansharpened", "tags": ["spectral", "red", "15m", "landsat"], "resolution_unit": "m", "data_description": "TOAR, 0-10000 is 0 - 100% reflective", "physical_range": [0.0, 1.0], "name_common": "red", "vendor_order": 4, "name": "red", "type": "spectral", "data_range": [0, 10000], "wavelength_fwhm": 37.5, "nodata": null, "resolution": 15}, "band": 2, "colorInterpretation": "Red", "type": "UInt16", "block": [120, 1], "metadata": {"": {"NBITS": "14"}}}, {"description": {"product": "landsat:LC08:PRE:TOAR", "nbits": 1, "description": "Alpha (valid data)", "data_description": "0: nodata, 1: valid data", "tags": ["mask", "alpha", "15m", "landsat"], "color": "Alpha", "dtype": "UInt16", "data_range": [0, 1], "resolution": 15, "resolution_unit": "m", "data_unit_description": "unitless", "name_common": "alpha", "type": "mask", "nodata": null, "default_range": [0, 1], "id": "landsat:LC08:PRE:TOAR:alpha", "name": "alpha"}, "band": 3, "colorInterpretation": "Alpha", "type": "UInt16", "block": [120, 1], "metadata": {"": {"NBITS": "1"}}}], "coordinateSystem": {"wkt": "PROJCS[\\"WGS 84 / UTM zone 15N\\",\\n GEOGCS[\\"WGS 84\\",\\n DATUM[\\"WGS_1984\\",\\n SPHEROID[\\"WGS 84\\",6378137,298.257223563,\\n AUTHORITY[\\"EPSG\\",\\"7030\\"]],\\n AUTHORITY[\\"EPSG\\",\\"6326\\"]],\\n PRIMEM[\\"Greenwich\\",0,\\n AUTHORITY[\\"EPSG\\",\\"8901\\"]],\\n UNIT[\\"degree\\",0.0174532925199433,\\n AUTHORITY[\\"EPSG\\",\\"9122\\"]],\\n AUTHORITY[\\"EPSG\\",\\"4326\\"]],\\n PROJECTION[\\"Transverse_Mercator\\"],\\n PARAMETER[\\"latitude_of_origin\\",0],\\n PARAMETER[\\"central_meridian\\",-93],\\n PARAMETER[\\"scale_factor\\",0.9996],\\n PARAMETER[\\"false_easting\\",500000],\\n PARAMETER[\\"false_northing\\",0],\\n UNIT[\\"metre\\",1,\\n AUTHORITY[\\"EPSG\\",\\"9001\\"]],\\n AXIS[\\"Easting\\",EAST],\\n AXIS[\\"Northing\\",NORTH],\\n AUTHORITY[\\"EPSG\\",\\"32615\\"]]"}, "geoTransform": [384219.440777, 600.0, 0.0, 4580160.51059, 0.0, -600.0], "size": [120, 122], "metadata": {"": {"id": "*", "Corder": "RPCL"}}}', - ), # noqa - '{"bands": ["red", "alpha"], "data_type": "UInt16", "inputs": ["landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1", "landsat:LC08:PRE:TOAR:meta_LC80260322016197_v1"], "resolution": 600}': ( - np.stack([np.zeros((122, 120), dtype="uint16"), alpha]), - '{"files": [], "cornerCoordinates": {"upperRight": [456219.441, 4580160.511], "lowerLeft": [384219.441, 4506960.511], "lowerRight": [456219.441, 4506960.511], "upperLeft": [384219.441, 4580160.511], "center": [420219.441, 4543560.511]}, "wgs84Extent": {"type": "Polygon", "coordinates": [[[-94.3843159, 41.3646333], [-94.3705723, 40.7054251], [-93.5183204, 40.7123983], [-93.5235196, 41.3717692], [-94.3843159, 41.3646333]]]}, "driverShortName": "MEM", "driverLongName": "In Memory Raster", "bands": [{"description": {"default_range": [0, 4000], "wavelength_max": 673.35, "data_unit": "TOAR", "wavelength_center": 654.6, "color": "Red", "dtype": "UInt16", "name_vendor": "B4", "id": "landsat:LC08:PRE:TOAR:red", "nbits": 14, "wavelength_unit": "nm", "wavelength_min": 635.85, "processing_level": "TOAR", "product": "landsat:LC08:PRE:TOAR", "data_unit_description": "Top of atmosphere reflectance", "description": "Red, Pansharpened", "tags": ["spectral", "red", "15m", "landsat"], "resolution_unit": "m", "data_description": "TOAR, 0-10000 is 0 - 100% reflective", "physical_range": [0.0, 1.0], "name_common": "red", "vendor_order": 4, "name": "red", "type": "spectral", "data_range": [0, 10000], "wavelength_fwhm": 37.5, "nodata": null, "resolution": 15}, "band": 1, "colorInterpretation": "Red", "type": "UInt16", "block": [120, 1], "metadata": {"": {"NBITS": "14"}}}, {"description": {"product": "landsat:LC08:PRE:TOAR", "nbits": 1, "description": "Alpha (valid data)", "data_description": "0: nodata, 1: valid data", "tags": ["mask", "alpha", "15m", "landsat"], "color": "Alpha", "dtype": "UInt16", "data_range": [0, 1], "resolution": 15, "resolution_unit": "m", "data_unit_description": "unitless", "name_common": "alpha", "type": "mask", "nodata": null, "default_range": [0, 1], "id": "landsat:LC08:PRE:TOAR:alpha", "name": "alpha"}, "band": 2, "colorInterpretation": "Alpha", "type": "UInt16", "block": [120, 1], "metadata": {"": {"NBITS": "1"}}}], "coordinateSystem": {"wkt": "PROJCS[\\"WGS 84 / UTM zone 15N\\",\\n GEOGCS[\\"WGS 84\\",\\n DATUM[\\"WGS_1984\\",\\n SPHEROID[\\"WGS 84\\",6378137,298.257223563,\\n AUTHORITY[\\"EPSG\\",\\"7030\\"]],\\n AUTHORITY[\\"EPSG\\",\\"6326\\"]],\\n PRIMEM[\\"Greenwich\\",0,\\n AUTHORITY[\\"EPSG\\",\\"8901\\"]],\\n UNIT[\\"degree\\",0.0174532925199433,\\n AUTHORITY[\\"EPSG\\",\\"9122\\"]],\\n AUTHORITY[\\"EPSG\\",\\"4326\\"]],\\n PROJECTION[\\"Transverse_Mercator\\"],\\n PARAMETER[\\"latitude_of_origin\\",0],\\n PARAMETER[\\"central_meridian\\",-93],\\n PARAMETER[\\"scale_factor\\",0.9996],\\n PARAMETER[\\"false_easting\\",500000],\\n PARAMETER[\\"false_northing\\",0],\\n UNIT[\\"metre\\",1,\\n AUTHORITY[\\"EPSG\\",\\"9001\\"]],\\n AXIS[\\"Easting\\",EAST],\\n AXIS[\\"Northing\\",NORTH],\\n AUTHORITY[\\"EPSG\\",\\"32615\\"]]"}, "geoTransform": [384219.440777, 600.0, 0.0, 4580160.51059, 0.0, -600.0], "size": [120, 122], "metadata": {"": {"id": "*", "Corder": "RPCL"}}}', - ), # noqa - '{"bands": ["alpha"], "data_type": "UInt16", "inputs": ["landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1", "landsat:LC08:PRE:TOAR:meta_LC80260322016197_v1"], "resolution": 600}': ( - np.stack([alpha]), - '{"files": [], "cornerCoordinates": {"upperRight": [456219.441, 4580160.511], "lowerLeft": [384219.441, 4506960.511], "lowerRight": [456219.441, 4506960.511], "upperLeft": [384219.441, 4580160.511], "center": [420219.441, 4543560.511]}, "wgs84Extent": {"type": "Polygon", "coordinates": [[[-94.3843159, 41.3646333], [-94.3705723, 40.7054251], [-93.5183204, 40.7123983], [-93.5235196, 41.3717692], [-94.3843159, 41.3646333]]]}, "driverShortName": "MEM", "driverLongName": "In Memory Raster", "bands": [{"band": 1, "description": {"product": "landsat:LC08:PRE:TOAR", "data_unit_description": "unitless", "description": "Alpha (valid data)", "tags": ["mask", "alpha", "15m", "landsat"], "color": "Alpha", "dtype": "UInt16", "data_description": "0: nodata, 1: valid data", "name_common": "alpha", "id": "landsat:LC08:PRE:TOAR:alpha", "nbits": 1, "name": "alpha", "type": "mask", "data_range": [0, 1], "resolution_unit": "m", "default_range": [0, 1], "nodata": null, "resolution": 15}, "colorInterpretation": "Alpha", "type": "UInt16", "block": [120, 1], "metadata": {"": {"NBITS": "1"}}}], "coordinateSystem": {"wkt": "PROJCS[\\"WGS 84 / UTM zone 15N\\",\\n GEOGCS[\\"WGS 84\\",\\n DATUM[\\"WGS_1984\\",\\n SPHEROID[\\"WGS 84\\",6378137,298.257223563,\\n AUTHORITY[\\"EPSG\\",\\"7030\\"]],\\n AUTHORITY[\\"EPSG\\",\\"6326\\"]],\\n PRIMEM[\\"Greenwich\\",0,\\n AUTHORITY[\\"EPSG\\",\\"8901\\"]],\\n UNIT[\\"degree\\",0.0174532925199433,\\n AUTHORITY[\\"EPSG\\",\\"9122\\"]],\\n AUTHORITY[\\"EPSG\\",\\"4326\\"]],\\n PROJECTION[\\"Transverse_Mercator\\"],\\n PARAMETER[\\"latitude_of_origin\\",0],\\n PARAMETER[\\"central_meridian\\",-93],\\n PARAMETER[\\"scale_factor\\",0.9996],\\n PARAMETER[\\"false_easting\\",500000],\\n PARAMETER[\\"false_northing\\",0],\\n UNIT[\\"metre\\",1,\\n AUTHORITY[\\"EPSG\\",\\"9001\\"]],\\n AXIS[\\"Easting\\",EAST],\\n AXIS[\\"Northing\\",NORTH],\\n AUTHORITY[\\"EPSG\\",\\"32615\\"]]"}, "geoTransform": [384219.440777, 600.0, 0.0, 4580160.51059, 0.0, -600.0], "metadata": {"": {"id": "*", "Corder": "RPCL"}}, "size": [120, 122]}', - ), # noqa - '{"bands": ["nir"], "data_type": "UInt16", "inputs": ["landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1", "landsat:LC08:PRE:TOAR:meta_LC80260322016197_v1"], "resolution": 600}': ( - np.zeros((122, 120), dtype="uint16"), - '{"files": [], "cornerCoordinates": {"upperRight": [456219.441, 4580160.511], "lowerLeft": [384219.441, 4506960.511], "lowerRight": [456219.441, 4506960.511], "upperLeft": [384219.441, 4580160.511], "center": [420219.441, 4543560.511]}, "wgs84Extent": {"type": "Polygon", "coordinates": [[[-94.3843159, 41.3646333], [-94.3705723, 40.7054251], [-93.5183204, 40.7123983], [-93.5235196, 41.3717692], [-94.3843159, 41.3646333]]]}, "driverShortName": "MEM", "driverLongName": "In Memory Raster", "bands": [{"band": 1, "description": {"wavelength_max": 878.85, "data_unit_description": "Top of atmosphere reflectance", "data_unit": "TOAR", "description": "Near Infrared", "tags": ["spectral", "nir", "near-infrared", "30m", "landsat"], "color": "Gray", "dtype": "UInt16", "wavelength_min": 850.55, "name_vendor": "B5", "product": "landsat:LC08:PRE:TOAR", "data_description": "TOAR, 0-10000 is 0 - 100% reflective", "physical_range": [0.0, 1.0], "name_common": "nir", "id": "landsat:LC08:PRE:TOAR:nir", "vendor_order": 5, "nbits": 14, "type": "spectral", "name": "nir", "wavelength_center": 864.7, "data_range": [0, 10000], "resolution_unit": "m", "wavelength_unit": "nm", "resolution": 30, "wavelength_fwhm": 28.3, "nodata": null, "default_range": [0, 10000], "processing_level": "TOAR"}, "colorInterpretation": "Gray", "type": "UInt16", "block": [120, 1], "metadata": {"": {"NBITS": "14"}}}], "coordinateSystem": {"wkt": "PROJCS[\\"WGS 84 / UTM zone 15N\\",\\n GEOGCS[\\"WGS 84\\",\\n DATUM[\\"WGS_1984\\",\\n SPHEROID[\\"WGS 84\\",6378137,298.257223563,\\n AUTHORITY[\\"EPSG\\",\\"7030\\"]],\\n AUTHORITY[\\"EPSG\\",\\"6326\\"]],\\n PRIMEM[\\"Greenwich\\",0,\\n AUTHORITY[\\"EPSG\\",\\"8901\\"]],\\n UNIT[\\"degree\\",0.0174532925199433,\\n AUTHORITY[\\"EPSG\\",\\"9122\\"]],\\n AUTHORITY[\\"EPSG\\",\\"4326\\"]],\\n PROJECTION[\\"Transverse_Mercator\\"],\\n PARAMETER[\\"latitude_of_origin\\",0],\\n PARAMETER[\\"central_meridian\\",-93],\\n PARAMETER[\\"scale_factor\\",0.9996],\\n PARAMETER[\\"false_easting\\",500000],\\n PARAMETER[\\"false_northing\\",0],\\n UNIT[\\"metre\\",1,\\n AUTHORITY[\\"EPSG\\",\\"9001\\"]],\\n AXIS[\\"Easting\\",EAST],\\n AXIS[\\"Northing\\",NORTH],\\n AUTHORITY[\\"EPSG\\",\\"32615\\"]]"}, "geoTransform": [384219.440777, 600.0, 0.0, 4580160.51059, 0.0, -600.0], "metadata": {"": {"id": "*", "Corder": "RPCL"}}, "size": [120, 122]}', - ), # noqa - '{"bands": ["red", "alpha"], "data_type": "UInt16", "inputs": ["landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1"], "resolution": 1000}': ( - np.stack([np.zeros((239, 235), dtype="uint16"), alpha1000]), - '{"metadata": {"": {"Corder": "RPCL", "id": "landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1"}}, "driverShortName": "MEM", "wgs84Extent": {"coordinates": [[[-95.9559596, 42.8041728], [-95.8589268, 40.654253], [-93.0793836, 40.6896344], [-93.0820826, 42.8423136], [-95.9559596, 42.8041728]]], "type": "Polygon"}, "geoTransform": [258292.5, 1000.0, 0.0, 4743307.5, 0.0, -1000.0], "coordinateSystem": {"wkt": "PROJCS[\\"WGS 84 / UTM zone 15N\\",\\n GEOGCS[\\"WGS 84\\",\\n DATUM[\\"WGS_1984\\",\\n SPHEROID[\\"WGS 84\\",6378137,298.257223563,\\n AUTHORITY[\\"EPSG\\",\\"7030\\"]],\\n AUTHORITY[\\"EPSG\\",\\"6326\\"]],\\n PRIMEM[\\"Greenwich\\",0,\\n AUTHORITY[\\"EPSG\\",\\"8901\\"]],\\n UNIT[\\"degree\\",0.0174532925199433,\\n AUTHORITY[\\"EPSG\\",\\"9122\\"]],\\n AUTHORITY[\\"EPSG\\",\\"4326\\"]],\\n PROJECTION[\\"Transverse_Mercator\\"],\\n PARAMETER[\\"latitude_of_origin\\",0],\\n PARAMETER[\\"central_meridian\\",-93],\\n PARAMETER[\\"scale_factor\\",0.9996],\\n PARAMETER[\\"false_easting\\",500000],\\n PARAMETER[\\"false_northing\\",0],\\n UNIT[\\"metre\\",1,\\n AUTHORITY[\\"EPSG\\",\\"9001\\"]],\\n AXIS[\\"Easting\\",EAST],\\n AXIS[\\"Northing\\",NORTH],\\n AUTHORITY[\\"EPSG\\",\\"32615\\"]]"}, "cornerCoordinates": {"upperRight": [493292.5, 4743307.5], "center": [375792.5, 4623807.5], "upperLeft": [258292.5, 4743307.5], "lowerRight": [493292.5, 4504307.5], "lowerLeft": [258292.5, 4504307.5]}, "files": [], "bands": [{"block": [235, 1], "metadata": {"": {"NBITS": "14"}}, "type": "UInt16", "description": {"nodata": null, "data_unit_description": "Top of atmosphere reflectance", "id": "landsat:LC08:PRE:TOAR:red", "processing_level": "TOAR", "description": "Red, Pansharpened", "resolution_unit": "m", "wavelength_max": 673.35, "product": "landsat:LC08:PRE:TOAR", "nbits": 14, "wavelength_unit": "nm", "color": "Red", "name_common": "red", "name": "red", "name_vendor": "B4", "data_unit": "TOAR", "data_range": [0, 10000], "wavelength_min": 635.85, "type": "spectral", "dtype": "UInt16", "data_description": "TOAR, 0-10000 is 0 - 100% reflective", "tags": ["spectral", "red", "15m", "landsat"], "physical_range": [0.0, 1.0], "default_range": [0, 4000], "vendor_order": 4, "resolution": 15, "wavelength_center": 654.6, "wavelength_fwhm": 37.5}, "band": 1, "colorInterpretation": "Red"}, {"block": [235, 1], "metadata": {"": {"NBITS": "1"}}, "type": "UInt16", "description": {"nodata": null, "color": "Alpha", "type": "mask", "id": "landsat:LC08:PRE:TOAR:alpha", "description": "Alpha (valid data)", "resolution_unit": "m", "data_description": "0: nodata, 1: valid data", "product": "landsat:LC08:PRE:TOAR", "data_unit_description": "unitless", "nbits": 1, "tags": ["mask", "alpha", "15m", "landsat"], "name_common": "alpha", "default_range": [0, 1], "name": "alpha", "data_range": [0, 1], "dtype": "UInt16", "resolution": 15}, "band": 2, "colorInterpretation": "Alpha"}], "size": [235, 239], "driverLongName": "In Memory Raster"}', - ), # noqa - '{"bands": ["red", "green"], "data_type": "Int32", "inputs": ["landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1"], "resolution": 600}': ( - np.stack( - [np.zeros((122, 120), dtype="int32"), np.zeros((122, 120), dtype="int32")] - ), - '{"metadata": {"": {"Corder": "RPCL", "id": "landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1"}}, "driverShortName": "MEM", "wgs84Extent": {"coordinates": [[[-95.9559596, 42.8041728], [-95.8589268, 40.654253], [-93.0793836, 40.6896344], [-93.0820826, 42.8423136], [-95.9559596, 42.8041728]]], "type": "Polygon"}, "geoTransform": [258292.5, 1000.0, 0.0, 4743307.5, 0.0, -1000.0], "coordinateSystem": {"wkt": "PROJCS[\\"WGS 84 / UTM zone 15N\\",\\n GEOGCS[\\"WGS 84\\",\\n DATUM[\\"WGS_1984\\",\\n SPHEROID[\\"WGS 84\\",6378137,298.257223563,\\n AUTHORITY[\\"EPSG\\",\\"7030\\"]],\\n AUTHORITY[\\"EPSG\\",\\"6326\\"]],\\n PRIMEM[\\"Greenwich\\",0,\\n AUTHORITY[\\"EPSG\\",\\"8901\\"]],\\n UNIT[\\"degree\\",0.0174532925199433,\\n AUTHORITY[\\"EPSG\\",\\"9122\\"]],\\n AUTHORITY[\\"EPSG\\",\\"4326\\"]],\\n PROJECTION[\\"Transverse_Mercator\\"],\\n PARAMETER[\\"latitude_of_origin\\",0],\\n PARAMETER[\\"central_meridian\\",-93],\\n PARAMETER[\\"scale_factor\\",0.9996],\\n PARAMETER[\\"false_easting\\",500000],\\n PARAMETER[\\"false_northing\\",0],\\n UNIT[\\"metre\\",1,\\n AUTHORITY[\\"EPSG\\",\\"9001\\"]],\\n AXIS[\\"Easting\\",EAST],\\n AXIS[\\"Northing\\",NORTH],\\n AUTHORITY[\\"EPSG\\",\\"32615\\"]]"}, "cornerCoordinates": {"upperRight": [493292.5, 4743307.5], "center": [375792.5, 4623807.5], "upperLeft": [258292.5, 4743307.5], "lowerRight": [493292.5, 4504307.5], "lowerLeft": [258292.5, 4504307.5]}, "files": [], "bands": [{"block": [235, 1], "mask": {"flags": ["PER_DATASET", "ALPHA"], "overviews": []}, "metadata": {"": {"NBITS": "14"}}, "type": "UInt16", "description": {"nodata": null, "data_unit_description": "Top of atmosphere reflectance", "id": "landsat:LC08:PRE:TOAR:red", "processing_level": "TOAR", "description": "Red, Pansharpened", "resolution_unit": "m", "wavelength_max": 673.35, "product": "landsat:LC08:PRE:TOAR", "nbits": 14, "wavelength_unit": "nm", "color": "Red", "name_common": "red", "name": "red", "name_vendor": "B4", "data_unit": "TOAR", "data_range": [0, 10000], "wavelength_min": 635.85, "type": "spectral", "dtype": "UInt16", "data_description": "TOAR, 0-10000 is 0 - 100% reflective", "tags": ["spectral", "red", "15m", "landsat"], "physical_range": [0.0, 1.0], "default_range": [0, 4000], "vendor_order": 4, "resolution": 15, "wavelength_center": 654.6, "wavelength_fwhm": 37.5}, "band": 1, "colorInterpretation": "Red"}, {"block": [235, 1], "mask": {"flags": ["PER_DATASET", "ALPHA"], "overviews": []}, "metadata": {"": {"NBITS": "14"}}, "type": "UInt16", "description": {"nodata": null, "data_unit_description": "Top of atmosphere reflectance", "id": "landsat:LC08:PRE:TOAR:green", "processing_level": "TOAR", "description": "Green, Pansharpened", "resolution_unit": "m", "wavelength_max": 590.05, "product": "landsat:LC08:PRE:TOAR", "nbits": 14, "wavelength_unit": "nm", "color": "Green", "name_common": "green", "name": "green", "name_vendor": "B3", "data_unit": "TOAR", "data_range": [0, 10000], "wavelength_min": 532.75, "type": "spectral", "dtype": "UInt16", "data_description": "TOAR, 0-10000 is 0 - 100% reflective", "tags": ["spectral", "green", "15m", "landsat"], "physical_range": [0.0, 1.0], "default_range": [0, 4000], "vendor_order": 3, "resolution": 15, "wavelength_center": 561.4, "wavelength_fwhm": 57.3}, "band": 2, "colorInterpretation": "Green"}], "size": [235, 239], "driverLongName": "In Memory Raster"}', - ), # noqa - '{"bands": ["red", "green", "blue", "alpha"], "data_type": "UInt16", "inputs": ["landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1"], "resolution": 1000}': ( - np.stack( - [ - np.zeros((239, 235), dtype="uint16"), - np.zeros((239, 235), dtype="uint16"), - np.zeros((239, 235), dtype="uint16"), - alpha1000, - ] - ), - '{"metadata": {"": {"Corder": "RPCL", "id": "landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1"}}, "driverShortName": "MEM", "wgs84Extent": {"coordinates": [[[-95.9559596, 42.8041728], [-95.8589268, 40.654253], [-93.0793836, 40.6896344], [-93.0820826, 42.8423136], [-95.9559596, 42.8041728]]], "type": "Polygon"}, "geoTransform": [258292.5, 1000.0, 0.0, 4743307.5, 0.0, -1000.0], "coordinateSystem": {"wkt": "PROJCS[\\"WGS 84 / UTM zone 15N\\",\\n GEOGCS[\\"WGS 84\\",\\n DATUM[\\"WGS_1984\\",\\n SPHEROID[\\"WGS 84\\",6378137,298.257223563,\\n AUTHORITY[\\"EPSG\\",\\"7030\\"]],\\n AUTHORITY[\\"EPSG\\",\\"6326\\"]],\\n PRIMEM[\\"Greenwich\\",0,\\n AUTHORITY[\\"EPSG\\",\\"8901\\"]],\\n UNIT[\\"degree\\",0.0174532925199433,\\n AUTHORITY[\\"EPSG\\",\\"9122\\"]],\\n AUTHORITY[\\"EPSG\\",\\"4326\\"]],\\n PROJECTION[\\"Transverse_Mercator\\"],\\n PARAMETER[\\"latitude_of_origin\\",0],\\n PARAMETER[\\"central_meridian\\",-93],\\n PARAMETER[\\"scale_factor\\",0.9996],\\n PARAMETER[\\"false_easting\\",500000],\\n PARAMETER[\\"false_northing\\",0],\\n UNIT[\\"metre\\",1,\\n AUTHORITY[\\"EPSG\\",\\"9001\\"]],\\n AXIS[\\"Easting\\",EAST],\\n AXIS[\\"Northing\\",NORTH],\\n AUTHORITY[\\"EPSG\\",\\"32615\\"]]"}, "cornerCoordinates": {"upperRight": [493292.5, 4743307.5], "center": [375792.5, 4623807.5], "upperLeft": [258292.5, 4743307.5], "lowerRight": [493292.5, 4504307.5], "lowerLeft": [258292.5, 4504307.5]}, "files": [], "bands": [{"block": [235, 1], "mask": {"flags": ["PER_DATASET", "ALPHA"], "overviews": []}, "metadata": {"": {"NBITS": "14"}}, "type": "UInt16", "description": {"nodata": null, "data_unit_description": "Top of atmosphere reflectance", "id": "landsat:LC08:PRE:TOAR:red", "processing_level": "TOAR", "description": "Red, Pansharpened", "resolution_unit": "m", "wavelength_max": 673.35, "product": "landsat:LC08:PRE:TOAR", "nbits": 14, "wavelength_unit": "nm", "color": "Red", "name_common": "red", "name": "red", "name_vendor": "B4", "data_unit": "TOAR", "data_range": [0, 10000], "wavelength_min": 635.85, "type": "spectral", "dtype": "UInt16", "data_description": "TOAR, 0-10000 is 0 - 100% reflective", "tags": ["spectral", "red", "15m", "landsat"], "physical_range": [0.0, 1.0], "default_range": [0, 4000], "vendor_order": 4, "resolution": 15, "wavelength_center": 654.6, "wavelength_fwhm": 37.5}, "band": 1, "colorInterpretation": "Red"}, {"block": [235, 1], "mask": {"flags": ["PER_DATASET", "ALPHA"], "overviews": []}, "metadata": {"": {"NBITS": "14"}}, "type": "UInt16", "description": {"nodata": null, "data_unit_description": "Top of atmosphere reflectance", "id": "landsat:LC08:PRE:TOAR:green", "processing_level": "TOAR", "description": "Green, Pansharpened", "resolution_unit": "m", "wavelength_max": 590.05, "product": "landsat:LC08:PRE:TOAR", "nbits": 14, "wavelength_unit": "nm", "color": "Green", "name_common": "green", "name": "green", "name_vendor": "B3", "data_unit": "TOAR", "data_range": [0, 10000], "wavelength_min": 532.75, "type": "spectral", "dtype": "UInt16", "data_description": "TOAR, 0-10000 is 0 - 100% reflective", "tags": ["spectral", "green", "15m", "landsat"], "physical_range": [0.0, 1.0], "default_range": [0, 4000], "vendor_order": 3, "resolution": 15, "wavelength_center": 561.4, "wavelength_fwhm": 57.3}, "band": 2, "colorInterpretation": "Green"}, {"block": [235, 1], "mask": {"flags": ["PER_DATASET", "ALPHA"], "overviews": []}, "metadata": {"": {"NBITS": "14"}}, "type": "UInt16", "description": {"nodata": null, "data_unit_description": "Top of atmosphere reflectance", "id": "landsat:LC08:PRE:TOAR:blue", "processing_level": "TOAR", "description": "Blue, Pansharpened", "resolution_unit": "m", "wavelength_max": 512.0, "product": "landsat:LC08:PRE:TOAR", "nbits": 14, "wavelength_unit": "nm", "color": "Blue", "name_common": "blue", "name": "blue", "name_vendor": "B2", "data_unit": "TOAR", "data_range": [0, 10000], "wavelength_min": 452.0, "type": "spectral", "dtype": "UInt16", "data_description": "TOAR, 0-10000 is 0 - 100% reflective", "tags": ["spectral", "blue", "15m", "landsat"], "physical_range": [0.0, 1.0], "default_range": [0, 4000], "vendor_order": 2, "resolution": 15, "wavelength_center": 482, "wavelength_fwhm": 60}, "band": 3, "colorInterpretation": "Blue"}, {"block": [235, 1], "metadata": {"": {"NBITS": "1"}}, "type": "UInt16", "description": {"nodata": null, "color": "Alpha", "type": "mask", "id": "landsat:LC08:PRE:TOAR:alpha", "description": "Alpha (valid data)", "resolution_unit": "m", "data_description": "0: nodata, 1: valid data", "product": "landsat:LC08:PRE:TOAR", "data_unit_description": "unitless", "nbits": 1, "tags": ["mask", "alpha", "15m", "landsat"], "name_common": "alpha", "default_range": [0, 1], "name": "alpha", "data_range": [0, 1], "dtype": "UInt16", "resolution": 15}, "band": 4, "colorInterpretation": "Alpha"}], "size": [235, 239], "driverLongName": "In Memory Raster"}', - ), # noqa - '{"bands": ["red", "nir"], "data_type": "UInt16", "inputs": ["landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1"], "resolution": 1000}': ( - np.stack( - [np.zeros((239, 235), dtype="uint16"), np.zeros((239, 235), dtype="uint16")] - ), - '{"metadata": {"": {"Corder": "RPCL", "id": "landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1"}}, "driverShortName": "MEM", "wgs84Extent": {"coordinates": [[[-95.9559596, 42.8041728], [-95.8589268, 40.654253], [-93.0793836, 40.6896344], [-93.0820826, 42.8423136], [-95.9559596, 42.8041728]]], "type": "Polygon"}, "geoTransform": [258292.5, 1000.0, 0.0, 4743307.5, 0.0, -1000.0], "coordinateSystem": {"wkt": "PROJCS[\\"WGS 84 / UTM zone 15N\\",\\n GEOGCS[\\"WGS 84\\",\\n DATUM[\\"WGS_1984\\",\\n SPHEROID[\\"WGS 84\\",6378137,298.257223563,\\n AUTHORITY[\\"EPSG\\",\\"7030\\"]],\\n AUTHORITY[\\"EPSG\\",\\"6326\\"]],\\n PRIMEM[\\"Greenwich\\",0,\\n AUTHORITY[\\"EPSG\\",\\"8901\\"]],\\n UNIT[\\"degree\\",0.0174532925199433,\\n AUTHORITY[\\"EPSG\\",\\"9122\\"]],\\n AUTHORITY[\\"EPSG\\",\\"4326\\"]],\\n PROJECTION[\\"Transverse_Mercator\\"],\\n PARAMETER[\\"latitude_of_origin\\",0],\\n PARAMETER[\\"central_meridian\\",-93],\\n PARAMETER[\\"scale_factor\\",0.9996],\\n PARAMETER[\\"false_easting\\",500000],\\n PARAMETER[\\"false_northing\\",0],\\n UNIT[\\"metre\\",1,\\n AUTHORITY[\\"EPSG\\",\\"9001\\"]],\\n AXIS[\\"Easting\\",EAST],\\n AXIS[\\"Northing\\",NORTH],\\n AUTHORITY[\\"EPSG\\",\\"32615\\"]]"}, "cornerCoordinates": {"upperRight": [493292.5, 4743307.5], "center": [375792.5, 4623807.5], "upperLeft": [258292.5, 4743307.5], "lowerRight": [493292.5, 4504307.5], "lowerLeft": [258292.5, 4504307.5]}, "files": [], "bands": [{"block": [235, 1], "metadata": {"": {"NBITS": "14"}}, "type": "UInt16", "description": {"nodata": null, "data_unit_description": "Top of atmosphere reflectance", "id": "landsat:LC08:PRE:TOAR:red", "processing_level": "TOAR", "description": "Red, Pansharpened", "resolution_unit": "m", "wavelength_max": 673.35, "product": "landsat:LC08:PRE:TOAR", "nbits": 14, "wavelength_unit": "nm", "color": "Red", "name_common": "red", "name": "red", "name_vendor": "B4", "data_unit": "TOAR", "data_range": [0, 10000], "wavelength_min": 635.85, "type": "spectral", "dtype": "UInt16", "data_description": "TOAR, 0-10000 is 0 - 100% reflective", "tags": ["spectral", "red", "15m", "landsat"], "physical_range": [0.0, 1.0], "default_range": [0, 4000], "vendor_order": 4, "resolution": 15, "wavelength_center": 654.6, "wavelength_fwhm": 37.5}, "band": 1, "colorInterpretation": "Red"}, {"block": [235, 1], "metadata": {"": {"NBITS": "14"}}, "type": "UInt16", "description": {"nodata": null, "data_unit_description": "Top of atmosphere reflectance", "id": "landsat:LC08:PRE:TOAR:nir", "processing_level": "TOAR", "description": "Near Infrared", "resolution_unit": "m", "wavelength_max": 878.85, "product": "landsat:LC08:PRE:TOAR", "nbits": 14, "wavelength_unit": "nm", "color": "Gray", "name_common": "nir", "name": "nir", "name_vendor": "B5", "data_unit": "TOAR", "data_range": [0, 10000], "wavelength_min": 850.55, "type": "spectral", "dtype": "UInt16", "data_description": "TOAR, 0-10000 is 0 - 100% reflective", "tags": ["spectral", "nir", "near-infrared", "30m", "landsat"], "physical_range": [0.0, 1.0], "default_range": [0, 10000], "vendor_order": 5, "resolution": 30, "wavelength_center": 864.7, "wavelength_fwhm": 28.3}, "band": 2, "colorInterpretation": "Gray"}], "size": [235, 239], "driverLongName": "In Memory Raster"}', - ), # noqa - '{"bands": ["Clear_sky_days", "Clear_sky_nights"], "data_type": "Byte", "inputs": ["modis:mod11a2:006:meta_MOD11A2.A2017305.h09v05.006.2017314042814_v1"], "resolution": 1000}': ( - np.stack( - [np.zeros((688, 473), dtype="uint16"), np.zeros((688, 473), dtype="uint16")] - ), - '{"files": [],"cornerCoordinates": {"upperRight": [340252.341, 6855234.987], "lowerLeft": [298972.341, 6826854.987], "lowerRight": [340252.341, 6826854.987], "upperLeft": [298972.341, 6855234.987], "center": [319612.341, 6841044.987]},"wgs84Extent": {"type": "Polygon", "coordinates": [[[-144.8118058, 61.7770149], [-144.7805921, 61.5228056], [-144.0056918, 61.5420874], [-144.0305346, 61.7965016], [-144.8118058, 61.7770149]]]},"driverShortName": "MEM","driverLongName": "In Memory Raster","bands": [{"description": {"default_range": [0, 4000], "wavelength_max": 680.0, "data_unit": "TOAR", "color": "Red", "dtype": "UInt16", "name_vendor": "B4", "type": "spectral", "id": "sentinel-2:L1C:red", "nbits": 14, "wavelength_unit": "nm", "wavelength_min": 650.0, "processing_level": "TOAR", "product": "sentinel-2:L1C", "data_unit_description": "Top of atmosphere reflectance", "description": "Red", "tags": ["spectral", "red", "10m", "sentinel-2"], "resolution_unit": "m", "vendor_order": 4, "physical_range": [0.0, 1.0], "name_common": "red", "data_description": "TOAR, 0-10000 is 0 - 100% reflective", "name": "red", "wavelength_center": 665, "data_range": [0, 10000], "wavelength_fwhm": 30, "nodata": null, "resolution": 10}, "mask": {"overviews": [], "flags": ["PER_DATASET", "ALPHA"]}, "band": 1, "colorInterpretation": "Red", "type": "Byte", "block": [688, 1], "metadata": {"": {"NBITS": "14"}}}, {"description": {"default_range": [0, 4000], "wavelength_max": 577.5, "data_unit": "TOAR", "color": "Green", "dtype": "UInt16", "name_vendor": "B3", "type": "spectral", "id": "sentinel-2:L1C:green", "nbits": 14, "wavelength_unit": "nm", "wavelength_min": 542.5, "processing_level": "TOAR", "product": "sentinel-2:L1C", "data_unit_description": "Top of atmosphere reflectance", "description": "Green", "tags": ["spectral", "green", "10m", "sentinel-2"], "resolution_unit": "m", "vendor_order": 3, "physical_range": [0.0, 1.0], "name_common": "green", "data_description": "TOAR, 0-10000 is 0 - 100% reflective", "name": "green", "wavelength_center": 560, "data_range": [0, 10000], "wavelength_fwhm": 35, "nodata": null, "resolution": 10}, "mask": {"overviews": [], "flags": ["PER_DATASET", "ALPHA"]}, "band": 2, "colorInterpretation": "Green", "type": "Byte", "block": [688, 1], "metadata": {"": {"NBITS": "14"}}}, {"description": {"default_range": [0, 4000], "wavelength_max": 522.5, "data_unit": "TOAR", "color": "Blue", "dtype": "UInt16", "name_vendor": "B2", "type": "spectral", "id": "sentinel-2:L1C:blue", "nbits": 14, "wavelength_unit": "nm", "wavelength_min": 457.5, "processing_level": "TOAR", "product": "sentinel-2:L1C", "data_unit_description": "Top of atmosphere reflectance", "description": "Blue", "tags": ["spectral", "blue", "10m", "sentinel-2"], "resolution_unit": "m", "vendor_order": 2, "physical_range": [0.0, 1.0], "name_common": "blue", "data_description": "TOAR, 0-10000 is 0 - 100% reflective", "name": "blue", "wavelength_center": 490, "data_range": [0, 10000], "wavelength_fwhm": 65, "nodata": null, "resolution": 10}, "mask": {"overviews": [], "flags": ["PER_DATASET", "ALPHA"]}, "band": 3, "colorInterpretation": "Blue", "type": "Byte", "block": [688, 1], "metadata": {"": {"NBITS": "14"}}}, {"description": {"default_range": [0, 1], "product": "sentinel-2:L1C", "nbits": 1, "description": "Alpha (valid data)", "tags": ["mask", "alpha", "10m", "sentinel-2"], "color": "Alpha", "dtype": "UInt16", "data_range": [0, 1], "resolution": 10, "name": "alpha", "resolution_unit": "m", "data_unit_description": "unitless", "name_common": "alpha", "nodata": null, "type": "mask", "id": "sentinel-2:L1C:alpha", "data_description": "0: nodata, 1: valid data"}, "band": 4, "colorInterpretation": "Alpha", "type": "Byte", "block": [688, 1], "metadata": {"": {"NBITS": "1"}}}],"coordinateSystem": {"wkt": "PROJCS[\\"WGS 84 / UTM zone 7N\\",\\n GEOGCS[\\"WGS 84\\",\\n DATUM[\\"WGS_1984\\",\\n SPHEROID[\\"WGS 84\\",6378137,298.257223563,\\n AUTHORITY[\\"EPSG\\",\\"7030\\"]],\\n AUTHORITY[\\"EPSG\\",\\"6326\\"]],\\n PRIMEM[\\"Greenwich\\",0,\\n AUTHORITY[\\"EPSG\\",\\"8901\\"]],\\n UNIT[\\"degree\\",0.0174532925199433,\\n AUTHORITY[\\"EPSG\\",\\"9122\\"]],\\n AUTHORITY[\\"EPSG\\",\\"4326\\"]],\\n PROJECTION[\\"Transverse_Mercator\\"],\\n PARAMETER[\\"latitude_of_origin\\",0],\\n PARAMETER[\\"central_meridian\\",-141],\\n PARAMETER[\\"scale_factor\\",0.9996],\\n PARAMETER[\\"false_easting\\",500000],\\n PARAMETER[\\"false_northing\\",0],\\n UNIT[\\"metre\\",1,\\n AUTHORITY[\\"EPSG\\",\\"9001\\"]],\\n AXIS[\\"Easting\\",EAST],\\n AXIS[\\"Northing\\",NORTH],\\n AUTHORITY[\\"EPSG\\",\\"32607\\"]]"},"geoTransform": [298972.341031, 60.0, 0.0, 6855234.98696, 0.0, -60.0],"metadata": {"": {"id": "sentinel-2:L1C:2017-08-07_07VCJ_99_S2A_v1", "Corder": "RPCL"}},"size": [688, 473]}', - ), # noqa - '{"bands": ["Clear_sky_days", "Clear_sky_nights"], "data_type": "Byte", "inputs": ["modis:mod11a2:006:meta_MOD11A2.A2017305.h09v05.006.2017314042814_v1", "modis:mod11a2:006:meta_MOD11A2.A2000049.h08v05.006.2015058135046_v1"], "resolution": 600}': ( - np.stack( - [ - np.zeros((1853, 3707), dtype="uint16"), - np.zeros((1853, 3707), dtype="uint16"), - ] - ), - '{"files": [], "cornerCoordinates": {"upperRight": [-8895305.198, 4447802.079], "lowerLeft": [-11119505.198, 3336002.079], "lowerRight": [-8895305.198, 3336002.079], "upperLeft": [-11119505.198, 4447802.079], "center": [-10007405.198, 3891902.079]}, "wgs84Extent": {"type": "Polygon", "coordinates": [[[-130.5407289, 40.0], [-115.4716289, 30.0013537], [-92.3741986, 30.0013537], [-104.4290734, 40.0], [-130.5407289, 40.0]]]}, "driverShortName": "MEM", "driverLongName": "In Memory Raster", "bands": [{"description": {"default_range": [1, 255], "product": "modis:mod11a2:006", "vendor_order": 11, "data_unit": "unitless", "description": "Day clear-sky coverage", "resolution_unit": "meters", "dtype": "Byte", "physical_range": [0.0, 255.0], "data_range": [0, 255], "name_vendor": "Clear_sky_days", "nbits": 8, "type": "spectral", "nodata": 0, "resolution": 1000, "id": "modis:mod11a2:006:Clear_sky_days", "name": "Clear_sky_days"}, "noDataValue": 0.0, "band": 1, "colorInterpretation": "Undefined", "type": "Byte", "block": [3707, 1], "metadata": {"": {"NBITS": "8"}}}, {"description": {"default_range": [1, 255], "product": "modis:mod11a2:006", "vendor_order": 12, "data_unit": "unitless", "description": "Night clear-sky coverage", "resolution_unit": "meters", "dtype": "Byte", "physical_range": [0.0, 255.0], "data_range": [0, 255], "name_vendor": "Clear_sky_nights", "nbits": 8, "type": "spectral", "nodata": 0, "resolution": 1000, "id": "modis:mod11a2:006:Clear_sky_nights", "name": "Clear_sky_nights"}, "noDataValue": 0.0, "band": 2, "colorInterpretation": "Undefined", "type": "Byte", "block": [3707, 1], "metadata": {"": {"NBITS": "8"}}}], "coordinateSystem": {"wkt": "PROJCS[\\"unnamed\\",\\n GEOGCS[\\"unnamed ellipse\\",\\n DATUM[\\"unknown\\",\\n SPHEROID[\\"unnamed\\",6371007.181,0]],\\n PRIMEM[\\"Greenwich\\",0],\\n UNIT[\\"degree\\",0.0174532925199433]],\\n PROJECTION[\\"Sinusoidal\\"],\\n PARAMETER[\\"longitude_of_center\\",0],\\n PARAMETER[\\"false_easting\\",0],\\n PARAMETER[\\"false_northing\\",0],\\n UNIT[\\"Meter\\",1]]"}, "geoTransform": [-11119505.197665, 600.0, 0.0, 4447802.079066, 0.0, -600.0], "metadata": {"": {"id": "*", "Corder": "RPCL"}}, "size": [3707, 1853]}', - ), # noqa -} - - -def _raster_ndarray(self, **kwargs): - a, meta = RASTER[ - json.dumps( - {k: kwargs[k] for k in ("bands", "data_type", "inputs", "resolution")}, - sort_keys=True, - ) - ] - - if kwargs.get("masked", True): - if not np.ma.is_masked(a): - mask = np.zeros(a.shape) - if kwargs.get("mask_alpha") and kwargs["bands"][-1] == "alpha": - mask[:] = ~(a[-1].astype(bool)) - a = np.ma.array(a, mask=mask) - else: - if np.ma.is_masked(a): - a = a.data - - if kwargs.get("drop_alpha", False): - a = a[:-1] - - return a, json.loads(meta) diff --git a/descarteslabs/core/catalog/tests/test_attributes.py b/descarteslabs/core/catalog/tests/test_attributes.py deleted file mode 100644 index 9e7a3c8e..00000000 --- a/descarteslabs/core/catalog/tests/test_attributes.py +++ /dev/null @@ -1,1039 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 pytest -import unittest -import textwrap -from copy import deepcopy -from datetime import datetime, timezone -from enum import Enum - -from strenum import StrEnum - -from ..catalog_base import CatalogObject -from ..attributes import ( - Attribute, - TypedAttribute, - Timestamp, - EnumAttribute, - MappingAttribute, - ListAttribute, - DocumentState, - Resolution, - File, - AttributeValidationError, - ExtraPropertiesAttribute, -) -from ..band import BandType - - -class CountToThree(StrEnum): - ONE = "One" - TWO = "Two" - THREE = "Three" - - -class CountToThreeOldStyle(str, Enum): - ONE = "One" - TWO = "Two" - THREE = "Three" - - -class Nested(MappingAttribute): - foo = Attribute() - dt = Timestamp(mutable=False) - en = EnumAttribute(CountToThree) - old = EnumAttribute(CountToThreeOldStyle) - - -class Mapping(MappingAttribute): - bar = Attribute() - nested = Nested() - - -class FakeCatalogObject(CatalogObject): - mapping = Mapping() - listmapping = ListAttribute(Mapping) - listattribute = ListAttribute(Attribute) - typedlistattribute = ListAttribute(TypedAttribute(str)) - - -class TestAttributes(unittest.TestCase): - def test_immutabletimestamp(self): - date = Timestamp(readonly=True) - assert date.deserialize(None) is None - - assert ( - date.deserialize("2019-02-01T00:00:00.0000Z", validate=False).tzinfo - == timezone.utc - ) - assert date.deserialize( - "2019-02-01T00:00:00.0000Z", validate=False - ) == datetime(2019, 2, 1, tzinfo=timezone.utc) - - date = Timestamp(readonly=True) - assert date.deserialize( - datetime(2013, 12, 31, 23, 59, 59), validate=False - ) == datetime(2013, 12, 31, 23, 59, 59, tzinfo=timezone.utc) - value = date.deserialize(datetime(2013, 12, 31, 23, 59, 59), validate=False) - assert date.serialize(value) == "2013-12-31T23:59:59+00:00" - - def test_mutable_timestamp(self): - mutable_date = Timestamp() - assert mutable_date.deserialize(None) is None - assert ( - mutable_date.deserialize("2019-02-01T00:00:00.0000Z", validate=False).tzinfo - == timezone.utc - ) - - class TimeObj(CatalogObject): - date = Timestamp() - - # does not deserialize when unsaved - obj = TimeObj(id="test-date", date="06/02/2019") - assert obj.date == "06/02/2019" - - # does not deserialize when modified from unsaved - obj.date = "Monday, June 2 2019" - assert not isinstance(obj.date, datetime) - - assert obj.serialize()["date"] == "Monday, June 2 2019" - - # deserializes to datetime when validate=False - obj._attribute_types["date"].__set__( - obj, "2019-06-02T00:00:00.0000Z", validate=False - ) - assert isinstance(obj.date, datetime) - - # does not deserialize when modified from saved - obj.date = "Monday, June 2 2019" - assert not isinstance(obj.date, datetime) - - obj.date = None - assert obj.date is None - - def test_datetime_invalid(self): - # This should not raise an exception - Timestamp(readonly=True).deserialize("123439", validate=False) - - def test_enum_attribute(self): - enum_attr = EnumAttribute(BandType) - assert enum_attr.deserialize("spectral") == "spectral" - assert enum_attr.serialize("spectral") == "spectral" - assert BandType.SPECTRAL == "spectral" - - def test_enum_attribute_invalid(self): - enum_attr = EnumAttribute(BandType) - with pytest.raises(ValueError): - enum_attr.deserialize("foobar") - - def test_enum_attribute_new(self): - owner = FakeCatalogObject(id="id") - enum_attr = EnumAttribute(BandType) - owner._attribute_types["something"] = enum_attr - enum_attr._attribute_name = "something" - - enum_attr.__set__(owner, "foobar", False) - with pytest.raises(ValueError): - enum_attr.__set__(owner, "foobar", True) - - def test_mapping_attributes(self): - nested = Nested(foo="foo", dt="2019-02-01T00:00:00.0000Z", validate=False) - mapping = Mapping(nested=nested) - model_object = FakeCatalogObject(id="id", mapping=mapping) - - assert model_object.mapping.nested.foo == "foo" - assert model_object.mapping.nested.dt == datetime( - 2019, 2, 1, tzinfo=timezone.utc - ) - assert model_object.mapping is mapping - assert model_object.mapping.nested is nested - - m_repr = repr(model_object.mapping) - match_str = """\ - Mapping: - nested: Nested: - dt: 2019-02-01 00:00:00+00:00 - foo: foo""" - assert m_repr.strip("\n") == textwrap.dedent(match_str) - - with pytest.raises(TypeError): - Mapping("positionals not accepted") - - def test_mapping_change_tracking(self): - nested = Nested(foo="foo", dt="2019-02-01T00:00:00.0000Z") - mapping = Mapping(nested=nested) - model_object = FakeCatalogObject(id="id", mapping=mapping, _saved=True) - assert not model_object.is_modified - - # changes to mapping objects not accessed from the model_object - # affect model state - nested.foo = "blah" - assert model_object.is_modified - assert model_object.mapping.nested.foo == "blah" - - # assigning a new attribute value to the model does propagate state changes - new_mapping = Mapping( - nested=Nested(foo="bar", dt=datetime(2019, 3, 1, tzinfo=timezone.utc)) - ) - model_object.mapping = new_mapping - assert model_object.is_modified - assert model_object.mapping.nested.foo == "bar" - assert model_object.mapping.nested.dt == datetime( - 2019, 3, 1, tzinfo=timezone.utc - ) - assert model_object.mapping is new_mapping - assert len(mapping._model_objects) == 0 - - def test_mapping_references(self): - nested = Nested(foo="foo", dt="2019-02-01T00:00:00.0000Z") - mapping = Mapping(nested=nested, bar="bar") - model_object = FakeCatalogObject(id="id", mapping=mapping, _saved=True) - assert not model_object.is_modified - - # once a model mapping attribute is accessed, the reference is reused - mapping1 = model_object.mapping - mapping2 = model_object.mapping - assert mapping1 is mapping2 - assert mapping1.nested is mapping2.nested - - # changes propagate to all references - assert mapping1.bar == "bar" - assert mapping2.bar == "bar" - mapping1.bar = "baz" - assert mapping1.bar == "baz" - assert mapping2.bar == "baz" - assert model_object.mapping.bar == "baz" - - def test_mapping_multiple_assignment(self): - nested = Nested(foo="foo", dt="2019-02-01T00:00:00.0000Z") - mapping = Mapping(nested=nested, bar="bar") - model_object1 = FakeCatalogObject(id="id", mapping=mapping, _saved=True) - model_object2 = FakeCatalogObject(id="id", mapping=mapping, _saved=True) - assert not model_object1.is_modified - assert not model_object2.is_modified - - # changing 1 reference propagates to all referencing objects - model_object1.mapping.bar = "baz" - assert model_object1.is_modified - assert model_object2.is_modified - - def test_mapping_nested_change_tracking(self): - nested = Nested(foo="foo", dt="2019-02-01T00:00:00.0000Z") - mapping = Mapping(nested=nested) - model_object = FakeCatalogObject(id="id", mapping=mapping, _saved=True) - assert not model_object.is_modified - - # state changes at any level propagate the change back to the model - model_object.mapping.nested.foo = "baz" - assert model_object.mapping.nested.foo == "baz" - assert model_object.is_modified - assert "mapping" in model_object._modified - - def test_mapping_serialization(self): - nested = Nested(foo="foo", dt="2019-02-01T00:00:00.0000Z", validate=False) - mapping = Mapping(nested=nested) - model_object = FakeCatalogObject(id="id", mapping=mapping) - - serialized = model_object.serialize(modified_only=True) - assert serialized == { - "mapping": {"nested": {"foo": "foo", "dt": "2019-02-01T00:00:00+00:00"}} - } - assert model_object._attributes is not serialized - - def test_mapping_deserialization(self): - # Creation with valid enum should be fine - model_object = FakeCatalogObject(id="id", mapping={"nested": {"en": "One"}}) - mapping = model_object.mapping - nested = mapping.nested - assert nested.en == "One" - - # Creation with invalid enum with values from server should be fine - model_object = FakeCatalogObject( - id="id", mapping={"nested": {"en": "Four"}}, _saved=True - ) - mapping = model_object.mapping - nested = mapping.nested - assert nested.en == "Four" - - # Creation with invalid enum causes exception - with pytest.raises(ValueError): - FakeCatalogObject(id="id", mapping={"nested": {"en": "Four"}}) - - # Creation with undefined attribute from server should be fine - model_object = FakeCatalogObject(id="id", mapping={"baz": "qux"}, _saved=True) - mapping = model_object.mapping - assert "baz" not in mapping._attributes - - # Creation with undefined attribute causes exception - with pytest.raises(AttributeError): - FakeCatalogObject(id="id", mapping={"baz": "qux"}) - - def test_mapping_equality(self): - assert Mapping() != Nested() - assert Mapping() == Mapping() - assert Mapping(bar="bar") == Mapping(bar="bar") - assert Mapping(bar="bar") != Mapping() - assert Mapping() != Mapping(bar="bar") - assert Mapping(bar="bar1") != Mapping(bar="bar2") - assert Mapping(bar="bar", nested=Nested(foo="foo")) == Mapping( - bar="bar", nested=Nested(foo="foo") - ) - assert Mapping(bar="bar", nested=Nested(foo="foo1")) != Mapping( - bar="bar", nested=Nested(foo="foo2") - ) - - def test_mapping_hash(self): - with pytest.raises(TypeError): - hash(Mapping()) - - def test_list_attributes(self): - nested1 = Nested(foo="zap", dt="2019-02-01T00:00:00.0000Z", validate=False) - nested2 = Nested(foo="zip", dt="2019-02-02T00:00:00.0000Z", validate=False) - - mapping1 = Mapping(nested=nested1) - mapping2 = Mapping(nested=nested2) - model_object = FakeCatalogObject( - id="id", listmapping=[mapping1, mapping2], listattribute=[12] - ) - - assert model_object.listmapping[0].nested.foo == "zap" - assert model_object.listmapping[1].nested.foo == "zip" - assert model_object.listmapping[0].nested.dt == datetime( - 2019, 2, 1, tzinfo=timezone.utc - ) - assert model_object.listmapping[1].nested.dt == datetime( - 2019, 2, 2, tzinfo=timezone.utc - ) - assert model_object.listmapping is not [mapping1, mapping2] - assert model_object.listmapping[0] is mapping1 - assert model_object.listattribute[0] == 12 - - m_repr = repr(model_object.listmapping) - match_str = """\ - [Mapping: - nested: Nested: - dt: 2019-02-01 00:00:00+00:00 - foo: zap, Mapping: - nested: Nested: - dt: 2019-02-02 00:00:00+00:00 - foo: zip]""" - - assert m_repr.strip("\n") == textwrap.dedent(match_str) - - def test_typed_list_attributes(self): - model_object = FakeCatalogObject(id="id", typedlistattribute=["string"]) - - assert model_object.typedlistattribute[0] == "string" - - with pytest.raises(AttributeValidationError): - FakeCatalogObject( - id="id", - typedlistattribute=[12], - ) - - def test_listattribute_change_tracking(self): - nested1 = Nested(foo="zap", dt="2019-02-01T00:00:00.0000Z", validate=False) - nested2 = Nested(foo="zip", dt="2019-02-02T00:00:00.0000Z", validate=False) - mapping1 = Mapping(nested=nested1) - mapping2 = Mapping(nested=nested2) - model_object = FakeCatalogObject( - id="id", listmapping=[mapping1, mapping2], _saved=True - ) - assert not model_object.is_modified - - # references to already instantiated objects are carried forward - assert model_object.listmapping[0] is mapping1 - assert model_object.listmapping[1] is mapping2 - - # changes to references not accessed from attribute still propagate changes - nested1.foo = "zop" - assert model_object.is_modified - assert model_object.listmapping[0].nested.foo == "zop" - - # assigning a new attribute value to the model does propagate state changes - new_mapping = Mapping( - nested=Nested(foo="meep", dt=datetime(2019, 3, 1, tzinfo=timezone.utc)) - ) - model_object.listmapping = [new_mapping] - assert model_object.is_modified - assert model_object.listmapping[0].nested.foo == "meep" - assert model_object.listmapping[0].nested.dt == datetime( - 2019, 3, 1, tzinfo=timezone.utc - ) - - def test_listattribute_deserialization(self): - # Creation with valid enum should be fine - model_object = FakeCatalogObject( - id="id", - listmapping=[{"nested": {"en": "One"}}, {"nested": {"en": "Three"}}], - ) - listmapping = model_object.listmapping - nested1 = listmapping[0].nested - assert nested1.en == "One" - nested2 = listmapping[1].nested - assert nested2.en == "Three" - - # Creation with invalid enum with values from server should be fine - model_object = FakeCatalogObject( - id="id", listmapping=[{"nested": {"en": "Four"}}], _saved=True - ) - listmapping = model_object.listmapping - nested = listmapping[0].nested - assert nested.en == "Four" - - # Creation with invalid enum causes exception - with pytest.raises(ValueError): - FakeCatalogObject(id="id", listmapping=[{"nested": {"en": "Four"}}]) - - def test_listattribute_container_methods(self): - nested1 = Nested(foo="zap", dt="2019-02-01T00:00:00.0000Z", validate=False) - nested2 = Nested(foo="zip", dt="2019-02-02T00:00:00.0000Z", validate=False) - mapping1 = Mapping(nested=nested1) - mapping2 = Mapping(nested=nested2) - model_object1 = FakeCatalogObject( - id="id1", - listmapping=[mapping1, mapping2], - listattribute=["hi"], - _saved=True, - ) - model_object2 = FakeCatalogObject( - id="id2", - listmapping=[mapping1, mapping2], - listattribute=["hi"], - _saved=True, - ) - - assert model_object1.listmapping == model_object2.listmapping - assert model_object1.listattribute == model_object2.listattribute - - model_object1.listmapping.append(Mapping(nested=nested1)) - model_object1.listattribute.append("hello") - - assert model_object1.listmapping != model_object2.listmapping - assert model_object1.listattribute != model_object2.listattribute - - assert len(model_object1.listmapping) == 3 - assert len(model_object1.listattribute) == 2 - - sliced_list = model_object1.listmapping[1:] - assert len(sliced_list) == 2 - assert sliced_list[0] == mapping2 - - # since model_object1 and model_object2 have different ListAttribute - # instances, one should me modified, and the other not - # they should still retain references to contained MappingAttributes though! - assert model_object1.state == DocumentState.MODIFIED - assert model_object2.state == DocumentState.SAVED - assert model_object1.listmapping[0] is model_object2.listmapping[0] - - popped_attr = model_object1.listattribute.pop() - popped_mapping = model_object1.listmapping.pop() - assert popped_attr == "hello" - assert popped_mapping == mapping1 - assert len(model_object1.listmapping) == 2 - assert len(model_object1.listattribute) == 1 - - def test_listattribute_delegate_methods(self): - nested1 = Nested(foo="zap", dt="2019-02-01T00:00:00.0000Z", validate=False) - nested2 = Nested(foo="zip", dt="2019-02-02T00:00:00.0000Z", validate=False) - mapping1 = Mapping(nested=nested1) - mapping2 = Mapping(nested=nested2) - model_object = FakeCatalogObject( - id="id1", - listmapping=[mapping1, mapping2], - listattribute=["hi", "bye"], - _saved=True, - ) - - # magigmethods - la = model_object.listattribute - map_la = model_object.listmapping - assert la + [2] == ["hi", "bye", 2] - assert "hi" in la - assert la * 2 == ["hi", "bye", "hi", "bye"] - assert list(iter(la)) == ["hi", "bye"] - assert list(reversed(la)) == ["bye", "hi"] - - assert map_la + [dict(bar="baz")] == [mapping1, mapping2, Mapping(bar="baz")] - assert mapping1 in map_la - assert map_la * 2 == [mapping1, mapping2, mapping1, mapping2] - assert list(iter(map_la)) == [mapping1, mapping2] - assert list(reversed(map_la)) == [mapping2, mapping1] - - # comparison magicmethods - assert la >= ["a"] - assert la >= ["hi"] - assert la > ["a"] - assert la == ["hi", "bye"] - assert la != ["hi!"] - assert la <= ["hi!"] - assert la <= ["hi", "bye"] - assert la < ["hi!"] - - assert map_la >= [mapping1] - assert map_la >= [mapping1, mapping2] - assert map_la > [mapping1] - assert map_la == [mapping1, mapping2] - assert map_la != [mapping1] - assert map_la <= [mapping1, mapping2, mapping2] - assert map_la <= [mapping1, mapping2] - assert map_la < [mapping1, mapping2, mapping2] - - with pytest.raises(TypeError): - la >= 1 - - # other methods - assert la.count("bye") == 1 - assert la.index("bye") == 1 - - assert map_la.count(mapping2) == 1 - assert map_la.index(mapping2) == 1 - - # copy is only in py3 - copy = la.copy() - assert copy is not la - assert copy == la - - map_copy = map_la.copy() - assert map_copy is not map_la - assert map_copy == map_la - - # none of these should have changed the list - assert la == ["hi", "bye"] - assert map_la == [mapping1, mapping2] - assert not model_object.is_modified - - # methods that create copies are shallow and don't detach the ListAttribute from - # contained MappingAttributes, so modifications to those contained objects - # still propagate changes - new_map_la = map_la + [dict(bar="baz")] - assert len(new_map_la) == 3 - assert not model_object.is_modified - new_map_la[-1].bar = "qux" - assert not model_object.is_modified - new_map_la[0].bar = "quux" - assert model_object.is_modified - - def test_list_attribute_delegate_mutable_methods_simple_items(self): - model_object = FakeCatalogObject( - id="id1", listattribute=["hi", "bye"], _saved=True - ) - - # magigmethods - la = model_object.listattribute - la += [2] - assert la == ["hi", "bye", 2] - assert model_object.is_modified - model_object._clear_modified_attributes() - - la.append(2) - assert la == ["hi", "bye", 2, 2] - assert model_object.is_modified - model_object._clear_modified_attributes() - - la *= 2 - assert la == ["hi", "bye", 2, 2, "hi", "bye", 2, 2] - assert model_object.is_modified - model_object._clear_modified_attributes() - - del la[:] - assert la == [] - assert model_object.is_modified - model_object._clear_modified_attributes() - - la.append(0) - la.clear() - assert la == [] - assert model_object.is_modified - model_object._clear_modified_attributes() - - la.extend([1, 2]) - assert la == [1, 2] - assert model_object.is_modified - model_object._clear_modified_attributes() - - la.insert(1, 3) - assert la == [1, 3, 2] - assert model_object.is_modified - model_object._clear_modified_attributes() - - la.pop() - assert la == [1, 3] - assert model_object.is_modified - model_object._clear_modified_attributes() - - la.remove(3) - assert la == [1] - assert model_object.is_modified - model_object._clear_modified_attributes() - - la.extend([2, 3]) - la.reverse() - assert la == [3, 2, 1] - assert model_object.is_modified - model_object._clear_modified_attributes() - - la.sort() - assert la == [1, 2, 3] - assert model_object.is_modified - model_object._clear_modified_attributes() - - # assignment - la[1] = "foo" - assert la == [1, "foo", 3] - assert model_object.is_modified - model_object._clear_modified_attributes() - - # slice assignment is particularly crazy - la[1:3] = "abc" - assert la == [1, "a", "b", "c"] - assert model_object.is_modified - model_object._clear_modified_attributes() - - def test_list_attribute_modification(self): - model_object = FakeCatalogObject( - id="id1", listmapping=[], listattribute=[5, 5, 5, 5], _saved=True - ) - la = model_object.listmapping - la.sort() - assert not model_object.is_modified - la.reverse() - assert not model_object.is_modified - del la[:] - assert not model_object.is_modified - la += [] - assert not model_object.is_modified - la *= 5 - assert not model_object.is_modified - - la += [{"bar": 5}] - assert model_object.is_modified - - model_object._clear_modified_attributes() - la.sort() - assert not model_object.is_modified - la.reverse() - assert not model_object.is_modified - la *= 1 - assert not model_object.is_modified - del la[:] - - laa = model_object.listattribute - model_object._clear_modified_attributes() - laa.sort() - assert not model_object.is_modified - laa.reverse() - assert not model_object.is_modified - - la = [Mapping(bar="foo"), Mapping(bar=1), Mapping(bar=2), Mapping(bar=3)] - model_object._clear_modified_attributes() - la[1:3] = [dict(bar=1), dict(bar=2)] - assert not model_object.is_modified - la[-1].bar = 3 - assert not model_object.is_modified - - def test_list_attribute_delegate_mutable_methods_mapping_items(self): - nested1 = Nested(foo="zap", dt="2019-02-01T00:00:00.0000Z", validate=False) - nested2 = Nested(foo="zip", dt="2019-02-02T00:00:00.0000Z", validate=False) - mapping1 = Mapping(nested=nested1) - mapping2 = Mapping(nested=nested2) - model_object = FakeCatalogObject( - id="id1", listmapping=[mapping1, mapping2], _saved=True - ) - - la = model_object.listmapping - la.append(dict(bar="foo")) - assert la == [mapping1, mapping2, Mapping(bar="foo")] - assert model_object.is_modified - model_object._clear_modified_attributes() - la[-1].bar = "baz" - assert model_object.is_modified - model_object._clear_modified_attributes() - - la *= 2 - assert la == [ - mapping1, - mapping2, - Mapping(bar="baz"), - mapping1, - mapping2, - Mapping(bar="baz"), - ] - assert model_object.is_modified - model_object._clear_modified_attributes() - la[-1].bar = "zab" # new different item is attached - assert model_object.is_modified - model_object._clear_modified_attributes() - - del la[:] - assert la == [] - assert model_object.is_modified - model_object._clear_modified_attributes() - mapping1.bar = "baz" - assert not model_object.is_modified - - la.append(dict(bar="foo")) - new_mapping = la[0] - la.clear() - assert la == [] - assert model_object.is_modified - model_object._clear_modified_attributes() - new_mapping.bar = "baz" - assert not model_object.is_modified - - la.extend([dict(bar="foo"), dict(bar="baz")]) - assert la == [Mapping(bar="foo"), Mapping(bar="baz")] - assert model_object.is_modified - model_object._clear_modified_attributes() - - la.insert(1, dict(bar="qux")) - assert la == [Mapping(bar="foo"), Mapping(bar="qux"), Mapping(bar="baz")] - assert model_object.is_modified - model_object._clear_modified_attributes() - - popped = la.pop() - assert la == [Mapping(bar="foo"), Mapping(bar="qux")] - assert model_object.is_modified - model_object._clear_modified_attributes() - popped.bar = "quux" - assert not model_object.is_modified - model_object._clear_modified_attributes() - - removed = la[1] - la.remove(Mapping(bar="qux")) - assert la == [Mapping(bar="foo")] - assert model_object.is_modified - model_object._clear_modified_attributes() - removed.bar = "quux" - assert not model_object.is_modified - - # slice assignment is crazy - la[1:3] = [dict(bar=1), dict(bar=2), dict(bar=3)] - assert la == [ - Mapping(bar="foo"), - Mapping(bar=1), - Mapping(bar=2), - Mapping(bar=3), - ] - assert model_object.is_modified - model_object._clear_modified_attributes() - la[-1].bar = 4 - assert model_object.is_modified - - def test_listattribute_extra_properties_attribute(self): - la = ListAttribute(ExtraPropertiesAttribute(mutable=False)) - la.append({"one": "two"}) - la.append({"three": "four"}) - assert la[0] == ExtraPropertiesAttribute({"one": "two"}) - assert la[1] == ExtraPropertiesAttribute({"three": "four"}) - - with self.assertRaises(AttributeValidationError): - la[1]["three"] = "six" - - class ExtraPropertiesListCatalogObject(CatalogObject): - epl = ListAttribute(ExtraPropertiesAttribute) - - eplco = ExtraPropertiesListCatalogObject( - epl=[{"one": "two"}, {"three": "four"}], _saved=True - ) - assert not eplco.is_modified - eplco.epl.append({"five": "six", "seven": "eight"}) - assert eplco.is_modified - - def test_deepcopy(self): - r = Resolution(value=2, unit="meters") - r_copy = deepcopy(r) - assert r is not r_copy - assert r == r_copy - - r.value = 0 - r_copy.value = 20 - assert r.value == 0 - assert r_copy.value == 20 - - f = File(href="foo") - f_copy = deepcopy(f) - assert f is not f_copy - assert f == f_copy - - f_copy.href = "bar" - assert f.href == "foo" - assert f_copy.href == "bar" - - la = ListAttribute(File) - la.append(f) - la_copy = deepcopy(la) - assert la is not la_copy - assert la == la_copy - - la_copy.append(f_copy) - assert la == [f] - assert la_copy == [f, f_copy] - - def test_create_non_attr(self): - with pytest.raises(AttributeError): - Resolution(value=2, bar="foo") - - def test_set_non_attr(self): - r = Resolution() - - with pytest.raises(AttributeError): - r.foo = "bad" - - def test_del(self): - class Obj(CatalogObject): - attr = Attribute() - mapping = Resolution() - list = ListAttribute(Attribute) - - o = Obj(attr="something", mapping={"value": 5}, list=["one"]) - assert len(o._attributes) == 3 - del o.attr - del o.mapping - del o.list - assert len(o._attributes) == 0 - - def test_set_immutable(self): - class Obj(CatalogObject): - attr = Attribute(mutable=False) - mapping = Resolution(mutable=False) - list = ListAttribute(Attribute, mutable=False) - - o = Obj() - o.attr = "something" - o.mapping = Resolution(value=5) - o.list = ["something"] - - # Cannot reassign - with self.assertRaises(AttributeValidationError): - o.attr = "something else" - with self.assertRaises(AttributeValidationError): - o.mapping = Resolution() - with self.assertRaises(AttributeValidationError): - o.list = [] - with self.assertRaises(AttributeValidationError): - o.mapping = None - with self.assertRaises(AttributeValidationError): - o.list = None - - # Cannot change value of immutable mapping - with self.assertRaises(AttributeValidationError): - o.mapping.unit = "meters" - with self.assertRaises(AttributeValidationError): - o.mapping.value = 6 - with self.assertRaises(AttributeValidationError): - o.list[0] = "something else" - with self.assertRaises(AttributeValidationError): - o.list.append("something else") - - def test_set_mutable_and_immutable(self): - class Obj(CatalogObject): - mapping = Resolution() - list = ListAttribute(Attribute) - - class ImmutableObj(CatalogObject): - mapping = Resolution(mutable=False) - list = ListAttribute(Attribute, mutable=False) - - r = Resolution(value=5) - la = ListAttribute(Attribute, items=["one", "two"]) - - mutable = Obj(mapping=r, list=la) - mutable.mapping.value = 6 - mutable.list.append("three") - - mutable2 = Obj(mapping=r, list=la) - mutable2.mapping.value = 7 - mutable2.list.append("four") - - assert mutable.mapping.value == 7 - - mutable2.mapping = Resolution() - mutable2.mapping.value = 8 - mutable2.list = ["six"] - - # Once assigned to an immutable object, the shared attributes are immutable - immutable = ImmutableObj(mapping=r, list=la) - - with self.assertRaises(AttributeValidationError): - mutable.mapping.value = 9 - with self.assertRaises(AttributeValidationError): - mutable.list.append("seven") - - # Can't delete immutable attributes - with self.assertRaises(AttributeValidationError): - del immutable.mapping - with self.assertRaises(AttributeValidationError): - del immutable.list - - assert r == Resolution(value=7) - assert la == ["one", "two", "three", "four"] - - def test_set_readonly(self): - class Obj(CatalogObject): - readonly_attr = Attribute(readonly=True) - readonly_mapping = Resolution(readonly=True) - readonly_list = ListAttribute(Attribute, readonly=True) - - # First check simple assignment - o = Obj() - with self.assertRaises(AttributeValidationError): - o.readonly_attr = "something" - with self.assertRaises(AttributeValidationError): - o.readonly_mapping = Resolution() - with self.assertRaises(AttributeValidationError): - o.readonly_list = [] - assert not o.is_modified - - # Next check re-assignment - o = Obj( - readonly_attr="something", - readonly_mapping=Resolution(value=5, unit="meters"), - readonly_list=[], - _saved=True, - ) - with self.assertRaises(AttributeValidationError): - o.readonly_attr = "something else" - with self.assertRaises(AttributeValidationError): - o.readonly_mapping = Resolution() - with self.assertRaises(AttributeValidationError): - o.readonly_mapping.value = 6 - with self.assertRaises(AttributeValidationError): - o.readonly_list = ["something"] - with self.assertRaises(AttributeValidationError): - o.readonly_list.append("something") - assert not o.is_modified - - def test_set_writable_and_readonly(self): - class Obj(CatalogObject): - mapping = Resolution() - list = ListAttribute(Attribute) - - class ReadonlyObj(CatalogObject): - mapping = Resolution(readonly=True) - list = ListAttribute(Attribute, readonly=True) - - r = Resolution(value=5) - la = ListAttribute(Attribute, items=["one", "two"]) - - obj = Obj(mapping=r, list=la) - obj.mapping.value = 6 - obj.list.append("three") - - # Once assigned to an immutable object, the shared attributes are immutable - readonly = ReadonlyObj(mapping=r, list=la, _saved=True) - - with self.assertRaises(AttributeValidationError): - readonly.mapping.value = 7 - with self.assertRaises(AttributeValidationError): - readonly.list.append("four") - - # Can't delete readonly attributes - with self.assertRaises(AttributeValidationError): - del readonly.mapping - with self.assertRaises(AttributeValidationError): - del readonly.list - - assert r == Resolution(value=6) - assert la == ["one", "two", "three"] - - def test_resolution_string(self): - Resolution("60m") - Resolution("-6.5 deg.") - with self.assertRaises(AttributeValidationError): - Resolution("60") - - class Foo(MappingAttribute): - r = Resolution() - - Foo.r = "60 m." - Foo.r = "1.234 °" - with self.assertRaises(AttributeValidationError): - Resolution("m") - - def test_create_and_assign_property_attribute(self): - d = {"one": "two", "three": "four"} - p = ExtraPropertiesAttribute(d) - assert p.serialize(p) == d - assert p.serialize(p) is not d - - class Foo(CatalogObject): - properties = ExtraPropertiesAttribute() - - f = Foo(properties=d) - f = Foo(properties=ExtraPropertiesAttribute(d)) - f.properties = d - f.properties = ExtraPropertiesAttribute(d) - - def test_model_for_property_attribute(self): - class Foo(CatalogObject): - properties = ExtraPropertiesAttribute() - - d = {"one": "two", "three": "four"} - f = Foo(properties=d) - assert f.is_modified - - p = f.properties - f.properties = d - f._clear_modified_attributes() - assert not f.is_modified - - p["five"] = "six" - assert not f.is_modified - - f.properties["seven"] = "eight" - assert f.is_modified - f._clear_modified_attributes() - - del f.properties["seven"] - assert f.is_modified - assert f.properties == d - assert f.properties != p - - del p["five"] - assert f.properties == p - assert f.properties is not p - - def test_create_bad_property_attribute(self): - with self.assertRaises(AttributeValidationError): - ExtraPropertiesAttribute({15: "something"}) - with self.assertRaises(AttributeValidationError): - ExtraPropertiesAttribute({"something": object()}) - with self.assertRaises(AttributeValidationError): - ExtraPropertiesAttribute({"something": {"something_else": 15}}) - - class Foo(CatalogObject): - properties = ExtraPropertiesAttribute() - - with self.assertRaises(AttributeValidationError): - Foo(properties=True) - with self.assertRaises(AttributeValidationError): - f = Foo(properties={}) - f.properties = object() - - def test_readonly_property_attribute(self): - class Foo(CatalogObject): - properties = ExtraPropertiesAttribute(readonly=True) - - d = {"one": "two", "three": "four"} - f = Foo(properties=d, _saved=True) - assert not f.is_modified - - with self.assertRaises(AttributeValidationError): - f.properties = d - with self.assertRaises(AttributeValidationError): - f.properties["six"] = "seven" - with self.assertRaises(AttributeValidationError): - del f.properties["one"] diff --git a/descarteslabs/core/catalog/tests/test_band.py b/descarteslabs/core/catalog/tests/test_band.py deleted file mode 100644 index a87fe9a4..00000000 --- a/descarteslabs/core/catalog/tests/test_band.py +++ /dev/null @@ -1,572 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 pytest -import responses -import textwrap - -from ...common.property_filtering import Properties - -from .base import ClientTestCase -from ..attributes import AttributeValidationError, DocumentState -from ..band import ( - Band, - MaskBand, - SpectralBand, - ProcessingStepAttribute, - DerivedParamsAttribute, - DataType, -) -from ..product import Product - - -class TestBand(ClientTestCase): - def test_create(self): - s = SpectralBand( - name="test", product_id="foo", physical_range=[0, 1], wavelength_nm_max=1200 - ) - assert "foo:test" == s.id - assert "spectral" == s.type - assert 1200 == s.wavelength_nm_max - assert (0.0, 1.0) == s.physical_range - with pytest.raises(AttributeError): - s.frequency # Attribute from a different band type - - s = SpectralBand(name="test", product=Product(id="foo", _saved=True)) - assert "foo:test" == s.id - assert "foo" == s.product_id - assert "product_id" in s._modified - - with pytest.raises(AttributeValidationError): - s = SpectralBand(id="someid", name="test", product_id="foo") - - with pytest.raises(AttributeValidationError): - SpectralBand( - name="test", product_id="foo", product=Product(id="bar", _saved=True) - ) - - def test_constructor_no_id(self): - s = SpectralBand() - s.name = "test" - s.product_id = "foo" - assert "foo:test" == s.id - assert "test" == s.name - assert "foo" == s.product_id - - def test_constructor_no_name_and_product_id(self): - s = SpectralBand() - s.id = "foo:test" - assert "foo:test" == s.id - assert "test" == s.name - assert "foo" == s.product_id - - def test_constructor_bad_id(self): - with pytest.raises(AttributeValidationError): - s = SpectralBand() - s.id = "foo" - - def test_set_id_using_type(self): - s = SpectralBand() - s.name = "test" - s.product_id = "foo" - assert "foo:test" == s.id - s._get_attribute_type("id").__set__(s, "foo:test") - - @responses.activate - def test_get_subtype(self): - self.mock_response( - responses.GET, - { - "data": { - "attributes": { - "modified": "2019-06-11T23:31:33.714883Z", - "created": "2019-06-11T23:31:33.714883Z", - "name": "blue", - "product_id": "p1", - "type": "spectral", - "physical_range": [0.0, 1.0], - "wavelength_nm_min": 2000, - }, - "type": "band", - "id": "p1:blue", - }, - "included": [ - {"attributes": {"name": "P1"}, "id": "p1", "type": "product"} - ], - "jsonapi": {"version": "1.0"}, - }, - ) - - b = Band.get("p1:blue", client=self.client) - assert isinstance(b, SpectralBand) - assert (0.0, 1.0) == b.physical_range - assert 2000 == b.wavelength_nm_min - assert "P1" == b.product.name - - b_repr = repr(b) - match_str = """\ - SpectralBand: blue - id: p1:blue - product: p1 - created: Tue Jun 11 23:31:33 2019""" - assert b_repr.strip("\n") == textwrap.dedent(match_str) - - b = SpectralBand.get("p1:blue", client=self.client) - assert isinstance(b, SpectralBand) - assert 2000 == b.wavelength_nm_min - - @responses.activate - def test_list_subtype(self): - self.mock_response( - responses.PUT, - { - "meta": {"count": 2}, - "data": [ - { - "attributes": { - "modified": "2019-06-11T23:31:33.714883Z", - "created": "2019-06-11T23:31:33.714883Z", - "name": "blue", - "product_id": "p1", - "type": "spectral", - "wavelength_nm_min": 2000, - }, - "type": "band", - "id": "p1:blue", - }, - { - "attributes": { - "modified": "2019-06-11T23:31:33.714883Z", - "created": "2019-06-11T23:31:33.714883Z", - "name": "alpha", - "product_id": "p1", - "type": "mask", - }, - "type": "band", - "id": "p1:alpha", - }, - ], - "links": {"self": "https://www.example.com/catalog/v2/bands"}, - "jsonapi": {"version": "1.0"}, - }, - ) - - results = list( - Band.search(client=self.client).filter(Properties().product_id == "p1") - ) - assert 2 == len(results) - assert isinstance(results[0], SpectralBand) - assert isinstance(results[1], MaskBand) - - def test_vendor_band_name(self): - s = SpectralBand( - name="test", - product_id="foo", - ) - assert s.vendor_band_name is None - - s = SpectralBand( - name="test", - product_id="foo", - vendor_band_name=None, - ) - assert s.vendor_band_name is None - - s = SpectralBand(name="test", product_id="foo", vendor_band_name="some_band") - assert "some_band" == s.vendor_band_name - - def test_search(self): - search = Band.search() - assert search._filter_properties is None - - search = MaskBand.search() - assert search._serialize_filters() == [ - {"name": "type", "val": "mask", "op": "eq"} - ] - - def test_instantiate_band(self): - with pytest.raises(TypeError): - Band(name="test", product_id="foo", wavelength_nm_max=1200) - - def test_id(self): - product_id = "some_product_id" - band_name = "some_band_name" - id = "{}:{}".format(product_id, band_name) - - # All successful permutations for id, product_id, and name - b = SpectralBand(id=id) - self.assertEqual(b.id, id) - self.assertEqual(b.product_id, product_id) - self.assertEqual(b.name, band_name) - - b = SpectralBand(id=id, name=band_name) - self.assertEqual(b.id, id) - self.assertEqual(b.product_id, product_id) - self.assertEqual(b.name, band_name) - - b = SpectralBand(id=id, product_id=product_id) - self.assertEqual(b.id, id) - self.assertEqual(b.product_id, product_id) - self.assertEqual(b.name, band_name) - - b = SpectralBand(product_id=product_id, name=band_name) - self.assertEqual(b.id, id) - self.assertEqual(b.product_id, product_id) - self.assertEqual(b.name, band_name) - - b = SpectralBand(id=id, product_id=product_id, name=band_name) - self.assertEqual(b.id, id) - self.assertEqual(b.product_id, product_id) - self.assertEqual(b.name, band_name) - - # Verify failures - with pytest.raises(AttributeValidationError): - b = SpectralBand(id=id, product_id="foo") - - with pytest.raises(AttributeValidationError): - b = SpectralBand(id=id, name="foo") - - with pytest.raises(AttributeValidationError): - b = SpectralBand(id=id, product_id="foo", name="foo") - - with pytest.raises(AttributeValidationError): - b = SpectralBand(id=band_name, product_id="foo", name="foo") - - with pytest.raises(AttributeValidationError): - b = SpectralBand(id=band_name) - - with pytest.raises(AttributeValidationError): - b = SpectralBand(id=band_name, name=band_name) - - with pytest.raises(AttributeValidationError): - b = SpectralBand(id=band_name, product_id=product_id, name="foo") - - def test_name_with_colon(self): - product_id = "some_product_id" - band_name = "some:band:name" - id = "{}:{}".format(product_id, band_name) - - # Verify that V1 data will deserialize correctly - b = SpectralBand(id=id, name=band_name, product_id=product_id, _saved=True) - self.assertEqual(b.id, id) - self.assertEqual(b.product_id, product_id) - self.assertEqual(b.name, band_name) - - def test_processing_levels_create(self): - band_id = "some_product_id:band" - - b = SpectralBand(id=band_id) - self.assertIsNone(b.processing_levels) - - b = SpectralBand(id=band_id, processing_levels={"default": "toa", "toa": []}) - self.assertEqual(b.processing_levels, {"default": "toa", "toa": []}) - - b = SpectralBand( - id=band_id, - processing_levels={ - "default": "toa_reflectance", - "toa_reflectance": [ - {"function": "fun", "parameter": "param", "index": 0} - ], - }, - ) - self.assertEqual( - b.processing_levels, - { - "default": "toa_reflectance", - "toa_reflectance": [ - ProcessingStepAttribute(function="fun", parameter="param", index=0) - ], - }, - ) - - b = SpectralBand( - id=band_id, - processing_levels={ - "default": "toa_reflectance", - "toa_reflectance": [ - { - "function": "fun", - "parameter": "param", - "index": 0, - "data_type": "Float64", - "data_range": [0, 1], - "display_range": [0, 0.4], - "physical_range": [0, 1], - "physical_range_unit": "reflectance", - } - ], - }, - ) - self.assertEqual( - b.processing_levels, - { - "default": "toa_reflectance", - "toa_reflectance": [ - ProcessingStepAttribute( - function="fun", - parameter="param", - index=0, - data_type=DataType("Float64"), - data_range=(0.0, 1.0), - display_range=(0, 0.4), - physical_range=(0.0, 1.0), - physical_range_unit="reflectance", - ) - ], - }, - ) - - with pytest.raises(AttributeValidationError): - SpectralBand(id=band_id, processing_levels={"default": 1}) - - with pytest.raises(AttributeValidationError): - SpectralBand(id=band_id, processing_levels=[]) - - with pytest.raises(AttributeValidationError): - SpectralBand( - id=band_id, processing_levels={"default": "toa", "toa": ["string"]} - ) - - with pytest.raises(AttributeValidationError): - SpectralBand( - id=band_id, - processing_levels={ - "default": "toa_reflectance", - "toa_reflectance": [ - {"function": "fun", "parameter": "param", "index": "foo"} - ], - }, - ) - - with pytest.raises(AttributeValidationError): - SpectralBand( - id=band_id, - processing_levels={ - "default": "toa_reflectance", - "toa_reflectance": [ - { - "function": "fun", - "parameter": "param", - "index": 0, - "foo": "bar", - } - ], - }, - ) - - def test_processing_levels_modified(self): - band_id = "some_product_id:band" - pl = { - "default": "surface_reflectance", - "surface_reflectance": [ - { - "function": "gain_bias", - "parameter": "reflectance_gain_bias", - "index": 0, - } - ], - } - b = SpectralBand( - id=band_id, - processing_levels=pl, - _saved=True, - ) - - assert b.state == DocumentState.SAVED - - b.processing_levels["surface_reflectance"][0].index = 1 - assert b.state == DocumentState.MODIFIED - - # reset modified state - b._modified = set() - assert b.state == DocumentState.SAVED - - b.processing_levels["surface_reflectance"].append( - {"function": "gain_bias", "parameter": "reflectance_gain_bias", "index": 2} - ) - assert b.state == DocumentState.MODIFIED - - @responses.activate - def test_processing_levels_io(self): - band_id = "some_product_id:band" - pl = { - "default": "surface_reflectance", - "surface_reflectance": [ - { - "function": "gain_bias", - "parameter": "reflectance_gain_bias", - "index": 0, - } - ], - } - - self.mock_response( - responses.POST, - { - "data": { - "attributes": { - "modified": "2019-06-11T23:31:33.714883Z", - "created": "2019-06-11T23:31:33.714883Z", - "name": "band", - "product_id": "some_product_id", - "type": "spectral", - "processing_levels": pl, - }, - "type": "band", - "id": "p1:blue", - }, - "links": { - "self": "https://www.example.com/catalog/v2/bands/{}".format( - band_id - ) - }, - "jsonapi": {"version": "1.0"}, - }, - ) - - b = SpectralBand( - id=band_id, - processing_levels=pl, - client=self.client, - ) - assert isinstance( - b.processing_levels["surface_reflectance"][0], ProcessingStepAttribute - ) - b.save() - assert isinstance( - b.processing_levels["surface_reflectance"][0], ProcessingStepAttribute - ) - assert self.get_request_body(0)["data"]["attributes"]["processing_levels"] == pl - - def test_derived_params_create(self): - band_id = "some_product_id:band" - - b = SpectralBand(id=band_id) - self.assertIsNone(b.derived_params) - - b = SpectralBand(id=band_id, derived_params=None) - self.assertIsNone(b.derived_params) - - b = SpectralBand( - id=band_id, derived_params={"function": "function", "bands": ["band"]} - ) - self.assertEqual( - b.derived_params, - DerivedParamsAttribute(function="function", bands=["band"]), - ) - - b = SpectralBand( - id=band_id, - derived_params={ - "function": "function", - "bands": ["band"], - "source_type": "UInt16", - }, - ) - self.assertEqual( - b.derived_params, - DerivedParamsAttribute( - function="function", bands=["band"], source_type="UInt16" - ), - ) - - with pytest.raises(AttributeValidationError): - SpectralBand(id=band_id, derived_params={}) - - with pytest.raises(AttributeValidationError): - SpectralBand(id=band_id, derived_params={"function": 1, "bands": ["band"]}) - - with pytest.raises(AttributeValidationError): - SpectralBand( - id=band_id, derived_params={"function": "function", "bands": [1]} - ) - - with pytest.raises(AttributeValidationError): - SpectralBand( - id=band_id, - derived_params={ - "function": "function", - "bands": ["band"], - "source_type": "not a data type", - }, - ) - - def test_derived_params_modified(self): - band_id = "some_product_id:band" - dp = { - "function": "function", - "bands": ["band"], - "source_type": "UInt16", - } - b = SpectralBand( - id=band_id, - derived_params=dp, - _saved=True, - ) - - assert b.state == DocumentState.SAVED - - b.derived_params.source_type = "UInt32" - assert b.state == DocumentState.MODIFIED - - # reset modified state - b._modified = set() - assert b.state == DocumentState.SAVED - - b.derived_params.bands.append("band2") - assert b.state == DocumentState.MODIFIED - - @responses.activate - def test_derived_params_io(self): - band_id = "some_product_id:band" - dp = { - "function": "function", - "bands": ["band"], - "source_type": "UInt16", - } - - self.mock_response( - responses.POST, - { - "data": { - "attributes": { - "modified": "2019-06-11T23:31:33.714883Z", - "created": "2019-06-11T23:31:33.714883Z", - "name": "band", - "product_id": "some_product_id", - "type": "spectral", - "derived_params": dp, - }, - "type": "band", - "id": "p1:blue", - }, - "links": { - "self": "https://www.example.com/catalog/v2/bands/{}".format( - band_id - ) - }, - "jsonapi": {"version": "1.0"}, - }, - ) - - b = SpectralBand( - id=band_id, - derived_params=dp, - client=self.client, - ) - assert isinstance(b.derived_params, DerivedParamsAttribute) - b.save() - assert isinstance(b.derived_params, DerivedParamsAttribute) - assert self.get_request_body(0)["data"]["attributes"]["derived_params"] == dp diff --git a/descarteslabs/core/catalog/tests/test_blob.py b/descarteslabs/core/catalog/tests/test_blob.py deleted file mode 100644 index aacaae96..00000000 --- a/descarteslabs/core/catalog/tests/test_blob.py +++ /dev/null @@ -1,1045 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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. - -# -*- coding: utf-8 -*- -import copy -import json -import os -import pytest -import responses - -import textwrap - -import shapely.geometry - -from datetime import datetime -from tempfile import NamedTemporaryFile -from unittest.mock import patch - -from descarteslabs.exceptions import BadRequestError -from .base import ClientTestCase -from ..attributes import AttributeValidationError -from ..blob import Blob, BlobCollection, BlobDeletionTaskStatus, BlobSearch, StorageType -from ..blob_upload import BlobUpload -from ..catalog_base import DocumentState, DeletedObjectError -from ...common.property_filtering import Properties - - -def _namespace_id(namespace_id, client=None): - return "someorg:test-namespace" - - -def _blob_do_download(_, dest=None, range=None): - mock_data = b"This is mock download data. It can be any binary data." - - if range: - if isinstance(range, str): - range_str = range - elif isinstance(range, (list, tuple)) and all( - map(lambda x: isinstance(x, int), range) - ): - if len(range) == 1: - range_str = f"bytes={range[0]}" - elif len(range) == 2: - range_str = f"bytes={range[0]}-{range[1]}" - else: - raise ValueError("invalid range value") - else: - raise ValueError("invalid range value") - - if len(range_str.split("-")) == 2: - start_byte = int(range_str.split("-")[0].split("=")[-1]) - end_byte = int(range_str.split("-")[-1]) - mock_data = mock_data[start_byte:end_byte] - - elif len(range_str.split("-")) == 1: - start_byte = int(range_str.split("=")[-1]) - mock_data = mock_data[start_byte:] - - if dest is None: - return mock_data - else: - dest.write(mock_data) - return dest.name - - -class TestBlob(ClientTestCase): - polygon_geometry = { - "type": "Polygon", - "coordinates": [ - [ - [-95.2989209, 42.7999878], - [-93.1167728, 42.3858464], - [-93.7138666, 40.703737], - [-95.8364984, 41.1150618], - [-95.2989209, 42.7999878], - ] - ], - } - multipolygon_geometry = { - "type": "MultiPolygon", - "coordinates": [ - [ - [ - [-95.2989209, 42.7999878], - [-93.1167728, 42.3858464], - [-93.7138666, 40.703737], - [-95.8364984, 41.1150618], - [-95.2989209, 42.7999878], - ] - ], - [ - [ - [-95.3989209, 42.7999878], - [-93.4167728, 42.3858464], - [-93.7138666, 40.703737], - [-95.6364984, 41.1150618], - [-95.3989209, 42.7999878], - ] - ], - ], - } - point_geometry = { - "type": "Point", - "coordinates": [-95.2989209, 42.7999878], - } - multipoint_geometry = { - "type": "MultiPoint", - "coordinates": [ - [-95.2989209, 42.7999878], - [-96.2989209, 43.7999878], - ], - } - multipoint_all_coincident_geometry = { - "type": "MultiPoint", - "coordinates": [ - [-95.2989209, 42.7999878], - [-95.2989209, 42.7999878], - ], - } - line_geometry = { - "type": "LineString", - "coordinates": [ - [-122.17523623224433, 47.90651694142758], - [-122.13437682048007, 47.88564432387702], - ], - } - horizontal_line_geometry = { - "type": "LineString", - "coordinates": [ - [-122.17523623224433, 47.88564432387702], - [-122.13437682048007, 47.88564432387702], - ], - } - vertical_line_geometry = { - "type": "LineString", - "coordinates": [ - [-122.17523623224433, 47.90651694142758], - [-122.17523623224433, 47.88564432387702], - ], - } - multiline_geometry = { - "type": "MultiLineString", - "coordinates": [ - [ - [-122.13826819210863, 47.90599522815964], - [-122.12931803524592, 47.91303790851427], - ], - [ - [-122.13865732936327, 47.920340416616256], - [-122.12892889799097, 47.912777085591244], - ], - [ - [-122.17601450675424, 47.91277722269507], - [-122.1293180361663, 47.91277722269507], - ], - ], - } - - test_geometries = [ - polygon_geometry, - multipolygon_geometry, - point_geometry, - multipoint_geometry, - line_geometry, - horizontal_line_geometry, - vertical_line_geometry, - multiline_geometry, - ] - - test_combinations = [ - {"value": b"This is mock download data. It can be any binary data."}, - {"range": (0, 4), "value": b"This"}, - {"range": "bytes=0-4", "value": b"This"}, - {"range": (49,), "value": b"data."}, - {"range": "bytes=49", "value": b"data."}, - ] - - def test_constructor(self): - b = Blob( - name="test-blob", - id="data/someorg:test-namespace/test-blob", - storage_type="data", - storage_state="available", - description="a description", - expires="2023-01-01", - tags=["TESTING BLOB"], - ) - - assert b.name == "test-blob" - assert b.id == "data/someorg:test-namespace/test-blob" - assert b.storage_type == StorageType.DATA - assert b.storage_state == "available" - assert b.description == "a description" - assert b.tags == ["TESTING BLOB"] - assert b.state == DocumentState.UNSAVED - - def test_repr(self): - b = Blob( - name="test-blob", - id="data/someorg:test-namespace/test-blob", - storage_type="data", - storage_state="available", - description="a description", - expires="2023-01-01", - tags=["TESTING BLOB"], - ) - b_repr = repr(b) - match_str = """\ - Blob: test-blob - id: data/someorg:test-namespace/test-blob - * Not up-to-date in the Descartes Labs catalog. Call `.save()` to save or update this record.""" - assert b_repr.strip("\n") == textwrap.dedent(match_str) - - def test_set_geometry(self): - b = Blob(id="data/someorg:test/test-blob", name="test-blob") - for test_geometry in self.test_geometries: - shape = shapely.geometry.shape(test_geometry) - - b.geometry = test_geometry - assert shape == b.geometry - - b.geometry = shape - assert shape == b.geometry - - with pytest.raises(AttributeValidationError): - b.geometry = {"type": "Lollipop"} - with pytest.raises(AttributeValidationError): - b.geometry = 2 - - def test_storage_type_new(self): - b = Blob( - id="data/someorg:test-namespace/test-blob", - name="test-blob", - storage_type="nodata", - _saved=True, - ) - assert b.description is None - assert b.storage_type == "nodata" - - with pytest.raises(ValueError): - StorageType("nodata") - - def test_search_intersects(self): - search = ( - Blob.search() - .intersects(self.polygon_geometry) - .filter(Properties().id == "b1") - ) - _, request_params = search._to_request() - assert self.polygon_geometry == json.loads(request_params["intersects"]) - assert "intersects_none" not in request_params - - def test_search_intersects_none(self): - search = ( - Blob.search() - .intersects(self.polygon_geometry, match_null_geometry=True) - .filter(Properties().id == "b1") - ) - _, request_params = search._to_request() - assert self.polygon_geometry == json.loads(request_params["intersects"]) - assert request_params["intersects_none"] is True - - @responses.activate - def test_get(self): - self.mock_response( - responses.GET, - { - "data": { - "attributes": { - "created": "2023-09-29T15:54:37.006769Z", - "description": "a generic description", - "expires": None, - "extra_properties": {}, - "geometry": self.polygon_geometry, - "hash": "28495fde1c101c01f2d3ae92d1af85a5", - "href": "s3://super/long/uri/data/someorg:test-namespace/test-blob", - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-blob", - "namespace": "someorg:test-namespace", - "owners": ["org:someorg"], - "readers": ["org:someorg"], - "writers": [], - "size_bytes": 1008, - "storage_state": "available", - "storage_type": "data", - "tags": ["TESTING BLOB"], - }, - "id": "data/someorg:test-namespace/test-blob", - "type": "storage", - } - }, - status=200, - ) - - b = Blob.get(id="data/someorg:test-namespace/test-blob", client=self.client) - assert isinstance(b.created, datetime) - assert b.description == "a generic description" - assert b.expires is None - assert b.geometry == shapely.geometry.shape(self.polygon_geometry) - assert b.hash == "28495fde1c101c01f2d3ae92d1af85a5" - assert b.href == "s3://super/long/uri/data/someorg:test-namespace/test-blob" - assert isinstance(b.modified, datetime) - assert b.name == "test-blob" - assert b.namespace == "someorg:test-namespace" - assert b.owners == ["org:someorg"] - assert b.readers == ["org:someorg"] - assert b.writers == [] - assert b.size_bytes == 1008 - assert b.storage_type == StorageType.DATA - assert b.tags == ["TESTING BLOB"] - - @responses.activate - def test_get_unknown_attribute(self): - self.mock_response( - responses.GET, - { - "data": { - "attributes": { - "created": "2023-09-29T15:54:37.006769Z", - "description": "a generic description", - "expires": None, - "extra_properties": {}, - "geometry": self.polygon_geometry, - "hash": "28495fde1c101c01f2d3ae92d1af85a5", - "href": "s3://super/long/uri/data/someorg:test-namespace/test-blob", - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-blob", - "namespace": "someorg:test-namespace", - "owners": ["org:someorg"], - "readers": ["org:someorg"], - "writers": [], - "size_bytes": 1008, - "storage_state": "available", - "storage_type": "data", - "tags": ["TESTING BLOB"], - "foobar": "unknown", - }, - "id": "data/someorg:test-namespace/test-blob", - "type": "storage", - } - }, - status=200, - ) - - b = Blob.get(id="data/someorg:test-namespace/test-blob", client=self.client) - assert not hasattr(b, "foobar") - - @responses.activate - def test_get_many(self): - self.mock_response( - responses.PUT, - { - "data": [ - { - "attributes": { - "created": "2023-09-29T15:54:37.006769Z", - "description": "a generic description", - "expires": None, - "extra_properties": {}, - "geometry": self.polygon_geometry, - "hash": "28495fde1c101c01f2d3ae92d1af85a5", - "href": "s3://super/long/uri/data/someorg:test-namespace/test-blob-1", - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-blob-1", - "namespace": "someorg:test-namespace", - "owners": ["org:someorg"], - "readers": ["org:someorg"], - "writers": [], - "size_bytes": 1008, - "storage_state": "available", - "storage_type": "data", - "tags": ["TESTING BLOB"], - }, - "id": "data/someorg:test-namespace/test-blob-1", - "type": "storage", - }, - { - "attributes": { - "created": "2023-09-29T15:54:37.006769Z", - "description": "a generic description", - "expires": None, - "extra_properties": {}, - "geometry": self.polygon_geometry, - "hash": "28495fde1c101c01f2d3ae92d1af85a5", - "href": "s3://super/long/uri/data/someorg:test-namespace/test-blob-2", - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-blob-2", - "namespace": "someorg:test-namespace", - "owners": ["org:someorg"], - "readers": ["org:someorg"], - "writers": [], - "size_bytes": 1008, - "storage_state": "available", - "storage_type": "data", - "tags": ["TESTING BLOB"], - }, - "id": "data/someorg:test-namespace/test-blob-2", - "type": "storage", - }, - ], - }, - status=200, - ) - - blobs = Blob.get_many( - [ - "data/someorg:test-namespace/test-blob-1", - "data/someorg:test-namespace/test-blob-2", - ], - client=self.client, - ) - - for i, b in enumerate(blobs): - assert isinstance(b, Blob) - assert b.id == f"data/someorg:test-namespace/test-blob-{i + 1}" - - @responses.activate - def test_get_or_create(self): - self.mock_response( - responses.GET, - { - "data": { - "attributes": { - "created": "2023-09-29T15:54:37.006769Z", - "description": "a generic description", - "expires": None, - "extra_properties": {}, - "geometry": self.polygon_geometry, - "hash": "28495fde1c101c01f2d3ae92d1af85a5", - "href": "s3://super/long/uri/data/someorg:test-namespace/test-blob", - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-blob", - "namespace": "someorg:test-namespace", - "owners": ["org:someorg"], - "readers": ["org:someorg"], - "writers": [], - "size_bytes": 1008, - "storage_state": "available", - "storage_type": "data", - "tags": ["TESTING BLOB"], - }, - "id": "data/someorg:test-namespace/test-blob", - "type": "storage", - } - }, - status=200, - ) - - b = Blob.get_or_create( - id="data/someorg:test-namespace/test-blob", client=self.client - ) - assert b.id == "data/someorg:test-namespace/test-blob" - - @responses.activate - def test_list(self): - self.mock_response( - responses.PUT, - { - "meta": {"count": 2}, - "links": {"self": "https://example.com/catalog/v2/storage"}, - "data": [ - { - "attributes": { - "created": "2023-09-29T15:54:37.006769Z", - "description": "a generic description", - "expires": None, - "extra_properties": {}, - "geometry": self.polygon_geometry, - "hash": "28495fde1c101c01f2d3ae92d1af85a5", - "href": "s3://super/long/uri/data/someorg:test-namespace/test-blob-1", - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-blob-1", - "namespace": "someorg:test-namespace", - "owners": ["org:someorg"], - "readers": ["org:someorg"], - "writers": [], - "size_bytes": 1008, - "storage_state": "available", - "storage_type": "data", - "tags": ["TESTING BLOB"], - }, - "id": "data/someorg:test-namespace/test-blob-1", - "type": "storage", - }, - { - "attributes": { - "created": "2023-09-29T15:54:37.006769Z", - "description": "a generic description", - "expires": None, - "extra_properties": {}, - "geometry": self.polygon_geometry, - "hash": "28495fde1c101c01f2d3ae92d1af85a5", - "href": "s3://super/long/uri/data/someorg:test-namespace/test-blob-2", - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-blob-2", - "namespace": "someorg:test-namespace", - "owners": ["org:someorg"], - "readers": ["org:someorg"], - "writers": [], - "size_bytes": 1008, - "storage_state": "available", - "storage_type": "data", - "tags": ["TESTING BLOB"], - }, - "id": "data/someorg:test-namespace/test-blob-2", - "type": "storage", - }, - ], - }, - status=200, - ) - - search = Blob.search(client=self.client) - assert search.count() == 2 - assert isinstance(search, BlobSearch) - bc = search.collect() - assert isinstance(bc, BlobCollection) - - @responses.activate - def test_list_no_results(self): - self.mock_response( - responses.PUT, - { - "meta": {"count": 0}, - "data": [], - }, - ) - - r = list(Blob.search(client=self.client)) - assert r == [] - - @responses.activate - def test_save(self): - self.mock_response( - responses.POST, - { - "data": { - "attributes": { - "created": "2023-09-29T15:54:37.006769Z", - "description": "a generic description", - "expires": None, - "extra_properties": {}, - "geometry": self.polygon_geometry, - "hash": "28495fde1c101c01f2d3ae92d1af85a5", - "href": "s3://super/long/uri/data/someorg:test-namespace/test-blob", - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-blob", - "namespace": "someorg:test-namespace", - "owners": ["org:someorg"], - "readers": ["org:someorg"], - "writers": [], - "size_bytes": 1008, - "storage_state": "available", - "storage_type": "data", - "tags": ["TESTING BLOB"], - }, - "id": "data/someorg:test-namespace/test-blob", - "type": "storage", - } - }, - status=200, - ) - - b = Blob( - id="data/someorg:test-namespace/test-blob", - name="test-blob", - storage_state="available", - client=self.client, - ) - assert b.state == DocumentState.UNSAVED - b.save() - assert responses.calls[0].request.url == self.url + "/storage" - assert b.state == DocumentState.SAVED - - @responses.activate - def test_save_dupe(self): - self.mock_response( - responses.POST, - { - "errors": [ - { - "status": "400", - "detail": "A document with id `data/someorg:test-namespace/test-blob` already exists.", - "title": "Bad request", - } - ], - "jsonapi": {"version": "1.0"}, - }, - status=400, - ) - b = Blob(id="data/someorg:test-namespace/test-blob", client=self.client) - with pytest.raises(BadRequestError): - b.save() - - @responses.activate - def test_exists(self): - self.mock_response(responses.HEAD, {}, status=200) - assert Blob.exists("data/someorg:test-namespace/test-blob", client=self.client) - assert ( - responses.calls[0].request.url - == "https://example.com/catalog/v2/storage/data/someorg:test-namespace/test-blob" - ) - - @responses.activate - def test_exists_false(self): - self.mock_response(responses.HEAD, self.not_found_json, status=404) - assert not Blob.exists( - "data/someorg:test-namespace/nonexistent-blob", client=self.client - ) - assert ( - responses.calls[0].request.url - == "https://example.com/catalog/v2/storage/data/someorg:test-namespace/nonexistent-blob" - ) - - @responses.activate - def test_update(self): - self.mock_response( - responses.POST, - { - "meta": {"count": 1}, - "data": { - "attributes": { - "owners": ["org:someorg"], - "name": "test-blob", - "namespace": "someorg:test-namespace", - "geometry": self.polygon_geometry, - "storage_type": "data", - "storage_state": "available", - "readers": [], - "modified": "2019-06-10T18:48:13.066192Z", - "created": "2019-06-10T18:48:13.066192Z", - "writers": [], - "description": "a description", - }, - "type": "storage", - "id": "data/someorg:test-namespace/test-blob", - }, - }, - status=200, - ) - - b = Blob( - id="data/someorg:test-namespace/test-blob", - name="test-blob", - storage_state="available", - client=self.client, - ) - b.save() - assert b.state == DocumentState.SAVED - b.readers = ["org:acme-corp"] - assert b.state == DocumentState.MODIFIED - self.mock_response( - responses.PATCH, - { - "meta": {"count": 1}, - "data": { - "attributes": { - "readers": ["org:acme-corp"], - }, - "type": "storage", - "id": "data/someorg:test-namespace/test-blob", - }, - }, - status=200, - ) - b.save() - assert b.readers == ["org:acme-corp"] - - @responses.activate - def test_reload(self): - self.mock_response( - responses.POST, - { - "meta": {"count": 1}, - "data": { - "attributes": { - "owners": ["org:someorg"], - "name": "test-blob", - "namespace": "someorg:test-namespace", - "geometry": self.polygon_geometry, - "storage_type": "data", - "storage_state": "available", - "readers": [], - "modified": "2019-06-10T18:48:13.066192Z", - "created": "2019-06-10T18:48:13.066192Z", - "writers": [], - "description": "a description", - }, - "type": "storage", - "id": "data/someorg:test/test-blob", - }, - "jsonapi": {"version": "1.0"}, - "links": {"self": "https://example.com/catalog/v2/storage"}, - }, - ) - - b = Blob( - id="data/someorg:test/test-blob", - name="test-blob", - storage_state="available", - client=self.client, - ) - b.save() - assert b.state == DocumentState.SAVED - b.readers = ["org:acme-corp"] - with pytest.raises(ValueError): - b.reload() - - @responses.activate - def test_delete(self): - b = Blob( - id="data/someorg:test-namespace/test-blob", - name="test-blob", - client=self.client, - _saved=True, - ) - self.mock_response( - responses.POST, - { - "data": { - "id": "123", - "attributes": { - "status": "RUNNING", - "ids": [b.id], - }, - "type": "storage_delete", - }, - "meta": {"message": "Object successfully deleted"}, - "jsonapi": {"version": "1.0"}, - }, - status=201, - ) - - task = b.delete() - assert isinstance(task, BlobDeletionTaskStatus) - assert b.state == DocumentState.DELETED - - @responses.activate - def test_class_delete(self): - blob_id = "data/someorg:test-namespace/test-blob" - self.mock_response( - responses.POST, - { - "data": { - "id": "123", - "attributes": { - "status": "RUNNING", - "ids": [blob_id], - }, - "type": "storage_delete", - }, - "jsonapi": {"version": "1.0"}, - }, - status=201, - ) - - task = Blob.delete(blob_id, client=self.client) - assert isinstance(task, BlobDeletionTaskStatus) - assert task.id == "123" - assert task.status == "RUNNING" - assert task.ids == [blob_id] - - self.mock_response( - responses.GET, - { - "data": { - "id": "123", - "attributes": { - "status": "SUCCESS", - "objects_deleted": 1, - "errors": None, - }, - "type": "storage_delete", - }, - "jsonapi": {"version": "1.0"}, - }, - ) - - task.wait_for_completion() - assert task.status == "SUCCESS" - assert task.objects_deleted == 1 - assert task.ids == [blob_id] - - @responses.activate - def test_delete_non_existent(self): - b = Blob( - id="data/someorg:test-namespace/nonexistent-blob", - name="nonexistent-blob", - client=self.client, - _saved=True, - ) - - self.mock_response( - responses.POST, - self.not_found_json, - status=404, - ) - - with pytest.raises(DeletedObjectError): - b.delete() - - @responses.activate - def test_delete_many(self): - self.mock_response( - responses.POST, - { - "data": { - "attributes": { - "status": "RUNNING", - "start_datetime": "2024-01-01T00:00:00Z", - "ids": [ - "data/someorg:test-namespace/test-blob-0", - "data/someorg:test-namespace/test-blob-1", - ], - }, - "id": "123", - "type": "storage_delete", - } - }, - 201, - ) - self.mock_response( - responses.GET, - { - "data": { - "attributes": { - "status": "SUCCESS", - "start_datetime": "2024-01-01T00:00:00Z", - "duration_in_seconds": 1.0, - "objects_deleted": 2, - }, - "id": "123", - "type": "storage_delete", - } - }, - 201, - ) - - deleted_blobs = Blob.delete_many( - [ - "data/someorg:test-namespace/test-blob-0", - "data/someorg:test/test-blob-1", - "data/someorg:test/nonexistent-blob", - ], - wait_for_completion=True, - client=self.client, - ) - - assert "data/someorg:test-namespace/test-blob-0" in deleted_blobs - assert "data/someorg:test-namespace/test-blob-1" in deleted_blobs - assert "data/someorg:test-namespace/nonexistent-blob" not in deleted_blobs - - def test_serialize(self): - u = BlobUpload( - storage=Blob( - name="test-blob", - id="data/someorg:test-namespace/test-blob", - storage_type="data", - storage_state="available", - description="a description", - expires="2023-01-01", - tags=["TESTING BLOB"], - client=self.client, - ) - ) - serialized = u.serialize(jsonapi_format=True) - - self.assertDictEqual( - dict( - data=dict( - type=BlobUpload._doc_type, - attributes=dict( - storage=dict( - data=dict( - type="storage", - attributes=dict( - name="test-blob", - storage_type="data", - storage_state="available", - description="a description", - expires="2023-01-01", - tags=["TESTING BLOB"], - ), - id="data/someorg:test-namespace/test-blob", - ) - ) - ), - ) - ), - serialized, - ) - - @patch.object(Blob, "_do_download", _blob_do_download) - def test_data(self): - b = Blob( - name="test-blob", - id="data/someorg:test-namespace/test-blob", - storage_type="data", - storage_state="available", - description="a description", - expires="2023-01-01", - tags=["tag"], - _saved=True, - client=self.client, - ) - - for test in self.test_combinations: - test_copy = copy.deepcopy(test) - value = test_copy.pop("value") - mock_data = b.data(**test_copy) - assert mock_data == value - - with pytest.raises(ValueError): - mock_data = b.data(range=(1, "a")) - - b._saved = False - - with pytest.raises(ValueError): - b.data() - - @patch.object(Blob, "_do_download", _blob_do_download) - def test_get_data(self): - mock_data = Blob.get_data(id="data/someorg:test-namespace/test-blob") - - for test in self.test_combinations: - test_copy = copy.deepcopy(test) - value = test_copy.pop("value") - mock_data = Blob.get_data( - id="data/someorg:test-namespace/test-blob", - client=self.client, - **test_copy, - ) - assert mock_data == value - - with pytest.raises(ValueError): - mock_data = Blob.get_data( - id="data/someorg:test-namespace/test-blob", range=(1, "a") - ) - - @patch.object(Blob, "_do_download", _blob_do_download) - def test_download(self): - b = Blob( - name="test-blob", - id="data/someorg:test-namespace/test-blob", - storage_type="data", - storage_state="available", - description="a description", - expires="2023-01-01", - tags=["TESTING BLOB"], - _saved=True, - client=self.client, - ) - - with NamedTemporaryFile(delete=False) as f1: - with NamedTemporaryFile(delete=False) as f2: - try: - f1.close() - f2.close() - - result = b.download(f1.name) - - assert result == f1.name - - with open(f1.name, "r") as handle: - line = handle.readlines()[0] - - assert ( - line == "This is mock download data. It can be any binary data." - ) - - with open(f2.name, "wb") as temp: - result = b.download(temp) - - assert result == f2.name - - with open(f2.name, "r") as handle: - line = handle.readlines()[0] - - assert ( - line == "This is mock download data. It can be any binary data." - ) - - with pytest.raises(ValueError): - b.download(1) - - b._saved = False - with pytest.raises(ValueError): - b.download("wrong") - - finally: - os.unlink(f1.name) - os.unlink(f2.name) - - @patch.object(Blob, "namespace_id", _namespace_id) - def test_invalid_upload_data(self): - b = Blob( - name="test-blob", - id="data/someorg:test-namespace/test-blob", - storage_type="data", - storage_state="available", - description="a description", - expires="2023-01-01", - tags=["TESTING BLOB"], - _saved=True, - client=self.client, - ) - - b.name = None - with pytest.raises(ValueError): - b.upload_data(data="") - - @patch.object(Blob, "namespace_id", _namespace_id) - def test_invalid_upload(self): - b = Blob( - name="test-blob", - id="data/someorg:test-namespace/test-blob", - storage_type="data", - storage_state="available", - description="a description", - expires="2023-01-01", - tags=["TESTING BLOB"], - _saved=True, - client=self.client, - ) - - with NamedTemporaryFile(delete=False) as f1: - try: - f1.close() - - with pytest.raises(ValueError): - _ = b.upload(f1.name) - - finally: - os.unlink(f1.name) diff --git a/descarteslabs/core/catalog/tests/test_catalog_base.py b/descarteslabs/core/catalog/tests/test_catalog_base.py deleted file mode 100644 index b1efc971..00000000 --- a/descarteslabs/core/catalog/tests/test_catalog_base.py +++ /dev/null @@ -1,746 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 pytest -import responses -from datetime import datetime, timezone - - -from descarteslabs.exceptions import ( - NotFoundError, - BadRequestError, - ConflictError, -) - -from ..attributes import ( - Attribute, - AttributeValidationError, - CatalogObjectReference, - DocumentState, -) -from ..catalog_base import ( - AuthCatalogObject as OriginalAuthCatalogObject, - CatalogClient, - CatalogObject as OriginalCatalogObject, - DeletedObjectError, - UnsavedObjectError, -) -from ..named_catalog_base import NamedCatalogObject -from .base import ClientTestCase - - -class AuthCatalogObject(OriginalAuthCatalogObject): - pass - - -class CatalogObject(OriginalCatalogObject): - pass - - -class Foo(CatalogObject): - _doc_type = "foo" - _url = "/foo" - bar = Attribute() - - -Foo._model_classes_by_type_and_derived_type = {("foo", None): Foo} - - -class TestCatalogObject(ClientTestCase): - def test_abstract_class(self): - with pytest.raises(TypeError): - OriginalCatalogObject() - - with pytest.raises(TypeError): - NamedCatalogObject() - - def test_abstract_class_methods(self): - with pytest.raises(TypeError): - OriginalCatalogObject.exists("foo") - - with pytest.raises(TypeError): - OriginalCatalogObject.search() - - with pytest.raises(TypeError): - OriginalCatalogObject.delete("foo") - - with pytest.raises(TypeError): - OriginalCatalogObject.get("foo") - - with pytest.raises(TypeError): - OriginalCatalogObject.get_many(["foo"]) - - def test_constructor(self): - c = CatalogObject(id="id") - self.assertCountEqual( - list(c._attribute_types.keys()), - [ - "id", - "created", - "modified", - "extra_properties", - "tags", - "v1_properties", - ], - ) - assert c.is_modified - - def test_constructor_no_id(self): - c = CatalogObject() - assert c.id is None - c.id = "id" - assert "id" == c.id - assert c.is_modified - - with pytest.raises(AttributeValidationError): - c.id = "oh no" - - def test_set_get(self): - c = CatalogObject(id={}) - assert not c.is_modified - - c.tags = ["foo", "bar"] - assert c.tags == ["foo", "bar"] - assert c.is_modified - - def test_create_non_attr(self): - with pytest.raises(AttributeError): - CatalogObject(foo="bad") - - def test_set_non_attr(self): - c = CatalogObject(id={}) - - with pytest.raises(AttributeError): - c.foo = "bad" - - def test_serialize(self): - c = CatalogObject(id="id", tags=["foo", "bar"]) - assert c.tags == ["foo", "bar"] - assert c.is_modified - assert {"id", "tags"} == c._modified - - self.assertDictEqual(c.serialize(), dict(tags=["foo", "bar"])) - - self.assertDictEqual( - c.serialize(jsonapi_format=True), - dict( - data=dict( - id="id", - type=None, - attributes=dict(tags=["foo", "bar"]), - ) - ), - ) - - def test_clear_modified_attributes(self): - c = CatalogObject(id="id", tags=["foo", "bar"], _saved=True) - assert not c.is_modified - c.tags = ["baz"] - assert c.is_modified - assert c.serialize(modified_only=True) == {"tags": ["baz"]} - - c._clear_modified_attributes() - assert not c.is_modified - - def test_list_properties(self): - c = CatalogObject(id="foo1", tags=["something"], _saved=True) - assert not c.is_modified - - c.tags.append("nothing") - - assert c.is_modified - assert c.serialize(modified_only=True) == { - "tags": ["something", "nothing"], - } - - @responses.activate - def test_get(self): - self.mock_response( - responses.GET, - { - "jsonapi": {"version": "1.0"}, - "data": { - "type": Foo._doc_type, - "id": "foo1", - "attributes": {"bar": "baz"}, - }, - }, - ) - - foo = Foo.get("foo1", client=self.client) - assert foo is not None - assert foo.id == "foo1" - assert foo.bar == "baz" - assert foo.state == DocumentState.SAVED - - CatalogClient.set_default_client(self.client) - foo = Foo.get("foo1") - assert foo._client is not None - - @responses.activate - def test_get_on_behalf_of(self): - self.mock_response( - responses.GET, - { - "jsonapi": {"version": "1.0"}, - "data": { - "type": Foo._doc_type, - "id": "foo1", - "attributes": {"bar": "baz"}, - }, - }, - ) - - foo = Foo.get("foo1", client=self.client, headers={"X-On-Behalf-Of": "user"}) - assert foo is not None - assert foo.id == "foo1" - assert foo.bar == "baz" - assert foo.state == DocumentState.SAVED - assert responses.calls[0].request.headers["X-On-Behalf-Of"] == "user" - - CatalogClient.set_default_client(self.client) - foo = Foo.get("foo1", headers={"X-On-Behalf-Of": "user"}) - assert foo._client is not None - assert responses.calls[1].request.headers["X-On-Behalf-Of"] == "user" - - @responses.activate - def test_get_many(self): - self.mock_response( - responses.PUT, - { - "data": [ - { - "attributes": {"bar": "baz"}, - "id": "p1:foo", - "type": Foo._doc_type, - }, - { - "attributes": {"bar": "qux"}, - "id": "p1:bar", - "type": Foo._doc_type, - }, - ], - "jsonapi": {"version": "1.0"}, - }, - ) - - with pytest.raises(NotFoundError): - foos = Foo.get_many(["p1:foo", "p1:bar", "p1:missing"], client=self.client) - - foos = Foo.get_many( - ["p1:foo", "p1:bar", "p1:missing"], ignore_missing=True, client=self.client - ) - assert ["p1:foo", "p1:bar"] == [f.id for f in foos] - assert ["baz", "qux"] == [f.bar for f in foos] - - @responses.activate - def test_get_many_on_behalf_of(self): - self.mock_response( - responses.PUT, - { - "data": [ - { - "attributes": {"bar": "baz"}, - "id": "p1:foo", - "type": Foo._doc_type, - }, - { - "attributes": {"bar": "qux"}, - "id": "p1:bar", - "type": Foo._doc_type, - }, - ], - "jsonapi": {"version": "1.0"}, - }, - ) - - with pytest.raises(NotFoundError): - foos = Foo.get_many( - ["p1:foo", "p1:bar", "p1:missing"], - client=self.client, - headers={"X-On-Behalf-Of": "user"}, - ) - assert responses.calls[0].request.headers["X-On-Behalf-Of"] == "user" - - foos = Foo.get_many( - ["p1:foo", "p1:bar", "p1:missing"], - ignore_missing=True, - client=self.client, - headers={"X-On-Behalf-Of": "user"}, - ) - assert ["p1:foo", "p1:bar"] == [f.id for f in foos] - assert ["baz", "qux"] == [f.bar for f in foos] - assert responses.calls[1].request.headers["X-On-Behalf-Of"] == "user" - - @responses.activate - def test_reload(self): - self.mock_response( - responses.GET, - { - "jsonapi": {"version": "1.0"}, - "data": { - "type": Foo._doc_type, - "id": "foo1", - "attributes": {"bar": "baz"}, - }, - }, - ) - self.mock_response( - responses.GET, - { - "jsonapi": {"version": "1.0"}, - "data": { - "type": Foo._doc_type, - "id": "foo1", - "attributes": {"bar": "qux"}, - }, - }, - ) - - foo = Foo.get("foo1", client=self.client) - assert foo is not None - assert foo.id == "foo1" - assert foo.bar == "baz" - assert foo.state == DocumentState.SAVED - - foo.reload() - assert foo.id == "foo1" - assert foo.bar == "qux" - assert foo.state == DocumentState.SAVED - - @responses.activate - def test_reload_on_behalf_of(self): - self.mock_response( - responses.GET, - { - "jsonapi": {"version": "1.0"}, - "data": { - "type": Foo._doc_type, - "id": "foo1", - "attributes": {"bar": "baz"}, - }, - }, - ) - self.mock_response( - responses.GET, - { - "jsonapi": {"version": "1.0"}, - "data": { - "type": Foo._doc_type, - "id": "foo1", - "attributes": {"bar": "qux"}, - }, - }, - ) - - foo = Foo.get("foo1", client=self.client) - assert foo is not None - assert foo.id == "foo1" - assert foo.bar == "baz" - assert foo.state == DocumentState.SAVED - - foo.reload(headers={"X-On-Behalf-Of": "user"}) - assert foo.id == "foo1" - assert foo.bar == "qux" - assert foo.state == DocumentState.SAVED - assert responses.calls[1].request.headers["X-On-Behalf-Of"] == "user" - - @responses.activate - def test_save_request_params(self): - self.mock_response( - responses.POST, - { - "jsonapi": {"version": "1.0"}, - "data": { - "type": Foo._doc_type, - "id": "foo1", - "attributes": {"bar": "baz", "foo": "bar"}, - }, - }, - ) - - foo = Foo(id="foo1", client=self.client) - foo.bar = "baz" - foo.save(request_params={"foo": "bar"}) - assert foo.state == DocumentState.SAVED - - body = self.get_request_body(0) - assert {"bar": "baz", "foo": "bar"} == body["data"]["attributes"] - - @responses.activate - def test_save_on_behalf_of(self): - self.mock_response( - responses.POST, - { - "jsonapi": {"version": "1.0"}, - "data": { - "type": Foo._doc_type, - "id": "foo1", - "attributes": {"bar": "baz", "foo": "bar"}, - }, - }, - ) - - foo = Foo(id="foo1", client=self.client) - foo.bar = "baz" - foo.save(headers={"X-On-Behalf-Of": "user"}) - assert foo.state == DocumentState.SAVED - assert responses.calls[0].request.headers["X-On-Behalf-Of"] == "user" - - @responses.activate - def test_save_update_request_params(self): - self.mock_response( - responses.PATCH, - { - "jsonapi": {"version": "1.0"}, - "data": { - "type": Foo._doc_type, - "id": "foo1", - "attributes": {"bar": "baz", "foo": "bar"}, - }, - }, - ) - - foo = Foo(id="foo1", bar="baz", client=self.client, _saved=True) - foo.save(request_params={"foo": "bar"}) - assert foo.state == DocumentState.SAVED - - body = self.get_request_body(0) - assert {"foo": "bar"} == body["data"]["attributes"] - - def test_equality(self): - assert Foo(id="foo2") != Foo(id="foo1") - assert CatalogObject(id="foo") != Foo(id="foo") - assert Foo(id="foo") != Foo(id="foo", _saved=True) - - foo1 = Foo(id="foo") - foo2 = Foo(id="foo") - assert foo1 == foo2 - - foo2.bar = "bar" - assert foo1 != foo2 - assert foo2 != foo1 - - class Bar(CatalogObject): - _doc_type = "bar" - _url = "/bar" - foo_id = Attribute() - foo = CatalogObjectReference(Foo) - - bar1 = Bar(id="bar", foo_id="foo") - bar2 = Bar(id="bar", foo_id="foo") - assert bar1 == bar2 - - bar1.foo = Foo(id="foo", _saved=True) - assert bar1 == bar2 - - bar2.foo = Foo(id="foo1", _saved=True) - assert bar1 != bar2 - assert bar2 != bar1 - - def test_hash(self): - with pytest.raises(TypeError): - hash(Foo(id="foo")) - - @responses.activate - def test_delete_classmethod_notfound(self): - self.mock_response(responses.DELETE, self.not_found_json, status=404) - assert not Foo.delete("nerp", client=self.client) - - @responses.activate - def test_delete_classmethod_conflict(self): - self.mock_response( - responses.DELETE, - { - "errors": [ - { - "detail": "One or more related objects exist", - "status": "409", - "title": "Related objects exist", - } - ], - "jsonapi": {"version": "1.0"}, - }, - status=409, - ) - with self.assertRaises(ConflictError): - Foo.delete("nerp", client=self.client) - - @responses.activate - def test_delete_classmethod(self): - self.mock_response( - responses.DELETE, - { - "meta": {"message": "Object successfully deleted"}, - "jsonapi": {"version": "1.0"}, - }, - ) - - assert Foo.delete("nerp", client=self.client) - - @responses.activate - def test_delete_instancemethod(self): - self.mock_response( - responses.DELETE, - { - "meta": {"message": "Object successfully deleted"}, - "jsonapi": {"version": "1.0"}, - }, - ) - instance = Foo(id="nerp", client=self.client, _saved=True) - instance.delete() - assert instance.state == DocumentState.DELETED - assert "* Deleted" in repr(instance) - - def test_delete_not_saved(self): - foo = Foo(id="nerp", client=self.client) - with pytest.raises(UnsavedObjectError): - foo.delete() - - @responses.activate - def test_delete_instancemethod_notfound(self): - self.mock_response(responses.DELETE, self.not_found_json, status=404) - foo = Foo(id="nerp", client=self.client, _saved=True) - assert foo.state == DocumentState.SAVED - with pytest.raises(DeletedObjectError): - foo.delete() - assert foo.state == DocumentState.DELETED - - @responses.activate - def test_prevent_operations_after_delete(self): - self.mock_response( - responses.DELETE, - { - "meta": {"message": "Object successfully deleted"}, - "jsonapi": {"version": "1.0"}, - }, - ) - instance = Foo(id="merp", client=self.client, _saved=True) - instance.delete() - - with pytest.raises(DeletedObjectError): - instance.reload() - - def test_update(self): - c = CatalogObject() - assert not c.is_modified - assert c.state == DocumentState.UNSAVED - - c.update(tags=["tag"]) - assert c.tags == ["tag"] - assert c.is_modified - - def test_update_immutable_attr(self): - timestamp = datetime.now(timezone.utc) - c = CatalogObject(id="id", created=timestamp, _saved=True) - assert not c.is_modified - assert c.state == DocumentState.SAVED - - with pytest.raises(AttributeValidationError): - c.update(created=["created"]) - - assert c.created == timestamp - assert not c.is_modified - assert c.state == DocumentState.SAVED - - def test_update_non_attr(self): - c = CatalogObject() - assert not c.is_modified - - with pytest.raises(AttributeError): - c.update(tags=["foo"], foo=["bar"]) - - assert c.tags is None - with pytest.raises(AttributeError): - c.foo - assert not c.is_modified - - def test_update_bad_value(self): - c = CatalogObject(id="id") - assert c._modified == set(("id",)) - - with pytest.raises(AttributeValidationError): - c.update(tags=123) - - assert c.id == "id" - assert c.tags is None - assert c._modified == set(("id",)) - - def test_update_ignore_errors(self): - c = CatalogObject(id="id") - assert c._modified == set(("id",)) - - c.update(tags=123, ignore_errors=True) - assert c.id == "id" - assert c.tags is None - assert c._modified == set(("id",)) - - @responses.activate - def test_update_deleted_object(self): - self.mock_response( - responses.DELETE, - { - "meta": {"message": "Object successfully deleted"}, - "jsonapi": {"version": "1.0"}, - }, - ) - - c = Foo(id="id", _saved=True, client=self.client) - c.delete() - - with pytest.raises(DeletedObjectError): - c.update(tags=["foo"]) - - @responses.activate - def test_rewritten_errors(self): - title = "Validation error" - - self.mock_response( - responses.POST, - { - "errors": [ - { - "detail": "Missing data for required field.", - "status": "422", - "title": title, - "source": {"pointer": "/data/attributes/name"}, - } - ], - "jsonapi": {"version": "1.0"}, - }, - status=400, - ) - - try: - foo = Foo(id="nerp", client=self.client) - foo.save() - assert False - except BadRequestError as error: - assert str(error) == "\n {}: {}".format( - title, "Missing data for required field: name" - ) - - def test_update_no_changes(self): - c = CatalogObject(id="id", tags=["tag"], _saved=True) - assert not c.is_modified - - c.update(tags=["tag"]) - assert not c.is_modified - - @responses.activate - def test_get_or_create(self): - self.mock_response(responses.GET, self.not_found_json, status=404) - - foo = Foo.get("foo1", client=self.client) - assert foo is None - - foo = Foo.get_or_create("foo1", bar="baz", client=self.client) - assert foo is not None - assert foo.id == "foo1" - assert foo.bar == "baz" - assert foo.state == DocumentState.UNSAVED - - @responses.activate - def test_get_or_create_on_behalf_of(self): - self.mock_response(responses.GET, self.not_found_json, status=404) - - foo = Foo.get("foo1", client=self.client) - assert foo is None - - foo = Foo.get_or_create( - "foo1", bar="baz", client=self.client, headers={"X-On-Behalf-Of": "user"} - ) - assert foo is not None - assert foo.id == "foo1" - assert foo.bar == "baz" - assert foo.state == DocumentState.UNSAVED - assert responses.calls[1].request.headers["X-On-Behalf-Of"] == "user" - - @responses.activate - def test_deleted_notfound(self): - self.mock_response(responses.PATCH, self.not_found_json, status=404) - instance = Foo(id="foo", client=self.client, _saved=True) - instance.bar = "something" - - with pytest.raises(DeletedObjectError): - instance.save() - assert instance.state == DocumentState.DELETED - - self.mock_response(responses.GET, self.not_found_json, status=404) - instance = Foo(id="foo", client=self.client, _saved=True) - - with pytest.raises(DeletedObjectError): - instance.reload() - assert instance.state == DocumentState.DELETED - - -class TestAuthCatalogObject(ClientTestCase): - def test_auth_catalog_object(self): - user = self.client.auth.namespace - org = self.client.auth.payload["org"] - obj = AuthCatalogObject( - id="id", - owners=[f"org:{org}", f"user:{user}"], - writers=["org:some-org"], - readers=["group:some-group"], - client=self.client, - ) - - assert obj.user_is_owner() - assert obj.user_can_write() - assert obj.user_can_read() - - auth = self.client.auth - payload = auth.payload - auth._clear_cache() - assert auth._namespace is None - assert "_payload" not in auth.__dict__ - payload.update( - { - "sub": "some|other-user", - } - ) - auth.__dict__["_payload"] = payload - - assert not obj.user_is_owner(auth) - assert obj.user_can_write(auth) - assert obj.user_can_read(auth) - - auth._clear_cache() - payload.update( - { - "sub": "some|other-user", - "org": "some-other-org", - "groups": ["some-group"], - } - ) - auth.__dict__["_payload"] = payload - - assert not obj.user_is_owner(auth) - assert not obj.user_can_write(auth) - assert obj.user_can_read(auth) - - auth._clear_cache() - payload.update( - { - "sub": "some|other-user", - "org": "some-other-org", - "groups": ["some-other-group"], - } - ) - auth.__dict__["_payload"] = payload - - assert not obj.user_is_owner(auth) - assert not obj.user_can_write(auth) - assert not obj.user_can_read(auth) diff --git a/descarteslabs/core/catalog/tests/test_download.py b/descarteslabs/core/catalog/tests/test_download.py deleted file mode 100644 index be702d22..00000000 --- a/descarteslabs/core/catalog/tests/test_download.py +++ /dev/null @@ -1,148 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 io -import pytest -import unittest -from unittest.mock import MagicMock, mock_open, patch - -from descarteslabs.exceptions import NotFoundError, BadRequestError -from ...common.geo import AOI -from .. import helpers - - -class TestFormat(unittest.TestCase): - def test__get_format(self): - assert helpers.get_format("png") == "PNG" - with pytest.raises(ValueError): - helpers.get_format("foo") - - def test__format_from_path(self): - assert helpers.format_from_path("foo/bar.tif") == "GTiff" - assert helpers.format_from_path("foo/bar.baz.jpg") == "JPEG" - assert helpers.format_from_path("spam.png") == "PNG" - with pytest.raises(ValueError): - helpers.format_from_path("foo") - - -@patch.object(helpers, "open", new_callable=mock_open) -@patch.object(helpers.os, "makedirs", new_callable=MagicMock) -# wrap return value in lambda so individual tests can safely mutate it -@patch.object( - helpers.Raster, - "raster", - new_callable=lambda: MagicMock( - side_effect=lambda *args, **kwargs: { - "files": {"foo:bar_nir-yellow.tiff": b"i'm a geotiff!"} - }, - ), -) -class TestDownload(unittest.TestCase): - id = "foo:bar" - bands = ["nir", "yellow"] - geocontext = AOI(bounds=[30, 40, 50, 60], resolution=2, crs="EPSG:4326") - - def download(self, dest, format="tif"): - return helpers.download( - inputs=[self.id], - bands_list=self.bands, - geocontext=self.geocontext, - data_type="UInt16", - dest=dest, - format=format, - ) - - def download_mosaic(self, dest, format="tif"): - return helpers.download( - inputs=[self.id, self.id], - bands_list=self.bands, - geocontext=self.geocontext, - data_type="UInt16", - dest=dest, - format=format, - ) - - def test_format_from_ext(self, mock_raster, mock_makedirs, mock_open): - dest = "foo.jpg" - self.download(dest) - mock_raster.assert_called_once() - called_format = mock_raster.call_args[1]["output_format"] - assert called_format == "JPEG" - - def test_different_format_and_ext(self, mock_raster, mock_makedirs, mock_open): - dest = "foo.tif" - self.download(dest, format="jpg") - mock_raster.assert_called_once() - called_format = mock_raster.call_args[1]["output_format"] - assert called_format == "GTiff" - - def test_to_file(self, mock_raster, mock_makedirs, mock_open): - file = io.BytesIO() - with pytest.raises(TypeError): - self.download(file, format="jpg") - - def test_to_file_invalid_format(self, mock_raster, mock_makedirs, mock_open): - file = io.BytesIO() - with pytest.raises(ValueError): - self.download(file, format="foo") - - def test_to_path(self, mock_raster, mock_makedirs, mock_open): - path = "foo/bar.tif" - result = self.download(path) - assert result == path - mock_makedirs.assert_called_once_with("foo") - - def test_to_existing_path(self, mock_raster, mock_makedirs, mock_open): - path = "../bar.tif" - self.download(path) - mock_makedirs.assert_not_called() - - def test_default_filename_single_scene(self, mock_raster, mock_makedirs, mock_open): - result = self.download(None) - assert result == "{id}-{bands}.tif".format( - id=self.id, bands="-".join(self.bands) - ) - result = self.download(None, format="jpg") - assert result == "{id}-{bands}.jpg".format( - id=self.id, bands="-".join(self.bands) - ) - with pytest.raises(ValueError): - self.download(None, format="baz") - - def test_default_filename_mosaic(self, mock_raster, mock_makedirs, mock_open): - result = self.download_mosaic(None) - assert result == "mosaic-{bands}.tif".format(bands="-".join(self.bands)) - result = self.download_mosaic(None, format="jpg") - assert result == "mosaic-{bands}.jpg".format(bands="-".join(self.bands)) - with pytest.raises(ValueError): - self.download_mosaic(None, format="baz") - - def test_to_pathlib(self, mock_raster, mock_makedirs, mock_open): - import pathlib - - path = pathlib.Path("foo/bar.tif") - self.download(path) - mock_makedirs.assert_called_once_with("foo") - - def test_raster_not_found(self, mock_raster, mock_makedirs, mock_open): - mock_raster.side_effect = NotFoundError("there is no foo") - with pytest.raises( - NotFoundError, match="does not exist in the Descartes catalog" - ): - self.download("file.tif") - - def test_raster_bad_request(self, mock_raster, mock_makedirs, mock_open): - mock_raster.side_effect = BadRequestError("what is a foo") - with pytest.raises(BadRequestError, match="Error with request"): - self.download("file.tif") diff --git a/descarteslabs/core/catalog/tests/test_event_api_destination.py b/descarteslabs/core/catalog/tests/test_event_api_destination.py deleted file mode 100644 index 38dee24e..00000000 --- a/descarteslabs/core/catalog/tests/test_event_api_destination.py +++ /dev/null @@ -1,617 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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. - -# -*- coding: utf-8 -*- -import pytest -import responses - -import textwrap - -from datetime import datetime - -from descarteslabs.exceptions import ConflictError -from .base import ClientTestCase -from ..event_api_destination import ( - EventApiDestination, - EventApiDestinationCollection, - EventApiDestinationSearch, - EventConnectionParameter, -) -from ..catalog_base import DocumentState, DeletedObjectError - - -class TestEventApiDestination(ClientTestCase): - def test_constructor(self): - d = EventApiDestination( - namespace="someorg:test-namespace", - name="test-api-destination", - id="someorg:test-namespace:test-api-destination", - description="a description", - endpoint="https://some.endpoint", - method="POST", - invocation_rate=1, - arn="some-arn", - connection_name="some-connection", - connection_description="a connection description", - connection_header_parameters=[ - EventConnectionParameter( - Key="some-header", Value="some-value", IsValueSecret=False - ) - ], - connection_query_string_parameters=[ - EventConnectionParameter( - Key="some-query", Value="some-value", IsValueSecret=False - ) - ], - connection_body_parameters=[ - EventConnectionParameter( - Key="some-body", Value="some-value", IsValueSecret=False - ) - ], - connection_authorization_type="OAUTH_CLIENT_CREDENTIALS", - connection_oauth_endpoint="https://some.oauth.endpoint", - connection_oauth_method="POST", - connection_oauth_client_id="some-client-id", - connection_oauth_client_secret="some-secret", - connection_oauth_header_parameters=[ - EventConnectionParameter( - Key="some-oauth-header", - Value="some-oauth-value", - IsValueSecret=False, - ) - ], - connection_oauth_query_string_parameters=[ - EventConnectionParameter( - Key="some-oauth-query", - Value="some-oauth-value", - IsValueSecret=False, - ) - ], - connection_oauth_body_parameters=[ - EventConnectionParameter( - Key="some-oauth-body", Value="some-oauth-value", IsValueSecret=False - ) - ], - connection_arn="some-connection-arn", - tags=["TESTING"], - ) - - assert d.namespace == "someorg:test-namespace" - assert d.name == "test-api-destination" - assert d.id == "someorg:test-namespace:test-api-destination" - assert d.description == "a description" - assert d.endpoint == "https://some.endpoint" - assert d.method == "POST" - assert d.invocation_rate == 1 - assert d.arn == "some-arn" - assert d.connection_name == "some-connection" - assert d.connection_description == "a connection description" - assert d.connection_header_parameters == [ - EventConnectionParameter( - Key="some-header", Value="some-value", IsValueSecret=False - ) - ] - assert d.connection_query_string_parameters == [ - EventConnectionParameter( - Key="some-query", Value="some-value", IsValueSecret=False - ) - ] - assert d.connection_body_parameters == [ - EventConnectionParameter( - Key="some-body", Value="some-value", IsValueSecret=False - ) - ] - assert d.connection_authorization_type == "OAUTH_CLIENT_CREDENTIALS" - assert d.connection_oauth_endpoint == "https://some.oauth.endpoint" - assert d.connection_oauth_method == "POST" - assert d.connection_oauth_client_id == "some-client-id" - assert d.connection_oauth_client_secret == "some-secret" - assert d.connection_oauth_header_parameters == [ - EventConnectionParameter( - Key="some-oauth-header", Value="some-oauth-value", IsValueSecret=False - ) - ] - assert d.connection_oauth_query_string_parameters == [ - EventConnectionParameter( - Key="some-oauth-query", Value="some-oauth-value", IsValueSecret=False - ) - ] - assert d.connection_oauth_body_parameters == [ - EventConnectionParameter( - Key="some-oauth-body", Value="some-oauth-value", IsValueSecret=False - ) - ] - assert d.connection_arn == "some-connection-arn" - assert d.tags == ["TESTING"] - assert d.state == DocumentState.UNSAVED - - def test_repr(self): - d = EventApiDestination( - name="test-api-destination", - id="someorg:test-namespace:test-api-destination", - ) - d_repr = repr(d) - match_str = """\ - EventApiDestination: test-api-destination - id: someorg:test-namespace:test-api-destination - * Not up-to-date in the Descartes Labs catalog. Call `.save()` to save or update this record.""" - assert d_repr.strip("\n") == textwrap.dedent(match_str) - - @responses.activate - def test_get(self): - self.mock_response( - responses.GET, - { - "data": { - "attributes": { - "arn": "some-arn", - "connection_arn": "some-connection-arn", - "connection_authorization_type": "OAUTH_CLIENT_CREDENTIALS", - "connection_body_parameters": [ - { - "Key": "some-body", - "Value": "some-value", - "IsValueSecret": False, - } - ], - "connection_description": "a connection description", - "connection_header_parameters": [ - { - "Key": "some-header", - "Value": "some-value", - "IsValueSecret": False, - } - ], - "connection_oauth_body_parameters": [ - { - "Key": "some-oauth-body", - "Value": "some-oauth-value", - "IsValueSecret": False, - } - ], - "connection_oauth_client_id": "some-client-id", - "connection_oauth_client_secret": "some-secret", - "connection_oauth_endpoint": "https://some.oauth.endpoint", - "connection_oauth_header_parameters": [ - { - "Key": "some-oauth-header", - "Value": "some-oauth-value", - "IsValueSecret": False, - } - ], - "connection_oauth_method": "POST", - "connection_oauth_query_string_parameters": [ - { - "Key": "some-oauth-query", - "Value": "some-oauth-value", - "IsValueSecret": False, - } - ], - "connection_name": "some-connection", - "connection_query_string_parameters": [ - { - "Key": "some-query", - "Value": "some-value", - "IsValueSecret": False, - } - ], - "created": "2023-09-29T15:54:37.006769Z", - "description": "a generic description", - "endpoint": "https://some.endpoint", - "invocation_rate": 1, - "method": "POST", - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-api-destination", - "namespace": "someorg:test-namespace", - "owners": ["org:someorg"], - "readers": ["org:someorg"], - "writers": [], - "tags": ["TESTING"], - }, - "id": "someorg:test-namespace:test-api-destination", - "type": "event_api_destination", - } - }, - status=200, - ) - - d = EventApiDestination.get( - id="someorg:test-namespace:test-api-destination", client=self.client - ) - assert isinstance(d.created, datetime) - assert isinstance(d.modified, datetime) - assert d.id == "someorg:test-namespace:test-api-destination" - assert d.name == "test-api-destination" - assert d.namespace == "someorg:test-namespace" - assert d.description == "a generic description" - assert d.endpoint == "https://some.endpoint" - assert d.method == "POST" - assert d.invocation_rate == 1 - assert d.arn == "some-arn" - assert d.connection_name == "some-connection" - assert d.connection_description == "a connection description" - assert d.connection_header_parameters == [ - EventConnectionParameter( - Key="some-header", Value="some-value", IsValueSecret=False - ) - ] - assert d.connection_query_string_parameters == [ - EventConnectionParameter( - Key="some-query", Value="some-value", IsValueSecret=False - ) - ] - assert d.connection_body_parameters == [ - EventConnectionParameter( - Key="some-body", Value="some-value", IsValueSecret=False - ) - ] - assert d.connection_authorization_type == "OAUTH_CLIENT_CREDENTIALS" - assert d.connection_oauth_endpoint == "https://some.oauth.endpoint" - assert d.connection_oauth_method == "POST" - assert d.connection_oauth_client_id == "some-client-id" - assert d.connection_oauth_client_secret == "some-secret" - assert d.connection_oauth_header_parameters == [ - EventConnectionParameter( - Key="some-oauth-header", Value="some-oauth-value", IsValueSecret=False - ) - ] - assert d.connection_oauth_query_string_parameters == [ - EventConnectionParameter( - Key="some-oauth-query", Value="some-oauth-value", IsValueSecret=False - ) - ] - assert d.connection_oauth_body_parameters == [ - EventConnectionParameter( - Key="some-oauth-body", Value="some-oauth-value", IsValueSecret=False - ) - ] - assert d.connection_arn == "some-connection-arn" - assert d.owners == ["org:someorg"] - assert d.readers == ["org:someorg"] - assert d.writers == [] - assert d.tags == ["TESTING"] - - @responses.activate - def test_get_unknown_attribute(self): - self.mock_response( - responses.GET, - { - "data": { - "attributes": { - "created": "2023-09-29T15:54:37.006769Z", - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-api-destination", - "namespace": "someorg:test-namespace", - "owners": ["org:someorg"], - "readers": ["org:someorg"], - "writers": [], - "tags": ["TESTING"], - "foobar": "baz", - }, - "id": "someorg:test-namespace:test-api-destination", - "type": "event_api_destination", - }, - }, - status=200, - ) - - d = EventApiDestination.get( - id="someorg:test-namespace:test-api-destination", client=self.client - ) - assert not hasattr(d, "foobar") - - @responses.activate - def test_get_many(self): - self.mock_response( - responses.PUT, - { - "data": [ - { - "attributes": { - "created": "2023-09-29T15:54:37.006769Z", - "description": "a generic description", - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-api-destination-1", - "namespace": "someorg:test-namespace", - }, - "id": "someorg:test-namespace:test-api-destination-1", - "type": "event_api_destination", - }, - { - "attributes": { - "created": "2023-09-29T15:54:37.006769Z", - "description": "a generic description", - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-api-destination-2", - "namespace": "someorg:test-namespace", - }, - "id": "someorg:test-namespace:test-api-destination-2", - "type": "event_api_destination", - }, - ], - }, - status=200, - ) - - api_destinations = EventApiDestination.get_many( - [ - "someorg:test-namespace:test-api-destination-1", - "someorg:test-namespace:test-api-destination-2", - ], - client=self.client, - ) - - for i, d in enumerate(api_destinations): - assert isinstance(d, EventApiDestination) - assert d.id == f"someorg:test-namespace:test-api-destination-{i + 1}" - - @responses.activate - def test_get_or_create(self): - self.mock_response( - responses.GET, - { - "data": { - "attributes": { - "created": "2023-09-29T15:54:37.006769Z", - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-api-destination", - "namespace": "someorg:test-namespace", - "owners": ["org:someorg"], - "readers": ["org:someorg"], - "writers": [], - "tags": ["TESTING"], - }, - "id": "someorg:test-namespace:test-api-destination", - "type": "event_api_destination", - }, - }, - status=200, - ) - - d = EventApiDestination.get_or_create( - id="someorg:test-namespace:test-api-destination", client=self.client - ) - assert d.id == "someorg:test-namespace:test-api-destination" - - @responses.activate - def test_list(self): - self.mock_response( - responses.PUT, - { - "meta": {"count": 2}, - "links": {"self": "https://example.com/catalog/v2/storage"}, - "data": [ - { - "attributes": { - "created": "2023-09-29T15:54:37.006769Z", - "description": "a generic description", - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-api-destination-1", - "namespace": "someorg:test-namespace", - }, - "id": "someorg:test-namespace:test-api-destination-1", - "type": "event_api_destination", - }, - { - "attributes": { - "created": "2023-09-29T15:54:37.006769Z", - "description": "a generic description", - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-api-destination-2", - "namespace": "someorg:test-namespace", - }, - "id": "someorg:test-namespace:test-api-destination-2", - "type": "event_api_destination", - }, - ], - }, - status=200, - ) - - search = EventApiDestination.search(client=self.client) - assert search.count() == 2 - assert isinstance(search, EventApiDestinationSearch) - dc = search.collect() - assert isinstance(dc, EventApiDestinationCollection) - - @responses.activate - def test_list_no_results(self): - self.mock_response( - responses.PUT, - { - "meta": {"count": 0}, - "data": [], - }, - ) - - d = list(EventApiDestination.search(client=self.client)) - assert d == [] - - @responses.activate - def test_save(self): - self.mock_response( - responses.POST, - { - "data": { - "attributes": { - "created": "2023-09-29T15:54:37.006769Z", - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-api-destination", - "namespace": "someorg:test-namespace", - "owners": ["org:someorg"], - "readers": ["org:someorg"], - "writers": [], - "tags": ["TESTING"], - }, - "id": "someorg:test-namespace:test-api-destination", - "type": "event_api_destination", - } - }, - status=201, - ) - - d = EventApiDestination( - id="someorg:test-namespace:test-api-destination", - name="test-api-destination", - client=self.client, - ) - assert d.state == DocumentState.UNSAVED - d.save() - assert responses.calls[0].request.url == self.url + "/event_api_destinations" - assert d.state == DocumentState.SAVED - - @responses.activate - def test_save_dupe(self): - self.mock_response( - responses.POST, - { - "errors": [ - { - "status": "409", - "detail": "A document with id `someorg:test-namespace:test-api-destination` already exists.", # noqa: E501 - "title": "Conflict", - } - ], - "jsonapi": {"version": "1.0"}, - }, - status=409, - ) - d = EventApiDestination( - id="someorg:test-namespace:test-api-destination", client=self.client - ) - with pytest.raises(ConflictError): - d.save() - - @responses.activate - def test_exists(self): - self.mock_response(responses.HEAD, {}, status=200) - assert EventApiDestination.exists( - "someorg:test-namespace:test-api-destination", client=self.client - ) - assert ( - responses.calls[0].request.url - == "https://example.com/catalog/v2/event_api_destinations/someorg:test-namespace:test-api-destination" - ) - - @responses.activate - def test_exists_false(self): - self.mock_response(responses.HEAD, self.not_found_json, status=404) - assert not EventApiDestination.exists( - "someorg:test-namespace:nonexistent-api_destination", - client=self.client, - ) - assert ( - responses.calls[0].request.url - == "https://example.com/catalog/v2/event_api_destinations/someorg:test-namespace:nonexistent-api_destination" # noqa: E501 - ) - - @responses.activate - def test_update(self): - self.mock_response( - responses.POST, - { - "meta": {"count": 1}, - "data": { - "attributes": { - "created": "2023-09-29T15:54:37.006769Z", - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-api-destination", - "namespace": "someorg:test-namespace", - "owners": ["org:someorg"], - "readers": ["org:someorg"], - "writers": [], - "tags": ["TESTING"], - }, - "id": "someorg:test-namespace:test-api-destination", - "type": "event_api_destination", - }, - }, - status=201, - ) - - d = EventApiDestination( - id="someorg:test-namespace:test-api-destination", - name="test-api-destination", - client=self.client, - ) - d.save() - assert d.state == DocumentState.SAVED - d.readers = ["org:acme-corp"] - assert d.state == DocumentState.MODIFIED - self.mock_response( - responses.PATCH, - { - "meta": {"count": 1}, - "data": { - "attributes": { - "readers": ["org:acme-corp"], - }, - "type": "event_api_destination", - "id": "someorg:test-namespace:test-api-destination", - }, - }, - status=200, - ) - d.save() - assert d.readers == ["org:acme-corp"] - - @responses.activate - def test_delete(self): - d = EventApiDestination( - id="someorg:test-namespace:test-api-destination", - name="test-api-destination", - client=self.client, - _saved=True, - ) - self.mock_response( - responses.DELETE, - { - "meta": {"message": "Object successfully deleted"}, - "jsonapi": {"version": "1.0"}, - }, - ) - - d.delete() - assert d.state == DocumentState.DELETED - - @responses.activate - def test_class_delete(self): - api_destination_id = "someorg:test-namespace:test-api-destination" - self.mock_response( - responses.DELETE, - { - "meta": {"message": "Object successfully deleted"}, - "jsonapi": {"version": "1.0"}, - }, - ) - - assert EventApiDestination.delete(api_destination_id, client=self.client) - - @responses.activate - def test_delete_non_existent(self): - d = EventApiDestination( - id="someorg:test-namespace:nonexistent-api_destination", - name="nonexistent-api_destination", - client=self.client, - _saved=True, - ) - - self.mock_response( - responses.DELETE, - self.not_found_json, - status=404, - ) - - with pytest.raises(DeletedObjectError): - d.delete() diff --git a/descarteslabs/core/catalog/tests/test_event_rule.py b/descarteslabs/core/catalog/tests/test_event_rule.py deleted file mode 100644 index ace0e3c1..00000000 --- a/descarteslabs/core/catalog/tests/test_event_rule.py +++ /dev/null @@ -1,514 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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. - -# -*- coding: utf-8 -*- -import pytest -import responses - -import textwrap - -from datetime import datetime - -from descarteslabs.exceptions import ConflictError -from .base import ClientTestCase -from ..event_rule import ( - EventRule, - EventRuleCollection, - EventRuleSearch, - EventRuleTarget, -) -from ..catalog_base import DocumentState, DeletedObjectError - - -class TestEventRule(ClientTestCase): - def test_constructor(self): - r = EventRule( - namespace="someorg:test-namespace", - name="test-rule", - id="someorg:test-namespace:test-rule", - description="a description", - event_pattern="""{"some": "pattern"}""", - targets=[ - EventRuleTarget( - name="test-target", - arn="some-destination-arn", - role_arn="some-role-arn", - input="some-input", - ttl=123, - retries=2, - dead_letter_arn="some-dead-letter-arn", - path_parameter_values=["some-path"], - header_parameters={ - "some-header": "some-value", - }, - query_string_parameters={ - "some-query": "some-value", - }, - event_api_destination_id="someorg:test-namespace:test-destination", - ), - ], - event_bus_arn="some-event-bus-arn", - rule_arn="some-rule-arn", - tags=["TESTING"], - ) - - assert r.namespace == "someorg:test-namespace" - assert r.name == "test-rule" - assert r.id == "someorg:test-namespace:test-rule" - assert r.description == "a description" - assert r.event_pattern == """{"some": "pattern"}""" - assert len(r.targets) == 1 - assert r.targets[0].name == "test-target" - assert r.targets[0].arn == "some-destination-arn" - assert r.targets[0].role_arn == "some-role-arn" - assert r.targets[0].input == "some-input" - assert r.targets[0].ttl == 123 - assert r.targets[0].retries == 2 - assert r.targets[0].dead_letter_arn == "some-dead-letter-arn" - assert r.targets[0].path_parameter_values == ["some-path"] - assert r.targets[0].header_parameters == {"some-header": "some-value"} - assert r.targets[0].query_string_parameters == {"some-query": "some-value"} - assert ( - r.targets[0].event_api_destination_id - == "someorg:test-namespace:test-destination" - ) - assert r.event_bus_arn == "some-event-bus-arn" - assert r.rule_arn == "some-rule-arn" - assert r.tags == ["TESTING"] - assert r.state == DocumentState.UNSAVED - - def test_repr(self): - r = EventRule( - name="test-rule", - id="someorg:test-namespace:test-rule", - ) - r_repr = repr(r) - match_str = """\ - EventRule: test-rule - id: someorg:test-namespace:test-rule - * Not up-to-date in the Descartes Labs catalog. Call `.save()` to save or update this record.""" - assert r_repr.strip("\n") == textwrap.dedent(match_str) - - @responses.activate - def test_get(self): - self.mock_response( - responses.GET, - { - "data": { - "attributes": { - "created": "2023-09-29T15:54:37.006769Z", - "description": "a generic description", - "event_pattern": """{"some": "pattern"}""", - "extra_properties": {}, - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-rule", - "namespace": "someorg:test-namespace", - "owners": ["org:someorg"], - "readers": ["org:someorg"], - "writers": [], - "tags": ["TESTING"], - "targets": [ - { - "arn": "some-destination-arn", - "dead_letter_arn": "some-dead-letter-arn", - "event_api_destination_id": "someorg:test-namespace:test-destination", - "header_parameters": { - "some-header": "some-value", - }, - "input": "some-input", - "name": "test-target", - "path_parameter_values": ["some-path"], - "retries": 2, - "role_arn": "some-role-arn", - "query_string_parameters": { - "some-query": "some-value", - }, - "ttl": 123, - }, - ], - }, - "id": "someorg:test-namespace:test-rule", - "type": "event_rule", - } - }, - status=200, - ) - - r = EventRule.get(id="someorg:test-namespace:test-rule", client=self.client) - assert isinstance(r.created, datetime) - assert isinstance(r.modified, datetime) - assert r.id == "someorg:test-namespace:test-rule" - assert r.name == "test-rule" - assert r.namespace == "someorg:test-namespace" - assert r.description == "a generic description" - assert r.event_pattern == """{"some": "pattern"}""" - assert len(r.targets) == 1 - assert type(r.targets[0]) is EventRuleTarget - assert r.targets[0].name == "test-target" - assert r.targets[0].arn == "some-destination-arn" - assert r.targets[0].role_arn == "some-role-arn" - assert r.targets[0].input == "some-input" - assert r.targets[0].ttl == 123 - assert r.targets[0].retries == 2 - assert r.targets[0].dead_letter_arn == "some-dead-letter-arn" - assert r.targets[0].path_parameter_values == ["some-path"] - assert r.targets[0].header_parameters == {"some-header": "some-value"} - assert r.targets[0].query_string_parameters == {"some-query": "some-value"} - assert ( - r.targets[0].event_api_destination_id - == "someorg:test-namespace:test-destination" - ) - assert r.owners == ["org:someorg"] - assert r.readers == ["org:someorg"] - assert r.writers == [] - assert r.tags == ["TESTING"] - - @responses.activate - def test_get_unknown_attribute(self): - self.mock_response( - responses.GET, - { - "data": { - "attributes": { - "created": "2023-09-29T15:54:37.006769Z", - "description": "a generic description", - "event_pattern": """{"some": "pattern"}""", - "extra_properties": {}, - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-rule", - "namespace": "someorg:test-namespace", - "targets": [], - "foobar": "baz", - }, - "id": "someorg:test-namespace:test-rule", - "type": "event_rule", - }, - }, - status=200, - ) - - r = EventRule.get(id="someorg:test-namespace:test-rule", client=self.client) - assert not hasattr(r, "foobar") - - @responses.activate - def test_get_many(self): - self.mock_response( - responses.PUT, - { - "data": [ - { - "attributes": { - "created": "2023-09-29T15:54:37.006769Z", - "description": "a generic description", - "event_pattern": """{"some": "pattern"}""", - "extra_properties": {}, - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-rule-1", - "namespace": "someorg:test-namespace", - "targets": [], - }, - "id": "someorg:test-namespace:test-rule-1", - "type": "event_rule", - }, - { - "attributes": { - "created": "2023-09-29T15:54:37.006769Z", - "description": "a generic description", - "event_pattern": """{"some": "pattern"}""", - "extra_properties": {}, - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-rule-2", - "namespace": "someorg:test-namespace", - "targets": [], - }, - "id": "someorg:test-namespace:test-rule-2", - "type": "event_rule", - }, - ], - }, - status=200, - ) - - rules = EventRule.get_many( - [ - "someorg:test-namespace:test-rule-1", - "someorg:test-namespace:test-rule-2", - ], - client=self.client, - ) - - for i, r in enumerate(rules): - assert isinstance(r, EventRule) - assert r.id == f"someorg:test-namespace:test-rule-{i + 1}" - - @responses.activate - def test_get_or_create(self): - self.mock_response( - responses.GET, - { - "data": { - "attributes": { - "created": "2023-09-29T15:54:37.006769Z", - "description": "a generic description", - "event_pattern": """{"some": "pattern"}""", - "extra_properties": {}, - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-rule", - "namespace": "someorg:test-namespace", - "targets": [], - }, - "id": "someorg:test-namespace:test-rule", - "type": "event_rule", - }, - }, - status=200, - ) - - r = EventRule.get_or_create( - id="someorg:test-namespace:test-rule", client=self.client - ) - assert r.id == "someorg:test-namespace:test-rule" - - @responses.activate - def test_list(self): - self.mock_response( - responses.PUT, - { - "meta": {"count": 2}, - "links": {"self": "https://example.com/catalog/v2/storage"}, - "data": [ - { - "attributes": { - "created": "2023-09-29T15:54:37.006769Z", - "description": "a generic description", - "event_pattern": """{"some": "pattern"}""", - "extra_properties": {}, - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-rule-1", - "namespace": "someorg:test-namespace", - "targets": [], - }, - "id": "someorg:test-namespace:test-rule-1", - "type": "event_rule", - }, - { - "attributes": { - "created": "2023-09-29T15:54:37.006769Z", - "description": "a generic description", - "event_pattern": """{"some": "pattern"}""", - "extra_properties": {}, - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-rule-2", - "namespace": "someorg:test-namespace", - "targets": [], - }, - "id": "someorg:test-namespace:test-rule-2", - "type": "event_rule", - }, - ], - }, - status=200, - ) - - search = EventRule.search(client=self.client) - assert search.count() == 2 - assert isinstance(search, EventRuleSearch) - sc = search.collect() - assert isinstance(sc, EventRuleCollection) - - @responses.activate - def test_list_no_results(self): - self.mock_response( - responses.PUT, - { - "meta": {"count": 0}, - "data": [], - }, - ) - - r = list(EventRule.search(client=self.client)) - assert r == [] - - @responses.activate - def test_save(self): - self.mock_response( - responses.POST, - { - "data": { - "attributes": { - "created": "2023-09-29T15:54:37.006769Z", - "description": "a generic description", - "event_pattern": """{"some": "pattern"}""", - "extra_properties": {}, - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-rule", - "namespace": "someorg:test-namespace", - "targets": [], - }, - "id": "someorg:test-namespace:test-rule", - "type": "event_rule", - } - }, - status=201, - ) - - r = EventRule( - id="someorg:test-namespace:test-rule", - name="test-rule", - client=self.client, - ) - assert r.state == DocumentState.UNSAVED - r.save() - assert responses.calls[0].request.url == self.url + "/event_rules" - assert r.state == DocumentState.SAVED - - @responses.activate - def test_save_dupe(self): - self.mock_response( - responses.POST, - { - "errors": [ - { - "status": "409", - "detail": "A document with id `someorg:test-namespace:test-rule` already exists.", - "title": "Conflict", - } - ], - "jsonapi": {"version": "1.0"}, - }, - status=409, - ) - r = EventRule(id="someorg:test-namespace:test-rule", client=self.client) - with pytest.raises(ConflictError): - r.save() - - @responses.activate - def test_exists(self): - self.mock_response(responses.HEAD, {}, status=200) - assert EventRule.exists("someorg:test-namespace:test-rule", client=self.client) - assert ( - responses.calls[0].request.url - == "https://example.com/catalog/v2/event_rules/someorg:test-namespace:test-rule" - ) - - @responses.activate - def test_exists_false(self): - self.mock_response(responses.HEAD, self.not_found_json, status=404) - assert not EventRule.exists( - "someorg:test-namespace:nonexistent-rule", client=self.client - ) - assert ( - responses.calls[0].request.url - == "https://example.com/catalog/v2/event_rules/someorg:test-namespace:nonexistent-rule" - ) - - @responses.activate - def test_update(self): - self.mock_response( - responses.POST, - { - "meta": {"count": 1}, - "data": { - "attributes": { - "created": "2023-09-29T15:54:37.006769Z", - "description": "a generic description", - "event_pattern": """{"some": "pattern"}""", - "extra_properties": {}, - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-rule", - "namespace": "someorg:test-namespace", - "targets": [], - }, - "id": "someorg:test-namespace:test-rule", - "type": "event_rule", - }, - }, - status=200, - ) - - r = EventRule( - id="someorg:test-namespace:test-rule", - name="test-rule", - client=self.client, - ) - r.save() - assert r.state == DocumentState.SAVED - r.readers = ["org:acme-corp"] - assert r.state == DocumentState.MODIFIED - self.mock_response( - responses.PATCH, - { - "meta": {"count": 1}, - "data": { - "attributes": { - "readers": ["org:acme-corp"], - }, - "type": "event_rule", - "id": "someorg:test-namespace:test-rule", - }, - }, - status=200, - ) - r.save() - assert r.readers == ["org:acme-corp"] - - @responses.activate - def test_delete(self): - r = EventRule( - id="someorg:test-namespace:test-rule", - name="test-rule", - client=self.client, - _saved=True, - ) - self.mock_response( - responses.DELETE, - { - "meta": {"message": "Object successfully deleted"}, - "jsonapi": {"version": "1.0"}, - }, - ) - - r.delete() - assert r.state == DocumentState.DELETED - - @responses.activate - def test_class_delete(self): - rule_id = "someorg:test-namespace:test-rule" - self.mock_response( - responses.DELETE, - { - "meta": {"message": "Object successfully deleted"}, - "jsonapi": {"version": "1.0"}, - }, - ) - - assert EventRule.delete(rule_id, client=self.client) - - @responses.activate - def test_delete_non_existent(self): - r = EventRule( - id="someorg:test-namespace:nonexistent-rule", - name="nonexistent-rule", - client=self.client, - _saved=True, - ) - - self.mock_response( - responses.DELETE, - self.not_found_json, - status=404, - ) - - with pytest.raises(DeletedObjectError): - r.delete() diff --git a/descarteslabs/core/catalog/tests/test_event_schedule.py b/descarteslabs/core/catalog/tests/test_event_schedule.py deleted file mode 100644 index 237d5513..00000000 --- a/descarteslabs/core/catalog/tests/test_event_schedule.py +++ /dev/null @@ -1,580 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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. - -# -*- coding: utf-8 -*- -import pytest -import responses - -import textwrap - -from datetime import datetime, timezone - -from descarteslabs.exceptions import ConflictError -from .base import ClientTestCase -from ..event_schedule import ( - EventSchedule, - EventScheduleCollection, - EventScheduleSearch, -) -from ..catalog_base import DocumentState, DeletedObjectError - - -class TestEventSchedule(ClientTestCase): - def test_constructor(self): - s = EventSchedule( - namespace="someorg:test-namespace", - name="test-sched", - id="someorg:test-namespace:test-sched", - description="a description", - schedule="cron(* * * * * *)", - schedule_timezone="America/New_York", - start_datetime="2023-01-01", - end_datetime="2024-01-01", - flexible_time_window=10, - enabled=True, - tags=["TESTING"], - ) - - assert s.namespace == "someorg:test-namespace" - assert s.name == "test-sched" - assert s.id == "someorg:test-namespace:test-sched" - assert s.description == "a description" - assert s.schedule == "cron(* * * * * *)" - assert s.schedule_timezone == "America/New_York" - assert s.start_datetime == "2023-01-01" - assert s.end_datetime == "2024-01-01" - assert s.flexible_time_window == 10 - assert s.enabled is True - assert s.tags == ["TESTING"] - assert s.state == DocumentState.UNSAVED - - def test_repr(self): - s = EventSchedule( - name="test-sched", - id="someorg:test-namespace:test-sched", - schedule="rate(1 days)", - ) - s_repr = repr(s) - match_str = """\ - EventSchedule: test-sched - id: someorg:test-namespace:test-sched - * Not up-to-date in the Descartes Labs catalog. Call `.save()` to save or update this record.""" - assert s_repr.strip("\n") == textwrap.dedent(match_str) - - @responses.activate - def test_get(self): - self.mock_response( - responses.GET, - { - "data": { - "attributes": { - "created": "2023-09-29T15:54:37.006769Z", - "description": "a generic description", - "enabled": True, - "end_datetime": "2024-01-01T00:00:00Z", - "extra_properties": {}, - "flexible_time_window": 10, - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-sched", - "namespace": "someorg:test-namespace", - "owners": ["org:someorg"], - "readers": ["org:someorg"], - "schedule": "cron(* * * * * *)", - "schedule_timezone": "America/New_York", - "start_datetime": "2023-01-01T00:00:00Z", - "tags": ["TESTING"], - "writers": [], - }, - "id": "someorg:test-namespace:test-sched", - "type": "event_schedule", - } - }, - status=200, - ) - - s = EventSchedule.get( - id="someorg:test-namespace:test-sched", client=self.client - ) - assert isinstance(s.created, datetime) - assert isinstance(s.modified, datetime) - assert s.id == "someorg:test-namespace:test-sched" - assert s.name == "test-sched" - assert s.namespace == "someorg:test-namespace" - assert s.description == "a generic description" - assert s.schedule == "cron(* * * * * *)" - assert s.schedule_timezone == "America/New_York" - assert s.start_datetime == datetime(2023, 1, 1, tzinfo=timezone.utc) - assert s.end_datetime == datetime(2024, 1, 1, tzinfo=timezone.utc) - assert s.flexible_time_window == 10 - assert s.enabled is True - assert s.owners == ["org:someorg"] - assert s.readers == ["org:someorg"] - assert s.writers == [] - assert s.tags == ["TESTING"] - - @responses.activate - def test_get_unknown_attribute(self): - self.mock_response( - responses.GET, - { - "data": { - "attributes": { - "created": "2023-09-29T15:54:37.006769Z", - "description": "a generic description", - "enabled": True, - "end_datetime": "2024-01-01T00:00:00Z", - "extra_properties": {}, - "flexible_time_window": 10, - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-sched", - "namespace": "someorg:test-namespace", - "owners": ["org:someorg"], - "readers": ["org:someorg"], - "schedule": "cron(* * * * * *)", - "schedule_timezone": "America/New_York", - "start_datetime": "2024-01-01T00:00:00Z", - "tags": ["TESTING"], - "writers": [], - "foobar": "unknown", - }, - "id": "someorg:test-namespace:test-sched", - "type": "event_schedule", - } - }, - status=200, - ) - - s = EventSchedule.get( - id="someorg:test-namespace:test-sched", client=self.client - ) - assert not hasattr(s, "foobar") - - @responses.activate - def test_get_many(self): - self.mock_response( - responses.PUT, - { - "data": [ - { - "attributes": { - "created": "2023-09-29T15:54:37.006769Z", - "description": "a generic description", - "enabled": True, - "end_datetime": "2024-01-01T00:00:00Z", - "extra_properties": {}, - "flexible_time_window": 10, - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-sched", - "namespace": "someorg:test-namespace", - "owners": ["org:someorg"], - "readers": ["org:someorg"], - "schedule": "cron(* * * * * *)", - "schedule_timezone": "America/New_York", - "start_datetime": "2024-01-01T00:00:00Z", - "tags": ["TESTING"], - "writers": [], - }, - "id": "someorg:test-namespace:test-sched-1", - "type": "event_schedule", - }, - { - "attributes": { - "created": "2023-09-29T15:54:37.006769Z", - "description": "a generic description", - "enabled": True, - "end_datetime": "2024-01-01T00:00:00Z", - "extra_properties": {}, - "flexible_time_window": 10, - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-sched", - "namespace": "someorg:test-namespace", - "owners": ["org:someorg"], - "readers": ["org:someorg"], - "schedule": "cron(* * * * * *)", - "schedule_timezone": "America/New_York", - "start_datetime": "2024-01-01T00:00:00Z", - "tags": ["TESTING"], - "writers": [], - }, - "id": "someorg:test-namespace:test-sched-2", - "type": "event_schedule", - }, - ], - }, - status=200, - ) - - scheds = EventSchedule.get_many( - [ - "someorg:test-namespace:test-sched-1", - "someorg:test-namespace:test-sched-2", - ], - client=self.client, - ) - - for i, r in enumerate(scheds): - assert isinstance(r, EventSchedule) - assert r.id == f"someorg:test-namespace:test-sched-{i + 1}" - - @responses.activate - def test_get_or_create(self): - self.mock_response( - responses.GET, - { - "data": { - "attributes": { - "created": "2023-09-29T15:54:37.006769Z", - "description": "a generic description", - "enabled": True, - "end_datetime": "2024-01-01T00:00:00Z", - "extra_properties": {}, - "flexible_time_window": 10, - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-sched", - "namespace": "someorg:test-namespace", - "owners": ["org:someorg"], - "readers": ["org:someorg"], - "schedule": "cron(* * * * * *)", - "schedule_timezone": "America/New_York", - "start_datetime": "2024-01-01T00:00:00Z", - "tags": ["TESTING"], - "writers": [], - }, - "id": "someorg:test-namespace:test-sched", - "type": "event_schedule", - }, - }, - status=200, - ) - - s = EventSchedule.get_or_create( - id="someorg:test-namespace:test-sched", client=self.client - ) - assert s.id == "someorg:test-namespace:test-sched" - - @responses.activate - def test_list(self): - self.mock_response( - responses.PUT, - { - "meta": {"count": 2}, - "links": {"self": "https://example.com/catalog/v2/storage"}, - "data": [ - { - "attributes": { - "created": "2023-09-29T15:54:37.006769Z", - "description": "a generic description", - "enabled": True, - "end_datetime": "2024-01-01T00:00:00Z", - "extra_properties": {}, - "flexible_time_window": 10, - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-sched", - "namespace": "someorg:test-namespace", - "owners": ["org:someorg"], - "readers": ["org:someorg"], - "schedule": "cron(* * * * * *)", - "schedule_timezone": "America/New_York", - "start_datetime": "2024-01-01T00:00:00Z", - "tags": ["TESTING"], - "writers": [], - }, - "id": "someorg:test-namespace:test-sched-1", - "type": "event_schedule", - }, - { - "attributes": { - "created": "2023-09-29T15:54:37.006769Z", - "description": "a generic description", - "enabled": True, - "end_datetime": "2024-01-01T00:00:00Z", - "extra_properties": {}, - "flexible_time_window": 10, - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-sched", - "namespace": "someorg:test-namespace", - "owners": ["org:someorg"], - "readers": ["org:someorg"], - "schedule": "cron(* * * * * *)", - "schedule_timezone": "America/New_York", - "start_datetime": "2024-01-01T00:00:00Z", - "tags": ["TESTING"], - "writers": [], - }, - "id": "someorg:test-namespace:test-sched-2", - "type": "event_schedule", - }, - ], - }, - status=200, - ) - - search = EventSchedule.search(client=self.client) - assert search.count() == 2 - assert isinstance(search, EventScheduleSearch) - sc = search.collect() - assert isinstance(sc, EventScheduleCollection) - - @responses.activate - def test_list_no_results(self): - self.mock_response( - responses.PUT, - { - "meta": {"count": 0}, - "data": [], - }, - ) - - r = list(EventSchedule.search(client=self.client)) - assert r == [] - - @responses.activate - def test_save(self): - self.mock_response( - responses.POST, - { - "data": { - "attributes": { - "created": "2023-09-29T15:54:37.006769Z", - "description": None, - "enabled": True, - "end_datetime": None, - "extra_properties": {}, - "flexible_time_window": 0, - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-sched", - "namespace": "someorg:test-namespace", - "owners": ["org:someorg"], - "readers": [], - "schedule": "rate(1 days)", - "schedule_timezone": None, - "start_datetime": None, - "tags": [], - "writers": [], - }, - "id": "someorg:test-namespace:test-sched", - "type": "event_schedule", - } - }, - status=201, - ) - - s = EventSchedule( - id="someorg:test-namespace:test-sched", - name="test-sched", - schedule="rate(1 days)", - client=self.client, - ) - assert s.state == DocumentState.UNSAVED - s.save() - assert responses.calls[0].request.url == self.url + "/event_schedules" - assert s.state == DocumentState.SAVED - - @responses.activate - def test_save_dupe(self): - self.mock_response( - responses.POST, - { - "errors": [ - { - "status": "409", - "detail": "A document with id `someorg:test-namespace:test-sched` already exists.", - "title": "Conflict", - } - ], - "jsonapi": {"version": "1.0"}, - }, - status=409, - ) - s = EventSchedule(id="someorg:test-namespace:test-sched", client=self.client) - with pytest.raises(ConflictError): - s.save() - - @responses.activate - def test_exists(self): - self.mock_response(responses.HEAD, {}, status=200) - assert EventSchedule.exists( - "someorg:test-namespace:test-sched", client=self.client - ) - assert ( - responses.calls[0].request.url - == "https://example.com/catalog/v2/event_schedules/someorg:test-namespace:test-sched" - ) - - @responses.activate - def test_exists_false(self): - self.mock_response(responses.HEAD, self.not_found_json, status=404) - assert not EventSchedule.exists( - "someorg:test-namespace:nonexistent-sched", client=self.client - ) - assert ( - responses.calls[0].request.url - == "https://example.com/catalog/v2/event_schedules/someorg:test-namespace:nonexistent-sched" - ) - - @responses.activate - def test_update(self): - self.mock_response( - responses.POST, - { - "meta": {"count": 1}, - "data": { - "attributes": { - "created": "2023-09-29T15:54:37.006769Z", - "description": None, - "enabled": True, - "end_datetime": None, - "extra_properties": {}, - "flexible_time_window": 0, - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-sched", - "namespace": "someorg:test-namespace", - "owners": ["org:someorg"], - "readers": [], - "schedule": "rate(1 days)", - "schedule_timezone": None, - "start_datetime": None, - "tags": [], - "writers": [], - }, - "id": "someorg:test-namespace:test-sched", - "type": "event_schedule", - }, - }, - status=200, - ) - - s = EventSchedule( - id="someorg:test-namespace:test-sched", - name="test-sched", - schedule="rate(1 days)", - client=self.client, - ) - s.save() - assert s.state == DocumentState.SAVED - s.readers = ["org:acme-corp"] - assert s.state == DocumentState.MODIFIED - self.mock_response( - responses.PATCH, - { - "meta": {"count": 1}, - "data": { - "attributes": { - "readers": ["org:acme-corp"], - }, - "type": "event_schedule", - "id": "someorg:test-namespace:test-sched", - }, - }, - status=200, - ) - s.save() - assert s.readers == ["org:acme-corp"] - - @responses.activate - def test_reload(self): - self.mock_response( - responses.POST, - { - "meta": {"count": 1}, - "data": { - "attributes": { - "created": "2023-09-29T15:54:37.006769Z", - "description": None, - "enabled": True, - "end_datetime": None, - "extra_properties": {}, - "flexible_time_window": 0, - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-sched", - "namespace": "someorg:test-namespace", - "owners": ["org:someorg"], - "readers": [], - "schedule": "rate(1 days)", - "schedule_timezone": None, - "start_datetime": None, - "tags": [], - "writers": [], - }, - "id": "someorg:test-namespace:test-sched", - "type": "event_schedule", - }, - "jsonapi": {"version": "1.0"}, - "links": {"self": "https://example.com/catalog/v2/storage"}, - }, - ) - - s = EventSchedule( - id="someorg:test-namespace:test-sched", - name="test-sched", - schedule="rate(1 days)", - client=self.client, - ) - s.save() - assert s.state == DocumentState.SAVED - s.readers = ["org:acme-corp"] - with pytest.raises(ValueError): - s.reload() - - @responses.activate - def test_delete(self): - s = EventSchedule( - id="someorg:test-namespace:test-sched", - name="test-sched", - schedule="rate(1 days)", - client=self.client, - _saved=True, - ) - self.mock_response( - responses.DELETE, - { - "meta": {"message": "Object successfully deleted"}, - "jsonapi": {"version": "1.0"}, - }, - ) - - s.delete() - assert s.state == DocumentState.DELETED - - @responses.activate - def test_class_delete(self): - sched_id = "someorg:test-namespace:test-sched" - self.mock_response( - responses.DELETE, - { - "meta": {"message": "Object successfully deleted"}, - "jsonapi": {"version": "1.0"}, - }, - ) - - assert EventSchedule.delete(sched_id, client=self.client) - - @responses.activate - def test_delete_non_existent(self): - s = EventSchedule( - id="someorg:test-namespace:nonexistent-sched", - name="nonexistent-sched", - schedule="rate(1 days)", - client=self.client, - _saved=True, - ) - - self.mock_response( - responses.DELETE, - self.not_found_json, - status=404, - ) - - with pytest.raises(DeletedObjectError): - s.delete() diff --git a/descarteslabs/core/catalog/tests/test_event_subscription.py b/descarteslabs/core/catalog/tests/test_event_subscription.py deleted file mode 100644 index 2473c447..00000000 --- a/descarteslabs/core/catalog/tests/test_event_subscription.py +++ /dev/null @@ -1,776 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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. - -# -*- coding: utf-8 -*- -import json -import pytest -import responses - -import textwrap - -import shapely.geometry - -from datetime import datetime - -from descarteslabs.exceptions import ConflictError -from .base import ClientTestCase -from ..attributes import AttributeValidationError -from ..event_subscription import ( - ComputeFunctionCompletedEventSubscription, - EventSubscription, - EventSubscriptionCollection, - EventSubscriptionComputeTarget, - EventSubscriptionSearch, - EventSubscriptionSqsTarget, - EventSubscriptionTarget, - EventType, - NewImageEventSubscription, - NewStorageEventSubscription, - NewVectorEventSubscription, - Placeholder, - ScheduledEventSubscription, -) -from ..catalog_base import DocumentState, DeletedObjectError -from ...common.property_filtering import Properties - - -class TestEventSubscription(ClientTestCase): - geometry = { - "type": "Polygon", - "coordinates": [ - [ - [-95.2989209, 42.7999878], - [-93.1167728, 42.3858464], - [-93.7138666, 40.703737], - [-95.8364984, 41.1150618], - [-95.2989209, 42.7999878], - ] - ], - } - - def test_constructor(self): - s = EventSubscription( - namespace="someorg:test-namespace", - name="test-sub", - id="someorg:test-namespace:test-sub", - description="a description", - geometry=self.geometry, - expires="2023-01-01", - event_type=[EventType.NEW_IMAGE], - event_source=["metadata"], - event_namespace=["some-product-id"], - event_filters=[ - ( - (Properties().cloud_fraction > 0.5) - & (Properties().cloud_fraction < 0.9) - ), - ], - targets=[ - EventSubscriptionTarget( - rule_id="someorg:some-rule", - detail_template="some-template", - ), - ], - enabled=True, - tags=["TESTING"], - ) - - assert s.namespace == "someorg:test-namespace" - assert s.name == "test-sub" - assert s.id == "someorg:test-namespace:test-sub" - assert s.description == "a description" - assert s.geometry == shapely.geometry.shape(self.geometry) - assert s.expires == "2023-01-01" - assert s.event_type == [EventType.NEW_IMAGE] - assert s.event_source == ["metadata"] - assert s.event_namespace == ["some-product-id"] - assert len(s.event_filters) == 1 - assert s.event_filters[0].is_same( - (Properties().cloud_fraction > 0.5) & (Properties().cloud_fraction < 0.9) - ) - assert len(s.targets) == 1 - assert s.targets[0].rule_id == "someorg:some-rule" - assert s.targets[0].detail_template == "some-template" - assert s.enabled is True - assert s.tags == ["TESTING"] - assert s.state == DocumentState.UNSAVED - - def test_repr(self): - s = EventSubscription( - name="test-sub", - id="someorg:test-namespace:test-sub", - description="a description", - expires="2023-01-01", - tags=["TESTING BLOB"], - ) - s_repr = repr(s) - match_str = """\ - EventSubscription: test-sub - id: someorg:test-namespace:test-sub - * Not up-to-date in the Descartes Labs catalog. Call `.save()` to save or update this record.""" - assert s_repr.strip("\n") == textwrap.dedent(match_str) - - def test_set_geometry(self): - shape = shapely.geometry.shape(self.geometry) - s = EventSubscription(id="someorg:test:test-sub", name="test-sub") - s.geometry = self.geometry - assert shape == s.geometry - - s.geometry = shape - assert shape == s.geometry - - with pytest.raises(AttributeValidationError): - s.geometry = {"type": "Lollipop"} - with pytest.raises(AttributeValidationError): - s.geometry = 2 - - def test_search_intersects(self): - search = ( - EventSubscription.search() - .intersects(self.geometry) - .filter(Properties().id == "s1") - ) - _, request_params = search._to_request() - assert self.geometry == json.loads(request_params["intersects"]) - assert "intersects_none" not in request_params - - def test_search_intersects_none(self): - search = ( - EventSubscription.search() - .intersects(self.geometry, match_null_geometry=True) - .filter(Properties().id == "s1") - ) - _, request_params = search._to_request() - assert self.geometry == json.loads(request_params["intersects"]) - assert request_params["intersects_none"] is True - - @responses.activate - def test_get(self): - self.mock_response( - responses.GET, - { - "data": { - "attributes": { - "created": "2023-09-29T15:54:37.006769Z", - "description": "a generic description", - "event_filters": [ - '{"and": [{"name": "cloud_fraction", "op": "gt", "val": 0.5}, {"name": "cloud_fraction", "op": "lt", "val": 0.9}]}', # noqa: E501 - ], - "event_namespace": ["some-product-id"], - "event_source": ["metadata"], - "event_type": ["new-image"], - "expires": None, - "extra_properties": {}, - "geometry": self.geometry, - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-sub", - "namespace": "someorg:test-namespace", - "owner": "user:somehash", - "owner_role_arn": "arn:aws:iam::123456789012:role/metadata-event-invoke-somehash", - "owners": ["org:someorg"], - "readers": ["org:someorg"], - "tags": ["TESTING"], - "targets": [ - { - "rule_id": "someorg:some-rule", - "detail_template": "some-template", - } - ], - "writers": [], - }, - "id": "someorg:test-namespace:test-sub", - "type": "event_subscription", - } - }, - status=200, - ) - - s = EventSubscription.get( - id="someorg:test-namespace:test-sub", client=self.client - ) - assert isinstance(s.created, datetime) - assert isinstance(s.modified, datetime) - assert s.id == "someorg:test-namespace:test-sub" - assert s.name == "test-sub" - assert s.namespace == "someorg:test-namespace" - assert s.description == "a generic description" - assert s.owner == "user:somehash" - assert ( - s.owner_role_arn - == "arn:aws:iam::123456789012:role/metadata-event-invoke-somehash" - ) - assert s.expires is None - assert s.geometry == shapely.geometry.shape(self.geometry) - assert s.event_type == [EventType.NEW_IMAGE] - assert s.event_source == ["metadata"] - assert s.event_namespace == ["some-product-id"] - assert len(s.event_filters) == 1 - assert s.event_filters[0].is_same( - (Properties().cloud_fraction > 0.5) & (Properties().cloud_fraction < 0.9) - ) - assert len(s.targets) == 1 - assert type(s.targets[0]) is EventSubscriptionTarget - assert s.targets[0].rule_id == "someorg:some-rule" - assert s.targets[0].detail_template == "some-template" - assert s.owners == ["org:someorg"] - assert s.readers == ["org:someorg"] - assert s.writers == [] - assert s.tags == ["TESTING"] - - @responses.activate - def test_get_unknown_attribute(self): - self.mock_response( - responses.GET, - { - "data": { - "attributes": { - "created": "2023-09-29T15:54:37.006769Z", - "description": "a generic description", - "event_filters": [ - '{"and": [{"name": "cloud_fraction", "op": "gt", "val": 0.5}, {"name": "cloud_fraction", "op": "lt", "val": 0.9}]}', # noqa: E501 - ], - "event_namespace": ["some-product-id"], - "event_source": ["metadata"], - "event_type": ["new-image"], - "expires": None, - "extra_properties": {}, - "geometry": self.geometry, - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-sub", - "namespace": "someorg:test-namespace", - "owner": "user:somehash", - "owners": ["org:someorg"], - "readers": ["org:someorg"], - "writers": [], - "tags": ["TESTING"], - "foobar": "unknown", - }, - "id": "someorg:test-namespace:test-sub", - "type": "event_subscription", - } - }, - status=200, - ) - - s = EventSubscription.get( - id="someorg:test-namespace:test-sub", client=self.client - ) - assert not hasattr(s, "foobar") - - @responses.activate - def test_get_many(self): - self.mock_response( - responses.PUT, - { - "data": [ - { - "attributes": { - "created": "2023-09-29T15:54:37.006769Z", - "description": "a generic description", - "event_filters": [], - "event_namespace": ["some-product-id"], - "event_source": ["metadata"], - "event_type": ["new-image"], - "expires": None, - "extra_properties": {}, - "geometry": None, - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-sub-1", - "namespace": "someorg:test-namespace", - "owner": "user:somehash", - "owners": ["org:someorg"], - "readers": ["org:someorg"], - "writers": [], - "tags": ["TESTING"], - }, - "id": "someorg:test-namespace:test-sub-1", - "type": "event_subscription", - }, - { - "attributes": { - "created": "2023-09-29T15:54:37.006769Z", - "description": "a generic description", - "event_filters": [], - "event_namespace": ["some-product-id"], - "event_source": ["metadata"], - "event_type": ["new-image"], - "expires": None, - "extra_properties": {}, - "geometry": None, - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-sub-2", - "namespace": "someorg:test-namespace", - "owner": "user:somehash", - "owners": ["org:someorg"], - "readers": ["org:someorg"], - "writers": [], - "tags": ["TESTING"], - }, - "id": "someorg:test-namespace:test-sub-2", - "type": "event_subscription", - }, - ], - }, - status=200, - ) - - subs = EventSubscription.get_many( - [ - "someorg:test-namespace:test-sub-1", - "someorg:test-namespace:test-sub-2", - ], - client=self.client, - ) - - for i, r in enumerate(subs): - assert isinstance(r, EventSubscription) - assert r.id == f"someorg:test-namespace:test-sub-{i + 1}" - - @responses.activate - def test_get_or_create(self): - self.mock_response( - responses.GET, - { - "data": { - "attributes": { - "created": "2023-09-29T15:54:37.006769Z", - "description": "a generic description", - "event_filters": [], - "event_namespace": ["some-product-id"], - "event_source": ["metadata"], - "event_type": ["new-image"], - "expires": None, - "extra_properties": {}, - "geometry": None, - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-sub", - "namespace": "someorg:test-namespace", - "owner": "user:somehash", - "owners": ["org:someorg"], - "readers": ["org:someorg"], - "writers": [], - "tags": ["TESTING"], - }, - "id": "someorg:test-namespace:test-sub", - "type": "event_subscription", - }, - }, - status=200, - ) - - s = EventSubscription.get_or_create( - id="someorg:test-namespace:test-sub", client=self.client - ) - assert s.id == "someorg:test-namespace:test-sub" - - @responses.activate - def test_list(self): - self.mock_response( - responses.PUT, - { - "meta": {"count": 2}, - "links": {"self": "https://example.com/catalog/v2/storage"}, - "data": [ - { - "attributes": { - "created": "2023-09-29T15:54:37.006769Z", - "description": "a generic description", - "event_filters": [], - "event_namespace": ["some-product-id"], - "event_source": ["metadata"], - "event_type": ["new-image"], - "expires": None, - "extra_properties": {}, - "geometry": None, - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-sub-1", - "namespace": "someorg:test-namespace", - "owner": "user:somehash", - "owners": ["org:someorg"], - "readers": ["org:someorg"], - "writers": [], - "tags": ["TESTING"], - }, - "id": "someorg:test-namespace:test-sub-1", - "type": "event_subscription", - }, - { - "attributes": { - "created": "2023-09-29T15:54:37.006769Z", - "description": "a generic description", - "event_filters": [], - "event_namespace": ["some-product-id"], - "event_source": ["metadata"], - "event_type": ["new-image"], - "expires": None, - "extra_properties": {}, - "geometry": None, - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-sub-2", - "namespace": "someorg:test-namespace", - "owner": "user:somehash", - "owners": ["org:someorg"], - "readers": ["org:someorg"], - "writers": [], - "tags": ["TESTING"], - }, - "id": "someorg:test-namespace:test-sub-2", - "type": "event_subscription", - }, - ], - }, - status=200, - ) - - search = EventSubscription.search(client=self.client) - assert search.count() == 2 - assert isinstance(search, EventSubscriptionSearch) - sc = search.collect() - assert isinstance(sc, EventSubscriptionCollection) - - @responses.activate - def test_list_no_results(self): - self.mock_response( - responses.PUT, - { - "meta": {"count": 0}, - "data": [], - }, - ) - - r = list(EventSubscription.search(client=self.client)) - assert r == [] - - @responses.activate - def test_save(self): - self.mock_response( - responses.POST, - { - "data": { - "attributes": { - "created": "2023-09-29T15:54:37.006769Z", - "description": "a generic description", - "event_filters": [], - "event_namespace": ["some-product-id"], - "event_source": ["metadata"], - "event_type": ["new-image"], - "expires": None, - "extra_properties": {}, - "geometry": None, - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-sub", - "namespace": "someorg:test-namespace", - "owner": "user:somehash", - "owner_role_arn": "arn:aws:iam::123456789012:role/metadata-event-invoke-somehash", - "owners": ["org:someorg"], - "readers": ["org:someorg"], - "writers": [], - "tags": ["TESTING"], - }, - "id": "someorg:test-namespace:test-sub", - "type": "event_subscription", - } - }, - status=201, - ) - - s = EventSubscription( - id="someorg:test-namespace:test-sub", - name="test-sub", - client=self.client, - ) - assert s.state == DocumentState.UNSAVED - s.save() - assert responses.calls[0].request.url == self.url + "/event_subscriptions" - assert s.state == DocumentState.SAVED - - @responses.activate - def test_save_dupe(self): - self.mock_response( - responses.POST, - { - "errors": [ - { - "status": "409", - "detail": "A document with id `someorg:test-namespace:test-sub` already exists.", - "title": "Conflict", - } - ], - "jsonapi": {"version": "1.0"}, - }, - status=409, - ) - s = EventSubscription(id="someorg:test-namespace:test-sub", client=self.client) - with pytest.raises(ConflictError): - s.save() - - @responses.activate - def test_exists(self): - self.mock_response(responses.HEAD, {}, status=200) - assert EventSubscription.exists( - "someorg:test-namespace:test-sub", client=self.client - ) - assert ( - responses.calls[0].request.url - == "https://example.com/catalog/v2/event_subscriptions/someorg:test-namespace:test-sub" - ) - - @responses.activate - def test_exists_false(self): - self.mock_response(responses.HEAD, self.not_found_json, status=404) - assert not EventSubscription.exists( - "someorg:test-namespace:nonexistent-sub", client=self.client - ) - assert ( - responses.calls[0].request.url - == "https://example.com/catalog/v2/event_subscriptions/someorg:test-namespace:nonexistent-sub" - ) - - @responses.activate - def test_update(self): - self.mock_response( - responses.POST, - { - "meta": {"count": 1}, - "data": { - "attributes": { - "created": "2023-09-29T15:54:37.006769Z", - "description": None, - "event_filters": [], - "event_namespace": [], - "event_source": [], - "event_type": [], - "expires": None, - "extra_properties": {}, - "geometry": None, - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-sub", - "namespace": "someorg:test-namespace", - "owner": "user:somehash", - "owners": ["org:someorg", "user:somehash"], - "readers": [], - "writers": [], - "tags": [], - }, - "id": "someorg:test-namespace:test-sub", - "type": "event_subscription", - }, - }, - status=200, - ) - - s = EventSubscription( - id="someorg:test-namespace:test-sub", - name="test-sub", - client=self.client, - ) - s.save() - assert s.state == DocumentState.SAVED - s.readers = ["org:acme-corp"] - assert s.state == DocumentState.MODIFIED - self.mock_response( - responses.PATCH, - { - "meta": {"count": 1}, - "data": { - "attributes": { - "readers": ["org:acme-corp"], - }, - "type": "event_subscription", - "id": "someorg:test-namespace:test-sub", - }, - }, - status=200, - ) - s.save() - assert s.readers == ["org:acme-corp"] - - @responses.activate - def test_reload(self): - self.mock_response( - responses.POST, - { - "meta": {"count": 1}, - "data": { - "attributes": { - "created": "2023-09-29T15:54:37.006769Z", - "description": None, - "event_filters": [], - "event_namespace": [], - "event_source": [], - "event_type": [], - "expires": None, - "extra_properties": {}, - "geometry": None, - "modified": "2023-09-29T15:54:37.006769Z", - "name": "test-sub", - "namespace": "someorg:test-namespace", - "owner": "user:somehash", - "owners": ["org:someorg", "user:somehash"], - "readers": [], - "writers": [], - "tags": [], - }, - "id": "someorg:test-namespace:test-sub", - "type": "event_subscription", - }, - "jsonapi": {"version": "1.0"}, - "links": {"self": "https://example.com/catalog/v2/storage"}, - }, - ) - - s = EventSubscription( - id="someorg:test-namespace:test-sub", - name="test-sub", - client=self.client, - ) - s.save() - assert s.state == DocumentState.SAVED - s.readers = ["org:acme-corp"] - with pytest.raises(ValueError): - s.reload() - - @responses.activate - def test_delete(self): - s = EventSubscription( - id="someorg:test-namespace:test-sub", - name="test-sub", - client=self.client, - _saved=True, - ) - self.mock_response( - responses.DELETE, - { - "meta": {"message": "Object successfully deleted"}, - "jsonapi": {"version": "1.0"}, - }, - ) - - s.delete() - assert s.state == DocumentState.DELETED - - @responses.activate - def test_class_delete(self): - sub_id = "someorg:test-namespace:test-sub" - self.mock_response( - responses.DELETE, - { - "meta": {"message": "Object successfully deleted"}, - "jsonapi": {"version": "1.0"}, - }, - ) - - assert EventSubscription.delete(sub_id, client=self.client) - - @responses.activate - def test_delete_non_existent(self): - s = EventSubscription( - id="someorg:test-namespace:nonexistent-sub", - name="nonexistent-sub", - client=self.client, - _saved=True, - ) - - self.mock_response( - responses.DELETE, - self.not_found_json, - status=404, - ) - - with pytest.raises(DeletedObjectError): - s.delete() - - def test_compute_target(self): - target = EventSubscriptionComputeTarget( - "some-function-id", - Placeholder("event.detail.id"), - detail=Placeholder("event.detail", unquoted=True), - source=Placeholder('"{{ event.source }}"', raw=True), - ) - assert isinstance(target, EventSubscriptionTarget) - assert target.rule_id == "internal:compute-job-create" - assert ( - target.detail_template - == '{"body": {"function_id": "some-function-id", "args": ["{{ event.detail.id }}"], "kwargs": {"detail": {{ event.detail }}, "source": "{{ event.source }}"}}}' # noqa: E501 - ) - - def test_sqs_target_positional(self): - target = EventSubscriptionSqsTarget( - "some-sqs-queue-url", - Placeholder("event.detail", unquoted=True), - ) - assert isinstance(target, EventSubscriptionTarget) - assert target.rule_id == "internal:sqs-forwarder" - assert ( - target.detail_template - == '{"message": {{ event.detail }}, "sqs_queue_url": "some-sqs-queue-url"}' - ) - - def test_sqs_target_kwargs(self): - target = EventSubscriptionSqsTarget( - "some-sqs-queue-url", - id=Placeholder("event.detail.id"), - ) - assert isinstance(target, EventSubscriptionTarget) - assert target.rule_id == "internal:sqs-forwarder" - assert ( - target.detail_template - == '{"message": {"id": "{{ event.detail.id }}"}, "sqs_queue_url": "some-sqs-queue-url"}' - ) - - def test_sqs_target_default(self): - target = EventSubscriptionSqsTarget( - "some-sqs-queue-url", - ) - assert isinstance(target, EventSubscriptionTarget) - assert target.rule_id == "internal:sqs-forwarder" - assert ( - target.detail_template - == '{"message": {{ event.detail }}, "sqs_queue_url": "some-sqs-queue-url"}' - ) - - def test_new_image_event_subscription(self): - sub = NewImageEventSubscription("some-product-id") - assert isinstance(sub, EventSubscription) - assert sub.event_source == ["catalog"] - assert sub.event_type == [EventType.NEW_IMAGE] - assert sub.event_namespace == ["some-product-id"] - - def test_new_storage_event_subscription(self): - sub = NewStorageEventSubscription("some-namespace") - assert isinstance(sub, EventSubscription) - assert sub.event_source == ["catalog"] - assert sub.event_type == [EventType.NEW_STORAGE] - assert sub.event_namespace == ["some-namespace"] - - def test_new_vector_event_subscription(self): - sub = NewVectorEventSubscription("some-product-id") - assert isinstance(sub, EventSubscription) - assert sub.event_source == ["vector"] - assert sub.event_type == [EventType.NEW_VECTOR] - assert sub.event_namespace == ["some-product-id"] - - def test_compute_function_completed_event_subscription(self): - sub = ComputeFunctionCompletedEventSubscription("some-function-id") - assert isinstance(sub, EventSubscription) - assert sub.event_source == ["compute"] - assert sub.event_type == [EventType.COMPUTE_FUNCTION_COMPLETED] - assert sub.event_namespace == ["some-function-id"] - - def test_scheduled_event_subscription(self): - sub = ScheduledEventSubscription("some-schedule-id") - assert isinstance(sub, EventSubscription) - assert sub.event_source == ["scheduler"] - assert sub.event_type == [EventType.SCHEDULED] - assert sub.event_namespace == ["some-schedule-id"] diff --git a/descarteslabs/core/catalog/tests/test_filters.py b/descarteslabs/core/catalog/tests/test_filters.py deleted file mode 100644 index 3f1cb7a6..00000000 --- a/descarteslabs/core/catalog/tests/test_filters.py +++ /dev/null @@ -1,189 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 unittest -from datetime import datetime - -from ...common.property_filtering import Properties - -from ..band import Band -from ..image import Image -from ..product import Product -from ..attributes import Resolution - -# -# Note that filter values are not validated, so supplying invalid values -# will send that value to the backend, which *will* validate the values. -# -# These tests make sure that there are no other problems with filters. -# - -prop = Properties() - - -class TestBoolFilter(unittest.TestCase): - def test_valid_bool_filter(self): - expr = prop.is_core == True # noqa: E712 - result = expr.jsonapi_serialize(Product) - assert result["val"] is True - - expr = prop.is_core == False # noqa: E712 - result = expr.jsonapi_serialize(Product) - assert result["val"] is False - - def test_invalid_bool_filter(self): - # No exception - expr = prop.is_core == 1.25 - result = expr.jsonapi_serialize(Product) - assert result["val"] is True - - -class TestIntFilter(unittest.TestCase): - def test_valid_int_filter(self): - expr = prop.band_index == 1 - result = expr.jsonapi_serialize(Band) - assert result["val"] == 1 - - def test_invalid_int_filter(self): - # No exception - expr = prop.band_index == 1.25 - result = expr.jsonapi_serialize(Band) - assert result["val"] == 1.25 - - -class TestFloatFilter(unittest.TestCase): - def test_valid_float_filter(self): - expr = prop.revisit_period_minutes_min == 1.25 - result = expr.jsonapi_serialize(Product) - assert result["val"] == 1.25 - - def test_valid_int_filter(self): - expr = prop.revisit_period_minutes_min == 1 - result = expr.jsonapi_serialize(Product) - assert result["val"] == 1.0 - - def test_valid_bool_filter(self): - # float(False) == 0.0; float(True) == 1.0 - expr = prop.revisit_period_minutes_min == False # noqa: E712 - result = expr.jsonapi_serialize(Product) - assert result["val"] == 0.0 - - expr = prop.revisit_period_minutes_min == True # noqa: E712 - result = expr.jsonapi_serialize(Product) - assert result["val"] == 1.0 - - def test_invalid_float_filter(self): - # No exception - expr = prop.revisit_period_minutes_min == [] - result = expr.jsonapi_serialize(Product) - assert result["val"] == [] - - -class TestCatalogObjectReferenceFilter(unittest.TestCase): - def test_valid_catalog_object_reference_filter(self): - expr = prop.product == Product(id="something") - result = expr.jsonapi_serialize(Band) - assert result["val"] == "something" - - def test_invalid_catalog_object_reference_filter(self): - # Sadly, any `id` will do... - expr = prop.product == Image(id="something:something") - result = expr.jsonapi_serialize(Band) - assert result["val"] == "something:something" - - def test_valid_id_filter(self): - expr = prop.product == "something" - result = expr.jsonapi_serialize(Band) - assert result["val"] == "something" - - def test_invalid_id_filter(self): - # No exception - expr = prop.product == 12 - result = expr.jsonapi_serialize(Band) - assert result["val"] == 12 - - -class TestTimestampFilter(unittest.TestCase): - def test_valid_timestamp_filter(self): - expr = prop.created == datetime.fromisoformat("2022-10-10") - result = expr.jsonapi_serialize(Band) - assert result["val"] == "2022-10-10T00:00:00" - - expr = prop.created == "2022-10-10" - result = expr.jsonapi_serialize(Band) - assert result["val"] == "2022-10-10" - - def test_invalid_timestamp_filter(self): - # No exception - expr = prop.created == "something" - result = expr.jsonapi_serialize(Band) - assert result["val"] == "something" - - -class TestEnumFilter(unittest.TestCase): - def test_valid_enum_filter(self): - expr = prop.data_type == "Byte" - result = expr.jsonapi_serialize(Band) - assert result["val"] == "Byte" - - def test_invalid_enum_filter(self): - # No exception - expr = prop.data_type == "BYTE" - result = expr.jsonapi_serialize(Band) - assert result["val"] == "BYTE" - - -class TestTupleAttributeFilter(unittest.TestCase): - def test_valid_tuple_attribute_filter(self): - expr = prop.data_range == (1, 2) - result = expr.jsonapi_serialize(Band) - assert result["val"] == (1, 2) - - def test_invalid_tuple_attribute_filter(self): - # No exception - expr = prop.data_range == "key" - result = expr.jsonapi_serialize(Band) - assert result["val"] == "key" - - -class TestResolutionFilter(unittest.TestCase): - def test_valid_resolution_filter(self): - expr = prop.resolution_min == Resolution(value=60, unit="meters") - result = expr.jsonapi_serialize(Product) - assert result["val"] == {"value": 60, "unit": "meters"} - - expr = prop.resolution_min == {"value": 60, "unit": "meters"} - result = expr.jsonapi_serialize(Product) - assert result["val"] == {"value": 60, "unit": "meters"} - - def test_invalid_resolution_filter(self): - # No exception - expr = prop.resolution_min == "key" - result = expr.jsonapi_serialize(Product) - assert result["val"] == "key" - - -class TestListAttributeFilter(unittest.TestCase): - # A list goes down to individual elements - - def test_valid_list_attribute_filter(self): - expr = prop.default_display_bands == "one" - result = expr.jsonapi_serialize(Product) - assert result["val"] == "one" - - def test_invalid_resolution_filter(self): - # No exception - expr = prop.default_display_bands == 12 - result = expr.jsonapi_serialize(Product) - assert result["val"] == 12 diff --git a/descarteslabs/core/catalog/tests/test_image.py b/descarteslabs/core/catalog/tests/test_image.py deleted file mode 100644 index 698a788c..00000000 --- a/descarteslabs/core/catalog/tests/test_image.py +++ /dev/null @@ -1,1232 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 datetime -import functools -import json -import os.path -import textwrap -import warnings -from tempfile import NamedTemporaryFile - -import numpy as np -import pytest -import responses -import shapely.geometry -from unittest.mock import patch - -from ...common.geo import AOI -from ...common.property_filtering import Properties -from ...common.shapely_support import shapely_to_geojson -from .. import image as image_module -from .. import image_upload as image_upload_module -from ..attributes import ( - AttributeValidationError, - DocumentState, - ListAttribute, - MappingAttribute, -) -from ..image import Image -from ..image_upload import ImageUploadStatus -from ..product import Product -from .base import ClientTestCase -from .mock_data import ( - BANDS_BY_PRODUCT, - _cached_bands_by_product, - _image_get, - _raster_ndarray, -) - - -class TestImage(ClientTestCase): - geometry = { - "type": "Polygon", - "coordinates": [ - [ - [-95.2989209, 42.7999878], - [-93.1167728, 42.3858464], - [-93.7138666, 40.703737], - [-95.8364984, 41.1150618], - [-95.2989209, 42.7999878], - ] - ], - } - - @responses.activate - def test_get(self): - self.mock_response( - responses.GET, - { - "data": { - "attributes": { - "modified": "2019-06-11T23:31:33.714883Z", - "created": "2019-06-11T23:31:33.714883Z", - "name": "myimage", - "product_id": "p1", - "geometry": self.geometry, - "c6s_dlsr": [], - "x_pixels": 15696, - "y_pixels": 15960, - "geotrans": [258292.5, 15.0, 0.0, 4743307.5, 0.0, -15.0], - "cs_code": "EPSG:32615", - }, - "type": "image", - "id": "p1:myimage", - }, - "included": [ - {"attributes": {"name": "A product"}, "type": "product", "id": "p1"} - ], - "jsonapi": {"version": "1.0"}, - }, - ) - # product bands request - self.mock_response( - responses.PUT, - { - "meta": {"count": 1}, - "data": [ - { - "attributes": { - "name": "myband", - "product_id": "p1", - "created": "2019-06-12T20:31:48.542725Z", - "resolution": {"value": 30, "unit": "meters"}, - "type": "spectral", - }, - "type": "band", - "id": "p1:myband", - } - ], - "jsonapi": {"version": "1.0"}, - "links": { - "self": "https://example.com/catalog/v2/bands", - }, - }, - ) - - i = Image.get("p1:myimage", client=self.client) - assert "p1" == i.product.id - assert shapely.geometry.shape(self.geometry) == i.geometry - - i_repr = repr(i) - match_str = """\ - Image: myimage - id: p1:myimage - product: p1 - created: Tue Jun 11 23:31:33 2019""" # noqa - assert i_repr.strip("\n") == textwrap.dedent(match_str) - - assert isinstance(i.geocontext, AOI) - # avoid differences between tuples and lists - assert shapely.geometry.shape(i.__geo_interface__) == shapely.geometry.shape( - self.geometry - ) - - def test_set_geometry(self): - shape = shapely.geometry.shape(self.geometry) - i = Image(name="myimage", product_id="p1") - i.geometry = self.geometry - assert shape == i.geometry - - i.geometry = shape - assert shape == i.geometry - - with pytest.raises(AttributeValidationError): - i.geometry = {"type": "Lollipop"} - with pytest.raises(AttributeValidationError): - i.geometry = 2 - - def test_serialize_geometry(self): - i = Image(name="myimage", product_id="p1", geometry=self.geometry) - assert shapely_to_geojson(i.geometry) == i.serialize()["geometry"] - - def test_geocontext(self): - # test doesn't fail with nothing - geocontext = Image().geocontext - assert geocontext == AOI(bounds_crs=None, align_pixels=False) - - # no geotrans - geocontext = Image(cs_code="EPSG:4326").geocontext - assert geocontext == AOI(crs="EPSG:4326", bounds_crs=None, align_pixels=False) - - # north-up geotrans - resolution - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter( - "always" - ) # otherwise, the duplicate warning is suppressed the second time - # origin: (0, 0), pixel size: 2, rotation: 0 degrees - geocontext = Image( - cs_code="EPSG:4326", geotrans=[0, 2, 0, 0, 0, -2] - ).geocontext - assert len(w) == 0 - assert geocontext.resolution == 2 - - # non-north-up geotrans - resolution - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - # origin: (0, 0), pixel size: 2, rotation: 30 degrees - geocontext = Image( - cs_code="EPSG:4326", - geotrans=( - 0.0, - 1.7320508075688774, - -1, - 0.0, - 1, - 1.7320508075688774, - ), - ).geocontext - warning = w[0] - assert "The GeoContext will *not* return this Image's original data" in str( - warning.message - ) - assert geocontext.resolution == 2 - - # north-up geotrans - bounds - # origin: (10, 20), pixel size: 2, rotation: 0 degrees - geocontext = Image( - cs_code="EPSG:4326", - geotrans=[10, 2, 0, 20, 0, -2], - x_pixels=1, - y_pixels=2, - ).geocontext - assert geocontext.bounds == (10, 16, 12, 20) - - # non-north-up geotrans - bounds - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - # origin: (0, 0), pixel size: 2, rotation: 45 degrees - geocontext = Image( - cs_code="EPSG:4326", - geotrans=( - 0.0, - np.sqrt(2), - np.sqrt(2), - 0.0, - np.sqrt(2), - -np.sqrt(2), - ), - x_pixels=1, - y_pixels=1, - ).geocontext - warning = w[0] - assert "The GeoContext will *not* return this Image's original data" in str( - warning.message - ) - diagonal = np.sqrt(2**2 + 2**2) - assert geocontext.bounds == (0, -diagonal / 2, diagonal, diagonal / 2) - - @responses.activate - def test_nanosecond_timestamp(self): - self.mock_response( - responses.GET, - { - "data": { - "attributes": { - "modified": "2019-06-11T23:31:33.714883Z", - "created": "2019-06-11T23:31:33.714883Z", - "name": "myimage", - "product_id": "p1", - "geometry": self.geometry, - "acquired": "2019-08-20T08:08:16.123456789Z", - }, - "type": "image", - "id": "p1:myimage", - }, - "included": [ - {"attributes": {"name": "A product"}, "type": "product", "id": "p1"} - ], - "jsonapi": {"version": "1.0"}, - }, - ) - - i = Image.get("p1:myimage", client=self.client) - assert ( - datetime.datetime.strptime( - "2019-08-20T08:08:16.123456Z", "%Y-%m-%dT%H:%M:%S.%fZ" - ).replace(tzinfo=datetime.timezone.utc) - == i.acquired - ) - - def test_search_intersects(self): - search = ( - Image.search() - .intersects(self.geometry) - .filter(Properties().product_id == "p1") - ) - _, request_params = search._to_request() - assert self.geometry == json.loads(request_params["intersects"]) - - @responses.activate - def test_files_attribute(self): - self.mock_response( - responses.GET, - { - "data": { - "type": "image", - "relationships": { - "product": {"data": {"type": "product", "id": "prod1"}} - }, - "attributes": { - "proj4": "+proj=utm +zone=29 +datum=WGS84 +units=m +no_defs ", - "satellite_id": "LANDSAT_8", - "product_id": "prod1", - "files": [ - { - "href": "gs://descartes-files/01.jp2", - "size_bytes": 121091191, - "hash": "c1fe6f604b0bf1e7265d06ed0379bf0c", - "provider_href": None, - "provider_id": None, - }, - { - "href": "gs://descartes-files/02.jp2", - "size_bytes": 53718630, - "hash": "37ea2fb38268875f7f473c52ba485bc6", - "provider_href": None, - "provider_id": None, - }, - ], - "name": "id1", - "storage_state": "available", - "extra_properties": {}, - "geometry": self.geometry, - "geotrans": [330592.5, 15.0, 0.0, 8918707.5, 0.0, -15.0], - "x_pixels": 18240, - "published": "2017-05-16T18:45:16Z", - "acquired": "2017-05-16T14:07:26.108914Z", - "created": "2017-05-21T00:51:08Z", - "y_pixels": 18216, - }, - "id": "prod1:id1", - }, - "included": [ - { - "type": "product", - "attributes": { - "readers": ["group:beta", "group:public"], - "owners": ["org:someorg"], - "start_datetime": "2013-01-01T00:00:00Z", - }, - "id": "prod1", - } - ], - "jsonapi": {"version": "1.0"}, - }, - ) - - i = Image.get("p1:myimage", client=self.client) - assert len(i.files) == 2 - assert isinstance(i.files, ListAttribute) - assert isinstance(i.files[0], MappingAttribute) - - file0 = i.files[0] - assert file0.href == "gs://descartes-files/01.jp2" - assert i.state == DocumentState.SAVED - - file0.href = "gs://new-bucket/file0.jp2" - assert i.state == DocumentState.MODIFIED - - serialized = i.serialize(modified_only=True) - assert list(serialized.keys()) == ["files"] - assert serialized["files"] == [ - { - "href": "gs://new-bucket/file0.jp2", - "size_bytes": 121091191, - "hash": "c1fe6f604b0bf1e7265d06ed0379bf0c", - "provider_href": None, - "provider_id": None, - }, - { - "href": "gs://descartes-files/02.jp2", - "size_bytes": 53718630, - "hash": "37ea2fb38268875f7f473c52ba485bc6", - "provider_href": None, - "provider_id": None, - }, - ] - - @patch.object(image_module.Image, "_upload_service") - @patch.object(image_upload_module.ImageUpload, "_POLLING_INTERVALS", [1]) - @responses.activate - def test_upload(self, upload_mock): - with NamedTemporaryFile(suffix=".tif", delete=False) as f: - try: - f.close() - # this is copied from the upload impl, should go away with new ingest - product_id = "p1" - image_name = "image" - image_id = "{}:{}".format(product_id, image_name) - upload_url = "https:www.fake.com/1" - - self.mock_response( - responses.GET, - { - "data": { - "attributes": { - "readers": [], - "writers": [], - "owners": ["org:someorg"], - "modified": "2019-06-11T23:31:33.714883Z", - "created": "2019-06-11T23:31:33.714883Z", - }, - "type": "product", - "id": product_id, - }, - "jsonapi": {"version": "1.0"}, - }, - ) - self.mock_response( - responses.HEAD, - { - "errors": [ - { - "detail": "Object not found: {}".format(image_id), - "status": "404", - "title": "Object not found", - } - ], - "jsonapi": {"version": "1.0"}, - }, - status=404, - ) - self.mock_response( - responses.POST, - { - "data": { - "type": "image_upload", - "id": "1", - "attributes": { - "created": "2019-01-02T03:04:05Z", - "modified": "2019-01-02T03:04:05Z", - "product_id": product_id, - "image_id": image_id, - "resumable_urls": [upload_url], - "status": ImageUploadStatus.TRANSFERRING.value, - }, - }, - "jsonapi": {"version": "1.0"}, - }, - ) - self.mock_response( - responses.PATCH, - { - "data": { - "type": "image_upload", - "id": "1", - "attributes": { - "created": "2019-01-02T03:04:05Z", - "modified": "2019-01-02T03:04:06Z", - "product_id": product_id, - "image_id": image_id, - "status": ImageUploadStatus.PENDING.value, - }, - }, - "jsonapi": {"version": "1.0"}, - }, - ) - self.mock_response( - responses.GET, - { - "data": { - "type": "image_upload", - "id": "1", - "attributes": { - "created": "2019-01-02T03:04:05Z", - "modified": "2019-01-02T03:04:06Z", - "product_id": product_id, - "image_id": image_id, - "status": ImageUploadStatus.SUCCESS.value, - }, - }, - "jsonapi": {"version": "1.0"}, - }, - ) - self.mock_response( - responses.GET, - { - "data": { - "attributes": { - "modified": "2019-06-11T23:31:33.714883Z", - "created": "2019-06-11T23:31:33.714883Z", - "name": image_name, - "product_id": product_id, - "acquired": "2001-01-01T00:00:00Z", - "geometry": self.geometry, - }, - "type": "image", - "id": image_id, - }, - "jsonapi": {"version": "1.0"}, - }, - ) - - image = Image( - name=image_name, - product_id=product_id, - acquired="2001-01-01", - client=self.client, - ) - - upload = image.upload(f.name) - finally: - # Manual cleanup required for Windows compatibility - os.unlink(f.name) - - assert image.state == DocumentState.UNSAVED - assert upload.id == "1" - assert upload.product_id == product_id - assert upload.image_id == image.id - assert upload.status == ImageUploadStatus.PENDING - upload_mock.session.put.assert_called_once() - assert upload_mock.session.put.call_args[0][0] == upload_url - - upload.wait_for_completion(15) - - assert upload.status == ImageUploadStatus.SUCCESS - - # when the new ingest is completed, we may implement the reload - # of the updated Image... - - @patch.object(image_module.Image, "_upload_service") - @patch.object(image_upload_module.ImageUpload, "_POLLING_INTERVALS", [1]) - @responses.activate - def test_upload_multi_file(self, upload_mock): - with NamedTemporaryFile(suffix=".tif", delete=False) as f1: - with NamedTemporaryFile(suffix=".tif", delete=False) as f2: - try: - f1.close() - f2.close() - # this is copied from the upload impl, should go away with new ingest - product_id = "p1" - image_name = "image" - image_id = "{}:{}".format(product_id, image_name) - upload_url1 = "https:www.fake.com/1" - upload_url2 = "https:www.fake.com/2" - - self.mock_response( - responses.GET, - { - "data": { - "attributes": { - "readers": [], - "writers": [], - "owners": ["org:someorg"], - "modified": "2019-06-11T23:31:33.714883Z", - "created": "2019-06-11T23:31:33.714883Z", - }, - "type": "product", - "id": product_id, - }, - "jsonapi": {"version": "1.0"}, - }, - ) - self.mock_response( - responses.HEAD, - { - "errors": [ - { - "detail": "Object not found: {}".format(image_id), - "status": "404", - "title": "Object not found", - } - ], - "jsonapi": {"version": "1.0"}, - }, - status=404, - ) - self.mock_response( - responses.POST, - { - "data": { - "type": "image_upload", - "id": "1", - "attributes": { - "created": "2019-01-02T03:04:05Z", - "modified": "2019-01-02T03:04:05Z", - "product_id": product_id, - "image_id": image_id, - "resumable_urls": [upload_url1, upload_url2], - "status": ImageUploadStatus.TRANSFERRING.value, - }, - }, - "jsonapi": {"version": "1.0"}, - }, - ) - self.mock_response( - responses.PATCH, - { - "data": { - "type": "image_upload", - "id": "1", - "attributes": { - "created": "2019-01-02T03:04:05Z", - "modified": "2019-01-02T03:04:05Z", - "product_id": product_id, - "image_id": image_id, - "status": ImageUploadStatus.PENDING.value, - }, - }, - "jsonapi": {"version": "1.0"}, - }, - ) - self.mock_response( - responses.GET, - { - "data": { - "type": "image_upload", - "id": "1", - "attributes": { - "created": "2019-01-02T03:04:05Z", - "modified": "2019-01-02T03:04:05Z", - "product_id": product_id, - "image_id": image_id, - "status": ImageUploadStatus.SUCCESS.value, - }, - }, - "jsonapi": {"version": "1.0"}, - }, - ) - self.mock_response( - responses.GET, - { - "data": { - "attributes": { - "modified": "2019-06-11T23:31:33.714883Z", - "created": "2019-06-11T23:31:33.714883Z", - "name": image_name, - "product_id": product_id, - "acquired": "2001-01-01T00:00:00Z", - "geometry": self.geometry, - }, - "type": "image", - "id": image_id, - }, - "jsonapi": {"version": "1.0"}, - }, - ) - - image = Image( - name=image_name, - product_id=product_id, - acquired="2001-01-01", - client=self.client, - ) - - upload = image.upload([f1.name, f2.name]) - finally: - # Manual cleanup required for Windows compatibility - os.unlink(f1.name) - os.unlink(f2.name) - - assert image.state == DocumentState.UNSAVED - assert upload.id == "1" - assert upload.product_id == product_id - assert upload.image_id == image.id - assert upload.status == ImageUploadStatus.PENDING - assert len(upload_mock.session.put.call_args_list) == 2 - assert upload_mock.session.put.call_args_list[0][0][0] == upload_url1 - assert upload_mock.session.put.call_args_list[1][0][0] == upload_url2 - - upload.wait_for_completion(15) - - assert upload.status == ImageUploadStatus.SUCCESS - - # when the new ingest is completed, we may implement the reload - # of the updated Image... - - @patch("descarteslabs.catalog.Image._do_upload", return_value=True) - @patch("descarteslabs.catalog.Image.exists", return_value=False) - def test_upload_warnings(self, *mocks): - p = Product(id="p1", name="Test Product", client=self.client, _saved=True) - image = Image(id="p1:image", product=p, acquired="2012-05-06", projection="foo") - - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - image.upload("somefile") - assert 1 == len(w) - assert "cs_code" in str(w[0].message) - - @patch.object(image_module.Image, "_upload_service") - @patch.object(image_upload_module.ImageUpload, "_POLLING_INTERVALS", [1]) - @responses.activate - def test_upload_ndarray(self, upload_mock): - # this is copied from the upload impl, should go away with new ingest - product_id = "p1" - image_name = "image" - image_id = "{}:{}".format(product_id, image_name) - upload_url = "https:www.fake.com/1" - - self.mock_response( - responses.GET, - { - "data": { - "attributes": { - "readers": [], - "writers": [], - "owners": ["org:someorg"], - "modified": "2019-06-11T23:31:33.714883Z", - "created": "2019-06-11T23:31:33.714883Z", - }, - "type": "product", - "id": product_id, - }, - "jsonapi": {"version": "1.0"}, - }, - ) - self.mock_response( - responses.HEAD, - { - "errors": [ - { - "detail": "Object not found: {}".format(image_id), - "status": "404", - "title": "Object not found", - } - ], - "jsonapi": {"version": "1.0"}, - }, - status=404, - ) - self.mock_response( - responses.POST, - { - "data": { - "type": "image_upload", - "id": "1", - "attributes": { - "created": "2019-01-02T03:04:05Z", - "modified": "2019-01-02T03:04:05Z", - "product_id": product_id, - "image_id": image_id, - "resumable_urls": [upload_url], - "status": ImageUploadStatus.TRANSFERRING.value, - }, - }, - "jsonapi": {"version": "1.0"}, - }, - ) - self.mock_response( - responses.PATCH, - { - "data": { - "type": "image_upload", - "id": "1", - "attributes": { - "created": "2019-01-02T03:04:05Z", - "modified": "2019-01-02T03:04:05Z", - "product_id": product_id, - "image_id": image_id, - "status": ImageUploadStatus.PENDING.value, - }, - }, - "jsonapi": {"version": "1.0"}, - }, - ) - self.mock_response( - responses.GET, - { - "data": { - "type": "image_upload", - "id": "1", - "attributes": { - "product_id": product_id, - "image_id": image_id, - "status": ImageUploadStatus.SUCCESS.value, - }, - }, - "jsonapi": {"version": "1.0"}, - }, - ) - self.mock_response( - responses.GET, - { - "data": { - "attributes": { - "modified": "2019-06-11T23:31:33.714883Z", - "created": "2019-06-11T23:31:33.714883Z", - "name": image_name, - "product_id": product_id, - "acquired": "2001-01-01T00:00:00Z", - "geometry": self.geometry, - }, - "type": "image", - "id": image_id, - }, - "jsonapi": {"version": "1.0"}, - }, - ) - - image = Image( - name=image_name, - product_id=product_id, - acquired="2001-01-01", - client=self.client, - ) - - ary = np.zeros((10, 10), dtype=np.dtype(np.uint16)) - raster_meta = { - "geoTransform": [0, 0, 0, 0, 0, 0], - "coordinateSystem": {"proj4": "proj4 string"}, - } - upload = image.upload_ndarray(ary, raster_meta=raster_meta) - - assert image.state == DocumentState.UNSAVED - assert upload.id == "1" - assert upload.product_id == product_id - assert upload.image_id == image.id - assert upload.status == ImageUploadStatus.PENDING - upload_mock.session.put.assert_called_once() - assert upload_mock.session.put.call_args[0][0] == upload_url - assert ( - self.get_request_body(2)["data"]["attributes"]["image_upload_options"][ - "upload_size" - ] - == 200 - ) - - upload.wait_for_completion(15) - - assert upload.status == ImageUploadStatus.SUCCESS - - @patch("descarteslabs.catalog.Image._do_upload", return_value=True) - @patch("descarteslabs.catalog.Image.exists", return_value=False) - def test_upload_ndarray_dtype(self, *mocks): - p = Product(id="p1", name="Test Product", client=self.client, _saved=True) - image = Image( - id="p1:image", - product=p, - acquired="2012-05-06", - geotrans=[42, 0, 0, 0, 0, 0], - projection="foo", - ) - - pytest.raises( - ValueError, image.upload_ndarray, np.zeros((1, 100, 100), np.int8) - ) - pytest.raises( - ValueError, image.upload_ndarray, np.zeros((1, 100, 100), np.int64) - ) - pytest.raises( - ValueError, image.upload_ndarray, np.zeros((1, 100, 100), np.uint64) - ) - - @patch("descarteslabs.catalog.Image.exists", return_value=False) - def test_upload_ndarray_bad_georef(self, *mocks): - p = Product(id="p1", name="Test Product", client=self.client, _saved=True) - image = Image( - id="p1:image", - product=p, - acquired="2012-05-06", - geotrans=[42, 0, 0, 0, 0, 0], - ) - pytest.raises(ValueError, image.upload_ndarray, np.zeros((100, 100))) - - @patch("descarteslabs.catalog.Image._do_upload", return_value=True) - @patch("descarteslabs.catalog.Image.exists", return_value=False) - def test_upload_ndarray_shape(self, *mocks): - p = Product(id="p1", name="Test Product", client=self.client, _saved=True) - image = Image( - id="p1:image", - product=p, - acquired="2012-05-06", - geotrans=[42, 0, 0, 0, 0, 0], - projection="foo", - ) - - pytest.raises(ValueError, image.upload_ndarray, np.zeros((100,))) - pytest.raises(ValueError, image.upload_ndarray, np.zeros((100, 100, 1, 2))) - - array = np.zeros((1, 100, 100)) - with warnings.catch_warnings(record=True) as w: - image.upload_ndarray(array) - assert 0 == len(w) - - array = np.zeros((100, 100)) - with warnings.catch_warnings(record=True) as w: - image.upload_ndarray(array) - assert 0 == len(w) - - array = np.zeros((100, 100, 1)) - with warnings.catch_warnings(record=True) as w: - image.upload_ndarray(array) - assert 1 == len(w) - - array = np.zeros((1, 100, 100)) - image.cs_code = "FOO:1" - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - image.upload_ndarray(array) - assert 1 == len(w) - assert "cs_code" in str(w[0].message) - - @patch("descarteslabs.catalog.Image._do_upload", return_value=True) - @patch("descarteslabs.catalog.Image.exists", return_value=False) - @patch("numpy.save") - def test_upload_ndarray_moves_band_axis(self, mock_np_save, *mocks): - p = Product(id="p1", name="Test Product", client=self.client, _saved=True) - image = Image( - id="p1:image", - product=p, - acquired="2012-05-06", - geotrans=[42, 0, 0, 0, 0, 0], - projection="foo", - ) - - array = np.zeros((1, 100, 100)) - with warnings.catch_warnings(record=True) as w: - image.upload_ndarray(array) - assert 0 == len(w) - - assert mock_np_save.called - - _, ndarray = mock_np_save.call_args[0] - assert ndarray.shape == (100, 100, 1) - - @patch("descarteslabs.catalog.Image._do_upload", return_value=True) - @patch("descarteslabs.catalog.Image.exists", return_value=False) - @patch("numpy.save") - def test_upload_ndarray_multiple(self, mock_np_save, *mocks): - p = Product(id="p1", name="Test Product", client=self.client, _saved=True) - image = Image( - id="p1:image", - product=p, - acquired="2012-05-06", - geotrans=[42, 0, 0, 0, 0, 0], - projection="foo", - ) - - # try a non iterable - class MyClass: - pass - - with self.assertRaisesRegex(ValueError, "instance of ndarray or an Iterable"): - image.upload_ndarray(MyClass()) - - # try an interable custom class - class MyArray: - def __init__(self, *args): - self.data = list(args) - - def __iter__(self): - return (x for x in self.data) - - def __setitem__(self, key, value): - self.data[key] = value - - array = np.zeros((1, 100, 100)) - array2 = np.zeros((1, 50, 50)) - image.upload_ndarray(MyArray(array, array2)) - - # try iterable but not ndarrays - with self.assertRaisesRegex(ValueError, "is not an ndarray"): - image.upload_ndarray(["something", "something else"]) - - # try a native list with ndarrays - array = np.zeros((1, 75, 75)) - array2 = np.zeros((1, 25, 25)) - image.upload_ndarray([array, array2]) - - assert mock_np_save.call_count == 4 - - shapes = [ - ndarray.shape - for (_tmp_file, ndarray), _kwargs in mock_np_save.call_args_list - ] - - assert shapes == [ - (100, 100, 1), - (50, 50, 1), - (75, 75, 1), - (25, 25, 1), - ] - - @patch.object(Image, "get", _image_get) - @patch.object( - image_module, - "cached_bands_by_product", - _cached_bands_by_product, - ) - @patch.object(image_module.Raster, "ndarray", _raster_ndarray) - def test_load_one_band(self): - image = Image.get("landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1") - arr, info = image.ndarray("red", resolution=1000, raster_info=True) - - assert arr.shape == (1, 239, 235) - assert arr.mask[0, 2, 2] - assert not arr.mask[0, 115, 116] - assert len(info["geoTransform"]) == 6 - - with pytest.raises(TypeError): - image.ndarray("blue", invalid_argument=True) - - @patch.object(Image, "get", _image_get) - @patch.object( - image_module, - "cached_bands_by_product", - _cached_bands_by_product, - ) - @patch.object(image_module.Raster, "ndarray") - def test_ndarray_geocontext(self, mock_raster): - mock_raster.side_effect = functools.partial(_raster_ndarray, None) - image = Image.get("landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1") - arr, info = image.ndarray("red", resolution=1000, raster_info=True) - - mock_raster.assert_called_once() - assert image.geocontext.geometry is not None - assert mock_raster.call_args[1]["cutline"] is None - - @patch.object(Image, "get", _image_get) - @patch.object( - image_module, - "cached_bands_by_product", - _cached_bands_by_product, - ) - def test_nonexistent_band_fails(self): - image = Image.get("landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1") - with pytest.raises(ValueError): - image.ndarray("blue yellow") - - @patch.object(Image, "get", _image_get) - @patch.object( - image_module, - "cached_bands_by_product", - _cached_bands_by_product, - ) - @patch.object(image_module.Raster, "ndarray", _raster_ndarray) - def test_different_band_dtypes(self): - image = Image.get("landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1") - with patch.object( - BANDS_BY_PRODUCT["landsat:LC08:PRE:TOAR"]["green"], "data_type", "Int16" - ): - arr, info = image.ndarray("red green", resolution=600, mask_alpha=False) - assert arr.dtype.type == np.int32 - - @patch.object(Image, "get", _image_get) - @patch.object( - image_module, - "cached_bands_by_product", - _cached_bands_by_product, - ) - @patch.object(image_module.Raster, "ndarray", _raster_ndarray) - def test_load_multiband(self): - image = Image.get("landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1") - arr = image.ndarray("red green blue", resolution=1000) - - assert arr.shape == (3, 239, 235) - assert (arr.mask[:, 2, 2]).all() - assert not (arr.mask[:, 115, 116]).all() - - @patch.object(Image, "get", _image_get) - @patch.object( - image_module, - "cached_bands_by_product", - _cached_bands_by_product, - ) - @patch.object(image_module.Raster, "ndarray", _raster_ndarray) - def test_load_multiband_axis_last(self): - image = Image.get("landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1") - arr = image.ndarray("red green blue", resolution=1000, bands_axis=-1) - - assert arr.shape == (239, 235, 3) - assert (arr.mask[2, 2, :]).all() - assert not (arr.mask[115, 116, :]).all() - - with pytest.raises(ValueError): - arr = image.ndarray("red green blue", resolution=1000, bands_axis=3) - with pytest.raises(ValueError): - arr = image.ndarray("red green blue", resolution=1000, bands_axis=-3) - - @patch.object(Image, "get", _image_get) - @patch.object( - image_module, - "cached_bands_by_product", - _cached_bands_by_product, - ) - @patch.object(image_module.Raster, "ndarray", _raster_ndarray) - def test_load_nomask(self): - image = Image.get("landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1") - arr = image.ndarray( - ["red", "nir"], - resolution=1000, - mask_nodata=False, - mask_alpha=False, - ) - - assert not hasattr(arr, "mask") - assert arr.shape == (2, 239, 235) - - @patch.object(Image, "get", _image_get) - @patch.object( - image_module, - "cached_bands_by_product", - _cached_bands_by_product, - ) - @patch.object(image_module.Raster, "ndarray", _raster_ndarray) - def test_auto_mask_alpha_false(self): - image = Image.get( - "modis:mod11a2:006:meta_MOD11A2.A2017305.h09v05.006.2017314042814_v1" - ) - arr = image.ndarray( - ["Clear_sky_days", "Clear_sky_nights"], - resolution=1000, - mask_nodata=False, - ) - - assert not hasattr(arr, "mask") - assert arr.shape == (2, 688, 473) - - @patch.object(Image, "get", _image_get) - @patch.object( - image_module, - "cached_bands_by_product", - _cached_bands_by_product, - ) - @patch.object(image_module.Raster, "ndarray", _raster_ndarray) - def test_mask_alpha_string(self): - image = Image.get( - "modis:mod11a2:006:meta_MOD11A2.A2017305.h09v05.006.2017314042814_v1" - ) - arr = image.ndarray( - ["Clear_sky_days", "Clear_sky_nights"], - resolution=1000, - mask_alpha="Clear_sky_nights", - mask_nodata=False, - ) - - assert hasattr(arr, "mask") - assert arr.shape == (2, 688, 473) - - @patch.object(Image, "get", _image_get) - @patch.object( - image_module, - "cached_bands_by_product", - _cached_bands_by_product, - ) - @patch.object(image_module.Raster, "ndarray", _raster_ndarray) - def test_mask_missing_alpha(self): - image = Image.get( - "modis:mod11a2:006:meta_MOD11A2.A2017305.h09v05.006.2017314042814_v1" - ) - with pytest.raises(ValueError): - image.ndarray( - ["Clear_sky_days", "Clear_sky_nights"], - resolution=1000, - mask_alpha=True, - mask_nodata=False, - ) - - @patch.object(Image, "get", _image_get) - @patch.object( - image_module, - "cached_bands_by_product", - _cached_bands_by_product, - ) - @patch.object(image_module.Raster, "ndarray", _raster_ndarray) - def test_mask_missing_band(self): - image = Image.get( - "modis:mod11a2:006:meta_MOD11A2.A2017305.h09v05.006.2017314042814_v1" - ) - with pytest.raises(ValueError): - image.ndarray( - ["Clear_sky_days", "Clear_sky_nights"], - resolution=1000, - mask_alpha="missing_band", - mask_nodata=False, - ) - - @patch.object(Image, "get", _image_get) - @patch.object( - image_module, - "cached_bands_by_product", - _cached_bands_by_product, - ) - @patch.object(image_module.Raster, "ndarray", _raster_ndarray) - def test_auto_mask_alpha_true(self): - image = Image.get("landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1") - arr = image.ndarray( - ["red", "green", "blue"], resolution=1000, mask_nodata=False - ) - - assert hasattr(arr, "mask") - assert arr.shape == (3, 239, 235) - - @patch.object(Image, "get", _image_get) - @patch.object( - image_module, - "cached_bands_by_product", - _cached_bands_by_product, - ) - @patch.object(image_module.Raster, "ndarray", _raster_ndarray) - def with_alpha(self): - image = Image.get("landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1") - - arr = image.ndarray(["red", "alpha"], resolution=1000) - assert arr.shape == (2, 239, 235) - assert (arr.mask == (arr.data[1] == 0)).all() - - arr = image.ndarray(["alpha"], resolution=1000, mask_nodata=False) - assert arr.shape == (1, 239, 235) - assert (arr.mask == (arr.data == 0)).all() - - with pytest.raises(ValueError): - arr = image.ndarray("alpha red", resolution=1000) - - def test_coverage(self): - geometry = shapely.geometry.Point(0.0, 0.0).buffer(1) - - image = Image(id="foo:bar", geometry=geometry) - - # same geometry (as a GeoJSON) - assert image.coverage(geometry.__geo_interface__) == pytest.approx( - 1.0, abs=1e-6 - ) - - # geom is larger - geom_larger = shapely.geometry.Point(0.0, 0.0).buffer(2) - assert image.coverage(geom_larger) == pytest.approx(0.25, abs=1e-6) - - # geom is smaller - geom_smaller = shapely.geometry.Point(0.0, 0.0).buffer(0.5) - assert image.coverage(geom_smaller) == pytest.approx(1.0, abs=1e-6) - - @patch.object(Image, "get", _image_get) - @patch.object( - image_module, - "cached_bands_by_product", - _cached_bands_by_product, - ) - @patch.object(image_module.Raster, "ndarray", _raster_ndarray) - @patch.object(image_module, "download") - def test_download(self, mock_download): - image = Image.get("landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1") - image.download("red green blue", resolution=120.0) - mock_download.assert_called_once() - # verify we nulled out the cutline - assert image.geocontext.geometry is not None - assert mock_download.call_args[1]["geocontext"].geometry is None - - @patch.object(Image, "get", _image_get) - @patch.object( - image_module, - "cached_bands_by_product", - _cached_bands_by_product, - ) - def test_scaling_parameters_display(self): - image = Image.get("landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1") - scales, data_type = image.scaling_parameters( - "red green blue alpha", scaling="display" - ) - assert scales == [(0, 4000, 0, 255), (0, 4000, 0, 255), (0, 4000, 0, 255), None] - assert data_type == "Byte" diff --git a/descarteslabs/core/catalog/tests/test_image_collection.py b/descarteslabs/core/catalog/tests/test_image_collection.py deleted file mode 100644 index ef8dba7f..00000000 --- a/descarteslabs/core/catalog/tests/test_image_collection.py +++ /dev/null @@ -1,402 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 pytest -import unittest -from unittest.mock import patch -import os.path -import shapely.geometry -import numpy as np - -from ...common.geo import AOI - -from .. import image_collection as icmod -from .. import image as imod -from ..image_collection import ImageCollection -from ..image import Image -from ..image_types import ResampleAlgorithm, DownloadFileFormat -from .mock_data import _image_get, _cached_bands_by_product, _raster_ndarray - - -class TestImageCollection(unittest.TestCase): - @patch.object(Image, "get", _image_get) - @patch.object( - imod, - "cached_bands_by_product", - _cached_bands_by_product, - ) - @patch.object( - icmod, - "cached_bands_by_product", - _cached_bands_by_product, - ) - @patch.object(imod.Raster, "ndarray", _raster_ndarray) - def test_stack(self): - image_ids = ( - "landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1", - "landsat:LC08:PRE:TOAR:meta_LC80260322016197_v1", - ) - images = [Image.get(image_id) for image_id in image_ids] - - overlap = images[0].geometry.intersection(images[1].geometry) - geocontext = images[0].geocontext.assign( - geometry=overlap, bounds="update", resolution=600 - ) - - ic = ImageCollection(images, geocontext=geocontext) - stack, metas = ic.stack("nir", raster_info=True) - assert stack.shape == (2, 1, 122, 120) - assert (stack.mask[:, 0, 2, 2]).all() - assert len(metas) == 2 - assert all(len(m["geoTransform"]) == 6 for m in metas) - - img_stack = ic.stack("nir red", bands_axis=-1) - assert img_stack.shape == (2, 122, 120, 2) - - no_mask = ic.stack("nir", mask_alpha=False, mask_nodata=False) - assert not hasattr(no_mask, "mask") - assert no_mask.shape == (2, 1, 122, 120) - - with pytest.raises(NotImplementedError): - ic.stack("nir red", geocontext=geocontext, bands_axis=0) - - stack_axis_1 = ic.stack("nir red", bands_axis=1) - assert stack_axis_1.shape == (2, 2, 122, 120) - - @patch.object(Image, "get", _image_get) - @patch.object( - imod, - "cached_bands_by_product", - _cached_bands_by_product, - ) - @patch.object( - icmod, - "cached_bands_by_product", - _cached_bands_by_product, - ) - @patch.object(imod.Raster, "ndarray", _raster_ndarray) - def test_stack_scaling(self): - image_ids = ( - "landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1", - "landsat:LC08:PRE:TOAR:meta_LC80260322016197_v1", - ) - images = [Image.get(image_id) for image_id in image_ids] - - overlap = images[0].geometry.intersection(images[1].geometry) - geocontext = images[0].geocontext.assign( - geometry=overlap, bounds="update", resolution=600 - ) - ic = ImageCollection(images, geocontext=geocontext) - - stack = ic.stack("nir alpha", scaling="raw") - assert stack.shape == (2, 2, 122, 120) - assert stack.dtype == np.uint16 - - stack = ic.stack("nir", scaling="raw") - assert stack.shape == (2, 1, 122, 120) - assert stack.dtype == np.uint16 - - stack = ic.stack("nir", scaling=[None]) - assert stack.shape == (2, 1, 122, 120) - assert stack.dtype == np.uint16 - - @patch.object(Image, "get", _image_get) - @patch.object( - imod, - "cached_bands_by_product", - _cached_bands_by_product, - ) - @patch.object( - icmod, - "cached_bands_by_product", - _cached_bands_by_product, - ) - @patch.object(imod.Raster, "ndarray", _raster_ndarray) - @patch.object(icmod.Raster, "ndarray", _raster_ndarray) - def test_stack_flatten(self): - image_ids = ( - "landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1", - "landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1", # note: just duplicated - "landsat:LC08:PRE:TOAR:meta_LC80260322016197_v1", - ) - images = [Image.get(image_id) for image_id in image_ids] - - overlap = images[0].geometry.intersection(images[2].geometry) - geocontext = images[0].geocontext.assign( - geometry=overlap, bounds="update", resolution=600 - ) - - ic = ImageCollection(images, geocontext=geocontext) - - flattened, metas = ic.stack("nir", flatten="id", raster_info=True) - - assert len(flattened) == 2 - assert len(metas) == 2 - - mosaic = ic.mosaic("nir") - allflat = ic.stack("nir", flatten="product_id") - assert (mosaic == allflat).all() - - @patch.object(Image, "get", _image_get) - @patch.object( - icmod, - "cached_bands_by_product", - _cached_bands_by_product, - ) - @patch.object(icmod.Raster, "ndarray", _raster_ndarray) - def test_mosaic(self): - image_ids = ( - "landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1", - "landsat:LC08:PRE:TOAR:meta_LC80260322016197_v1", - ) - images = [Image.get(image_id) for image_id in image_ids] - - overlap = images[0].geometry.intersection(images[1].geometry) - geocontext = images[0].geocontext.assign( - geometry=overlap, bounds="update", resolution=600 - ) - - ic = ImageCollection(images, geocontext=geocontext) - mosaic, meta = ic.mosaic("nir", raster_info=True) - assert mosaic.shape == (1, 122, 120) - assert (mosaic.mask[:, 2, 2]).all() - assert len(meta["geoTransform"]) == 6 - - img_mosaic = ic.mosaic("nir red", bands_axis=-1) - assert img_mosaic.shape == (122, 120, 2) - - mosaic_with_alpha = ic.mosaic(["red", "alpha"]) - assert mosaic_with_alpha.shape == (2, 122, 120) - - mosaic_only_alpha = ic.mosaic("alpha") - assert mosaic_only_alpha.shape == (1, 122, 120) - assert ((mosaic_only_alpha.data == 0) == mosaic_only_alpha.mask).all() - - no_mask = ic.mosaic("nir", mask_alpha=False, mask_nodata=False) - assert not hasattr(no_mask, "mask") - assert no_mask.shape == (1, 122, 120) - - with pytest.raises(ValueError): - ic.mosaic("alpha red") - - with pytest.raises(TypeError): - ic.mosaic("red", invalid_argument=True) - - mask_non_alpha = mosaic_with_alpha = ic.mosaic(["nir", "red"], mask_alpha="red") - assert hasattr(mask_non_alpha, "mask") - assert mask_non_alpha.shape == (2, 122, 120) - - @patch.object(Image, "get", _image_get) - @patch.object( - icmod, - "cached_bands_by_product", - _cached_bands_by_product, - ) - @patch.object(icmod.Raster, "ndarray", _raster_ndarray) - def test_mosaic_scaling(self): - image_ids = ( - "landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1", - "landsat:LC08:PRE:TOAR:meta_LC80260322016197_v1", - ) - images = [Image.get(image_id) for image_id in image_ids] - - overlap = images[0].geometry.intersection(images[1].geometry) - geocontext = images[0].geocontext.assign( - geometry=overlap, bounds="update", resolution=600 - ) - ic = ImageCollection(images, geocontext=geocontext) - - mosaic = ic.mosaic("nir alpha", scaling="raw") - assert mosaic.shape == (2, 122, 120) - assert mosaic.dtype == np.uint16 - - mosaic = ic.mosaic("nir", scaling="raw") - assert mosaic.shape == (1, 122, 120) - assert mosaic.dtype == np.uint16 - - mosaic = ic.mosaic("nir", scaling=[None]) - assert mosaic.shape == (1, 122, 120) - assert mosaic.dtype == np.uint16 - - @patch.object(Image, "get", _image_get) - @patch.object( - icmod, - "cached_bands_by_product", - _cached_bands_by_product, - ) - @patch.object(icmod.Raster, "ndarray", _raster_ndarray) - def test_mosaic_no_alpha(self): - image_ids = ( - "modis:mod11a2:006:meta_MOD11A2.A2017305.h09v05.006.2017314042814_v1", - "modis:mod11a2:006:meta_MOD11A2.A2000049.h08v05.006.2015058135046_v1", - ) - images = [Image.get(image_id) for image_id in image_ids] - overlap = images[0].geometry.intersection(images[1].geometry) - geocontext = images[0].geocontext.assign( - geometry=overlap, bounds="update", resolution=600 - ) - - ic = ImageCollection(images, geocontext=geocontext) - no_mask = ic.mosaic(["Clear_sky_days", "Clear_sky_nights"], mask_nodata=False) - assert not hasattr(no_mask, "mask") - - masked_alt_alpha_band = ic.mosaic( - ["Clear_sky_days", "Clear_sky_nights"], mask_alpha="Clear_sky_nights" - ) - assert hasattr(masked_alt_alpha_band, "mask") - - # errors when alternate alpha band is provided but not available in the image - with pytest.raises(ValueError): - ic.mosaic(["Clear_sky_days", "Clear_sky_nights"], mask_alpha="alt-alpha") - - @patch.object(Image, "get", _image_get) - def test_filter_coverage(self): - polygon = shapely.geometry.Point(0.0, 0.0).buffer(3) - geocontext = AOI(geometry=polygon) - - images = ImageCollection( - [ - Image(id="foo:bar", geometry=polygon), - Image(id="foo:baz", geometry=polygon.buffer(-0.1)), - ] - ) - - assert len(images.filter_coverage(geocontext)) == 1 - - @patch.object(Image, "get", _image_get) - @patch.object( - icmod, - "cached_bands_by_product", - _cached_bands_by_product, - ) - def test_scaling_parameters(self): - image_ids = ( - "landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1", - "landsat:LC08:PRE:TOAR:meta_LC80260322016197_v1", - ) - images = [Image.get(image_id) for image_id in image_ids] - - ic = ImageCollection(images) - scales, data_type = ic.scaling_parameters("red green blue alpha") - assert scales is None - assert data_type == "UInt16" - - -@patch.object(Image, "get", _image_get) -@patch.object( - imod, - "cached_bands_by_product", - _cached_bands_by_product, -) -@patch.object( - icmod, - "cached_bands_by_product", - _cached_bands_by_product, -) -class TestImageCollectionDownload(unittest.TestCase): - def setUp(self): - with patch.object(Image, "get", _image_get): - images = [ - Image.get("landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1"), - Image.get("landsat:LC08:PRE:TOAR:meta_LC80260322016197_v1"), - ] - overlap = images[0].geometry.intersection(images[1].geometry) - geocontext = images[0].geocontext.assign( - geometry=overlap, bounds="update", resolution=600 - ) - self.images = ImageCollection(images, geocontext=geocontext) - - @patch.object(imod, "download") - def test_directory(self, mock_download): - dest = "rasters" - paths = self.images.download( - "nir green", dest=dest, format=DownloadFileFormat.PNG - ) - - assert paths == [ - os.path.join(dest, f"{self.images[0].id}-nir-green.png"), - os.path.join(dest, f"{self.images[1].id}-nir-green.png"), - ] - - assert mock_download.call_count == len(self.images) - for image, path in zip(self.images, paths): - mock_download.assert_any_call( - inputs=[image.id], - bands_list=["nir", "green"], - geocontext=self.images.geocontext, - dest=path, - format=DownloadFileFormat.TIF, - resampler=ResampleAlgorithm.NEAR, - processing_level=None, - nodata=None, - scales=None, - data_type="UInt16", - progress=None, - ) - - @patch.object(imod, "download") - def test_custom_paths(self, mock_download): - filenames = [ - os.path.join("foo", "img1.tif"), - os.path.join("bar", "img2.jpg"), - ] - result = self.images.download("nir green", dest=filenames) - assert result == filenames - - assert mock_download.call_count == len(self.images) - for image, path in zip(self.images, filenames): - mock_download.assert_any_call( - inputs=[image.id], - bands_list=["nir", "green"], - geocontext=self.images.geocontext, - dest=path, - format=DownloadFileFormat.TIF, - resampler=ResampleAlgorithm.NEAR, - processing_level=None, - nodata=None, - scales=None, - data_type="UInt16", - progress=None, - ) - - @patch.object(imod, "download") - def test_non_unique_paths(self, mock_download): - nonunique_paths = ["img.tif", "img.tif"] - with pytest.raises(RuntimeError): - self.images.download("nir green", dest=nonunique_paths) - - @patch.object(imod, "download") - def test_wrong_number_of_dest(self, mock_download): - with pytest.raises(ValueError): - self.images.download("nir", dest=["a", "b", "c"]) - - @patch.object(imod, "download") - def test_wrong_type_of_dest(self, mock_download): - with pytest.raises(TypeError): - self.images.download("nir", dest=4) - - @patch.object(imod, "download") - def test_download_failure(self, mock_download): - mock_download.side_effect = RuntimeError("blarf") - dest = "rasters" - with pytest.raises(RuntimeError): - self.images.download("nir", dest=dest) - - @patch.object(icmod, "download") - def test_download_mosaic(self, mock_download): - self.images.download_mosaic("nir green") - - mock_download.assert_called_once() - called_ids = mock_download.call_args[1]["inputs"] - assert called_ids == self.images.each.id.collect() diff --git a/descarteslabs/core/catalog/tests/test_image_upload.py b/descarteslabs/core/catalog/tests/test_image_upload.py deleted file mode 100644 index 0648e2fb..00000000 --- a/descarteslabs/core/catalog/tests/test_image_upload.py +++ /dev/null @@ -1,615 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 responses -from unittest.mock import patch - -from datetime import datetime, timezone - -from .base import ClientTestCase -from ..catalog_base import DocumentState -from .. import image_upload as image_upload_module -from ..image_upload import ( - ImageUpload, - ImageUploadOptions, - ImageUploadType, - ImageUploadStatus, - ImageUploadEventType, - ImageUploadEventSeverity, -) -from ..image import Image - - -class TestImageUpload(ClientTestCase): - def test_constructor(self): - u = ImageUpload( - product_id="product_id", - image=Image(name="image_name", product_id="product_id"), - image_upload_options=ImageUploadOptions(upload_type=ImageUploadType.FILE), - ) - assert u.id is None - assert u.created is None - assert u.modified is None - assert u.product_id == "product_id" - assert u.image_id == "product_id:image_name" - assert u.image.id == "product_id:image_name" - assert u.image_upload_options.upload_type == ImageUploadType.FILE - assert u.state == DocumentState.UNSAVED - assert u.status is None - assert not u.events - - def test_constructor_no_product_id(self): - u = ImageUpload( - image=Image(name="image_name", product_id="product_id"), - image_upload_options=ImageUploadOptions(upload_type=ImageUploadType.FILE), - ) - assert u.image.id == u.image.id - assert u.image_upload_options.upload_type == ImageUploadType.FILE - assert u.state == DocumentState.UNSAVED - - def test_serialize(self): - u = ImageUpload( - image=Image(name="image_name", product_id="product_id"), - image_upload_options=ImageUploadOptions(upload_type=ImageUploadType.FILE), - ) - serialized = u.serialize(jsonapi_format=True) - self.assertDictEqual( - dict( - data=dict( - type=ImageUpload._doc_type, - attributes=dict( - image_upload_options=dict(upload_type="file"), - image_id="product_id:image_name", - image=dict( - data=dict( - id="product_id:image_name", - type=Image._doc_type, - attributes=dict( - name="image_name", product_id="product_id" - ), - ) - ), - ), - ) - ), - serialized, - ) - - @responses.activate - def test_save(self): - self.mock_response( - # The initial upload request creation - responses.POST, - { - "data": { - "type": "image_upload", - "id": "1", - "attributes": { - "created": "2020-01-01T00:00:00.000000Z", - "modified": "2020-01-01T00:00:00.000000Z", - "resumable_urls": ["http://example.com/uploads/1"], - "status": ImageUploadStatus.TRANSFERRING.value, - "product_id": "product_id", - "image_id": "product_id:image_name", - }, - "relationships": {"events": {"data": []}}, - }, - "jsonapi": {"version": "1.0"}, - }, - ) - # The retrieval of the product when the product_id is set on the image - self.mock_response( - responses.GET, - { - "data": {"attributes": {}, "type": "product", "id": "product_id"}, - "jsonapi": {"version": "1.0"}, - }, - ) - # The update of the upload request - self.mock_response( - responses.PATCH, - { - "data": { - "type": "image_upload", - "id": "1", - "attributes": { - "created": "2020-01-01T00:00:00.000000Z", - "modified": "2020-01-01T00:00:00.000000Z", - "status": ImageUploadStatus.PENDING.value, - "product_id": "product_id", - "image_id": "product_id:image_name", - }, - "relationships": { - "events": {"data": [{"type": "image_upload_event", "id": "1"}]} - }, - }, - "included": [ - { - "type": "image_upload_event", - "id": "1", - "attributes": { - "event_datetime": "2020-01-01T00:00:00.000000Z", - "component": "yaas", - "component_id": "yaas-1", - "event_type": ImageUploadEventType.QUEUE.value, - "severity": ImageUploadEventSeverity.INFO.value, - "message": "message-id=1", - }, - } - ], - "jsonapi": {"version": "1.0"}, - }, - ) - # The cancel of the upload request - self.mock_response( - responses.PATCH, - { - "data": { - "type": "image_upload", - "id": "1", - "attributes": { - "created": "2020-01-01T00:00:00.000000Z", - "modified": "2020-01-01T00:00:00.000000Z", - "status": ImageUploadStatus.CANCELED.value, - "product_id": "product_id", - "image_id": "product_id:image_name", - }, - "relationships": { - "events": { - "data": [ - {"type": "image_upload_event", "id": "1"}, - {"type": "image_upload_event", "id": "2"}, - ] - } - }, - }, - "included": [ - { - "type": "image_upload_event", - "id": "1", - "attributes": { - "event_datetime": "2020-01-01T00:00:00.000000Z", - "component": "yaas", - "component_id": "yaas-1", - "event_type": ImageUploadEventType.QUEUE.value, - "severity": ImageUploadEventSeverity.INFO.value, - "message": "message-id=1", - }, - }, - { - "type": "image_upload_event", - "id": "2", - "attributes": { - "event_datetime": "2020-01-01T00:00:00.000000Z", - "component": "yaas", - "component_id": "yaas-1", - "event_type": ImageUploadEventType.CANCEL.value, - "severity": ImageUploadEventSeverity.INFO.value, - "message": "Canceled", - }, - }, - ], - "jsonapi": {"version": "1.0"}, - }, - ) - - u = ImageUpload( - image=Image( - name="image_name", - product_id="product_id", - acquired="2020-01-01", - client=self.client, - ), - image_upload_options=ImageUploadOptions(upload_type=ImageUploadType.FILE), - client=self.client, - ) - assert u.image_id == "product_id:image_name" - assert u.image_upload_options.upload_type == ImageUploadType.FILE - assert u.state == DocumentState.UNSAVED - - u.save() - - assert u.id == "1" - assert u.created == datetime(2020, 1, 1, 0, 0, 0, tzinfo=timezone.utc) - assert u.modified == datetime(2020, 1, 1, 0, 0, 0, tzinfo=timezone.utc) - assert u.product_id == "product_id" - assert u.image_id == "product_id:image_name" - assert u.image_upload_options.upload_type == ImageUploadType.FILE - assert u.resumable_urls == ["http://example.com/uploads/1"] - assert u.status == ImageUploadStatus.TRANSFERRING - assert u.state == DocumentState.SAVED - - u.status = ImageUploadStatus.PENDING - assert u.state == DocumentState.MODIFIED - - u.save() - - assert u.status == ImageUploadStatus.PENDING - assert u.state == DocumentState.SAVED - assert len(u.events) == 1 - assert u.events[0].event_datetime == datetime( - 2020, 1, 1, 0, 0, 0, tzinfo=timezone.utc - ) - assert u.events[0].event_type == ImageUploadEventType.QUEUE - assert u.events[0].severity == ImageUploadEventSeverity.INFO - - u.cancel() - - assert u.status == ImageUploadStatus.CANCELED - assert u.state == DocumentState.SAVED - assert len(u.events) == 2 - assert u.events[1].event_datetime == datetime( - 2020, 1, 1, 0, 0, 0, tzinfo=timezone.utc - ) - assert u.events[1].event_type == ImageUploadEventType.CANCEL - assert u.events[1].severity == ImageUploadEventSeverity.INFO - - @responses.activate - @patch.object(image_upload_module.ImageUpload, "_POLLING_INTERVALS", [1]) - def test_wait_for_completion(self): - self.mock_response( - responses.POST, - { - "data": { - "type": "image_upload", - "id": "1", - "attributes": { - "created": "2020-01-01T00:00:00.000000Z", - "modified": "2020-01-01T00:00:00.000000Z", - "product_id": "product_id", - "image_id": "product_id:image_name", - "resumable_urls": ["http://example.com/uploads/1"], - "status": ImageUploadStatus.TRANSFERRING.value, - }, - "relationships": {"events": {"data": []}}, - }, - "jsonapi": {"version": "1.0"}, - }, - ) - self.mock_response( - responses.PATCH, - { - "data": { - "type": "image_upload", - "id": "1", - "attributes": { - "created": "2020-01-01T00:00:00.000000Z", - "modified": "2020-01-01T00:00:00.000000Z", - "status": ImageUploadStatus.PENDING.value, - "product_id": "product_id", - "image_id": "product_id:image_name", - }, - "relationships": { - "events": {"data": [{"type": "image_upload_event", "id": "1"}]} - }, - }, - "included": [ - { - "type": "image_upload_event", - "id": "1", - "attributes": { - "event_datetime": "2020-01-01T00:00:00.000000Z", - "component": "yaas", - "component_id": "yaas-1", - "event_type": ImageUploadEventType.QUEUE.value, - "severity": ImageUploadEventSeverity.INFO.value, - "message": "message-id=1", - }, - } - ], - "jsonapi": {"version": "1.0"}, - }, - ) - self.mock_response( - responses.GET, - { - "data": { - "type": "image_upload", - "id": "1", - "attributes": { - "created": "2020-01-01T00:00:00.000000Z", - "modified": "2020-01-01T00:00:00.000000Z", - "product_id": "product_id", - "image_id": "product_id:image_name", - "status": ImageUploadStatus.RUNNING.value, - }, - "relationships": { - "events": { - "data": [ - {"type": "image_upload_event", "id": "1"}, - {"type": "image_upload_event", "id": "2"}, - ] - } - }, - }, - "included": [ - { - "type": "image_upload_event", - "id": "1", - "attributes": { - "event_datetime": "2020-01-01T00:00:00.000000Z", - "component": "yaas", - "component_id": "yaas-1", - "event_type": ImageUploadEventType.QUEUE.value, - "severity": ImageUploadEventSeverity.INFO.value, - "message": "message-id=1", - }, - }, - { - "type": "image_upload_event", - "id": "2", - "attributes": { - "event_datetime": "2020-01-01T00:00:00.000000Z", - "component": "yaas-worker", - "component_id": "yaas-worker-1", - "event_type": ImageUploadEventType.RUN.value, - "severity": ImageUploadEventSeverity.INFO.value, - "message": "Starting job attempt 1", - }, - }, - ], - "jsonapi": {"version": "1.0"}, - }, - ) - self.mock_response( - responses.GET, - { - "data": { - "type": "image_upload", - "id": "1", - "attributes": { - "created": "2020-01-01T00:00:00.000000Z", - "modified": "2020-01-01T00:00:00.000000Z", - "product_id": "product_id", - "image_id": "product_id:image_name", - "status": ImageUploadStatus.SUCCESS.value, - "events": [], - }, - "relationships": { - "events": { - "data": [ - {"type": "image_upload_event", "id": "1"}, - {"type": "image_upload_event", "id": "2"}, - {"type": "image_upload_event", "id": "3"}, - ] - } - }, - }, - "included": [ - { - "type": "image_upload_event", - "id": "1", - "attributes": { - "event_datetime": "2020-01-01T00:00:00.000000Z", - "component": "yaas", - "component_id": "yaas-1", - "event_type": ImageUploadEventType.QUEUE.value, - "severity": ImageUploadEventSeverity.INFO.value, - "message": "message-id=1", - }, - }, - { - "type": "image_upload_event", - "id": "2", - "attributes": { - "event_datetime": "2020-01-01T00:00:00.000000Z", - "component": "yaas-worker", - "component_id": "yaas-worker-1", - "event_type": ImageUploadEventType.RUN.value, - "severity": ImageUploadEventSeverity.INFO.value, - "message": "Starting job attempt 1", - }, - }, - { - "type": "image_upload_event", - "id": "3", - "attributes": { - "event_datetime": "2020-01-01T00:00:00.000000Z", - "component": "yaas-worker", - "component_id": "yaas-worker-1", - "event_type": ImageUploadEventType.COMPLETE.value, - "severity": ImageUploadEventSeverity.INFO.value, - "message": "Success", - }, - }, - ], - "jsonapi": {"version": "1.0"}, - }, - ) - - self.mock_response( - responses.GET, - { - "data": { - "type": "image", - "id": "product_id:image_name", - "attributes": { - "created": "2020-01-01T00:00:00.000000Z", - "modified": "2020-01-01T00:00:00.000000Z", - "product_id": "product_id", - "name": "image_name", - "readers": [], - "writers": [], - "owners": ["org:someorg"], - "acquired": "2020-01-01T00:00:00Z", - "geometry": { - "type": "Polygon", - "coordinates": [ - [ - [-9.000262842437783, 46.9537091787344], - [-8.325270159894608, 46.95172107428039], - [-8.336543403548475, 46.925857032669434], - [-9.000262842437783, 46.7807657614384], - [-9.000262842437783, 46.9537091787344], - ] - ], - }, - }, - }, - "jsonapi": {"version": "1.0"}, - }, - ) - - u = ImageUpload( - image=Image( - name="image_name", - product_id="product_id", - acquired="2020-01-01", - client=self.client, - ), - image_upload_options=ImageUploadOptions(upload_type=ImageUploadType.FILE), - client=self.client, - ) - u.save() - u.status = ImageUploadStatus.PENDING - u.save() - - assert u.status == ImageUploadStatus.PENDING - assert u.state == DocumentState.SAVED - - u.wait_for_completion(15) - - assert u.status == ImageUploadStatus.SUCCESS - assert len(u.events) == 3 - assert [e.event_type for e in u.events] == [ - ImageUploadEventType.QUEUE, - ImageUploadEventType.RUN, - ImageUploadEventType.COMPLETE, - ] - - @responses.activate - @patch.object(image_upload_module.ImageUpload, "_POLLING_INTERVALS", [1]) - def test_reload_failed(self): - self.mock_response( - responses.POST, - { - "data": { - "type": "image_upload", - "id": "1", - "attributes": { - "created": "2020-01-01T00:00:00.000000Z", - "modified": "2020-01-01T00:00:00.000000Z", - "product_id": "product_id", - "image_id": "product_id:image_name", - "status": ImageUploadStatus.PENDING.value, - }, - "relationships": { - "events": {"data": [{"type": "image_upload_event", "id": "1"}]} - }, - }, - "included": [ - { - "type": "image_upload_event", - "id": "1", - "attributes": { - "event_datetime": "2020-01-01T00:00:00.000000Z", - "component": "yaas", - "component_id": "yaas-1", - "event_type": ImageUploadEventType.QUEUE.value, - "severity": ImageUploadEventSeverity.INFO.value, - "message": "message-id=1", - }, - } - ], - "jsonapi": {"version": "1.0"}, - }, - ) - - self.mock_response( - responses.GET, - { - "meta": {"count": 1}, - "data": { - "type": "image_upload", - "id": "1", - "attributes": { - "created": "2020-01-01T00:00:00.000000Z", - "modified": "2020-01-01T00:00:00.000000Z", - "product_id": "product_id", - "image_id": "product_id:image2", - "status": ImageUploadStatus.FAILURE.value, - }, - "relationships": { - "events": { - "data": [ - {"type": "image_upload_event", "id": "1"}, - {"type": "image_upload_event", "id": "2"}, - {"type": "image_upload_event", "id": "3"}, - ] - } - }, - }, - "included": [ - { - "type": "image_upload_event", - "id": "1", - "attributes": { - "event_datetime": "2020-01-01T00:00:00.000000Z", - "component": "yaas", - "component_id": "yaas-1", - "event_type": ImageUploadEventType.QUEUE.value, - "severity": ImageUploadEventSeverity.INFO.value, - "message": "message-id=1", - }, - }, - { - "type": "image_upload_event", - "id": "2", - "attributes": { - "event_datetime": "2020-01-01T00:00:00.000000Z", - "component": "yaas-worker", - "component_id": "yaas-worker-1", - "event_type": ImageUploadEventType.RUN.value, - "severity": ImageUploadEventSeverity.INFO.value, - "message": "Starting job attempt 1", - }, - }, - { - "type": "image_upload_event", - "id": "3", - "attributes": { - "event_datetime": "2020-01-01T00:00:00.000000Z", - "component": "yaas-worker", - "component_id": "yaas-worker-1", - "event_type": ImageUploadEventType.COMPLETE.value, - "severity": ImageUploadEventSeverity.ERROR.value, - "message": "Failure", - }, - }, - ], - "jsonapi": {"version": "1.0"}, - }, - ) - u = ImageUpload( - image=Image( - name="image_name", - product_id="product_id", - acquired="2020-01-01", - client=self.client, - ), - image_upload_options=ImageUploadOptions(upload_type=ImageUploadType.FILE), - client=self.client, - ) - - u.save() - assert u.status == ImageUploadStatus.PENDING - u.reload() - - assert u.status == ImageUploadStatus.FAILURE - assert len(u.events) == 3 - assert [e.event_type for e in u.events] == [ - ImageUploadEventType.QUEUE, - ImageUploadEventType.RUN, - ImageUploadEventType.COMPLETE, - ] diff --git a/descarteslabs/core/catalog/tests/test_product.py b/descarteslabs/core/catalog/tests/test_product.py deleted file mode 100644 index 5fa89d17..00000000 --- a/descarteslabs/core/catalog/tests/test_product.py +++ /dev/null @@ -1,670 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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. - -# -*- coding: utf-8 -*- -import pytest -import responses -import textwrap -import warnings - -from datetime import datetime - -from descarteslabs.exceptions import BadRequestError - -from ..attributes import AttributeValidationError -from ..catalog_base import DocumentState, DeletedObjectError -from ..image_upload import ImageUploadStatus -from ..product import ( - Product, - Resolution, - DeletionTaskStatus, -) -from ..task import TaskState, TaskStatus -from .base import ClientTestCase - - -class TestProduct(ClientTestCase): - def test_constructor(self): - p = Product( - id="p1", name="Test Product", start_datetime="2019-01-01", tags=["tag"] - ) - assert p.id == "p1" - assert p.name == "Test Product" - assert p.tags == ["tag"] - assert p.state == DocumentState.UNSAVED - - def test_repr_non_ascii(self): - p = Product(id="plieades", name="Pléiades") - p_repr = repr(p) - match_str = """\ - Product: Pléiades - id: plieades - * Not up-to-date in the Descartes Labs catalog. Call `.save()` to save or update this record.""" - assert p_repr.strip("\n") == textwrap.dedent(match_str) - - def test_resolution(self): - p = Product( - id="p1", - name="Test Product", - resolution_min=Resolution(value=10.0, unit="meters"), - _saved=True, - ) - - assert isinstance(p.resolution_min, Resolution) - assert not p.is_modified - - p.tags = ["tag"] - assert p.is_modified - - def test_resolution_new(self): - p = Product( - id="p1", - name="Test Product", - resolution_min={"value": 10.0, "unit": "miles"}, - _saved=True, - ) - - assert p.resolution_min.unit == "miles" - with pytest.raises(AttributeValidationError): - Resolution(value=15.0, unit="miles") - - @responses.activate - def test_list(self): - self.mock_response( - responses.PUT, - { - "meta": {"count": 1}, - "data": [ - { - "attributes": { - "owners": ["org:someorg"], - "name": "My Test Product", - "readers": [], - "revisit_period_minutes_min": None, - "revisit_period_minutes_max": None, - "modified": "2019-06-10T18:48:13.066192Z", - "created": "2019-06-10T18:48:13.066192Z", - "start_datetime": "2019-01-01T00:00:00Z", - "writers": [], - "end_datetime": None, - "description": None, - "resolution_min": {"value": 10.0, "unit": "meters"}, - }, - "type": "product", - "id": "someorg:test", - } - ], - "jsonapi": {"version": "1.0"}, - "links": {"self": "https://example.com/catalog/v2/products"}, - }, - ) - - r = list(Product.search(client=self.client)) - assert len(r) == 1 - product = r[0] - assert responses.calls[0].request.url == self.url + "/products" - assert product.id == "someorg:test" - assert isinstance(product.created, datetime) - assert isinstance(product.resolution_min, Resolution) - - with pytest.raises(AttributeValidationError): - product.created = "2018-06-10T18:48:13.066192Z" - - assert isinstance(product.start_datetime, datetime) - - @responses.activate - def test_list_no_results(self): - self.mock_response( - responses.PUT, - { - "meta": {"count": 0}, - "data": [], - "jsonapi": {"version": "1.0"}, - "links": {"self": "https://example.com/catalog/v2/products"}, - }, - ) - - r = list(Product.search(client=self.client)) - assert r == [] - - @responses.activate - def test_save(self): - self.mock_response( - responses.POST, - { - "data": { - "attributes": { - "owners": ["org:someorg"], - "name": "My Test Product", - "readers": [], - "revisit_period_minutes_min": None, - "revisit_period_minutes_max": None, - "modified": "2019-06-10T18:48:13.066192Z", - "created": "2019-06-10T18:48:13.066192Z", - "start_datetime": "2019-01-01T00:00:00Z", - "writers": [], - "end_datetime": None, - "description": None, - "resolution_min": {"value": 10.0, "unit": "meters"}, - }, - "type": "product", - "id": "someorg:test", - }, - "jsonapi": {"version": "1.0"}, - }, - ) - - p = Product(id="p1", name="Test Product", client=self.client) - assert p.state == DocumentState.UNSAVED - p.save() - assert responses.calls[0].request.url == self.url + "/products" - assert p.state == DocumentState.SAVED - # id updated on initial save - assert "p1" != p.id - assert isinstance(p.start_datetime, datetime) - - @responses.activate - def test_save_dupe(self): - self.mock_response( - responses.POST, - { - "errors": [ - { - "status": "400", - "detail": "A document with id `someorg:p1` already exists.", - "title": "Bad request", - } - ], - "jsonapi": {"version": "1.0"}, - }, - status=400, - ) - p = Product(id="p", name="Test Product", client=self.client) - with pytest.raises(BadRequestError): - p.save() - - @responses.activate - def test_an_update(self): - self.mock_response( - responses.GET, - { - "data": { - "attributes": { - "owners": ["org:someorg"], - "name": "My Product", - "readers": [], - "modified": "2019-06-11T23:59:46.800792Z", - "created": "2019-06-11T23:52:35.114938Z", - "start_datetime": None, - "writers": [], - "end_datetime": None, - "description": "A descriptive description", - }, - "type": "product", - "id": "someorg:my-product", - }, - "jsonapi": {"version": "1.0"}, - }, - ) - - p1 = Product.get("someorg:my-product", client=self.client) - assert p1.state == DocumentState.SAVED - - p1_repr = repr(p1) - match_str = """\ - Product: My Product - id: someorg:my-product - created: Tue Jun 11 23:52:35 2019""" - assert p1_repr.strip("\n") == textwrap.dedent(match_str) - - p1.description = "An updated description" - assert p1.state == DocumentState.MODIFIED - self.mock_response( - responses.PATCH, - { - "data": { - "attributes": { - "owners": ["org:someorg"], - "name": "My Product", - "readers": [], - "modified": "2019-06-11T23:59:46.800792Z", - "created": "2019-06-11T23:52:35.114938Z", - "start_datetime": None, - "writers": [], - "end_datetime": None, - "description": "An updated description", - }, - "type": "product", - "id": "someorg:my-product", - }, - "jsonapi": {"version": "1.0"}, - }, - ) - - p1_repr = repr(p1) - match_str = """\ - Product: My Product - id: someorg:my-product - created: Tue Jun 11 23:52:35 2019 - * Not up-to-date in the Descartes Labs catalog. Call `.save()` to save or update this record.""" - - assert p1_repr.strip("\n") == textwrap.dedent(match_str) - - p1.save() - assert self.get_request_body(1) == { - "data": { - "type": "product", - "id": "someorg:my-product", - "attributes": {"description": "An updated description"}, - } - } - - @responses.activate - def test_delete(self): - p = Product( - id="someorg:my-product", - name="My Product", - client=self.client, - _saved=True, - ) - self.mock_response( - responses.DELETE, - { - "meta": {"message": "Object successfully deleted"}, - "jsonapi": {"version": "1.0"}, - }, - ) - - p.delete() - assert p.state == DocumentState.DELETED - - @responses.activate - def test_delete_non_existent(self): - p = Product( - id="ne-my-product", name="Non-existent", client=self.client, _saved=True - ) - self.mock_response(responses.DELETE, self.not_found_json, status=404) - - with pytest.raises(DeletedObjectError): - p.delete() - - @responses.activate - def test_exists(self): - # head request, no JSON is returned - self.mock_response(responses.HEAD, {}) - assert Product.exists("my-id:id", client=self.client) - assert ( - responses.calls[0].request.url - == "https://example.com/catalog/v2/products/my-id:id" - ) - - @responses.activate - def test_exists_false(self): - self.mock_response(responses.HEAD, self.not_found_json, status=404) - assert not Product.exists("my-id:id", client=self.client) - assert ( - responses.calls[0].request.url - == "https://example.com/catalog/v2/products/my-id:id" - ) - - @responses.activate - def test_get_unknown_attribute(self): - self.mock_response( - responses.GET, - { - "data": { - "attributes": { - "owners": ["org:someorg"], - "name": "My Product", - "readers": [], - "modified": "2019-06-11T23:59:46.800792Z", - "created": "2019-06-11T23:52:35.114938Z", - "start_datetime": None, - "writers": [], - "end_datetime": None, - "description": "A descriptive description", - "foobar": "unkown", - }, - "type": "product", - "id": "someorg:my-product", - }, - "jsonapi": {"version": "1.0"}, - }, - ) - - p = Product.get("someorg:my-product", client=self.client) - assert not hasattr(p, "foobar") - - @responses.activate - def test_create_product_delete_task(self): - p = Product(id="p1", name="Test Product", client=self.client) - self.mock_response( - responses.POST, - { - "data": { - "attributes": {"status": "RUNNING"}, - "type": "product_delete_task", - "id": "someorg:test-product", - }, - "jsonapi": {"version": "1.0"}, - }, - status=201, - ) - r = p.delete_related_objects() - req = responses.calls[0].request - assert r.status == TaskState.RUNNING - assert ( - req.url - == "https://example.com/catalog/v2/products/p1/delete_related_objects" - ) - assert req.body == b'{"data": {"type": "product_delete_task"}}' - - @responses.activate - def test_no_objects_to_delete(self): - p = Product(id="p1", name="Test Product", client=self.client) - self.mock_response( - responses.POST, - # For some reason, responses doesn't like returning a 204 with a body - # (it forcibly sets a content-length of 0, which then blows up requests/urllib3) - # The body returned by the actual service doesn't matter for this test. - None, - status=204, - ) - r = p.delete_related_objects() - assert not r - - def test_abstract_status_class(self): - with pytest.raises(TypeError): - TaskStatus() - - @responses.activate - def test_get_delete_status(self): - p = Product(id="p1", name="Test Product", client=self.client) - self.mock_response( - responses.GET, - { - "data": { - "attributes": { - "status": "SUCCESS", - "start_datetime": "2019-08-10T00:10:17.528903Z", - "errors": None, - "duration_in_seconds": 0.36756521779382323, - "objects_deleted": 2, - }, - "type": "product_delete_task", - "id": "p1", - }, - "jsonapi": {"version": "1.0"}, - }, - ) - r = p.get_delete_status() - assert r.status == TaskState.SUCCEEDED - assert isinstance(r, DeletionTaskStatus) - - status_repr = repr(r) - match_str = """\ - p1 delete task status: SUCCESS - - started: 2019-08-10T00:10:17.528903Z - - took 0.3676 seconds - - 2 objects deleted""" - - assert status_repr.strip("\n") == textwrap.dedent(match_str) - - @responses.activate - def test_image_uploads(self): - product_id = "p1" - - self.mock_response( - responses.GET, - { - "data": { - "attributes": { - "readers": [], - "writers": [], - "owners": ["org:someorg"], - "modified": "2019-06-11T23:31:33.714883Z", - "created": "2019-06-11T23:31:33.714883Z", - }, - "type": "product", - "id": product_id, - }, - "jsonapi": {"version": "1.0"}, - }, - ) - self.mock_response( - responses.PUT, - { - "meta": {"count": 1}, - "data": [ - { - "type": "image_upload", - "id": "1", - "attributes": { - "created": "2020-01-01T00:00:00.000000Z", - "modified": "2020-01-01T00:00:00.000000Z", - "product_id": product_id, - "image_id": product_id + ":image", - "start_datetime": "2020-01-01T00:00:00Z", - "end_datetime": "2020-01-01T00:00:00Z", - "status": ImageUploadStatus.SUCCESS.value, - }, - }, - { - "type": "image_upload", - "id": "2", - "attributes": { - "created": "2020-01-01T00:00:00.000000Z", - "modified": "2020-01-01T00:00:00.000000Z", - "product_id": product_id, - "image_id": product_id + ":image2", - "start_datetime": "2020-01-01T00:00:00Z", - "end_datetime": "2020-01-01T00:00:00Z", - "status": ImageUploadStatus.FAILURE.value, - }, - }, - ], - "links": {}, - "jsonapi": {"version": "1.0"}, - }, - ) - - product = Product.get(product_id, client=self.client) - - uploads = list(product.image_uploads()) - - assert len(uploads) == 2 - upload = uploads[0] - assert upload.id == "1" - assert upload.product_id == product_id - assert upload.image_id == product_id + ":image" - assert upload.status == ImageUploadStatus.SUCCESS - failed_upload = uploads[1] - assert failed_upload.id == "2" - assert failed_upload.image_id == product_id + ":image2" - assert failed_upload.status == ImageUploadStatus.FAILURE - - @responses.activate - def test_core_product(self): - product_id = "p1" - - self.mock_response( - responses.POST, - { - "data": { - "attributes": { - "readers": [], - "writers": [], - "owners": ["org:someorg"], - "modified": "2019-06-11T23:31:33.714883Z", - "created": "2019-06-11T23:31:33.714883Z", - "is_core": True, - "product_tier": "standard", - }, - "type": "product", - "id": "product_id", - }, - "jsonapi": {"version": "1.0"}, - }, - ) - - product = Product(client=self.client) - product.id = product_id - product.is_core = "True" - product.product_tier = "standard" - - self.assertEqual( - product.serialize(), {"is_core": True, "product_tier": "standard"} - ) - product.save() - self.assertEqual(product.is_core, True) - self.assertEqual(product.product_tier, "standard") - - def test_namespace_id(self): - class Client(object): - class auth(object): - namespace = "mynamespace" - payload = {"org": "someorg"} - - assert Product.namespace_id("foo", client=Client) == "someorg:foo" - assert Product.namespace_id("someorg:foo", client=Client) == "someorg:foo" - assert ( - Product.namespace_id("someorg:foo:bar:baz", client=Client) - == "someorg:foo:bar:baz" - ) - - del Client.auth.payload["org"] - - assert Product.namespace_id("foo", client=Client) == "mynamespace:foo" - - def test_named_id(self): - p = Product(id="id1") - assert p.named_id("band1") == "id1:band1" - - @responses.activate - def test_warnings(self): - self.mock_response( - responses.GET, - { - "data": { - "attributes": { - "readers": [], - "writers": [], - "owners": ["org:someorg"], - "modified": "2019-06-11T23:31:33.714883Z", - "created": "2019-06-11T23:31:33.714883Z", - "is_core": False, - }, - "type": "product", - "id": "product_id", - }, - "meta": { - "warnings": [ - { - "category": "FutureWarning", - "message": "This is a test of a FutureWarning", - }, - { - "category": "SomeWarning", - "message": "This is a test of a warning with a bad category", - }, - ] - }, - "jsonapi": {"version": "1.0"}, - }, - ) - - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - Product.get("product_id", client=self.client) - - assert w[0].category is FutureWarning - assert str(w[0].message) == "This is a test of a FutureWarning" - assert w[1].category is UserWarning - assert ( - str(w[1].message) - == "SomeWarning: This is a test of a warning with a bad category" - ) - - def test_extra_properties(self): - p = Product(id="id1", extra_properties={"one": "two"}, _saved=True) - assert p.extra_properties["one"] == "two" - assert not p.is_modified - - p.extra_properties["three"] = "four" - assert p.extra_properties["one"] == "two" - assert p.extra_properties["three"] == "four" - assert p.is_modified - - with self.assertRaises(AttributeValidationError): - p.extra_properties["five"] = object() - - @responses.activate - def test_bad_warnings(self): - self.mock_response( - responses.GET, - { - "data": { - "attributes": { - "readers": [], - "writers": [], - "owners": ["org:someorg"], - "modified": "2019-06-11T23:31:33.714883Z", - "created": "2019-06-11T23:31:33.714883Z", - "is_core": False, - }, - "type": "product", - "id": "product_id", - }, - "meta": { - "warnings": [ - { - "category": "FutureWarning", - "bad_message": "This is a test of a FutureWarning", - }, - "some_other_garbage", - ] - }, - "jsonapi": {"version": "1.0"}, - }, - ) - - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - p = Product.get("product_id", client=self.client) - assert isinstance(p, Product) - assert p.id == "product_id" - - assert len(w) == 0 - - @responses.activate - def test_deleted_band_image(self): - self.mock_response(responses.GET, self.not_found_json, status=404) - p = Product(id="p1", name="Product 1", client=self.client, _saved=True) - p.get_band("p1:b1", client=self.client) - p.get_image("p1:i1", client=self.client) - - @responses.activate - def test_deleted(self): - self.mock_response(responses.POST, self.not_found_json, status=404) - self.mock_response(responses.GET, self.not_found_json, status=404) - - p = Product(id="p1", name="Product 1", client=self.client, _saved=True) - with self.assertRaises(DeletedObjectError): - p.delete_related_objects() - assert p.state == DocumentState.DELETED - - p = Product(id="p1", name="Product 1", client=self.client, _saved=True) - with self.assertRaises(DeletedObjectError): - p.get_delete_status() - assert p.state == DocumentState.DELETED diff --git a/descarteslabs/core/catalog/tests/test_scaling.py b/descarteslabs/core/catalog/tests/test_scaling.py deleted file mode 100644 index 2745a5cd..00000000 --- a/descarteslabs/core/catalog/tests/test_scaling.py +++ /dev/null @@ -1,891 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 pytest -import unittest - -from .. import ( - SpectralBand, - ClassBand, - MaskBand, - ProcessingStepAttribute, -) -from ..scaling import scaling_parameters, multiproduct_scaling_parameters - - -class TestScaling(unittest.TestCase): - RGBA_BANDS = { - "red": SpectralBand( - id="foo:red", - data_type="UInt16", - data_range=(0, 10000), - display_range=(0, 4000), - physical_range=(0, 1), - ), - "green": SpectralBand( - id="foo:green", - data_type="UInt16", - data_range=(0, 10000), - display_range=(0, 4000), - physical_range=(0, 1), - ), - "blue": SpectralBand( - id="foo:blue", - data_type="UInt16", - data_range=(0, 10000), - display_range=(0, 4000), - physical_range=(0, 1), - ), - "alpha": MaskBand(id="foo:alpha", data_type="UInt16", data_range=(0, 1)), - } - - RGBTA_BANDS_PL = { - "red": SpectralBand( - id="foo:red", - data_type="UInt16", - data_range=[0, 65535], - processing_levels={ - "DN": [], - "default": "toa_reflectance", - "toa": "toa_reflectance", - "toa_reflectance": [ - ProcessingStepAttribute( - function="gain_bias", - parameter="reflectance_gain_bias", - index=2, - ), - ], - "toa_radiance": [ - ProcessingStepAttribute( - function="gain_bias", - parameter="radiance_gain_bias", - index=2, - data_type="Float64", - data_range=[0.0, 100.0], - ), - ], - }, - ), - "green": SpectralBand( - id="foo:green", - data_type="UInt16", - data_range=[0, 65535], - processing_levels={ - "DN": [], - "default": "toa_reflectance", - "toa": "toa_reflectance", - "toa_reflectance": [ - ProcessingStepAttribute( - function="gain_bias", - parameter="reflectance_gain_bias", - index=2, - ), - ], - "toa_radiance": [ - ProcessingStepAttribute( - function="gain_bias", - parameter="radiance_gain_bias", - index=2, - data_type="Float64", - data_range=[0.0, 100.0], - ), - ], - }, - ), - "blue": SpectralBand( - id="foo:blue", - data_type="UInt16", - data_range=[0, 65535], - processing_levels={ - "DN": [], - "default": "toa_reflectance", - "toa": "toa_reflectance", - "toa_reflectance": [ - ProcessingStepAttribute( - function="gain_bias", - parameter="reflectance_gain_bias", - index=2, - ), - ], - "toa_radiance": [ - ProcessingStepAttribute( - function="gain_bias", - parameter="radiance_gain_bias", - index=2, - # not realistic but to test the edge case - data_type="UInt16", - data_range=[0.0, 100.0], - ), - ], - }, - ), - "tirs1": SpectralBand( - id="foo:tirs1", - data_type="UInt16", - data_range=[0, 65535], - processing_levels={ - "DN": [], - "default": "toa_brightness_temperature", - "toa": "toa_brightness_temperature", - "toa_brightness_temperature": [ - ProcessingStepAttribute( - function="gain_bias", - parameter="radiance_gain_bias", - index=10, - ), - ProcessingStepAttribute( - function="brightness_temperature", - parameter="brightness_temperature_k1_k2", - index=1, - data_type="Float64", - data_range=[0.0, 333.0], - # not realistic, but to test the edge case - physical_range=[0.0, 1.0], - ), - ], - "toa_radiance": [ - ProcessingStepAttribute( - function="gain_bias", - parameter="radiance_gain_bias", - index=10, - data_type="Float64", - data_range=[0.0, 100.0], - # not realistic, but to test the edge case - physical_range=[0.0, 1.0], - ), - ], - }, - ), - "alpha": MaskBand(id="foo:alpha", data_type="UInt16", data_range=[0, 1]), - } - - def test_raw_data_type(self): - bands = { - "one": SpectralBand(id="foo:one", data_type="UInt16"), - "two": SpectralBand(id="foo:two", data_type="UInt16"), - "its_a_byte": ClassBand(id="foo:its_a_byte", data_type="Byte"), - "signed": SpectralBand(id="foo:signed", data_type="Int16"), - "alpha": MaskBand(id="foo:alpha", data_type="Byte"), - } - - assert scaling_parameters(bands, ["its_a_byte"], None, None, None)[1] == "Byte" - assert ( - scaling_parameters(bands, ["one", "two"], None, None, None)[1] == "UInt16" - ) - assert ( - scaling_parameters(bands, ["its_a_byte", "alpha"], None, None, None)[1] - == "Byte" - ) - # alpha ignored from common datatype - assert ( - scaling_parameters(bands, ["one", "alpha"], None, None, None)[1] == "UInt16" - ) - assert scaling_parameters(bands, ["alpha"], None, None, None)[1] == "Byte" - assert ( - scaling_parameters( - bands, - ["one", "two"], - None, - None, - None, - )[1] - == "UInt16" - ) - assert ( - scaling_parameters(bands, ["one", "its_a_byte"], None, None, None)[1] - == "UInt16" - ) - assert ( - scaling_parameters(bands, ["signed", "its_a_byte"], None, None, None)[1] - == "Int16" - ) - assert ( - scaling_parameters(bands, ["one", "signed"], None, None, None)[1] == "Int32" - ) - - with pytest.raises(ValueError, match="is not available"): - scaling_parameters(bands, ["one", "woohoo"], None, None, None) - - def test_scaling_parameters_none(self): - scales, data_type = scaling_parameters( - self.RGBA_BANDS, ["red", "green", "blue", "alpha"], None, None, None - ) - assert scales is None - assert data_type == "UInt16" - - def test_scaling_parameters_data_type(self): - scales, data_type = scaling_parameters( - self.RGBA_BANDS, - ["red", "green", "blue", "alpha"], - None, - None, - "UInt32", - ) - assert scales is None - assert data_type == "UInt32" - - def test_scaling_parameters_raw(self): - scales, data_type = scaling_parameters( - self.RGBA_BANDS, ["red", "green", "blue", "alpha"], None, "raw", None - ) - assert scales is None - assert data_type == "UInt16" - - def test_scaling_parameters_display(self): - scales, data_type = scaling_parameters( - self.RGBA_BANDS, - ["red", "green", "blue", "alpha"], - None, - "display", - None, - ) - assert scales == [(0, 4000, 0, 255), (0, 4000, 0, 255), (0, 4000, 0, 255), None] - assert data_type == "Byte" - - def test_scaling_parameters_display_uint16(self): - scales, data_type = scaling_parameters( - self.RGBA_BANDS, - ["red", "green", "blue", "alpha"], - None, - "display", - "UInt16", - ) - assert scales == [(0, 4000, 0, 255), (0, 4000, 0, 255), (0, 4000, 0, 255), None] - assert data_type == "UInt16" - - def test_scaling_parameters_auto(self): - scales, data_type = scaling_parameters( - self.RGBA_BANDS, ["red", "green", "blue", "alpha"], None, "auto", None - ) - assert scales == [(), (), (), None] - assert data_type == "Byte" - - def test_scaling_parameters_physical(self): - scales, data_type = scaling_parameters( - self.RGBA_BANDS, - ["red", "green", "blue", "alpha"], - None, - "physical", - None, - ) - assert scales == [ - (0, 10000, 0.0, 1.0), - (0, 10000, 0.0, 1.0), - (0, 10000, 0.0, 1.0), - None, - ] - assert data_type == "Float64" - - def test_scaling_parameters_physical_int32(self): - scales, data_type = scaling_parameters( - self.RGBA_BANDS, - ["red", "green", "blue", "alpha"], - None, - "physical", - "Int32", - ) - assert scales == [ - (0, 10000, 0.0, 1.0), - (0, 10000, 0.0, 1.0), - (0, 10000, 0.0, 1.0), - None, - ] - assert data_type == "Int32" - - def test_scaling_parameters_bad_mode(self): - with pytest.raises(ValueError): - scales, data_type = scaling_parameters( - self.RGBA_BANDS, - ["red", "green", "blue", "alpha"], - None, - "mode", - None, - ) - - def test_scaling_parameters_list(self): - scales, data_type = scaling_parameters( - self.RGBA_BANDS, - ["red", "green", "blue", "alpha"], - None, - [(0, 10000), "display", (), None], - None, - ) - assert scales == [(0, 10000, 0, 255), (0, 4000, 0, 255), (), None] - assert data_type == "Byte" - - def test_scaling_parameters_list_alpha(self): - scales, data_type = scaling_parameters( - self.RGBA_BANDS, - ["red", "green", "blue", "alpha"], - None, - [(0, 4000), (0, 4000), (0, 4000), "raw"], - None, - ) - assert scales == [(0, 4000, 0, 255), (0, 4000, 0, 255), (0, 4000, 0, 255), None] - assert data_type == "Byte" - - def test_scaling_parameters_list_bad_length(self): - with pytest.raises(ValueError): - scales, data_type = scaling_parameters( - self.RGBA_BANDS, - ["red", "green", "blue", "alpha"], - None, - [(0, 10000), "display", ()], - None, - ) - - def test_scaling_parameters_list_bad_mode(self): - with pytest.raises(ValueError): - scales, data_type = scaling_parameters( - self.RGBA_BANDS, - ["red", "green", "blue", "alpha"], - None, - [(0, 10000), "mode", (), None], - None, - ) - - def test_scaling_parameters_dict(self): - scales, data_type = scaling_parameters( - self.RGBA_BANDS, - ["red", "green", "blue", "alpha"], - None, - {"red": "display", "green": (0, 10000), "default_": "auto"}, - None, - ) - assert scales == [(0, 4000, 0, 255), (0, 10000, 0, 255), (), None] - assert data_type == "Byte" - - def test_scaling_parameters_dict_default(self): - scales, data_type = scaling_parameters( - self.RGBA_BANDS, - ["red", "green", "blue", "alpha"], - None, - {"red": (0, 4000, 0, 255), "default_": "raw"}, - None, - ) - assert scales == [(0, 4000, 0, 255), None, None, None] - assert data_type == "UInt16" - - def test_scaling_parameters_dict_default_none(self): - scales, data_type = scaling_parameters( - self.RGBA_BANDS, - ["red", "green", "blue", "alpha"], - None, - {"red": "display", "green": "display"}, - None, - ) - assert scales == [(0, 4000, 0, 255), (0, 4000, 0, 255), None, None] - assert data_type == "Byte" - - def test_scaling_parameters_tuple_range(self): - scales, data_type = scaling_parameters( - self.RGBA_BANDS, - ["red", "green", "blue", "alpha"], - None, - [(0, 10000, 0, 255), (0, 4000), (), None], - None, - ) - assert scales == [(0, 10000, 0, 255), (0, 4000, 0, 255), (), None] - assert data_type == "Byte" - - def test_scaling_parameters_tuple_range_uint16(self): - scales, data_type = scaling_parameters( - self.RGBA_BANDS, - ["red", "green", "blue", "alpha"], - None, - [(0, 10000, 0, 10000), (0, 4000), (), None], - None, - ) - assert scales == [(0, 10000, 0, 10000), (0, 4000, 0, 65535), (), None] - assert data_type == "UInt16" - - def test_scaling_parameters_tuple_range_float(self): - scales, data_type = scaling_parameters( - self.RGBA_BANDS, - ["red", "green", "blue", "alpha"], - None, - [(0, 10000, 0, 1.0), (0, 4000), (0, 4000), None], - None, - ) - assert scales == [(0, 10000, 0, 1), (0, 4000, 0, 1), (0, 4000, 0, 1), None] - assert data_type == "Float64" - - def test_scaling_parameters_tuple_pct(self): - scales, data_type = scaling_parameters( - self.RGBA_BANDS, - ["red", "green", "blue", "alpha"], - None, - [("0%", "100%", "0%", "100%"), ("2%", "98%", "2%", "98%"), "display", None], - None, - ) - assert scales == [ - (0, 4000, 0, 255), - (80, 3920, 5, 250), - (0, 4000, 0, 255), - None, - ] - assert data_type == "Byte" - - def test_scaling_parameters_tuple_pct_float(self): - scales, data_type = scaling_parameters( - self.RGBA_BANDS, - ["red", "green", "blue", "alpha"], - None, - [ - ("0%", "100%", "0%", "100%"), - ("2%", "98%", "2%", "98%"), - "physical", - None, - ], - None, - ) - assert scales == [ - (0, 10000, 0, 1), - (200, 9800, 0.02, 0.98), - (0, 10000, 0, 1), - None, - ] - assert data_type == "Float64" - - def test_scaling_parameters_bad_data_type(self): - with pytest.raises(ValueError): - scales, data_type = scaling_parameters( - self.RGBA_BANDS, - ["red", "green", "blue", "alpha"], - None, - None, - "data_type", - ) - - def test_scaling_parameters_pl(self): - scales, data_type = scaling_parameters( - self.RGBTA_BANDS_PL, - ["red", "green", "blue", "alpha"], - None, - None, - None, - ) - assert scales is None - assert data_type == "Float64" - - def test_scaling_parameters_pl_ref(self): - scales, data_type = scaling_parameters( - self.RGBTA_BANDS_PL, - ["red", "green", "blue", "alpha"], - "toa_reflectance", - None, - None, - ) - assert scales is None - assert data_type == "Float64" - - def test_scaling_parameters_pl_rad(self): - scales, data_type = scaling_parameters( - self.RGBTA_BANDS_PL, - ["red", "green", "blue", "tirs1", "alpha"], - "toa_radiance", - None, - None, - ) - assert scales is None - assert data_type == "Float64" - - def test_scaling_parameters_pl_rad_uint16(self): - scales, data_type = scaling_parameters( - self.RGBTA_BANDS_PL, ["blue", "alpha"], "toa_radiance", None, None - ) - assert scales is None - assert data_type == "UInt16" - - def test_scaling_parameters_pl_rad_physical(self): - scales, data_type = scaling_parameters( - self.RGBTA_BANDS_PL, - ["red", "green", "blue", "tirs1", "alpha"], - "toa_radiance", - "physical", - None, - ) - assert scales == [None, None, None, (0.0, 100.0, 0.0, 1.0), None] - assert data_type == "Float64" - - def test_scaling_parameters_pl_bt(self): - scales, data_type = scaling_parameters( - self.RGBTA_BANDS_PL, - ["tirs1"], - "toa_brightness_temperature", - None, - None, - ) - assert scales is None - assert data_type == "Float64" - - def test_scaling_parameters_pl_bt_physical(self): - scales, data_type = scaling_parameters( - self.RGBTA_BANDS_PL, - ["tirs1"], - "toa_brightness_temperature", - "physical", - None, - ) - assert scales == [(0.0, 333.0, 0.0, 1.0)] - assert data_type == "Float64" - - -class TestMultiProductScaling(unittest.TestCase): - RGBA_BANDS = { - "red": SpectralBand( - id="foo:red", - data_type="UInt16", - data_range=[0, 10000], - display_range=[0, 4000], - physical_range=[0.0, 1.0], - ), - "green": SpectralBand( - id="foo:green", - data_type="UInt16", - data_range=[0, 10000], - display_range=[0, 4000], - physical_range=[0.0, 1.0], - ), - "blue": SpectralBand( - id="foo:blue", - data_type="UInt16", - data_range=[0, 10000], - display_range=[0, 4000], - physical_range=[0.0, 1.0], - ), - "alpha": MaskBand(id="foo:alpha", data_type="UInt16", data_range=[0, 1]), - } - - RGA_BANDS = { - "red": SpectralBand( - id="foo:red", - data_type="Int16", - data_range=[0, 10000], - display_range=[0, 4000], - physical_range=[0.0, 1.0], - ), - "green": SpectralBand( - id="foo:green", - data_type="UInt16", - data_range=[0, 10000], - display_range=[0, 4000], - physical_range=[-1.0, 1.0], - ), - "alpha": MaskBand(id="foo:alpha", data_type="UInt16", data_range=[0, 1]), - } - - RGBTA_BANDS_PL = { - "red": SpectralBand( - id="foo:red", - data_type="UInt16", - data_range=[0, 65535], - processing_levels={ - "DN": [], - "default": "toa_reflectance", - "toa": "toa_reflectance", - "toa_reflectance": [ - ProcessingStepAttribute( - function="gain_bias", - parameter="reflectance_gain_bias", - index=2, - ), - ], - "toa_radiance": [ - ProcessingStepAttribute( - function="gain_bias", - parameter="radiance_gain_bias", - index=2, - data_type="Float64", - data_range=[0.0, 100.0], - ), - ], - }, - ), - "green": SpectralBand( - id="foo:green", - data_type="UInt16", - data_range=[0, 65535], - processing_levels={ - "DN": [], - "default": "toa_reflectance", - "toa": "toa_reflectance", - "toa_reflectance": [ - ProcessingStepAttribute( - function="gain_bias", - parameter="reflectance_gain_bias", - index=2, - ), - ], - "toa_radiance": [ - ProcessingStepAttribute( - function="gain_bias", - parameter="radiance_gain_bias", - index=2, - data_type="Float64", - data_range=[0.0, 100.0], - ), - ], - }, - ), - "blue": SpectralBand( - id="foo:blue", - data_type="UInt16", - data_range=[0, 65535], - processing_levels={ - "DN": [], - "default": "toa_reflectance", - "toa": "toa_reflectance", - "toa_reflectance": [ - ProcessingStepAttribute( - function="gain_bias", - parameter="reflectance_gain_bias", - index=2, - ), - ], - "toa_radiance": [ - ProcessingStepAttribute( - function="gain_bias", - parameter="radiance_gain_bias", - index=2, - # not realistic but to test the edge case - data_type="UInt16", - data_range=[0.0, 100.0], - ), - ], - }, - ), - "tirs1": SpectralBand( - id="foo:tirs1", - data_type="UInt16", - data_range=[0, 65535], - processing_levels={ - "DN": [], - "default": "toa_brightness_temperature", - "toa": "toa_brightness_temperature", - "toa_brightness_temperature": [ - ProcessingStepAttribute( - function="gain_bias", - parameter="radiance_gain_bias", - index=10, - ), - ProcessingStepAttribute( - function="brightness_temperature", - parameter="brightness_temperature_k1_k2", - index=1, - data_type="Float64", - data_range=[0.0, 333.0], - # not realistic, but to test the edge case - physical_range=[0.0, 1.0], - ), - ], - "toa_radiance": [ - ProcessingStepAttribute( - function="gain_bias", - parameter="radiance_gain_bias", - index=10, - data_type="Float64", - data_range=[0.0, 100.0], - # not realistic, but to test the edge case - physical_range=[0.0, 1.0], - ), - ], - }, - ), - "alpha": MaskBand(id="foo:alpha", data_type="UInt16", data_range=[0, 1]), - } - - def test_scaling_parameters_single(self): - scales, data_type = multiproduct_scaling_parameters( - {"product1": self.RGBA_BANDS}, - ["red", "green", "blue", "alpha"], - None, - None, - None, - ) - assert scales is None - assert data_type == "UInt16" - - def test_scaling_parameters_none(self): - scales, data_type = multiproduct_scaling_parameters( - {"product1": self.RGBA_BANDS, "product2": self.RGBA_BANDS}, - ["red", "green", "blue", "alpha"], - None, - None, - None, - ) - assert scales is None - assert data_type == "UInt16" - - def test_scaling_parameters_display(self): - scales, data_type = multiproduct_scaling_parameters( - {"product1": self.RGBA_BANDS, "product2": self.RGBA_BANDS}, - ["red", "green", "blue", "alpha"], - None, - "display", - None, - ) - assert scales == [(0, 4000, 0, 255), (0, 4000, 0, 255), (0, 4000, 0, 255), None] - assert data_type == "Byte" - - def test_scaling_parameters_missing_band(self): - with pytest.raises(ValueError, match="not available"): - scales, data_type = multiproduct_scaling_parameters( - {"product1": self.RGBA_BANDS, "product3": self.RGA_BANDS}, - ["red", "green", "blue", "alpha"], - None, - None, - None, - ) - - def test_scaling_parameters_none_data_type(self): - scales, data_type = multiproduct_scaling_parameters( - {"product1": self.RGBA_BANDS, "product3": self.RGA_BANDS}, - ["red", "alpha"], - None, - None, - None, - ) - assert scales is None - assert data_type == "Int32" - - def test_scaling_parameters_display_range(self): - scales, data_type = multiproduct_scaling_parameters( - {"product1": self.RGBA_BANDS, "product3": self.RGA_BANDS}, - ["red", "alpha"], - None, - "display", - None, - ) - assert scales == [(0, 4000, 0, 255), None] - assert data_type == "Byte" - - def test_scaling_parameters_raw_range(self): - scales, data_type = multiproduct_scaling_parameters( - {"product1": self.RGBA_BANDS, "product3": self.RGA_BANDS}, - ["red", "alpha"], - None, - "raw", - None, - ) - assert scales == [None, None] - assert data_type == "Int32" - - def test_scaling_parameters_physical_incompatible(self): - with pytest.raises(ValueError, match="incompatible"): - scales, data_type = multiproduct_scaling_parameters( - {"product1": self.RGBA_BANDS, "product3": self.RGA_BANDS}, - ["green", "alpha"], - None, - "physical", - None, - ) - - def test_scaling_parameters_pl(self): - scales, data_type = multiproduct_scaling_parameters( - { - "product1": self.RGBTA_BANDS_PL, - "product2": self.RGBTA_BANDS_PL, - }, - ["red", "green", "blue", "alpha"], - None, - None, - None, - ) - assert scales is None - assert data_type == "Float64" - - def test_scaling_parameters_pl_ref(self): - scales, data_type = multiproduct_scaling_parameters( - { - "product1": self.RGBTA_BANDS_PL, - "product2": self.RGBTA_BANDS_PL, - }, - ["red", "green", "blue", "alpha"], - "toa_reflectance", - None, - None, - ) - assert scales is None - assert data_type == "Float64" - - def test_scaling_parameters_pl_rad(self): - scales, data_type = multiproduct_scaling_parameters( - { - "product1": self.RGBTA_BANDS_PL, - "product2": self.RGBTA_BANDS_PL, - }, - ["red", "green", "blue", "tirs1", "alpha"], - "toa_radiance", - None, - None, - ) - assert scales is None - assert data_type == "Float64" - - def test_scaling_parameters_pl_rad_uint16(self): - scales, data_type = multiproduct_scaling_parameters( - { - "product1": self.RGBTA_BANDS_PL, - "product2": self.RGBTA_BANDS_PL, - }, - ["blue", "alpha"], - "toa_radiance", - None, - None, - ) - assert scales is None - assert data_type == "UInt16" - - def test_scaling_parameters_pl_rad_physical(self): - scales, data_type = multiproduct_scaling_parameters( - { - "product1": self.RGBTA_BANDS_PL, - "product2": self.RGBTA_BANDS_PL, - }, - ["red", "green", "blue", "tirs1", "alpha"], - "toa_radiance", - "physical", - None, - ) - assert scales == [None, None, None, (0.0, 100.0, 0.0, 1.0), None] - assert data_type == "Float64" - - def test_scaling_parameters_pl_bt(self): - scales, data_type = multiproduct_scaling_parameters( - { - "product1": self.RGBTA_BANDS_PL, - "product2": self.RGBTA_BANDS_PL, - }, - ["tirs1"], - "toa_brightness_temperature", - None, - None, - ) - assert scales is None - assert data_type == "Float64" - - def test_scaling_parameters_pl_bt_physical(self): - scales, data_type = multiproduct_scaling_parameters( - { - "product1": self.RGBTA_BANDS_PL, - "product2": self.RGBTA_BANDS_PL, - }, - ["tirs1"], - "toa_brightness_temperature", - "physical", - None, - ) - assert scales == [(0.0, 333.0, 0.0, 1.0)] - assert data_type == "Float64" diff --git a/descarteslabs/core/catalog/tests/test_search.py b/descarteslabs/core/catalog/tests/test_search.py deleted file mode 100644 index 989111e8..00000000 --- a/descarteslabs/core/catalog/tests/test_search.py +++ /dev/null @@ -1,603 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 responses -import json -import shapely.geometry - -from ...common.collection import Collection -from ...common.geo import AOI -from ...common.property_filtering import Properties -from .base import ClientTestCase -from .. import properties as p -from ..search import Search -from ..image import Image, ImageSearch -from ..image_collection import ImageCollection -from ..attributes import DocumentState -from ..product import Product - - -class TestSearch(ClientTestCase): - def setUp(self): - super(TestSearch, self).setUp() - self.search = Search(Product, client=self.client) - - def sort_filters(self, filters): - """ - Sort all lists in filter definitions in a more or less stable way - in place for comparison. - """ - if type(filters) is list: - filters.sort( - key=lambda i: ( - len(i.get("and", [])), - len(i.get("or", [])), - i.get("name", ""), - i.get("op", ""), - i.get("val", ""), - ) - ) - for filter in filters: - self.sort_filters(filter) - elif "and" in filters: - self.sort_filters(filters["and"]) - elif "or" in filters: - self.sort_filters(filters["or"]) - return filters - - @responses.activate - def test_search(self): - assert self.search._to_request() == ("/products", {}) - self.mock_response( - responses.PUT, - { - "meta": {"count": 1}, - "data": [ - { - "attributes": { - "owners": ["org:someorg"], - "name": "My Product", - "readers": [], - "modified": "2019-06-12T20:31:48.542725Z", - "created": "2019-06-12T20:31:48.542725Z", - "start_datetime": None, - "writers": [], - "end_datetime": None, - "description": "This is a test product", - }, - "type": "product", - "id": "someorg:my-product", - } - ], - "jsonapi": {"version": "1.0"}, - "links": { - "self": "https://example.com/catalog/v2/products", - "next": "https://example.com/catalog/v2/products?continuation=.xxx", - }, - }, - ) - - self.mock_response( - responses.PUT, - { - "meta": {"count": 0}, - "data": [], - "jsonapi": {"version": "1.0"}, - "links": {"self": "https://example.com/catalog/v2/products"}, - }, - ) - results = list(self.search) - assert len(results) == 1 - assert type(results[0]) is Product - # followed continuation token - assert responses.calls[0].request.url == self.url + "/products" - assert ( - responses.calls[1].request.url == self.url + "/products?continuation=.xxx" - ) - - @responses.activate - def test_search_on_behalf_of(self): - assert self.search._to_request() == ("/products", {}) - self.mock_response( - responses.PUT, - { - "meta": {"count": 1}, - "data": [ - { - "attributes": { - "owners": ["org:someorg"], - "name": "My Product", - "readers": [], - "modified": "2019-06-12T20:31:48.542725Z", - "created": "2019-06-12T20:31:48.542725Z", - "start_datetime": None, - "writers": [], - "end_datetime": None, - "description": "This is a test product", - }, - "type": "product", - "id": "someorg:my-product", - } - ], - "jsonapi": {"version": "1.0"}, - "links": { - "self": "https://example.com/catalog/v2/products", - "next": "https://example.com/catalog/v2/products?continuation=.xxx", - }, - }, - ) - - self.mock_response( - responses.PUT, - { - "meta": {"count": 0}, - "data": [], - "jsonapi": {"version": "1.0"}, - "links": {"self": "https://example.com/catalog/v2/products"}, - }, - ) - results = list( - Search(Product, client=self.client, headers={"X-On-Behalf-Of": "user"}) - ) - assert len(results) == 1 - assert type(results[0]) is Product - # followed continuation token - assert responses.calls[0].request.url == self.url + "/products" - assert ( - responses.calls[1].request.url == self.url + "/products?continuation=.xxx" - ) - # and supplied headers - assert responses.calls[0].request.headers["X-On-Behalf-Of"] == "user" - assert responses.calls[1].request.headers["X-On-Behalf-Of"] == "user" - - @responses.activate - def test_search_collect(self): - assert self.search._to_request() == ("/products", {}) - self.mock_response( - responses.PUT, - { - "meta": {"count": 1}, - "data": [ - { - "attributes": { - "owners": ["org:someorg"], - "name": "My Product", - "readers": [], - "modified": "2019-06-12T20:31:48.542725Z", - "created": "2019-06-12T20:31:48.542725Z", - "start_datetime": None, - "writers": [], - "end_datetime": None, - "description": "This is a test product", - }, - "type": "product", - "id": "someorg:my-product", - } - ], - "jsonapi": {"version": "1.0"}, - "links": { - "self": "https://example.com/catalog/v2/products", - "next": "https://example.com/catalog/v2/products?continuation=.xxx", - }, - }, - ) - - self.mock_response( - responses.PUT, - { - "meta": {"count": 0}, - "data": [], - "jsonapi": {"version": "1.0"}, - "links": {"self": "https://example.com/catalog/v2/products"}, - }, - ) - results = self.search.collect() - assert isinstance(results, Collection) - assert results._item_type == Product - assert len(results) == 1 - # followed continuation token - assert responses.calls[0].request.url == self.url + "/products" - assert ( - responses.calls[1].request.url == self.url + "/products?continuation=.xxx" - ) - - @responses.activate - def test_count(self): - self.mock_response( - responses.PUT, - { - "meta": {"count": 1}, - "data": [], - "jsonapi": {"version": "1.0"}, - "links": {"self": "https://example.com/catalog/v2/products"}, - }, - ) - - count = self.search.count() - assert self.get_request_body(0) == {"limit": 0} - assert count == 1 - - @responses.activate - def test_count_limit(self): - s = self.search.limit(10) - self.mock_response( - responses.PUT, - { - "meta": {"count": 1}, - "data": [], - "jsonapi": {"version": "1.0"}, - "links": {"self": "https://example.com/catalog/v2/products"}, - }, - ) - count = s.count() - # limit has no impact for count request - assert self.get_request_body(0) == {"limit": 0} - assert count == 1 - - @responses.activate - def test_filter_single(self): - s = self.search.filter(p.revisit_period_minutes_min == 60) - assert s._serialize_filters() == [ - {"op": "eq", "name": "revisit_period_minutes_min", "val": 60} - ] - - self.mock_response( - responses.PUT, - { - "meta": {"count": 1}, - "data": [ - { - "attributes": { - "owners": ["org:someorg"], - "name": "My Product", - "readers": [], - "modified": "2019-06-12T20:31:48.542725Z", - "created": "2019-06-12T20:31:48.542725Z", - "start_datetime": None, - "writers": [], - "end_datetime": None, - "description": "This is a test product", - "revisit_period_minutes_min": 60, - }, - "type": "product", - "id": "someorg:my-product", - } - ], - "jsonapi": {"version": "1.0"}, - "links": { - "self": "https://example.com/catalog/v2/products", - "next": "https://example.com/catalog/v2/products?continuation=.xxx", - }, - }, - ) - self.mock_response( - responses.PUT, - { - "meta": {"count": 1}, - "data": [], - "jsonapi": {"version": "1.0"}, - "links": {"self": "https://example.com/catalog/v2/products"}, - }, - ) - - results = list(s) - assert len(results) == 1 - product = results[0] - assert product.revisit_period_minutes_min == 60 - assert product._saved - assert product.state == DocumentState.SAVED - - def test_sort(self): - s = self.search.sort("start_datetime").sort("created", ascending=False) - assert s._to_request() == ("/products", {"sort": "-created"}) - - def test_filter_nested(self): - s = self.search.filter( - ( - (p.tags == "test") - & ( - (p.start_datetime == "2016-01-02") - | (p.start_datetime == "2016-01-01") - ) - ) - & (1000 >= p.revisit_period_minutes_max > 100) - ) - filters = s._serialize_filters() - assert self.sort_filters(filters) == self.sort_filters( - [ - {"name": "tags", "val": "test", "op": "eq"}, - { - "or": [ - {"name": "start_datetime", "val": "2016-01-02", "op": "eq"}, - {"name": "start_datetime", "val": "2016-01-01", "op": "eq"}, - ] - }, - { - "and": [ - {"name": "revisit_period_minutes_max", "val": 100, "op": "gt"}, - { - "name": "revisit_period_minutes_max", - "val": 1000, - "op": "lte", - }, - ] - }, - ] - ) - - def test_filter_range_only(self): - s = self.search.filter(p.revisit_period_minutes_max > 100) - filters = s._serialize_filters() - assert filters == [ - {"name": "revisit_period_minutes_max", "val": 100, "op": "gt"} - ] - - def test_filter_multirange_nested_or(self): - s = self.search.filter( - (1000 >= p.revisit_period_minutes_max > 100) - | (p.revisit_period_minutes_max > 2000) - ) - filters = s._serialize_filters() - assert self.sort_filters(filters) == self.sort_filters( - [ - { - "or": [ - { - "and": [ - { - "name": "revisit_period_minutes_max", - "val": 100, - "op": "gt", - }, - { - "name": "revisit_period_minutes_max", - "val": 1000, - "op": "lte", - }, - ] - }, - {"name": "revisit_period_minutes_max", "val": 2000, "op": "gt"}, - ] - } - ] - ) - - def test_filter_contains(self): - s = self.search.filter(p.revisit_period_minutes_min.in_([60, 120])) - filters = s._serialize_filters() - assert filters == [ - { - "or": [ - {"name": "revisit_period_minutes_min", "val": 60, "op": "eq"}, - {"name": "revisit_period_minutes_min", "val": 120, "op": "eq"}, - ] - } - ] - - def test_filter_geometry(self): - geometry = { - "type": "Polygon", - "coordinates": ( - ( - (-9.000262842437783, 46.9537091787344), - (-8.325270159894608, 46.95172107428039), - (-8.336543403548475, 46.925857032669434), - (-8.39987774007129, 46.7807657614384), - (-8.463235968271405, 46.63558741606639), - (-8.75144712554016, 45.96528086358922), - (-9.0002581299532, 45.9655511480415), - (-9.000262842437783, 46.9537091787344), - ), - ), - } - - s = Search(Image, client=self.client).filter( - p.geometry == shapely.geometry.shape(geometry) - ) - filters = s._serialize_filters() - assert filters[0]["val"] == geometry - - def test_filter_object(self): - my_product = Product(id="my_product") - - s = ImageSearch(Image, client=self.client) - s = s.filter(p.product == my_product) - filters = s._serialize_filters() - assert filters[0]["name"] == "product_id" - assert filters[0]["op"] == "eq" - assert filters[0]["val"] == my_product.id - - s = ImageSearch(Image, client=self.client) - s = s.filter(p.product != my_product) - filters = s._serialize_filters() - assert filters[0]["name"] == "product_id" - assert filters[0]["op"] == "ne" - assert filters[0]["val"] == my_product.id - - # Not supported for <, <=, >, >= - - @responses.activate - def test_filter_resolution(self): - s = self.search.filter(p.resolution_min == 60) - assert s._serialize_filters() == [ - {"op": "eq", "name": "resolution_min", "val": 60} - ] - - @responses.activate - def test_limit(self): - s = self.search.limit(2) - assert s._to_request() == ("/products", {"limit": 2}) - self.mock_response( - responses.PUT, - { - "meta": {"count": 2}, - "data": [ - { - "attributes": { - "owners": ["org:someorg"], - "name": "P1", - "readers": [], - "modified": "2019-06-12T20:31:48.542725Z", - "created": "2019-06-12T20:31:48.542725Z", - "description": "This is a test product", - }, - "type": "product", - "id": "someorg:p1", - }, - { - "attributes": { - "owners": ["org:someorg"], - "name": "P2", - "readers": [], - "modified": "2019-06-12T20:31:48.542725Z", - "created": "2019-06-12T20:31:48.542725Z", - "description": "This is a test product", - }, - "type": "product", - "id": "someorg:p2", - }, - ], - "jsonapi": {"version": "1.0"}, - "links": {"self": "https://example.com/catalog/v2/products"}, - }, - ) - results = list(s) - assert len(results) == 2 - # does not follow continuation token after limit is reached - assert len(responses.calls) == 1 - - def test_search_find_text(self): - s = self.search.find_text("test") - assert s._to_request() == ("/products", {"text": "test"}) - - s = ( - self.search.limit(10) - .filter(p.tags == "drone") - .find_text("test") - .sort("start_datetime") - ) - - _, request_params = s._to_request() - assert json.loads(request_params["filter"]) == [ - {"name": "tags", "val": "drone", "op": "eq"} - ] - assert request_params["limit"] == 10 - assert request_params["sort"] == "start_datetime" - assert request_params["text"] == "test" - - def test_default_includes(self): - s = ImageSearch(Image, client=self.client).filter( - Properties().product_id == "p1" - ) - assert s._to_request() == ( - "/images", - { - "filter": '[{"op":"eq","name":"product_id","val":"p1"}]', - "include": "product", - }, - ) - - @responses.activate - def test_search_image_collection(self): - my_product = Product(id="someorg:my_product") - s = ImageSearch(Image, client=self.client) - s = s.filter(p.product == my_product) - aoi = { - "type": "Polygon", - "coordinates": ( - ( - (-95.0, 42.0), - (-94.0, 42.0), - (-94.0, 41.0), - (-95.0, 41.0), - (-95.0, 42.0), - ), - ), - } - s = s.intersects(aoi) - - self.mock_response( - responses.PUT, - { - "meta": {"count": 1}, - "data": [ - { - "attributes": { - "name": "my-image", - "product_id": "someorg:my-product", - "created": "2019-06-12T20:31:48.542725Z", - "acquired": "2019-06-12T20:31:48.542725Z", - "files": [ - { - "hash": "abcdefg0123456789", - "href": "gs://some-bucket/file.tif", - "size_bytes": 1, - }, - ], - "geometry": { - "type": "Polygon", - "coordinates": [ - [ - [-95.2989209, 42.7999878], - [-93.1167728, 42.3858464], - [-93.7138666, 40.703737], - [-95.8364984, 41.1150618], - [-95.2989209, 42.7999878], - ] - ], - }, - }, - "type": "image", - "id": "someorg:my-product:my-image", - } - ], - "jsonapi": {"version": "1.0"}, - "links": { - "self": "https://example.com/catalog/v2/images", - }, - }, - ) - # product bands request - self.mock_response( - responses.PUT, - { - "meta": {"count": 1}, - "data": [ - { - "attributes": { - "name": "my-band", - "product_id": "someorg:my-product", - "created": "2019-06-12T20:31:48.542725Z", - "resolution": {"value": 30, "unit": "meters"}, - "type": "spectral", - }, - "type": "band", - "id": "someorg:my-product:my-band", - } - ], - "jsonapi": {"version": "1.0"}, - "links": { - "self": "https://example.com/catalog/v2/bands", - }, - }, - ) - assert s._intersects == aoi - assert s._intersects_none is False - - results = s.collect() - assert isinstance(results, ImageCollection) - assert results._item_type == Image - assert len(results) == 1 - assert isinstance(results.geocontext, AOI) - assert results.geocontext.__geo_interface__ == aoi diff --git a/descarteslabs/core/catalog/tests/test_summary.py b/descarteslabs/core/catalog/tests/test_summary.py deleted file mode 100644 index 88843e75..00000000 --- a/descarteslabs/core/catalog/tests/test_summary.py +++ /dev/null @@ -1,141 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 textwrap -from datetime import datetime -from urllib.parse import urlparse - -import pytest -import responses - -from .. import properties as p -from ..image import Image -from ..product import Product -from .base import ClientTestCase - - -class TestImageSummary(ClientTestCase): - def setUp(self): - super(TestImageSummary, self).setUp() - self.search = Image.search(client=self.client) - - def mock_response(self, method, json, status=200, **kwargs): - responses.add(method, self.match_url, json=json, status=status, **kwargs) - - @responses.activate - def test_image_summary(self): - self.mock_response( - responses.PUT, - { - "data": { - "attributes": { - "count": 1, - "bytes": 44306192, - "products": ["someorg:fake-product"], - }, - "type": "image_summary", - "id": "all", - }, - "jsonapi": {"version": "1.0"}, - }, - ) - - s = self.search.filter(p.product_id == "someorg:fake-product") - summary = s.summary() - parsed_url = urlparse(responses.calls[0].request.url) - assert parsed_url.path == "/catalog/v2/images/summary/all" - params = self.get_request_body(0) - - assert json.loads(params["filter"]) == [ - {"name": "product_id", "val": "someorg:fake-product", "op": "eq"} - ] - - assert summary.products == ["someorg:fake-product"] - summary_repr = repr(summary) - match_str = """\ - Summary for 1 images: - - Total bytes: 44,306,192 - - Products: someorg:fake-product""" - - assert summary_repr.strip("\n") == textwrap.dedent(match_str) - - INTERVAL_RESPONSE = { - "meta": {"count": 1}, - "data": [ - { - "attributes": { - "count": 1, - "interval_start": "2019-01-01T00:00:00Z", - "bytes": 44306192, - }, - "type": "image_interval_summary", - "id": "2019-01-01T00:00:00Z", - } - ], - "jsonapi": {"version": "1.0"}, - "links": { - "self": "https://www.example.com/catalog/v2/images/summary/acquired/year" - }, - } - - @responses.activate - def test_summary_interval(self): - response = self.INTERVAL_RESPONSE - response["links"] = dict( - self="https://www.example.com/catalog/v2/images/summary/created/month" - ) - self.mock_response(responses.PUT, response) - - results = self.search.summary_interval( - aggregate_date_field="created", - interval="month", - start_datetime=datetime(2018, 1, 1), - end_datetime="2019-01-01", - ) - parsed_url = urlparse(responses.calls[0].request.url) - assert parsed_url.path == "/catalog/v2/images/summary/created/month" - - request_params = self.get_request_body(0) - assert request_params == {"_start": "2018-01-01T00:00:00", "_end": "2019-01-01"} - - assert len(results) == 1 - assert isinstance(results[0].interval_start, datetime) - - @responses.activate - def test_summary_interval_defaults(self): - self.mock_response(responses.PUT, self.INTERVAL_RESPONSE) - results = self.search.summary_interval() - parsed_url = urlparse(responses.calls[0].request.url) - assert parsed_url.path == "/catalog/v2/images/summary/acquired/year" - - request_params = self.get_request_body(0) - assert request_params == {} - - assert len(results) == 1 - assert isinstance(results[0].interval_start, datetime) - - @responses.activate - def test_summary_interval_unbounded(self): - self.mock_response(responses.PUT, self.INTERVAL_RESPONSE) - self.search.summary_interval(start_datetime=0, end_datetime=0) - parsed_url = urlparse(responses.calls[0].request.url) - assert parsed_url.path == "/catalog/v2/images/summary/acquired/year" - - request_params = self.get_request_body(0) - assert request_params == {"_start": "", "_end": ""} - - def test_invalid_summary(self): - with pytest.raises(AttributeError): - Product.search().summary() diff --git a/descarteslabs/core/client/__init__.py b/descarteslabs/core/client/__init__.py deleted file mode 100644 index 8db1f697..00000000 --- a/descarteslabs/core/client/__init__.py +++ /dev/null @@ -1,44 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 sys -import warnings - -if sys.version_info < (3, 9): - msg = "Python version {}.{} not supported by the descarteslabs client".format( - sys.version_info.major, sys.version_info.minor - ) - raise ImportError(msg) - -if sys.version_info >= (3, 13): - msg = "Python version {}.{} is not supported yet. You may encounter unexpected errors.".format( - sys.version_info.major, sys.version_info.minor - ) - warnings.warn(msg, FutureWarning) - - -def clear_client_state(): - """Clear all cached client state.""" - from descarteslabs.auth import Auth - from ..common.http.service import DefaultClientMixin - from ..catalog.helpers import ( - BANDS_BY_PRODUCT_CACHE as catalog_bands_by_product_cache, - ) - - Auth.set_default_auth(None) - DefaultClientMixin.clear_all_default_clients() - catalog_bands_by_product_cache.clear() - - -__all__ = ["clear_client_state"] diff --git a/descarteslabs/core/client/addons.py b/descarteslabs/core/client/addons.py deleted file mode 100644 index 7e8322db..00000000 --- a/descarteslabs/core/client/addons.py +++ /dev/null @@ -1,29 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 ThirdParty(object): - _package = None - - def __init__(self, package): - self._package = package - - def __getattr__(self, name): - raise ImportError("Please install the %s package" % self._package) - - def __dir__(self): - raise ImportError("Please install the %s package" % self._package) - - def __call__(self, *args, **kwargs): - raise ImportError("Please install the %s package" % self._package) diff --git a/descarteslabs/core/client/auth/README.md b/descarteslabs/core/client/auth/README.md deleted file mode 100644 index f8c22cd2..00000000 --- a/descarteslabs/core/client/auth/README.md +++ /dev/null @@ -1,3 +0,0 @@ -This package implements the cli support for auth. - -It is not part of the `descarteslabs.auth` package to avoid injecting unnecessary dependencies everywhere due to the widespread use of `descarteslabs.auth` everywhere. diff --git a/descarteslabs/core/client/auth/__init__.py b/descarteslabs/core/client/auth/__init__.py deleted file mode 100644 index 246574c8..00000000 --- a/descarteslabs/core/client/auth/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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. - -# this is a convenience import for backwards compatibility with external existing code -from descarteslabs.auth import Auth - - -__all__ = ["Auth"] diff --git a/descarteslabs/core/client/auth/cli/__init__.py b/descarteslabs/core/client/auth/cli/__init__.py deleted file mode 100644 index 0fcc29ad..00000000 --- a/descarteslabs/core/client/auth/cli/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 .cli import cli - -__all__ = ["cli"] diff --git a/descarteslabs/core/client/auth/cli/cli.py b/descarteslabs/core/client/auth/cli/cli.py deleted file mode 100644 index 6a0dde84..00000000 --- a/descarteslabs/core/client/auth/cli/cli.py +++ /dev/null @@ -1,133 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 binascii -import json -import os - -import click - -from descarteslabs.auth.auth import ( - DEFAULT_TOKEN_INFO_PATH, - DESCARTESLABS_CLIENT_ID, - DESCARTESLABS_CLIENT_SECRET, - DESCARTESLABS_REFRESH_TOKEN, - DESCARTESLABS_TOKEN_INFO_PATH, - Auth, - base64url_decode, - get_app_domain as get_auth_refresh_domain, -) - - -LOGIN_URL = f"{get_auth_refresh_domain()}/account/refresh-token" - - -# this is defined this way to support mocking in the tests -# def get_default_domain(): -# from descarteslabs.auth.auth import get_default_domain as get_auth_domain - -# return get_auth_domain() - - -@click.group() -def cli(): - pass - - -@cli.command() -def login(): - """Log in to Descartes Labs""" - click.echo(f"Follow this link to login and request a token:\n\n {LOGIN_URL}\n") - - while True: - try: - s = input("...then come back here and paste the generated token: ") - - if not s: - raise KeyboardInterrupt() - except KeyboardInterrupt: - click.echo("\nExiting without logging in") - break - - if isinstance(s, str): - s = s.encode("utf-8") - - if s: - retry_message = f""" -You entered the wrong token. Please go to - -{LOGIN_URL} - -to retrieve your token""" - - try: - json_data = base64url_decode(s).decode("utf-8") - except (UnicodeDecodeError, binascii.Error): - click.echo(retry_message) - continue - - try: - token_info = json.loads(json_data) - except (UnicodeDecodeError, json.JSONDecodeError): - click.echo(retry_message) - continue - - if Auth.KEY_REFRESH_TOKEN not in token_info: - token_info[Auth.KEY_REFRESH_TOKEN] = token_info.get( - Auth.KEY_CLIENT_SECRET - ) - - Auth._write_token_info( - os.environ.get(DESCARTESLABS_TOKEN_INFO_PATH, DEFAULT_TOKEN_INFO_PATH), - token_info, - ) - - # Get a fresh Auth token - auth = Auth() - name = auth.payload["name"] - click.echo(f"Welcome, {name}!") - break - - -@cli.command() -def payload(): - """Print the current token payload.""" - click.echo(json.dumps(Auth().payload, sort_keys=True, indent=4)) - - -@cli.command() -def token(): - """Print the current token.""" - click.echo(Auth().token) - - -@cli.command() -def name(): - """Print the name of the current user.""" - click.echo(Auth().payload.get("name", "")) - - -@cli.command() -def groups(): - """Print the groups of the current user.""" - click.echo(json.dumps(sorted(Auth().payload["groups"]), indent=4)) - - -@cli.command() -def env(): - """Print the environment settings for the current user.""" - click.echo(f"{DESCARTESLABS_CLIENT_ID}={Auth().client_id}") - click.echo(f"{DESCARTESLABS_CLIENT_SECRET}={Auth().client_secret}") - click.echo(f"{DESCARTESLABS_REFRESH_TOKEN}={Auth().refresh_token}") diff --git a/descarteslabs/core/client/auth/cli/tests/__init__.py b/descarteslabs/core/client/auth/cli/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/descarteslabs/core/client/auth/cli/tests/test_cli.py b/descarteslabs/core/client/auth/cli/tests/test_cli.py deleted file mode 100644 index 295ee97a..00000000 --- a/descarteslabs/core/client/auth/cli/tests/test_cli.py +++ /dev/null @@ -1,147 +0,0 @@ -# 2018-2023 Descartes Labs. -# -# 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 base64 -import json -import os -import unittest -from unittest.mock import patch - -import click.testing -import responses - -from .. import cli - - -REFRESH = { - "id": "some id", - "client_id": "some_client_id", - "name": "API token", - "revoke_url": "https://iam.descarteslabs.com/auth/credentials/revoke/revoke.me", - "client_secret": "some_client_secret", -} -REFRESH_TOKEN = base64.urlsafe_b64encode( - json.dumps(REFRESH, separators=(",", ":")).encode("utf-8") -).decode("utf-8") - -PAYLOAD = { - "name": "Some Body", - "groups": ["public"], - "org": "someorg", - "email": "some_body@someorg.com", - "email_verified": True, - "iss": "https://descarteslabs.auth0.com/", - "sub": "google-oauth2|202801449858648638555", - "aud": "some_client_id", - "exp": 1610770917, - "iat": 1610734917, - "azp": "some_client_id", -} -PAYLOAD_JSON = json.dumps(PAYLOAD, separators=(",", ":")) - - -class Open: - """Emulate `open()` statement and return pre-defined strings - - When there was no written string, return the initial string. - Once a string is written, return that instead - """ - - def __init__(self, initial_payload): - self._initial_payload = initial_payload - self._payload = "" - - def __call__(self, *args, **kwargs): - if len(args) > 1 and "w" in args[1]: - self._payload = "" - - return self - - def __enter__(self, *args): - return self - - def __exit__(self, *args): - pass - - def read(self, *args): - if self._payload: - print("Returing {}".format(self._payload)) - return self._payload - else: - return self._initial_payload - - def write(self, payload): - self._payload += payload - - -# -# Note that the `open()` patch cannot be shared across tests. -# Note that the environment must be cleaned in order to get -# expected behavior (i.e. no credentials present). -# -@patch("descarteslabs.auth.auth.makedirs_if_not_exists") -@patch( - "descarteslabs.auth.auth.get_default_domain", - return_value="https://descarteslabs.auth0.com", -) -@patch("descarteslabs.auth.auth.DEFAULT_TOKEN_INFO_PATH", None) -class TestAuth(unittest.TestCase): - def setUp(self): - self.runner = click.testing.CliRunner() - - @responses.activate - @patch("builtins.open", Open(PAYLOAD_JSON)) - @patch.dict( - os.environ, - { - "DESCARTESLABS_CLIENT_ID": "some_client_id", - "DESCARTESLABS_CLIENT_SECRET": "some_client_secret", - }, - ) - def test_login(self, *mocks): - payload = base64.urlsafe_b64encode(PAYLOAD_JSON.encode("utf-8")).decode("utf-8") - responses.add( - responses.POST, - "https://descarteslabs.auth0.com/token", - json={ - "access_token": f".{payload}.", - }, - ) - - result = self.runner.invoke(cli, ["login"], input=REFRESH_TOKEN + "\n") - assert result.exit_code == 0 - assert "Welcome, Some Body!" in result.output - - @responses.activate - @patch("builtins.open", Open(PAYLOAD_JSON)) - @patch.dict( - os.environ, - { - "DESCARTESLABS_CLIENT_ID": "some_client_id", - "DESCARTESLABS_CLIENT_SECRET": "some_client_secret", - }, - ) - def test_payload(self, *mocks): - payload = base64.urlsafe_b64encode(PAYLOAD_JSON.encode("utf-8")).decode("utf-8") - responses.add( - responses.POST, - "https://descarteslabs.auth0.com/token", - json={ - "access_token": f".{payload}.", - }, - ) - - result = self.runner.invoke(cli, ["payload"]) - assert result.exit_code == 0 - assert json.loads(result.output) == PAYLOAD diff --git a/descarteslabs/core/client/deprecation.py b/descarteslabs/core/client/deprecation.py deleted file mode 100644 index 0224b087..00000000 --- a/descarteslabs/core/client/deprecation.py +++ /dev/null @@ -1,269 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 functools import wraps -import inspect -import warnings - - -SUPPRESS_DEPRECATION_WARNINGS = "_suppress_deprecation_warnings" - - -def check_deprecated_kwargs( - kwargs, - renamed=None, - required=None, - deprecated=None, - removed=None, - stacklevel=2, -): - """Support a deprecation cycle for function/method parameters - - - Changing renamed kwarg calls - - Depcrecating but allowing kwarg calls - - Removing kwarg calls - - Specifying that some kwargs with defaults are required - - Suppressing warnings in case calls are nested - - Parameters - ---------- - kwargs : mapping - The keyword parameters. If the keyword `_suppress_deprecation_warnings` is - included, it must be an iterable of parameter names that are skipped in the - `deprecated` and `removed` checks, and will be removed. - renamed : mapping, optional - A mapping {'old name': 'new name'}. If `old_name` is found in the parameters, - it will generate a warning and rename `old_name` to `new_name`. If `new_name` - already exists, it will raise an `SyntaxError`. - required : iterable, optional - An iterable of parameter names that are required. You only - need to specify parameters that are kwargs with defaults, positional parameters - are checked automatically. - deprecated : iterable, optional - An iterable of parameters names that are deprecated and will generate a warning - without removing the parameter. - removed : iterable, optional - An iterable of parameters names that are deprecated and will generate a warning - and also remove the parameter. - stacklevel : int - Which stack frame the warning message will use - - Raises - ------ - SyntaxError - A required kwarg is missing, or if both the - old and new versions of the kwarg are specified by the called. - TypeError - More args are supplied than are defined in the function. - - Examples - -------- - Renaming parameters: - - def f(a, b, kwarg=None): - pass - - to rename ``b`` to ``c`` - - @deprecate(renames={'b': 'c'}) - def f(a, c, kwarg=None): - pass - - Making positional parameter optional: - - def f(a, b, c): - pass - - to make ``b`` optional and ``c`` remain required - - @deprecate(required=['c']): - def f(a, b=None, c=None): - pass - - Warning about parameter use: - - def f(a, b=None, c=None): - pass - - to warn if ``b`` is supplied but still pass it on - - @deprecate(deprecated=['b']) - def f(a, b=None, c=None): - pass - - Removing parameters: - - def f(a, b, c, kwarg=None): - pass - - to remove ``b`` completely - - @deprecate(removed=['b']) - def f(a, c, kwarg=None): - pass - - calls like ``f('a', 'b', 'c', 'd')`` will raise a TypeError, so this is the - last step in the deprecation cycle before removing the parameter or decorator - completely. - - Suppressing duplicate warnings in a call hierarchy: - - It often arises that a parameter to be deprecated is passed through multiple - function calls. Using a ``@deprecate(deprecated=['b'])`` or - ``@deprecate(removed=['b'])`` on each of the - functions will yield multiple warnings, when it is desired that only the - top-most call should yield a warning. This can be achieved by passing the - `_suppress_deprecation_warnings` parameter with the name of the params to ignore: - - def f(a, b=None, c=None): - g(a, b=b, b=b) - - def g(a, b=None, c=None): - pass - - To suppress a warning about ``b`` from ``g()`` when called by ``f()`` - - @deprecate(deprecated=['b']) - def f(a, b=None, c=None): - g(a, b=b, c=c, _suppress_deprecation_warnings=['b']) - - @deprecate(deprecated=['b']) - def g(a, b=None, c=None): - pass - """ - msgs = [] - suppress_deprecation_warnings = kwargs.pop(SUPPRESS_DEPRECATION_WARNINGS, None) - - if suppress_deprecation_warnings: - for key in suppress_deprecation_warnings: - if deprecated and key in deprecated: - deprecated.remove(key) - - if removed and key in removed: - removed.remove(key) - - if renamed: - # Rename any parameters before checking the other cases - for old, new in renamed.items(): - if old in kwargs: - if new in kwargs: - msg = ( - f"Parameter `{old}` has been renamed to `{new}`, and " - "will be removed in future versions. Do not specify both " - "parameters, and use only `{new}`." - ) - raise SyntaxError(msg) - else: - msgs.append( - f"Parameter `{old}` has been renamed to `{new}`, and " - "will be removed in future versions. Use " - f"`{new}` instead." - ) - kwargs[new] = kwargs.pop(old) - - if required: - for key in required: - if key not in kwargs: - raise SyntaxError(f"Missing required parameter {key}") - - if deprecated: - for key in deprecated: - if key in kwargs: - msgs.append( - f"Parameter `{key}` has been deprecated and will be removed completely " - "in future versions." - ) - - if removed: - for key in removed: - if key in kwargs: - msgs.append( - f"Parameter `{key}` has been deprecated and is no longer supported." - ) - kwargs.pop(key) - - for msg in msgs: - warnings.warn(msg, FutureWarning, stacklevel=stacklevel) - - -def deprecate_func(message=None): - """ - This decorator emits a deprecation warning for a function with a custom - message, if applicable. - """ - - def wrapper(f): - @wraps(f) - def wrapped(*args, **kwargs): - if not kwargs.pop(SUPPRESS_DEPRECATION_WARNINGS, False): - if message: - msg = message - else: - msg = "{} has been deprecated and will be removed competely in a future version".format( - f.__name__ - ) - warnings.warn(msg, FutureWarning, stacklevel=2) - return f(*args, **kwargs) - - return wrapped - - return wrapper - - -def deprecate( - renamed=None, - required=None, - deprecated=None, - removed=None, -): - """Decorator for a deprecation cycle as outlined in `check_deprecated_kwargs`. - - Don't use this decorator with varargs or kwargs. - """ - - def wrapper(f): - @wraps(f) - def wrapped(*args, **kwargs): - func_spec = inspect.getfullargspec(f) - func_args = func_spec.args - signature = inspect.signature(f, follow_wrapped=False) - signature_args = list(signature.parameters.keys()) - if ( - len(func_args) == len(signature_args) + 1 - and func_args[0] == "cls" - and func_args[1:] == signature_args - ): - # it's a class method, lose the class arg - func_args = signature_args - # func_spec.args might be shorter than args due to removed - # parameters, raise TypeError in these cases - if len(args) > len(func_args): - raise TypeError( - "{}() takes {} arguments " - "but {} were given.".format(f.__name__, len(args), len(func_args)) - ) - kwargs.update(dict(zip(func_args, args))) - check_deprecated_kwargs( - kwargs, - renamed, - required, - deprecated, - removed, - stacklevel=3, - ) - return f(**kwargs) - - return wrapped - - return wrapper diff --git a/descarteslabs/core/client/exceptions.py b/descarteslabs/core/client/exceptions.py deleted file mode 100644 index 6c8629be..00000000 --- a/descarteslabs/core/client/exceptions.py +++ /dev/null @@ -1,15 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 descarteslabs.exceptions import * # noqa 401 diff --git a/descarteslabs/core/client/scripts/__init__.py b/descarteslabs/core/client/scripts/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/descarteslabs/core/client/scripts/__main__.py b/descarteslabs/core/client/scripts/__main__.py deleted file mode 100644 index 284b7d95..00000000 --- a/descarteslabs/core/client/scripts/__main__.py +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env python -# © 2025 EarthDaily Analytics Corp. -# -# 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 sys - -import click - -try: - from descarteslabs.core.client.scripts.cli import cli -except ImportError: - # run from monorepo, somewhat unusual - from descarteslabs.client.scripts.cli import cli - - -def main(): - try: - cli() - except Exception as e: - click.echo(f"{e.__class__.__name__}: {e}", err=True) - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/descarteslabs/core/client/scripts/cli.py b/descarteslabs/core/client/scripts/cli.py deleted file mode 100644 index 09faa1bc..00000000 --- a/descarteslabs/core/client/scripts/cli.py +++ /dev/null @@ -1,59 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 descarteslabs.config import get_settings, select_env -from ..auth.cli import cli as auth_cli -from .. import version as version_module -from .lazy_group import LazyGroup - -import click - -# Hack to determine where the client root is installed -MODULE_PREFIX = version_module.__package__.rsplit(".", 1)[0] - - -# Use a lazy group to avoid loading these large packages until they are needed -@click.group( - cls=LazyGroup, - lazy_subcommands={ - "catalog": f"{MODULE_PREFIX}.catalog.cli.cli", - # "compute": f"{MODULE_PREFIX}.compute.scripts.cli", - # "vector": f"{MODULE_PREFIX}.vector.scripts.cli", - }, - help="Descartes Labs command-line interface", -) -@click.option( - "--env", help="The environment to use", envvar="DESCARTESLABS_ENV", default=None -) -@click.pass_context -def cli(ctx, env): - if env: - select_env(env) - ctx.obj = get_settings() - - -cli.add_command(auth_cli, name="auth") - - -@cli.command() -def version(): - """Print the version of the CLI""" - click.echo(version_module.__version__) - - -@cli.command() -@click.pass_context -def env(ctx): - """Print the version of the CLI""" - click.echo(ctx.obj.env) diff --git a/descarteslabs/core/client/scripts/lazy_group.py b/descarteslabs/core/client/scripts/lazy_group.py deleted file mode 100644 index a6168246..00000000 --- a/descarteslabs/core/client/scripts/lazy_group.py +++ /dev/null @@ -1,54 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 importlib -import click - -# This is taken straight from the Click documentation - - -class LazyGroup(click.Group): - def __init__(self, *args, lazy_subcommands=None, **kwargs): - super().__init__(*args, **kwargs) - # lazy_subcommands is a map of the form: - # - # {command-name} -> {module-name}.{command-object-name} - # - self.lazy_subcommands = lazy_subcommands or {} - - def list_commands(self, ctx): - base = super().list_commands(ctx) - lazy = sorted(self.lazy_subcommands.keys()) - return base + lazy - - def get_command(self, ctx, cmd_name): - if cmd_name in self.lazy_subcommands: - return self._lazy_load(cmd_name) - return super().get_command(ctx, cmd_name) - - def _lazy_load(self, cmd_name): - # lazily loading a command, first get the module name and attribute name - import_path = self.lazy_subcommands[cmd_name] - modname, cmd_object_name = import_path.rsplit(".", 1) - # do the import - mod = importlib.import_module(modname) - # get the Command object from that module - cmd_object = getattr(mod, cmd_object_name) - # check the result to make debugging easier - if not isinstance(cmd_object, click.BaseCommand): - raise ValueError( - f"Lazy loading of {import_path} failed by returning " - "a non-command object" - ) - return cmd_object diff --git a/descarteslabs/core/client/scripts/tests/__init__.py b/descarteslabs/core/client/scripts/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/descarteslabs/core/client/scripts/tests/test_cli.py b/descarteslabs/core/client/scripts/tests/test_cli.py deleted file mode 100644 index 561d034a..00000000 --- a/descarteslabs/core/client/scripts/tests/test_cli.py +++ /dev/null @@ -1,35 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 unittest - -import click.testing - -from ...version import __version__ -from ..cli import cli - - -class TestCli(unittest.TestCase): - def setUp(self): - self.runner = click.testing.CliRunner() - - def test_version(self): - result = self.runner.invoke(cli, ["version"]) - assert result.exit_code == 0 - assert result.output == f"{__version__}\n" - - def test_env(self): - result = self.runner.invoke(cli, ["env"]) - assert result.exit_code == 0 - assert result.output == "testing\n" diff --git a/descarteslabs/core/client/services/__init__.py b/descarteslabs/core/client/services/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/descarteslabs/core/client/services/raster/__init__.py b/descarteslabs/core/client/services/raster/__init__.py deleted file mode 100644 index 98968790..00000000 --- a/descarteslabs/core/client/services/raster/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 .raster import Raster - -__all__ = ["Raster"] diff --git a/descarteslabs/core/client/services/raster/geotiff_utils.py b/descarteslabs/core/client/services/raster/geotiff_utils.py deleted file mode 100644 index 4f561a8f..00000000 --- a/descarteslabs/core/client/services/raster/geotiff_utils.py +++ /dev/null @@ -1,450 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from typing import List, Tuple, Union -from enum import Enum -import numpy as np -from affine import Affine - -from tifffile import TiffWriter - -# Rasterio is used in `make_geotiffs` but ONLY if installed -try: - import rasterio - from rasterio.crs import CRS -except ImportError: - rasterio = None - - -############################################################################## -# GeoTiff Tags -############################################################################## - -# Geotiff-specific data structure, see spec - -GeoKeyDirectory = List[int] - -# 6 elements, defines the origin coordinate -ModelTiePoint = List[float] - -# 3 elements, defines the resolution -ModelPixelScale = List[float] - -# EPSG Code -ProjectedCSTypeGeoKey = int - - -# General type of coordinate system -# https://docs.opengeospatial.org/is/19-008r4/19-008r4.html#_requirements_class_gtmodeltypegeokey -class GTModelTypeGeoKey(Enum): - UNKNOWN = 0 - PROJECTED_2D = 1 - GEOGRAPHIC_2D = 2 - GEOCENTRIC_3D = 3 - USER_DEFINED = 32767 - - -# https://docs.opengeospatial.org/is/19-008r4/19-008r4.html#_requirements_class_units_geokeys -class GeogAngularUnitsGeoKey(Enum): - ANGULAR_RADIAN = 9101 - ANGULAR_DEGREE = 9102 # most common - ANGULAR_ARC_MINUTE = 9103 - ANGULAR_ARC_SECOND = 9104 - ANGULAR_GRAD = 9105 - ANGULAR_GON = 9106 - ANGULAR_DMS = 9107 - ANGULAR_DMS_HEMISPHERE = 9108 - - -class ProjLinearUnitsGeoKey(Enum): - LINEAR_METER = 9001 - LINEAR_FOOT = 9002 - LINEAR_FOOT_US_SURVEY = 9003 - LINEAR_FOOT_MODIFIED_AMERICAN = 9004 - LINEAR_FOOT_CLARKE = 9005 - LINEAR_FOOT_INDIAN = 9006 - LINEAR_LINK = 9007 - LINEAR_LINK_BENOIT = 9008 - LINEAR_LINK_SEARS = 9009 - LINEAR_CHAIN_BENOIT = 9010 - LINEAR_CHAIN_SEARS = 9011 - LINEAR_YARD_SEARS = 9012 - LINEAR_YARD_INDIAN = 9013 - LINEAR_FATHOM = 9014 - LINEAR_MILE_INTERNATIONAL_NAUTICAL = 9015 - - -def make_geotiff_profile(metadata, blosc_meta): - dtype = { - "uint16": np.uint16, - "uint8": np.uint8, - "int16": np.int16, - "uint32": np.uint32, - "int32": np.int32, - "float32": np.float32, - "float64": np.float64, - } - - if blosc_meta["dtype"] not in dtype: - raise ValueError("Unknown data type {} returned".format(blosc_meta["dtype"])) - - gt = metadata["geoTransform"] - transform = Affine.from_gdal(*gt) - - geotiff_profile = dict( - driver="GTiff", - count=blosc_meta["shape"][0], - height=blosc_meta["shape"][1], - width=blosc_meta["shape"][2], - dtype=dtype[blosc_meta["dtype"]], - tiled=True, - transform=transform, - blockxsize=512, - blockysize=512, - ) - - return geotiff_profile - - -def construct_geokeydirectory( - gtmodeltypegeokey: GTModelTypeGeoKey, - geogangularunitsgeokey: GeogAngularUnitsGeoKey, - projectedcstypegeokey: ProjectedCSTypeGeoKey, - projlinearunitsgeokey: ProjLinearUnitsGeoKey, - projcs, - geogcs, -) -> GeoKeyDirectory: - """Takes our nicely typed components and constructs this awful data structure - - Behold, the GeoKeyDirectory. Interpreted as groups of four integers with a header... - """ - data = [ - # Header - # ... KeyDirectoryVersion, KeyRevision, MinorRevision, NumberOfKeys - 1, - 1, - 0, - 7, - # - # Now the keys themselves - # ... KeyID, TIFFTagLocation, Count, Value_Offset - # GTModelTypeGeoKey - 1024, - 0, - 1, - gtmodeltypegeokey.value, - # - # GTRasterTypeGeoKey - 1025, - 0, - 1, - 1, # i.e. AREA_OR_POINT=Area in GDAL. hardcoded for now - # - # GTCitationGeoKey (First part of the projection desc) - 1026, - 34737, - len(projcs), - 0, - # - # GeogCitationGeoKey (citation for geographic coordinate system) - 2049, - 34737, - len(geogcs), - len(projcs), - # - # GeogAngularUnitsGeoKey - 2054, - 0, - 1, - geogangularunitsgeokey.value, - # - # ProjectedCSTypeGeoKey - 3072, - 0, - 1, - projectedcstypegeokey, - # - # ProjLinearUnitsGeoKey - 3076, - 0, - 1, - projlinearunitsgeokey.value, - ] - - # TODO: custom SRS definitions - - # if projectedcstypegeokey == 32767: - # data.extend([ - # # Datum - # 2050, - # 0, - # 1, - # 6308, - # # - # ]) - # data[3] += 1 - - return data - - -def parse_projection(metadata) -> Tuple[GeoKeyDirectory, str, str]: - """ - Given a projection string or epsg code (TBD), - construct the geokeydirectory and the proj strings - - TODO this is hardcoded for now. We need to have `libproj` or - some way to parse the input into these items. Alternatively, - we could get them from the npz endpoint. - - Returns: - gkd, projcs, geogcs - """ - - if "PROJCS" in metadata["metadata"]: - projcs = metadata["metadata"]["PROJCS"] + "|" - else: - projcs = "unknown|" - - if "epsg" in metadata["coordinateSystem"]: - epsg_code = metadata["coordinateSystem"]["epsg"] - if "GEOGCS" in metadata["metadata"]: - geogcs = "{}|".format(metadata["metadata"]["GEOGCS"]) - else: - geogcs = "unknown|" - else: - epsg_code = 32767 # designated user-defined srs code - - if "GEOGCS" in metadata["metadata"]: - geogcs = "GCS Name = {}|".format(metadata["metadata"]["GEOGCS"]) - - if "GEOGCS|DATUM" in metadata["metadata"]: - geogcs += "Datum = {}|".format(metadata["metadata"]["GEOGCS|DATUM"]) - if "GEOGCS|SPHEROID" in metadata["metadata"]: - geogcs += "Ellipsoid = {}|".format( - metadata["metadata"]["GEOGCS|SPHEROID"] - ) - if "GEOGCS|PRIMEM" in metadata["metadata"]: - geogcs += "Primem = {}||".format(metadata["metadata"]["GEOGCS|PRIMEM"]) - else: - geogcs = "unknown|" - - epsg: ProjectedCSTypeGeoKey = epsg_code - - gtmodeltypegeokey = GTModelTypeGeoKey.PROJECTED_2D - geogangularunitsgeokey = GeogAngularUnitsGeoKey.ANGULAR_DEGREE - projlinearunitsgeokey = ProjLinearUnitsGeoKey.LINEAR_METER - - gkd = construct_geokeydirectory( - gtmodeltypegeokey, - geogangularunitsgeokey, - epsg, - projlinearunitsgeokey, - projcs, - geogcs, - ) - return (gkd, projcs, geogcs) - - -def parse_transform(transform) -> Tuple[ModelTiePoint, ModelPixelScale]: - """Given an affine transform defining the placement of pixels in space, - convert to the geotiff model. - """ - mtp = [0.0, 0.0, 0.0, transform.xoff, transform.yoff, 0.0] - - # We re-invert the y resolution here, geotiff spec needs a positive value here - mps = [transform.a, -1 * transform.e, 0.0] - - return mtp, mps - - -def make_gdalinfo(metadata): - info = "\n" - info += ' {}\n'.format(metadata["id"]) - - c = 0 - for b in metadata["bands"]: - if "colorInterpretation" in b: - info += ' {}\n'.format( - c, b["colorInterpretation"] - ) - if "description" in b: - info += ' {}\n'.format( - c, b["description"] - ) - c += 1 - info += "" - return info - - -def convert_to_geotiff_tags( - gkd: GeoKeyDirectory, - mtp: ModelTiePoint, - mps: ModelPixelScale, - projcs: str, - geogcs: str, - gdalinfo: str, - nodata: Union[int, float, complex, np.number, str, None], -) -> List[Tuple]: - """Creates the bare minimum geotiff tags to match typical GDAL output. - - Returns list of tuples suitable as direct input for tifffile - - Tifffile.write(extra_tags=convert_to_geotiff_tags(...)) - """ - projdesc = projcs + geogcs - tags = [ - # ModelPixelScaleTag: - (33550, 12, 3, mps, False), - # ModelTiePointTag: - (33922, 12, 6, mtp, False), - # GeoKeyDirectoryTag: - (34735, 3, len(gkd), gkd, False), - # GeoAsciiParamsTag: - (34737, 2, len(projdesc), projdesc, False), - # GDAL Info - (42112, 2, len(gdalinfo), gdalinfo, False), - ] - if nodata is not None: - nodata_str = str(nodata) - tags.append((42113, 2, len(nodata_str), nodata_str, False)) - return tags - - -def make_geotiff(outfile, chunk_iter, metadata, blosc_meta, compress, nodata): - if rasterio is not None: - make_rasterio_geotiff( - outfile, chunk_iter, metadata, blosc_meta, compress, nodata - ) - else: - make_tifffile_geotiff( - outfile, chunk_iter, metadata, blosc_meta, compress, nodata - ) - - -def make_rasterio_geotiff(outfile, chunk_iter, metadata, blosc_meta, compress, nodata): - """Use rasterio to create a geotiff. Uses libgdal to write thus offering full functionality - and more likely to be compatible with the rest of the geospatial software ecosystem - - :param outfile: string, path to output geotiff file. - :param chunk_iter: Iterator yielding "chunks", a 3D array of (rows, cols, bands) representing one - geotiff block. Streamed from the npz service. The order and length of the chunk sequence - must match that of the underlying blocks on disk. npz and all our tiff writers - agree on the correct order. - :param metadata: dict of image and per-band metdata - :param blosc_meta: dict of metadata describing the npz payload shape - :param compress: string, compression method to use when writing geotiff. Defaults to "LZW". - For rasterio geotiffs, accepts any of the algorithms supported by the - underlying GDAL shared library. - :param nodata: numeric, global value to represent masked (nodata) regions - """ - geotiff_profile = make_geotiff_profile(metadata, blosc_meta) - - if compress is None: - # Always default to lossless compression to save disk space. - compress = "LZW" - - crs = CRS.from_proj4(metadata["coordinateSystem"]["proj4"]) - - with rasterio.open( - outfile, mode="w", compress=compress, nodata=nodata, crs=crs, **geotiff_profile - ) as dst: - for i, bandmeta in enumerate(metadata["bands"]): - dst.update_tags(i + 1, **bandmeta["description"]) - - # Windowed writing ensures that we never need more than one chunk in memory - # each "chunk" corresponds to a raster block (ie tile) by design - for chunk, (_, window) in zip(chunk_iter, dst.block_windows()): - # swap axis order from (rows, cols, bands) to (bands, rows, cols) - # single bands come back as 2d - arr = np.transpose(np.atleast_3d(chunk), [2, 0, 1]) - dst.write(arr, window=window) - - -def make_tifffile_geotiff(outfile, chunk_iter, metadata, blosc_meta, compress, nodata): - """ - Use the tiffwriter which makes viable GeoTiffs but with limited optionality - specifically, they lack lossless compression - - :param outfile: string, path to output geotiff file. - :param chunk_iter: Iterator yielding "chunks", a 3D array of (rows, cols, bands) representing one - geotiff block. Streamed from the npz service. The order and length of the chunk sequenmce - must match that of the underlying blocks on disk. npz and all our tiff writers - agree on the correct order. - :param metadata: dict of image and per-band metdata - :param blosc_meta: dict of metadata describing the npz payload shape - :param compress: string, compression method to use when writing geotiff. - Defaults to uncompressed. Also supports "JPEG" and "PNG". - :param nodata: numeric, global value to represent masked (nodata) regions - """ - geotiff_profile = make_geotiff_profile(metadata, blosc_meta) - gkd, projcs, geogcs = parse_projection(metadata) - mtp, mps = parse_transform(geotiff_profile["transform"]) - gdalinfo = make_gdalinfo(metadata) - extra_tags = convert_to_geotiff_tags( - gkd, mtp, mps, projcs, geogcs, gdalinfo, nodata - ) - - nbands, height, width = blosc_meta["shape"] - - if compress == "JPEG": - if nbands == 2 or nbands > 3: - raise ValueError( - "JPEG output format does not allow {} bands:".format(nbands) - + "must be 1 (gray) or 3 (rgb) bands" - ) - elif compress == "PNG": - compress = None - if nbands == 2 or nbands > 4: - raise ValueError( - "PNG output format does not allow {} bands:".format(nbands) - + "must be 1 (gray), 3 (rgb), or 4 (rgba) bands" - ) - - if height < 1 or width < 1: - raise ValueError("Height or width less than one pixel in dimension") - - with TiffWriter(outfile) as tif: - if nbands > 1: - pconfig = "CONTIG" - shape = ( - geotiff_profile["height"], - geotiff_profile["width"], - geotiff_profile["count"], - ) - else: - pconfig = None - shape = (geotiff_profile["height"], geotiff_profile["width"]) - - if ( - height < geotiff_profile["blockxsize"] - and width < geotiff_profile["blockysize"] - and compress != "JPEG" - ): - tile = None - else: - tile = ( - geotiff_profile["blockxsize"], - geotiff_profile["blockysize"], - ) - - tif.write( - data=chunk_iter, - tile=tile, - shape=shape, - dtype=geotiff_profile["dtype"], - software="descarteslabs", - extratags=extra_tags, - compression=compress, - planarconfig=pconfig, - ) diff --git a/descarteslabs/core/client/services/raster/raster.py b/descarteslabs/core/client/services/raster/raster.py deleted file mode 100644 index 308598e6..00000000 --- a/descarteslabs/core/client/services/raster/raster.py +++ /dev/null @@ -1,776 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 -import random -import struct -import time -from collections.abc import Iterable -from concurrent import futures - -import blosc -import numpy as np -from descarteslabs.auth import Auth -from descarteslabs.config import get_settings -from descarteslabs.exceptions import ServerError -from PIL import Image -from tqdm import tqdm -from urllib3.exceptions import IncompleteRead, ProtocolError, ReadTimeoutError - -from ....common.dltile import Tile -from ....common.http.service import DefaultClientMixin -from ..service.service import Service -from .geotiff_utils import make_geotiff - -DEFAULT_MAX_WORKERS = 8 -DEFAULT_MAX_RETRIES = 8 - - -def as_json_string(str_or_dict): - if not str_or_dict: - return str_or_dict - elif isinstance(str_or_dict, dict): - return json.dumps(str_or_dict) - else: - return str_or_dict - - -def read_blosc_buffer(data): - header = data.read(16) - if len(header) != 16: - raise ServerError( - f"Received incomplete header (got {len(header)} bytes, expected 16)" - ) - - _, size, _, compressed_size = struct.unpack("= 500. Normally won't - # occur thanks to the client Retry configuration. - if retry_count == MAX_RETRIES: - raise - # MaxRetry and all other ClientError types will be raised to our caller - - if retry_count: - # see https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/ - # this is a variation on "Full Jitter" with JITTER as a tuning parameter. - delay = min(DELAY * MULTIPLIER ** (retry_count - 1), MAX_DELAY) - delay = random.uniform(1.0 - JITTER, 1.0) * delay - time.sleep(delay) - retry_count += 1 - - -class Raster(Service, DefaultClientMixin): - """ - The Raster API retrieves data from the Descartes Labs Catalog. Direct use of - the Raster API is not recommended. Consider using the Descartes Labs Catalog API instead. - """ - - # https://requests.readthedocs.io/en/master/user/advanced/#timeouts - CONNECT_TIMEOUT = 9.5 - READ_TIMEOUT = 300 - - TIMEOUT = (CONNECT_TIMEOUT, READ_TIMEOUT) - - def __init__(self, url=None, auth=None): - """The parent Service class implements authentication and exponential - backoff/retry. Override the url parameter to use a different instance - of the backing service. - """ - if auth is None: - auth = Auth.get_default_auth() - - if url is None: - url = get_settings().raster_url - - super(Raster, self).__init__(url, auth=auth) - - def raster( - self, - inputs, - bands, - scales=None, - data_type=None, - output_format="GTiff", - srs=None, - dimensions=None, - resolution=None, - bounds=None, - bounds_srs=None, - cutline=None, - align_pixels=False, - resampler=None, - dltile=None, - processing_level=None, - outfile_basename=None, - headers=None, - progress=None, - nodata=None, - _retry=_retry, - **pass_through_params, - ): - """Given a list of image identifiers, - retrieve a translated and warped mosaic as an image file. - - :param inputs: Iterable of image identifiers. - :param bands: List of requested bands. If the last item in the list is an alpha - band (with data range `[0, 1]`) it affects rastering of all other bands: - When rastering multiple images, they are combined image-by-image only where - each respective image's alpha band is `1` (pixels where the alpha band is not - `1` are "transparent" in the overlap between images). If a pixel is fully - masked considering all combined alpha bands it will be `0` in all non-alpha - bands. - :param scales: List of tuples specifying the scaling to be applied to each band. - A tuple has 4 elements in the order ``(src_min, src_max, out_min, out_max)``, - meaning values in the source range ``src_min`` to ``src_max`` will be scaled - to the output range ``out_min`` to ``out_max``. A tuple with 2 elements - ``(src_min, src_max)`` is also allowed, in which case the output range - defaults to ``(0, 255)`` (a useful default for the common output type - ``Byte``). If no scaling is desired for a band, use ``None``. This tuple - format and behaviour is identical to GDAL's scales during translation. - Example argument: ``[(0, 10000, 0, 127), (0, 1, 0, 1), (0, 10000)]`` - the first - band will have source values 0-10000 scaled to 0-127, the second band will - not be scaled, the third band will have 0-10000 scaled to 0-255. - :param str output_format: Output format (one of ``GTiff``, ``PNG``, ``JPEG``). - The default is ``GTiff``. - :param str data_type: Output data type (one of ``Byte``, ``UInt16``, ``Int16``, - ``UInt32``, ``Int32``, ``Float32``, ``Float64``). - :param str srs: Output spatial reference system definition understood by GDAL. - :param float resolution: Desired resolution in output SRS units. Incompatible with - `dimensions` - :param tuple dimensions: Desired output (width, height) in pixels within which - the raster should fit; i.e. the longer side of the raster will be min(dimensions). - Incompatible with `resolution`. - :param str cutline: A GeoJSON object to be used as a cutline, or WKT string. - GeoJSON coordinates must be in WGS84 lat-lon. - :param tuple bounds: ``(min_x, min_y, max_x, max_y)`` in target SRS. - :param str bounds_srs: - Override the coordinate system in which bounds are expressed. - If not given, bounds are assumed to be expressed in the output SRS. - :param bool align_pixels: Align pixels to the target coordinate system. - :param str resampler: Resampling algorithm to be used during warping (``near``, - ``bilinear``, ``cubic``, ``cubicsplice``, ``lanczos``, ``average``, ``mode``, - ``max``, ``min``, ``med``, ``q1``, ``q3``). - :param str dltile: a dltile key used to specify the resolution, bounds, and srs. - :param str processing_level: How the processing level of the underlying data - should be adjusted, one of ``toa`` (top of atmosphere) and ``surface``. For - products that support it, ``surface`` applies Descartes Labs' general surface - reflectance algorithm to the output. - :param str outfile_basename: Overrides default filename using this string as a base. - :param bool progress: Display a progress bar. - :param None or number: A nodata value to use in the file where pixels are masked. - Only used for non-JPEG geotiff files. - - :return: A tuple of (`filename`, ``metadata`` dictionary). - The dictionary contains details about the raster operation that happened. - These details can be useful for debugging but shouldn't otherwise be relied on - (there are no guarantees that certain keys will be present). - """ - - params = self._construct_npz_params( - inputs=inputs, - bands=bands, - scales=scales, - data_type=data_type, - srs=srs, - resolution=resolution, - dimensions=dimensions, - cutline=cutline, - bounds=bounds, - bounds_srs=bounds_srs, - align_pixels=align_pixels, - resampler=resampler, - dltile=dltile, - processing_level=processing_level, - output_window=None, - pass_through_params=pass_through_params, - ) - - if outfile_basename is None: - outfile_basename = params["ids"][0] - - file_ext = { - "GTiff": ".tif", - "JPEG": ".jpeg", - "PNG": ".png", - } - - if output_format not in file_ext: - raise ValueError("output_format must be one of GTiff, JPEG, PNG") - ext = file_ext[output_format] - - outfile = outfile_basename + ext - - def retry_req(headers): - r = self.session.post( - "/npz", headers=headers or {}, json=params, stream=True - ) - metadata = json.loads(r.raw.readline().decode("utf-8").strip()) - blosc_meta = json.loads(r.raw.readline().decode("utf-8").strip()) - - chunk_iter = yield_chunks(blosc_meta, r.raw, progress, nodata) - - if "id" not in metadata: - metadata["id"] = params["ids"][0] - - try: - if output_format == "GTiff": - make_geotiff( - outfile, chunk_iter, metadata, blosc_meta, None, nodata - ) - elif output_format == "JPEG": - make_geotiff( - outfile, chunk_iter, metadata, blosc_meta, "JPEG", None - ) - elif output_format == "PNG": - tif_out = outfile_basename + ".tif" - try: - make_geotiff( - tif_out, chunk_iter, metadata, blosc_meta, "PNG", None - ) - try: - im = Image.open(tif_out) - im.save(outfile) - except Exception: - raise RuntimeError("Cannot save PNG image") - finally: - if os.path.isfile(tif_out): - os.remove(tif_out) - except Exception: - if os.path.isfile(outfile): - os.remove(outfile) - raise - - return (outfile, metadata) - - return _retry(retry_req, headers=headers) - - def ndarray( - self, - inputs, - bands, - scales=None, - data_type=None, - srs=None, - resolution=None, - dimensions=None, - cutline=None, - bounds=None, - bounds_srs=None, - align_pixels=False, - resampler=None, - order="image", - dltile=None, - processing_level=None, - output_window=None, - headers=None, - progress=None, - masked=True, - _retry=_retry, - **pass_through_params, - ): - """Retrieve a raster as a NumPy array. - - :param inputs: List of image identifiers. - :param bands: List of requested bands. If the last item in the list is an alpha - band (with data range `[0, 1]`) it affects rastering of all other bands: - When rastering multiple images, they are combined image-by-image only where - each respective image's alpha band is `1` (pixels where the alpha band is not - `1` are "transparent" in the overlap between images). If a pixel is fully - masked considering all combined alpha bands it will be `0` in all non-alpha - bands. - :param scales: List of tuples specifying the scaling to be applied to each band. - A tuple has 4 elements in the order ``(src_min, src_max, out_min, out_max)``, - meaning values in the source range ``src_min`` to ``src_max`` will be scaled - to the output range ``out_min`` to ``out_max``. A tuple with 2 elements - ``(src_min, src_max)`` is also allowed, in which case the output range - defaults to ``(0, 255)`` (a useful default for the common output type - ``Byte``). If no scaling is desired for a band, use ``None``. This tuple - format and behaviour is identical to GDAL's scales during translation. - Example argument: ``[(0, 10000, 0, 127), (0, 1, 0, 1), (0, 10000)]`` - the first - band will have source values 0-10000 scaled to 0-127, the second band will - not be scaled, the third band will have 0-10000 scaled to 0-255. - :param str data_type: Output data type (one of ``Byte``, ``UInt16``, ``Int16``, - ``UInt32``, ``Int32``, ``Float32``, ``Float64``). - :param str srs: Output spatial reference system definition understood by GDAL. - :param float resolution: Desired resolution in output SRS units. Incompatible with - `dimensions` - :param tuple dimensions: Desired output (width, height) in pixels within which - the raster should fit; i.e. the longer side of the raster will be min(dimensions). - Incompatible with `resolution`. - :param str cutline: A GeoJSON object to be used as a cutline, or WKT string. - GeoJSON coordinates must be in WGS84 lat-lon. - :param tuple bounds: ``(min_x, min_y, max_x, max_y)`` in target SRS. - :param str bounds_srs: - Override the coordinate system in which bounds are expressed. - If not given, bounds are assumed to be expressed in the output SRS. - :param bool align_pixels: Align pixels to the target coordinate system. - :param str resampler: Resampling algorithm to be used during warping (``near``, - ``bilinear``, ``cubic``, ``cubicsplice``, ``lanczos``, ``average``, ``mode``, - ``max``, ``min``, ``med``, ``q1``, ``q3``). - :param str order: Order of the returned array. `image` returns arrays as - ``(row, column, band)`` while `gdal` returns arrays as ``(band, row, column)``. - :param str dltile: a dltile key used to specify the resolution, bounds, and srs. - :param str processing_level: How the processing level of the underlying data - should be adjusted, one of ``toa`` (top of atmosphere) and ``surface``. For - products that support it, ``surface`` applies Descartes Labs' general surface - reflectance algorithm to the output. - :param bool masked: Whether to return a masked array or a regular Numpy array. - :param bool progress: Display a progress bar. - - :return: A tuple of ``(np_array, metadata)``. The first element (``np_array``) is - the rastered image as a NumPy array. The second element (``metadata``) is a - dictionary containing details about the raster operation that happened. These - details can be useful for debugging but shouldn't otherwise be relied on (there - are no guarantees that certain keys will be present). - """ - - params = self._construct_npz_params( - inputs=inputs, - bands=bands, - scales=scales, - data_type=data_type, - srs=srs, - resolution=resolution, - dimensions=dimensions, - cutline=cutline, - bounds=bounds, - bounds_srs=bounds_srs, - align_pixels=align_pixels, - resampler=resampler, - dltile=dltile, - processing_level=processing_level, - output_window=output_window, - pass_through_params=pass_through_params, - ) - - def retry_req(headers): - r = self.session.post( - "/npz", headers=headers or {}, json=params, stream=True - ) - metadata = json.loads(r.raw.readline().decode("utf-8").strip()) - array_meta = json.loads(r.raw.readline().decode("utf-8").strip()) - array = read_tiled_blosc_array(array_meta, r.raw, progress=progress) - return array, metadata - - array, metadata = _retry(retry_req, headers=headers) - - if not masked: - array = array.data - - if len(array.shape) > 2: - if order == "image": - return array.transpose((1, 2, 0)), metadata - elif order == "gdal": - return array, metadata - else: - return array, metadata - - def _serial_ndarray(self, id_groups, *args, **kwargs): - for i, id_group in enumerate(id_groups): - arr, meta = self.ndarray(id_group, *args, **kwargs) - yield i, arr, meta - - def _threaded_ndarray(self, id_groups, *args, **kwargs): - """ - Thread ndarray calls by id group, keeping the same `args` and - `kwargs` for each raster.ndarray call. - """ - max_workers = kwargs.pop( - "max_workers", min(len(id_groups), DEFAULT_MAX_WORKERS) - ) - with futures.ThreadPoolExecutor(max_workers=max_workers) as executor: - future_ndarrays = {} - for i, id_group in enumerate(id_groups): - future_ndarrays[ - executor.submit(self.ndarray, id_group, *args, **kwargs) - ] = i - - for future in futures.as_completed(future_ndarrays): - i = future_ndarrays[future] - arr, meta = future.result() - yield i, arr, meta - - def stack( - self, - inputs, - bands, - scales=None, - data_type="UInt16", - srs=None, - resolution=None, - dimensions=None, - cutline=None, - bounds=None, - bounds_srs=None, - align_pixels=False, - resampler=None, - order="image", - dltile=None, - processing_level=None, - max_workers=None, - masked=True, - progress=None, - **pass_through_params, - ): - """Retrieve a stack of rasters as a 4-D NumPy array. - - To ensure every raster in the stack has the same shape and covers the same - spatial extent, you must either: - - * set ``dltile``, or - * set [``resolution`` or ``dimensions``], ``srs``, and ``bounds`` - - :param inputs: Iterable, or Iterable of Iterables, of image identifiers. - The stack will follow the same order as this list. - Each element in the list is treated as a separate input to ``raster.ndarray``, - so if a list of lists is given, each sublist's identifiers will be mosaiced together - to become a single level in the stack. - :param bands: List of requested bands. If the last item in the list is an alpha - band (with data range `[0, 1]`) it affects rastering of all other bands: - When rastering multiple images, they are combined image-by-image only where - each respective image's alpha band is `1` (pixels where the alpha band is not - `1` are "transparent" in the overlap between images). If a pixel is fully - masked considering all combined alpha bands it will be `0` in all non-alpha - bands. - :param scales: List of tuples specifying the scaling to be applied to each band. - A tuple has 4 elements in the order ``(src_min, src_max, out_min, out_max)``, - meaning values in the source range ``src_min`` to ``src_max`` will be scaled - to the output range ``out_min`` to ``out_max``. A tuple with 2 elements - ``(src_min, src_max)`` is also allowed, in which case the output range - defaults to ``(0, 255)`` (a useful default for the common output type - ``Byte``). If no scaling is desired for a band, use ``None``. This tuple - format and behaviour is identical to GDAL's scales during translation. - Example argument: ``[(0, 10000, 0, 127), (0, 1, 0, 1), (0, 10000)]`` - the first - band will have source values 0-10000 scaled to 0-127, the second band will - not be scaled, the third band will have 0-10000 scaled to 0-255. - :param str data_type: Output data type (one of ``Byte``, ``UInt16``, ``Int16``, - ``UInt32``, ``Int32``, ``Float32``, ``Float64``). - :param str srs: Output spatial reference system definition understood by GDAL. - :param float resolution: Desired resolution in output SRS units. Incompatible with - `dimensions` - :param tuple dimensions: Desired output (width, height) in pixels within which - the raster should fit; i.e. the longer side of the raster will be min(dimensions). - Incompatible with `resolution`. - :param str cutline: A GeoJSON object to be used as a cutline, or WKT string. - GeoJSON coordinates must be in WGS84 lat-lon. - :param tuple bounds: ``(min_x, min_y, max_x, max_y)`` in target SRS. - :param str bounds_srs: - Override the coordinate system in which bounds are expressed. - If not given, bounds are assumed to be expressed in the output SRS. - :param bool align_pixels: Align pixels to the target coordinate system. - :param str resampler: Resampling algorithm to be used during warping (``near``, - ``bilinear``, ``cubic``, ``cubicsplice``, ``lanczos``, ``average``, ``mode``, - ``max``, ``min``, ``med``, ``q1``, ``q3``). - :param str order: Order of the returned array. `image` returns arrays as - ``(image, row, column, band)`` while `gdal` returns arrays as ``(image, band, row, column)``. - :param str dltile: a dltile key used to specify the resolution, bounds, and srs. - :param str processing_level: How the processing level of the underlying data - should be adjusted, one of ``toa`` (top of atmosphere) and ``surface``. For - products that support it, ``surface`` applies Descartes Labs' general surface - reflectance algorithm to the output. - :param int max_workers: Maximum number of threads over which to - parallelize individual ndarray calls. If `None`, will be set to the minimum - of the number of inputs and `DEFAULT_MAX_WORKERS`. - :param bool masked: Whether to return a masked array or a regular Numpy array. - :param bool progress: Display a progress bar. - - :return: A tuple of ``(stack, metadata)``. - - * ``stack``: 4D ndarray. The axes are ordered ``(image, band, y, x)`` - (or ``(image, y, x, band)`` if ``order="gdal"``). The images in the outermost - axis are in the same order as the list of identifiers given as ``inputs``. - * ``metadata``: List[dict] of the rasterization metadata for each element in ``inputs``. - As with the metadata returned by :meth:`ndarray` and :meth:`raster`, these dictionaries - contain useful information about the raster, such as its geotransform matrix and WKT - of its coordinate system, but there are no guarantees that certain keys will be present. - """ - if isinstance(inputs, str): - inputs = list(inputs) - if isinstance(inputs, (list, tuple)): - pass - elif isinstance(inputs, Iterable): - inputs = list(inputs) - else: - raise TypeError( - "Inputs must be a Iterable, instead got '{}'".format(type(inputs)) - ) - - if dltile is None: - if resolution is None and dimensions is None: - raise ValueError("Must set `resolution` or `dimensions`") - if srs is None: - raise ValueError("Must set `srs`") - if bounds is None: - raise ValueError("Must set `bounds`") - - params = dict( - bands=bands, - scales=scales, - data_type=data_type, - srs=srs, - resolution=resolution, - dimensions=dimensions, - cutline=cutline, - bounds=bounds, - bounds_srs=bounds_srs, - align_pixels=align_pixels, - resampler=resampler, - order=order, - dltile=dltile, - processing_level=processing_level, - max_workers=max_workers, - masked=masked, - progress=progress, - **pass_through_params, - ) - - full_stack = None - metadata = [None] * len(inputs) - for i, arr, meta in self._threaded_ndarray(inputs, **params): - if len(arr.shape) == 2: - if order == "image": - arr = np.expand_dims(arr, -1) - elif order == "gdal": - arr = np.expand_dims(arr, 0) - else: - raise ValueError( - "Unknown order '{}'; should be one of 'image' or 'gdal'".format( - order - ) - ) - if full_stack is None: - stack_shape = (len(inputs),) + arr.shape - if masked: - full_stack = np.ma.empty(stack_shape, dtype=arr.dtype) - else: - full_stack = np.empty(stack_shape, dtype=arr.dtype) - - full_stack[i] = arr - metadata[i] = meta - - return full_stack, metadata - - def _construct_npz_params( - self, - inputs, - bands, - scales, - data_type, - srs, - resolution, - dimensions, - cutline, - bounds, - bounds_srs, - align_pixels, - resampler, - dltile, - processing_level, - output_window, - pass_through_params, - ): - cutline = as_json_string(cutline) - - if type(inputs) is str: - inputs = [inputs] - - params = { - "ids": list(inputs), - "bands": bands, - "scales": scales, - "ot": data_type, - "srs": srs, - "resolution": resolution, - "shape": cutline, - "outputBounds": bounds, - "outputBoundsSRS": bounds_srs, - "outsize": dimensions, - "targetAlignedPixels": align_pixels, - "resampleAlg": resampler, - "processing_level": processing_level, - "output_window": output_window, - "of": "blosc", - } - params.update(pass_through_params) - - if dltile is not None: - if isinstance(dltile, dict): - tile_key = dltile["properties"]["key"] - else: - tile_key = dltile - - tile_params = Tile.from_key(tile_key).geocontext["properties"] - params["outputBounds"] = tile_params["outputBounds"] - params["resolution"] = tile_params["resolution"] - params["srs"] = tile_params["cs_code"] - - return params diff --git a/descarteslabs/core/client/services/raster/smoke_tests/test_geotiff_utils.py b/descarteslabs/core/client/services/raster/smoke_tests/test_geotiff_utils.py deleted file mode 100644 index 6c72886c..00000000 --- a/descarteslabs/core/client/services/raster/smoke_tests/test_geotiff_utils.py +++ /dev/null @@ -1,213 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 tempfile -import numpy as np -import pytest - -try: - import rasterio -except ImportError: - rasterio = None - -from ..geotiff_utils import ( - make_rasterio_geotiff, - make_tifffile_geotiff, -) - - -def source_band(bidx, dtype="Float32"): - return { - "band": bidx, - "block": [128, 128], - "colorInterpretation": "Undefined", - "description": { - "data_range": [0.0, 1_000_000.0], - "dtype": dtype, - "jpx_layer": 0, - "name": f"band{bidx}", - "nodata": -99.0, - "srcband": 7, - "srcfile": 0, - "tags": [], - "type": "other", - }, - "metadata": {}, - "noDataValue": -99.0, - "overviews": [ - {"size": [180, 161]}, - {"size": [45, 41]}, - {"size": [12, 11]}, - ], - "type": dtype, - } - - -def simulate_npz_data(chunk_shapes, rowcol, bands, dtype): - rows, cols = rowcol - - metadata = { - "bands": list(source_band(x + 1, dtype) for x in range(bands)), - "coordinateSystem": { - "dataAxisToSRSAxisMapping": [2, 1], - "epsg": 4326, - "proj4": "+proj=longlat +datum=WGS84 +no_defs", - "wkt": 'GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563,AUTHORITY["EPSG","7030"]],AUTHORITY["EPSG","6326"]],PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],UNIT["degree",0.0174532925199433,AUTHORITY["EPSG","9122"]],AXIS["Latitude",NORTH],AXIS["Longitude",EAST],AUTHORITY["EPSG","4326"]]', # noqa - }, - "cornerCoordinates": { - "center": [-95.02000000000001, 35.620000000000005], - "lowerLeft": [-99.98, 30.11], - "lowerRight": [-90.06, 30.11], - "upperLeft": [-99.98, 41.13], - "upperRight": [-90.06, 41.13], - }, - "driverLongName": "Virtual Raster", - "driverShortName": "VRT", - "geoTransform": [-99.98, 0.0125, 0.0, 41.13, 0.0, -0.0125], - "metadata": { - "GEOGCS": "WGS 84", - "GEOGCS|DATUM": "WGS_1984", - "GEOGCS|PRIMEM": "Greenwich", - "GEOGCS|SPHEROID": "WGS 84", - }, - "size": [720, 641], # the source image size - "wgs84Extent": { - "coordinates": [ - [ - [-180.0, 80.0], - [-180.0, -80.25], - [0.0, -80.25], - [0.0, 80.0], - [-180.0, 80.0], - ] - ], - "type": "Polygon", - }, - "id": "e", - } - - blosc_meta = { - "chunks": len(chunk_shapes), - "dtype": dtype, - "shape": [bands, rows, cols], - } - - def gen_chunks(): - for shape in chunk_shapes: - yield (np.random.rand(*shape) * 100.0).astype(dtype) - - return gen_chunks(), metadata, blosc_meta - - -# Block shapes are (rows, cols, bands) and -# are always returned in Left-to-right, Bottom-to-top order -CHUNK_SHAPES_MULTIBLOCK = [(512, 512), (512, 282), (370, 512), (370, 282)] -CHUNK_SHAPES_SINGLEBLOCK = [(400, 400)] - -GOOD_COMBINATIONS = ( - (CHUNK_SHAPES_SINGLEBLOCK, (400, 400), 1, "float32"), - (CHUNK_SHAPES_MULTIBLOCK, (882, 794), 3, "float32"), - (CHUNK_SHAPES_SINGLEBLOCK, (400, 400), 3, "float32"), - (CHUNK_SHAPES_MULTIBLOCK, (882, 794), 4, "float32"), - (CHUNK_SHAPES_SINGLEBLOCK, (400, 400), 4, "float32"), - (CHUNK_SHAPES_SINGLEBLOCK, (400, 400), 1, "uint8"), - (CHUNK_SHAPES_MULTIBLOCK, (882, 794), 3, "uint8"), - (CHUNK_SHAPES_SINGLEBLOCK, (400, 400), 3, "uint8"), - (CHUNK_SHAPES_MULTIBLOCK, (882, 794), 4, "uint8"), - (CHUNK_SHAPES_SINGLEBLOCK, (400, 400), 4, "uint8"), -) - -# These don't work with tifffile for some reason -BAD_TIFFFILE_COMBINATIONS = ( - (CHUNK_SHAPES_MULTIBLOCK, (882, 794), 1, "float32"), - (CHUNK_SHAPES_MULTIBLOCK, (882, 794), 1, "uint8"), -) - - -@pytest.mark.skipif(rasterio is None, reason="requires rasterio") -@pytest.mark.parametrize("chunk_shapes,rowcol,bands,dtype", GOOD_COMBINATIONS) -def test_geotiff_rasterio_lzw(chunk_shapes, rowcol, bands, dtype): - nodata = 0 - compress = None # default is LZW - chunk_shapes_3d = [(cs[0], cs[1], bands) for cs in chunk_shapes] - chunk_iter, metadata, blosc_meta = simulate_npz_data( - chunk_shapes_3d, rowcol, bands, dtype - ) - - with tempfile.NamedTemporaryFile(mode="wb", suffix=".tif") as tmp: - make_rasterio_geotiff( - tmp.name, chunk_iter, metadata, blosc_meta, compress, nodata - ) - - with rasterio.open(tmp.name) as dst: - assert list(dst.read().shape) == blosc_meta["shape"] - assert dst.profile["compress"].lower() == "lzw" - - -@pytest.mark.skipif(rasterio is None, reason="requires rasterio") -@pytest.mark.parametrize("chunk_shapes,rowcol,bands,dtype", GOOD_COMBINATIONS) -def test_geotiff_rasterio_deflate(chunk_shapes, rowcol, bands, dtype): - nodata = 0 - compress = "DEFLATE" - chunk_shapes_3d = [(cs[0], cs[1], bands) for cs in chunk_shapes] - chunk_iter, metadata, blosc_meta = simulate_npz_data( - chunk_shapes_3d, rowcol, bands, dtype - ) - - with tempfile.NamedTemporaryFile(mode="wb", suffix=".tif") as tmp: - make_rasterio_geotiff( - tmp.name, chunk_iter, metadata, blosc_meta, compress, nodata - ) - - with rasterio.open(tmp.name) as dst: - assert list(dst.read().shape) == blosc_meta["shape"] - assert dst.profile["compress"].lower() == "deflate" - - -@pytest.mark.skipif(rasterio is None, reason="requires rasterio") -@pytest.mark.parametrize("chunk_shapes,rowcol,bands,dtype", GOOD_COMBINATIONS) -def test_geotiff_rasterio_jpeg(chunk_shapes, rowcol, bands, dtype): - nodata = 0 - compress = "JPEG" - chunk_shapes_3d = [(cs[0], cs[1], bands) for cs in chunk_shapes] - chunk_iter, metadata, blosc_meta = simulate_npz_data( - chunk_shapes_3d, rowcol, bands, dtype - ) - - with tempfile.NamedTemporaryFile(mode="wb", suffix=".tif") as tmp: - make_rasterio_geotiff( - tmp.name, chunk_iter, metadata, blosc_meta, compress, nodata - ) - - with rasterio.open(tmp.name) as dst: - assert list(dst.read().shape) == blosc_meta["shape"] - assert dst.profile["compress"].lower() == "jpeg" - - @pytest.mark.parametrize("chunk_shapes,rowcol,bands,dtype", GOOD_COMBINATIONS) - def test_geotiff_tifffile(chunk_shapes, rowcol, bands, dtype): - nodata = 0 - compress = None - chunk_shapes_3d = [(cs[0], cs[1], bands) for cs in chunk_shapes] - chunk_iter, metadata, blosc_meta = simulate_npz_data( - chunk_shapes_3d, rowcol, bands, dtype - ) - - with tempfile.NamedTemporaryFile(mode="wb", suffix=".tif") as tmp: - make_tifffile_geotiff( - tmp.name, chunk_iter, metadata, blosc_meta, compress, nodata - ) - - with rasterio.open(tmp.name) as dst: - assert list(dst.read().shape) == blosc_meta["shape"] - assert "compress" not in dst.profile diff --git a/descarteslabs/core/client/services/raster/tests/__init__.py b/descarteslabs/core/client/services/raster/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/descarteslabs/core/client/services/raster/tests/e2e/__init__.py b/descarteslabs/core/client/services/raster/tests/e2e/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/descarteslabs/core/client/services/raster/tests/e2e/iowa_geometry.py b/descarteslabs/core/client/services/raster/tests/e2e/iowa_geometry.py deleted file mode 100644 index 2737c508..00000000 --- a/descarteslabs/core/client/services/raster/tests/e2e/iowa_geometry.py +++ /dev/null @@ -1,1135 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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. - -iowa_geom = { - "coordinates": [ - [ - [-94.015492, 40.573914], - [-94.270456, 40.571531], - [-94.594196, 40.57096], - [-95.765645, 40.585208], - [-95.753148, 40.59284], - [-95.750274, 40.596317], - [-95.748626, 40.603355], - [-95.751271, 40.609057], - [-95.768926, 40.621264], - [-95.771325, 40.639393], - [-95.776251, 40.647463], - [-95.786568, 40.657253], - [-95.795489, 40.662384], - [-95.822913, 40.66724], - [-95.842316, 40.677171], - [-95.845765, 40.681806], - [-95.84868, 40.695973], - [-95.854456, 40.704164], - [-95.861798, 40.709242], - [-95.883178, 40.717579], - [-95.888907, 40.731855], - [-95.888697, 40.736292], - [-95.883643, 40.747831], - [-95.872281, 40.758349], - [-95.850144, 40.766663], - [-95.842824, 40.771093], - [-95.836903, 40.776477], - [-95.834156, 40.783016], - [-95.834523, 40.787778], - [-95.843745, 40.803783], - [-95.84522, 40.809831], - [-95.844852, 40.815307], - [-95.838601, 40.826175], - [-95.837122, 40.834257], - [-95.841309, 40.845604], - [-95.847084, 40.854174], - [-95.848565, 40.859665], - [-95.847785, 40.864328], - [-95.840788, 40.871236], - [-95.824989, 40.875], - [-95.815933, 40.879846], - [-95.810709, 40.886681], - [-95.809379, 40.893279], - [-95.814302, 40.902936], - [-95.834906, 40.919574], - [-95.839743, 40.93278], - [-95.840275, 40.939942], - [-95.837951, 40.950618], - [-95.829829, 40.963857], - [-95.828329, 40.972378], - [-95.829792, 40.977344], - [-95.833537, 40.98266], - [-95.838908, 40.986484], - [-95.860116, 40.995242], - [-95.866951, 41.001085], - [-95.869502, 41.009429], - [-95.865886, 41.017418], - [-95.859918, 41.025403], - [-95.859654, 41.035695], - [-95.861782, 41.039427], - [-95.881588, 41.054378], - [-95.882342, 41.059419], - [-95.881011, 41.066303], - [-95.865835, 41.080079], - [-95.862427, 41.089687], - [-95.866661, 41.104975], - [-95.867228, 41.121493], - [-95.881222, 41.141665], - [-95.883129, 41.148196], - [-95.88254, 41.155881], - [-95.877368, 41.163914], - [-95.87111, 41.167635], - [-95.865929, 41.168129], - [-95.853451, 41.165518], - [-95.846957, 41.166604], - [-95.842039, 41.171367], - [-95.842188, 41.177421], - [-95.845433, 41.18193], - [-95.850374, 41.18509], - [-95.863797, 41.188245], - [-95.878133, 41.187941], - [-95.90051, 41.184609], - [-95.916119, 41.185279], - [-95.923205, 41.190282], - [-95.927975, 41.200069], - [-95.926117, 41.211619], - [-95.914272, 41.223858], - [-95.910629, 41.234579], - [-95.91186, 41.241023], - [-95.919093, 41.252698], - [-95.922304, 41.26302], - [-95.921497, 41.267604], - [-95.914091, 41.272428], - [-95.928646, 41.281332], - [-95.929651, 41.292262], - [-95.927642, 41.29844], - [-95.920247, 41.301191], - [-95.905899, 41.301038], - [-95.904222, 41.297591], - [-95.906144, 41.287488], - [-95.912581, 41.279527], - [-95.90284, 41.274374], - [-95.884495, 41.280689], - [-95.876051, 41.285739], - [-95.871442, 41.293408], - [-95.872269, 41.30307], - [-95.878292, 41.313081], - [-95.889251, 41.319474], - [-95.899943, 41.321481], - [-95.918651, 41.320079], - [-95.929383, 41.32313], - [-95.946532, 41.333139], - [-95.953418, 41.33947], - [-95.957137, 41.345902], - [-95.95713, 41.349593], - [-95.953953, 41.353776], - [-95.936923, 41.359247], - [-95.929681, 41.367058], - [-95.929486, 41.375326], - [-95.936411, 41.385876], - [-95.937694, 41.39477], - [-95.929677, 41.411071], - [-95.932778, 41.430815], - [-95.920857, 41.443428], - [-95.919989, 41.452604], - [-95.92451, 41.458914], - [-95.93634, 41.465304], - [-95.945007, 41.466485], - [-95.958372, 41.463218], - [-95.966688, 41.463425], - [-95.984279, 41.470939], - [-96.004936, 41.472413], - [-96.013585, 41.478469], - [-96.018563, 41.484991], - [-96.019543, 41.491592], - [-96.000649, 41.50254], - [-95.996194, 41.50696], - [-95.992777, 41.514596], - [-95.994308, 41.523743], - [-95.999966, 41.53948], - [-96.005112, 41.54325], - [-96.01915, 41.54514], - [-96.026657, 41.540366], - [-96.031127, 41.523004], - [-96.034441, 41.521051], - [-96.034353, 41.512761], - [-96.036603, 41.509047], - [-96.040613, 41.506893], - [-96.048311, 41.507262], - [-96.055048, 41.509508], - [-96.072651, 41.523088], - [-96.084031, 41.526875], - [-96.091908, 41.534049], - [-96.096586, 41.545397], - [-96.096313, 41.550989], - [-96.092222, 41.560886], - [-96.082486, 41.571145], - [-96.081188, 41.574296], - [-96.082285, 41.580683], - [-96.087186, 41.58655], - [-96.101331, 41.591411], - [-96.109455, 41.596775], - [-96.115811, 41.605659], - [-96.118233, 41.613291], - [-96.115995, 41.622024], - [-96.097777, 41.639625], - [-96.095131, 41.647767], - [-96.096116, 41.654472], - [-96.100055, 41.661077], - [-96.111156, 41.667547], - [-96.119347, 41.674722], - [-96.122604, 41.68304], - [-96.119861, 41.692649], - [-96.113461, 41.697569], - [-96.103814, 41.699403], - [-96.08464, 41.697709], - [-96.077331, 41.700232], - [-96.073225, 41.704062], - [-96.073361, 41.710199], - [-96.076642, 41.715657], - [-96.085272, 41.721313], - [-96.102212, 41.727641], - [-96.105329, 41.731505], - [-96.106354, 41.738046], - [-96.104805, 41.743016], - [-96.100981, 41.747572], - [-96.087361, 41.752176], - [-96.081211, 41.757081], - [-96.078497, 41.762632], - [-96.079021, 41.77235], - [-96.077546, 41.777822], - [-96.066377, 41.788981], - [-96.06479, 41.794406], - [-96.06756, 41.800993], - [-96.075353, 41.807716], - [-96.09748, 41.813917], - [-96.106565, 41.819613], - [-96.109384, 41.823135], - [-96.110962, 41.828827], - [-96.107617, 41.8404], - [-96.110548, 41.849199], - [-96.116597, 41.855103], - [-96.13496, 41.863252], - [-96.141908, 41.868999], - [-96.145974, 41.875093], - [-96.148783, 41.888205], - [-96.161756, 41.90182], - [-96.161988, 41.905553], - [-96.159098, 41.910057], - [-96.142265, 41.915379], - [-96.136743, 41.920826], - [-96.136613, 41.927167], - [-96.143493, 41.937387], - [-96.144583, 41.941544], - [-96.142597, 41.945908], - [-96.133318, 41.955732], - [-96.129186, 41.965136], - [-96.129505, 41.971673], - [-96.132537, 41.974625], - [-96.141229, 41.978063], - [-96.15209, 41.979661], - [-96.163338, 41.979839], - [-96.177204, 41.976308], - [-96.187219, 41.977946], - [-96.190608, 41.980729], - [-96.192141, 41.984461], - [-96.184255, 41.996634], - [-96.184002, 42.003063], - [-96.185919, 42.005914], - [-96.194556, 42.008662], - [-96.206083, 42.009267], - [-96.215225, 42.006701], - [-96.221812, 41.997382], - [-96.225463, 41.994734], - [-96.236488, 41.996429], - [-96.242035, 42.000911], - [-96.24192, 42.006963], - [-96.23886, 42.012315], - [-96.223611, 42.022652], - [-96.22173, 42.026205], - [-96.223822, 42.033346], - [-96.235034, 42.040137], - [-96.226977, 42.037353], - [-96.228261, 42.038302], - [-96.234356, 42.040712], - [-96.246832, 42.041616], - [-96.258067, 42.038274], - [-96.267195, 42.04119], - [-96.272545, 42.046202], - [-96.278043, 42.058412], - [-96.279479, 42.07088], - [-96.276753, 42.081696], - [-96.267739, 42.097055], - [-96.266988, 42.106172], - [-96.269459, 42.114216], - [-96.27458, 42.120334], - [-96.283002, 42.125031], - [-96.300154, 42.127696], - [-96.309758, 42.132101], - [-96.314046, 42.136449], - [-96.32074, 42.147928], - [-96.341571, 42.15908], - [-96.349688, 42.170702], - [-96.350268, 42.177943], - [-96.347278, 42.185774], - [-96.347374, 42.193075], - [-96.349567, 42.198141], - [-96.35987, 42.210553], - [-96.356591, 42.215182], - [-96.336998, 42.217466], - [-96.323723, 42.229887], - [-96.322868, 42.233637], - [-96.330006, 42.240225], - [-96.327706, 42.249992], - [-96.328897, 42.254723], - [-96.335964, 42.264777], - [-96.341387, 42.269087], - [-96.35655, 42.276594], - [-96.365751, 42.285814], - [-96.368467, 42.292619], - [-96.369212, 42.308344], - [-96.375307, 42.318339], - [-96.384169, 42.325874], - [-96.407998, 42.337408], - [-96.413895, 42.343393], - [-96.417786, 42.351449], - [-96.417093, 42.361443], - [-96.408436, 42.376092], - [-96.409153, 42.381491], - [-96.41498, 42.393442], - [-96.415186, 42.404203], - [-96.410307, 42.412965], - [-96.387608, 42.432494], - [-96.380705, 42.446393], - [-96.381305, 42.461695], - [-96.386003, 42.474496], - [-96.396124, 42.484361], - [-96.401962, 42.48644], - [-96.447988, 42.490439], - [-96.455941, 42.492577], - [-96.463512, 42.490464], - [-96.469573, 42.491406], - [-96.471505, 42.489268], - [-96.476461, 42.490128], - [-96.477908, 42.494668], - [-96.473334, 42.503536], - [-96.476048, 42.507783], - [-96.479023, 42.510843], - [-96.488163, 42.511284], - [-96.493722, 42.516742], - [-96.492085, 42.519625], - [-96.479949, 42.524246], - [-96.479007, 42.526394], - [-96.479813, 42.529601], - [-96.476581, 42.546467], - [-96.47696, 42.555972], - [-96.49554, 42.55644], - [-96.498544, 42.558116], - [-96.498997, 42.560832], - [-96.486146, 42.573149], - [-96.485797, 42.57502], - [-96.495654, 42.579541], - [-96.495499, 42.582792], - [-96.491271, 42.586806], - [-96.501082, 42.589311], - [-96.500183, 42.594104], - [-96.509453, 42.612704], - [-96.515024, 42.616183], - [-96.518747, 42.616448], - [-96.525718, 42.609276], - [-96.527816, 42.608961], - [-96.531335, 42.613642], - [-96.529914, 42.617859], - [-96.521453, 42.617676], - [-96.518114, 42.619816], - [-96.515551, 42.624983], - [-96.515661, 42.629711], - [-96.521601, 42.634883], - [-96.525143, 42.634746], - [-96.52663, 42.64145], - [-96.537472, 42.645951], - [-96.537818, 42.6556], - [-96.542365, 42.660934], - [-96.546588, 42.661485], - [-96.558999, 42.657679], - [-96.559808, 42.662868], - [-96.55577, 42.664207], - [-96.564389, 42.671328], - [-96.566964, 42.676417], - [-96.569707, 42.67497], - [-96.571567, 42.670896], - [-96.578399, 42.67184], - [-96.574354, 42.673677], - [-96.572053, 42.676704], - [-96.572623, 42.678926], - [-96.578757, 42.678123], - [-96.574972, 42.68073], - [-96.57518, 42.682757], - [-96.586186, 42.683177], - [-96.586342, 42.689794], - [-96.589842, 42.691041], - [-96.591852, 42.690546], - [-96.592295, 42.68716], - [-96.599639, 42.686893], - [-96.599381, 42.688904], - [-96.596559, 42.688288], - [-96.593613, 42.689794], - [-96.596539, 42.691932], - [-96.593928, 42.694327], - [-96.597624, 42.695637], - [-96.599637, 42.698854], - [-96.601381, 42.699003], - [-96.605643, 42.694683], - [-96.612086, 42.69558], - [-96.609624, 42.698425], - [-96.604603, 42.698473], - [-96.605596, 42.70202], - [-96.609801, 42.702324], - [-96.615148, 42.698433], - [-96.616078, 42.700437], - [-96.619194, 42.699812], - [-96.623957, 42.7076], - [-96.625305, 42.707129], - [-96.625529, 42.704181], - [-96.630154, 42.705438], - [-96.629333, 42.709015], - [-96.626855, 42.709926], - [-96.624406, 42.714011], - [-96.624799, 42.715955], - [-96.628011, 42.717798], - [-96.624545, 42.720944], - [-96.624414, 42.725212], - [-96.634189, 42.724306], - [-96.634704, 42.726122], - [-96.630522, 42.730234], - [-96.63672, 42.733231], - [-96.639468, 42.736821], - [-96.630586, 42.735286], - [-96.635612, 42.741894], - [-96.629429, 42.743641], - [-96.632155, 42.74521], - [-96.630003, 42.747142], - [-96.630019, 42.750696], - [-96.619262, 42.754609], - [-96.620267, 42.757585], - [-96.622833, 42.758398], - [-96.625566, 42.754457], - [-96.627243, 42.754413], - [-96.628129, 42.757185], - [-96.6361, 42.763986], - [-96.636, 42.765421], - [-96.631522, 42.766095], - [-96.632715, 42.76862], - [-96.635508, 42.768034], - [-96.636382, 42.76974], - [-96.633054, 42.771332], - [-96.631309, 42.76965], - [-96.629245, 42.771994], - [-96.626361, 42.771403], - [-96.626985, 42.775302], - [-96.621985, 42.77683], - [-96.619503, 42.784043], - [-96.615569, 42.785016], - [-96.61471, 42.782247], - [-96.612563, 42.78462], - [-96.610489, 42.782578], - [-96.607993, 42.784434], - [-96.60444, 42.78311], - [-96.602345, 42.788113], - [-96.605007, 42.791696], - [-96.595214, 42.792896], - [-96.599683, 42.798435], - [-96.598172, 42.799966], - [-96.59645, 42.79759], - [-96.594076, 42.798235], - [-96.590465, 42.808304], - [-96.595626, 42.810343], - [-96.595945, 42.814954], - [-96.590724, 42.815383], - [-96.588943, 42.818487], - [-96.584109, 42.819005], - [-96.585808, 42.823776], - [-96.580549, 42.824882], - [-96.577654, 42.827791], - [-96.582305, 42.83375], - [-96.58147, 42.837726], - [-96.57833, 42.837799], - [-96.579717, 42.835626], - [-96.578164, 42.834727], - [-96.574424, 42.837978], - [-96.568382, 42.837781], - [-96.569683, 42.8345], - [-96.563604, 42.828174], - [-96.561518, 42.829467], - [-96.563012, 42.831356], - [-96.562862, 42.835887], - [-96.560944, 42.839134], - [-96.558543, 42.839512], - [-96.553986, 42.836106], - [-96.550322, 42.837531], - [-96.548598, 42.840891], - [-96.553805, 42.843182], - [-96.556083, 42.849231], - [-96.550195, 42.847584], - [-96.549454, 42.851208], - [-96.545262, 42.849854], - [-96.541455, 42.856868], - [-96.54262, 42.859688], - [-96.545212, 42.857081], - [-96.546819, 42.857436], - [-96.5461, 42.861161], - [-96.550451, 42.863548], - [-96.546573, 42.863914], - [-96.546174, 42.867552], - [-96.549701, 42.86988], - [-96.547146, 42.873955], - [-96.53767, 42.878462], - [-96.543485, 42.883765], - [-96.537695, 42.886748], - [-96.539838, 42.889803], - [-96.52721, 42.890617], - [-96.525287, 42.89191], - [-96.529692, 42.894218], - [-96.528873, 42.897926], - [-96.536124, 42.900879], - [-96.539531, 42.899939], - [-96.54293, 42.903756], - [-96.542416, 42.905054], - [-96.536467, 42.905643], - [-96.538586, 42.90784], - [-96.537809, 42.910954], - [-96.5401, 42.912318], - [-96.537759, 42.917318], - [-96.541164, 42.919406], - [-96.541283, 42.923927], - [-96.539926, 42.925066], - [-96.536409, 42.923106], - [-96.534106, 42.92371], - [-96.532952, 42.924754], - [-96.534061, 42.928166], - [-96.525312, 42.935627], - [-96.521704, 42.935145], - [-96.519799, 42.931976], - [-96.516835, 42.932347], - [-96.51645, 42.9352], - [-96.519858, 42.937989], - [-96.51987, 42.939969], - [-96.514198, 42.944851], - [-96.509288, 42.945153], - [-96.507907, 42.948391], - [-96.508919, 42.953715], - [-96.498963, 42.957802], - [-96.501839, 42.960777], - [-96.502682, 42.967605], - [-96.50516, 42.971095], - [-96.515773, 42.972844], - [-96.520838, 42.980314], - [-96.516831, 42.98102], - [-96.518364, 42.985187], - [-96.515342, 42.984141], - [-96.512382, 42.985658], - [-96.511163, 42.99031], - [-96.512834, 42.991912], - [-96.50986, 42.995228], - [-96.496823, 42.998609], - [-96.491981, 43.010197], - [-96.495672, 43.015869], - [-96.499532, 43.016548], - [-96.49904, 43.019073], - [-96.503937, 43.020366], - [-96.512032, 43.026212], - [-96.513971, 43.030324], - [-96.510824, 43.031855], - [-96.509624, 43.035199], - [-96.509252, 43.037794], - [-96.511048, 43.039727], - [-96.513233, 43.04035], - [-96.520086, 43.037762], - [-96.521302, 43.03966], - [-96.512521, 43.04108], - [-96.514812, 43.045174], - [-96.510523, 43.049913], - [-96.501804, 43.048658], - [-96.49093, 43.050812], - [-96.486967, 43.055779], - [-96.47674, 43.062697], - [-96.472753, 43.063711], - [-96.467442, 43.061947], - [-96.460742, 43.064337], - [-96.46055, 43.066324], - [-96.458233, 43.067561], - [-96.459387, 43.069843], - [-96.455555, 43.074319], - [-96.455798, 43.080845], - [-96.45408, 43.083845], - [-96.455467, 43.088283], - [-96.463083, 43.089967], - [-96.46232, 43.092885], - [-96.460242, 43.095284], - [-96.456846, 43.095915], - [-96.457023, 43.097647], - [-96.45238, 43.098193], - [-96.452673, 43.100575], - [-96.450403, 43.10085], - [-96.450536, 43.103775], - [-96.446992, 43.105514], - [-96.446268, 43.109703], - [-96.439832, 43.113423], - [-96.436472, 43.120166], - [-96.441581, 43.124496], - [-96.440191, 43.128317], - [-96.44276, 43.128615], - [-96.443134, 43.133636], - [-96.445599, 43.136513], - [-96.448587, 43.136228], - [-96.44708, 43.138338], - [-96.45043, 43.14239], - [-96.455754, 43.144235], - [-96.46043, 43.14363], - [-96.459248, 43.147488], - [-96.462172, 43.14918], - [-96.465144, 43.147475], - [-96.468499, 43.151887], - [-96.464981, 43.152726], - [-96.463981, 43.155489], - [-96.469556, 43.158353], - [-96.465955, 43.161422], - [-96.467544, 43.163734], - [-96.466987, 43.168354], - [-96.46913, 43.169421], - [-96.46557, 43.171315], - [-96.466871, 43.174219], - [-96.464856, 43.18196], - [-96.467307, 43.18478], - [-96.472525, 43.185601], - [-96.474009, 43.189639], - [-96.472106, 43.196342], - [-96.475007, 43.202569], - [-96.470772, 43.205112], - [-96.47066, 43.207326], - [-96.476469, 43.212969], - [-96.47496, 43.217396], - [-96.476654, 43.221941], - [-96.485653, 43.224222], - [-96.496639, 43.223716], - [-96.501198, 43.220574], - [-96.508551, 43.221117], - [-96.50906, 43.21716], - [-96.513309, 43.218773], - [-96.520692, 43.217983], - [-96.521656, 43.220561], - [-96.526524, 43.224052], - [-96.535915, 43.227634], - [-96.540246, 43.225632], - [-96.555281, 43.226666], - [-96.559326, 43.222934], - [-96.56529, 43.23037], - [-96.568289, 43.231344], - [-96.569023, 43.237189], - [-96.571486, 43.238847], - [-96.565191, 43.244454], - [-96.561146, 43.243779], - [-96.553103, 43.247508], - [-96.549187, 43.246814], - [-96.552689, 43.252012], - [-96.55266, 43.258148], - [-96.554664, 43.259917], - [-96.558556, 43.258944], - [-96.564823, 43.260227], - [-96.575947, 43.267823], - [-96.583569, 43.267662], - [-96.58566, 43.269352], - [-96.586149, 43.27448], - [-96.583765, 43.27666], - [-96.583111, 43.274549], - [-96.581196, 43.274669], - [-96.57756, 43.279212], - [-96.581416, 43.287958], - [-96.579176, 43.292473], - [-96.579473, 43.295689], - [-96.588033, 43.292379], - [-96.588492, 43.296175], - [-96.573706, 43.299026], - [-96.569319, 43.295539], - [-96.566475, 43.296048], - [-96.56438, 43.294639], - [-96.554712, 43.296509], - [-96.554938, 43.294025], - [-96.552056, 43.29285], - [-96.540815, 43.295533], - [-96.538764, 43.298544], - [-96.530381, 43.299823], - [-96.531905, 43.304233], - [-96.526962, 43.305229], - [-96.529154, 43.309078], - [-96.524059, 43.311381], - [-96.528114, 43.313631], - [-96.529011, 43.316395], - [-96.526519, 43.315863], - [-96.525639, 43.318633], - [-96.530962, 43.320669], - [-96.528848, 43.324337], - [-96.533189, 43.328714], - [-96.531458, 43.331883], - [-96.535087, 43.336502], - [-96.531665, 43.339335], - [-96.532759, 43.342744], - [-96.528444, 43.342246], - [-96.524279, 43.347422], - [-96.527716, 43.348295], - [-96.525114, 43.353579], - [-96.527748, 43.356325], - [-96.527261, 43.361559], - [-96.52946, 43.362636], - [-96.530961, 43.368433], - [-96.528911, 43.369317], - [-96.526718, 43.368147], - [-96.524762, 43.369859], - [-96.524982, 43.37258], - [-96.522059, 43.37219], - [-96.521358, 43.375874], - [-96.524517, 43.379776], - [-96.521983, 43.38138], - [-96.524606, 43.382271], - [-96.521662, 43.385905], - [-96.525242, 43.396072], - [-96.529583, 43.397624], - [-96.53064, 43.395544], - [-96.535519, 43.394836], - [-96.54091, 43.397426], - [-96.544429, 43.396189], - [-96.5456, 43.397222], - [-96.544881, 43.400007], - [-96.548408, 43.403662], - [-96.557234, 43.406166], - [-96.560439, 43.412462], - [-96.564637, 43.413094], - [-96.568258, 43.416903], - [-96.573555, 43.418969], - [-96.57, 43.425633], - [-96.570289, 43.428547], - [-96.576103, 43.431874], - [-96.588047, 43.431465], - [-96.594141, 43.433824], - [-96.593661, 43.436967], - [-96.597499, 43.44004], - [-96.596502, 43.442275], - [-96.599446, 43.44253], - [-96.599072, 43.445273], - [-96.603051, 43.450828], - [-96.600017, 43.450577], - [-96.600949, 43.455943], - [-96.593853, 43.458852], - [-96.594653, 43.46223], - [-96.590551, 43.462903], - [-96.589436, 43.466456], - [-96.587008, 43.464502], - [-96.582419, 43.466706], - [-96.585292, 43.470974], - [-96.583741, 43.475106], - [-96.586892, 43.477873], - [-96.581082, 43.481498], - [-96.582422, 43.483418], - [-96.586787, 43.483833], - [-96.587002, 43.487579], - [-96.584551, 43.489099], - [-96.586915, 43.491514], - [-96.590156, 43.491975], - [-96.590211, 43.494123], - [-96.594797, 43.493114], - [-96.599013, 43.495348], - [-96.599191, 43.500456], - [-95.800982, 43.499703], - [-94.615129, 43.501128], - [-93.266987, 43.499361], - [-91.217706, 43.50055], - [-91.217615, 43.491008], - [-91.215282, 43.484798], - [-91.216035, 43.481142], - [-91.220399, 43.471306], - [-91.224586, 43.465525], - [-91.232241, 43.460018], - [-91.233367, 43.455168], - [-91.22875, 43.445537], - [-91.207145, 43.425031], - [-91.200359, 43.412701], - [-91.19767, 43.395334], - [-91.198953, 43.389835], - [-91.206072, 43.374976], - [-91.21499, 43.368006], - [-91.20662, 43.352524], - [-91.201847, 43.349103], - [-91.181115, 43.345926], - [-91.154806, 43.334826], - [-91.132813, 43.32803], - [-91.107237, 43.313645], - [-91.085652, 43.29187], - [-91.07371, 43.274746], - [-91.071724, 43.271392], - [-91.072649, 43.262129], - [-91.069937, 43.260272], - [-91.05975, 43.259074], - [-91.057918, 43.255366], - [-91.059684, 43.248566], - [-91.066398, 43.239293], - [-91.087456, 43.221891], - [-91.12217, 43.197255], - [-91.124428, 43.187886], - [-91.138649, 43.169993], - [-91.143283, 43.156413], - [-91.1462, 43.152405], - [-91.1562, 43.142945], - [-91.175253, 43.134665], - [-91.178251, 43.124982], - [-91.175193, 43.103771], - [-91.177264, 43.072983], - [-91.179457, 43.067427], - [-91.177894, 43.064206], - [-91.174692, 43.038713], - [-91.15749, 42.991475], - [-91.156562, 42.978226], - [-91.148001, 42.966155], - [-91.145935, 42.96077], - [-91.14554, 42.95651], - [-91.14988, 42.941955], - [-91.1438, 42.922877], - [-91.145868, 42.914967], - [-91.145615, 42.908006], - [-91.143491, 42.904698], - [-91.117411, 42.895837], - [-91.100565, 42.883078], - [-91.098238, 42.875798], - [-91.097656, 42.859871], - [-91.091402, 42.84986], - [-91.095114, 42.834966], - [-91.09406, 42.830813], - [-91.090136, 42.829237], - [-91.08277, 42.829977], - [-91.078665, 42.827678], - [-91.078097, 42.806526], - [-91.07083, 42.782225], - [-91.069549, 42.769628], - [-91.060261, 42.761847], - [-91.061432, 42.757974], - [-91.065492, 42.757081], - [-91.065059, 42.751338], - [-91.060172, 42.750481], - [-91.056297, 42.747341], - [-91.053733, 42.738238], - [-91.049972, 42.736905], - [-91.044139, 42.738605], - [-91.035418, 42.73734], - [-91.032013, 42.734484], - [-91.029692, 42.726774], - [-91.026786, 42.724228], - [-91.017239, 42.719566], - [-91.009577, 42.720123], - [-91.000128, 42.716189], - [-90.988776, 42.708724], - [-90.977735, 42.696816], - [-90.965048, 42.693233], - [-90.94921, 42.685569], - [-90.937045, 42.683399], - [-90.921155, 42.685406], - [-90.900261, 42.676254], - [-90.88743, 42.67247], - [-90.84391, 42.663071], - [-90.769495, 42.651443], - [-90.756946, 42.647756], - [-90.731132, 42.643437], - [-90.717821, 42.639914], - [-90.706303, 42.634169], - [-90.702671, 42.630756], - [-90.700095, 42.622461], - [-90.692031, 42.610366], - [-90.686975, 42.591774], - [-90.677935, 42.580031], - [-90.661527, 42.567999], - [-90.659127, 42.5579], - [-90.654127, 42.5499], - [-90.645627, 42.5441], - [-90.643927, 42.540401], - [-90.636727, 42.518702], - [-90.636927, 42.513202], - [-90.648635, 42.498084], - [-90.655924, 42.491708], - [-90.656327, 42.483603], - [-90.654027, 42.478503], - [-90.646727, 42.471904], - [-90.624328, 42.458904], - [-90.606328, 42.451505], - [-90.567968, 42.440389], - [-90.560439, 42.432897], - [-90.558168, 42.420984], - [-90.555018, 42.416138], - [-90.506829, 42.398792], - [-90.500128, 42.395539], - [-90.487154, 42.385141], - [-90.477279, 42.383794], - [-90.470273, 42.378355], - [-90.462619, 42.367253], - [-90.452724, 42.359303], - [-90.443874, 42.355218], - [-90.430546, 42.33686], - [-90.419027, 42.328505], - [-90.415937, 42.322699], - [-90.421047, 42.316109], - [-90.418915, 42.308328], - [-90.424326, 42.293326], - [-90.430735, 42.284211], - [-90.430884, 42.27823], - [-90.419326, 42.254467], - [-90.400653, 42.239293], - [-90.393151, 42.227186], - [-90.375129, 42.214811], - [-90.356964, 42.205445], - [-90.328273, 42.201047], - [-90.317774, 42.193789], - [-90.298442, 42.187576], - [-90.282173, 42.178846], - [-90.26908, 42.1745], - [-90.250129, 42.171469], - [-90.216107, 42.15673], - [-90.20826, 42.15099], - [-90.20536, 42.139079], - [-90.201404, 42.130937], - [-90.190452, 42.125779], - [-90.17041, 42.125021], - [-90.162895, 42.116718], - [-90.161326, 42.109721], - [-90.161779, 42.096836], - [-90.163406, 42.087609], - [-90.168358, 42.075779], - [-90.165555, 42.062638], - [-90.166853, 42.05491], - [-90.164485, 42.042105], - [-90.151579, 42.030633], - [-90.148096, 42.020014], - [-90.142306, 42.011839], - [-90.140061, 42.003252], - [-90.140613, 41.995999], - [-90.146033, 41.988139], - [-90.146225, 41.981329], - [-90.153834, 41.974116], - [-90.164135, 41.956178], - [-90.163847, 41.944934], - [-90.1516, 41.931002], - [-90.153584, 41.906614], - [-90.157019, 41.898019], - [-90.170041, 41.876439], - [-90.173237, 41.86461], - [-90.173009, 41.857393], - [-90.181901, 41.843216], - [-90.183973, 41.83307], - [-90.181889, 41.827755], - [-90.180954, 41.809354], - [-90.181973, 41.80707], - [-90.187969, 41.803163], - [-90.216889, 41.795335], - [-90.263286, 41.772112], - [-90.278633, 41.767358], - [-90.302782, 41.750031], - [-90.310878, 41.742325], - [-90.315549, 41.734426], - [-90.317668, 41.722689], - [-90.317421, 41.718333], - [-90.312893, 41.707528], - [-90.313435, 41.698082], - [-90.317315, 41.69167], - [-90.330222, 41.683954], - [-90.334525, 41.679559], - [-90.336729, 41.664532], - [-90.343452, 41.646959], - [-90.339528, 41.598633], - [-90.343228, 41.587833], - [-90.364128, 41.579633], - [-90.39793, 41.572233], - [-90.412825, 41.565329], - [-90.42223, 41.554233], - [-90.432731, 41.549533], - [-90.445231, 41.536133], - [-90.461432, 41.523533], - [-90.474332, 41.519733], - [-90.500633, 41.518033], - [-90.540935, 41.526133], - [-90.556235, 41.524232], - [-90.567236, 41.517532], - [-90.591037, 41.512832], - [-90.602137, 41.506032], - [-90.605936, 41.494232], - [-90.618537, 41.485032], - [-90.632538, 41.478732], - [-90.655839, 41.462132], - [-90.676439, 41.460832], - [-90.701159, 41.454743], - [-90.745946, 41.44973], - [-90.773417, 41.450882], - [-90.798931, 41.454025], - [-90.846558, 41.455141], - [-90.860626, 41.451393], - [-90.900471, 41.431154], - [-90.929254, 41.421544], - [-90.949791, 41.424163], - [-90.974185, 41.433712], - [-90.982913, 41.434171], - [-91.005846, 41.426135], - [-91.027787, 41.423603], - [-91.037131, 41.420017], - [-91.04589, 41.414085], - [-91.050067, 41.401496], - [-91.05158, 41.385283], - [-91.066232, 41.366367], - [-91.071555, 41.339648], - [-91.074841, 41.305578], - [-91.08688, 41.294371], - [-91.092034, 41.286911], - [-91.101142, 41.267169], - [-91.114186, 41.250029], - [-91.113657, 41.241401], - [-91.109582, 41.236566], - [-91.072984, 41.207151], - [-91.055068, 41.185789], - [-91.041557, 41.166162], - [-91.027231, 41.163383], - [-91.007594, 41.166187], - [-90.994963, 41.16063], - [-90.989662, 41.155707], - [-90.970851, 41.130103], - [-90.965908, 41.119559], - [-90.957265, 41.111067], - [-90.946625, 41.096628], - [-90.94939, 41.072783], - [-90.945549, 41.06173], - [-90.945999, 41.056336], - [-90.942253, 41.034702], - [-90.945324, 41.019279], - [-90.945949, 41.006495], - [-90.949634, 40.995248], - [-90.958142, 40.979767], - [-90.952715, 40.962087], - [-90.952233, 40.954047], - [-90.959947, 40.937736], - [-90.96256, 40.925831], - [-90.965344, 40.921633], - [-90.985462, 40.912141], - [-90.9985, 40.90812], - [-91.009536, 40.900565], - [-91.021562, 40.884021], - [-91.027489, 40.879173], - [-91.039097, 40.873565], - [-91.044653, 40.868356], - [-91.05643, 40.848387], - [-91.067159, 40.841997], - [-91.092993, 40.821079], - [-91.096946, 40.811403], - [-91.097649, 40.805575], - [-91.092256, 40.792909], - [-91.091703, 40.779708], - [-91.098105, 40.763233], - [-91.1082, 40.750935], - [-91.110424, 40.745528], - [-91.115735, 40.725168], - [-91.111095, 40.708282], - [-91.11194, 40.697018], - [-91.115407, 40.691825], - [-91.12082, 40.672777], - [-91.123928, 40.669152], - [-91.138055, 40.660893], - [-91.18698, 40.637297], - [-91.197906, 40.636107], - [-91.218437, 40.638437], - [-91.253074, 40.637962], - [-91.264953, 40.633893], - [-91.306568, 40.626219], - [-91.339719, 40.613488], - [-91.348733, 40.609695], - [-91.359873, 40.601805], - [-91.379752, 40.57445], - [-91.401482, 40.559458], - [-91.405241, 40.554641], - [-91.406851, 40.547557], - [-91.404125, 40.539127], - [-91.388067, 40.533069], - [-91.381857, 40.528247], - [-91.369059, 40.512532], - [-91.363879, 40.498062], - [-91.36391, 40.490122], - [-91.366463, 40.478869], - [-91.378144, 40.456394], - [-91.381468, 40.44604], - [-91.380965, 40.435395], - [-91.372826, 40.414279], - [-91.372921, 40.399108], - [-91.375712, 40.391925], - [-91.384201, 40.38643], - [-91.396996, 40.383127], - [-91.413011, 40.382277], - [-91.419422, 40.378264], - [-91.425662, 40.382491], - [-91.441243, 40.386255], - [-91.445168, 40.382461], - [-91.446627, 40.377918], - [-91.454535, 40.37544], - [-91.465009, 40.376223], - [-91.465891, 40.378365], - [-91.463554, 40.385547], - [-91.476551, 40.382072], - [-91.483153, 40.382492], - [-91.490912, 40.39298], - [-91.487829, 40.403866], - [-91.498093, 40.401926], - [-91.505272, 40.403512], - [-91.510288, 40.407258], - [-91.522333, 40.409648], - [-91.526425, 40.413404], - [-91.526555, 40.419872], - [-91.521388, 40.426488], - [-91.519134, 40.432822], - [-91.529132, 40.434272], - [-91.533471, 40.437715], - [-91.533555, 40.440589], - [-91.524053, 40.448437], - [-91.523072, 40.452254], - [-91.52509, 40.457845], - [-91.5286, 40.459002], - [-91.552691, 40.458769], - [-91.563844, 40.460988], - [-91.574746, 40.465664], - [-91.581528, 40.472876], - [-91.586884, 40.487233], - [-91.590817, 40.492292], - [-91.612821, 40.502377], - [-91.621353, 40.510072], - [-91.622192, 40.517039], - [-91.618793, 40.526286], - [-91.618028, 40.53403], - [-91.620071, 40.540817], - [-91.632783, 40.545029], - [-91.681714, 40.553035], - [-91.6887, 40.55739], - [-91.690804, 40.559893], - [-91.691561, 40.564867], - [-91.685723, 40.576785], - [-91.686357, 40.580875], - [-91.696359, 40.588148], - [-91.716769, 40.59853], - [-91.729115, 40.61364], - [-91.947709, 40.605471], - [-92.485216, 40.595085], - [-92.686693, 40.589809], - [-92.943472, 40.587762], - [-93.260429, 40.580814], - [-93.547578, 40.580407], - [-94.015492, 40.573914], - ] - ], - "type": "Polygon", -} diff --git a/descarteslabs/core/client/services/raster/tests/e2e/test_raster.py b/descarteslabs/core/client/services/raster/tests/e2e/test_raster.py deleted file mode 100644 index 955ea89a..00000000 --- a/descarteslabs/core/client/services/raster/tests/e2e/test_raster.py +++ /dev/null @@ -1,435 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import os -import tempfile -import shutil -import unittest -import json -import hashlib -import sys - -import numpy as np - -from ...raster import Raster - - -class TestRaster(unittest.TestCase): - raster = None - places = None - - @classmethod - def setUpClass(cls): - cls.raster = Raster() - # make sure we aren't testing with rasterio - cls.rasterio = None - if "rasterio" in sys.modules: - cls.rasterio = sys.modules["rasterio"] - del sys.modules["rasterio"] - - @classmethod - def tearDownClass(cls): - # put back rasterio - sys.modules["rasterio"] = cls.rasterio - - def test_raster(self): - filename, metadata = self.raster.raster( - inputs=["landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1"], - bands=["red", "green", "blue", "alpha"], - resolution=960, - ) - assert os.path.exists(filename) - try: - assert metadata is not None - finally: - os.unlink(filename) - - def test_raster_basename(self): - tmpdir = tempfile.mkdtemp() - try: - filename, metadata = self.raster.raster( - inputs=["landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1"], - bands=["red", "green", "blue", "alpha"], - resolution=960, - outfile_basename="{}/my-raster".format(tmpdir), - ) - assert filename == "{}/my-raster.tif".format(tmpdir) - assert os.path.exists(filename) - finally: - shutil.rmtree(tmpdir) - - def test_ndarray(self): - data, metadata = self.raster.ndarray( - inputs=["landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1"], - bands=["red", "green", "blue", "alpha"], - resolution=960, - ) - assert data.shape == (249, 245, 4) - assert data.dtype == np.uint16 - assert len(metadata["bands"]) == 4 - - def test_ndarray_single_band(self): - data, metadata = self.raster.ndarray( - inputs=["landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1"], - bands=["red"], - resolution=960, - ) - assert data.shape == (249, 245, 1) - assert data.dtype == np.uint16 - assert len(metadata["bands"]) == 1 - - def test_stack_dltile(self): - dltile = "128:16:960.0:15:-2:37" - keys = [ - "landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1", - "landsat:LC08:PRE:TOAR:meta_LC80260322016197_v1", - ] - - stack, metadata = self.raster.stack( - keys, dltile=dltile, bands=["red", "green", "blue", "alpha"] - ) - assert stack.shape == (2, 160, 160, 4) - assert stack.dtype == np.uint16 - assert len(metadata) == 2 - assert metadata[0] != metadata[1] - - def test_stack_dltile_gdal_order(self): - dltile = "128:16:960.0:15:-2:37" - keys = [ - "landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1", - "landsat:LC08:PRE:TOAR:meta_LC80260322016197_v1", - ] - - stack, metadata = self.raster.stack( - keys, dltile=dltile, bands=["red", "green", "blue", "alpha"], order="gdal" - ) - assert stack.shape == (2, 4, 160, 160) - assert stack.dtype == np.uint16 - assert len(metadata) == 2 - assert metadata[0] != metadata[1] - - def test_stack_one_image(self): - dltile = "128:16:960.0:15:-2:37" - keys = ["landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1"] - - stack, metadata = self.raster.stack( - keys, dltile=dltile, bands=["red", "green", "blue", "alpha"] - ) - assert stack.shape == (1, 160, 160, 4) - assert stack.dtype == np.uint16 - assert len(metadata) == 1 - - def test_stack_one_band(self): - dltile = "128:16:960.0:15:-2:37" - keys = ["landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1"] - - stack, metadata = self.raster.stack(keys, dltile=dltile, bands=["red"]) - assert stack.shape == (1, 160, 160, 1) - assert stack.dtype == np.uint16 - assert len(metadata) == 1 - - def test_stack_one_band_gdal_order(self): - dltile = "128:16:960.0:15:-2:37" - keys = ["landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1"] - - stack, metadata = self.raster.stack( - keys, dltile=dltile, bands=["red"], order="gdal" - ) - assert stack.shape == (1, 1, 160, 160) - assert stack.dtype == np.uint16 - - def test_stack_res_cutline_utm(self): - geom = { - "coordinates": ( - ( - (-95.66055514862535, 41.24469400862013), - (-94.74931826062456, 41.26199387228942), - (-94.76311013534223, 41.95357639323731), - (-95.69397431605952, 41.93542085595837), - (-95.66055514862535, 41.24469400862013), - ), - ), - "type": "Polygon", - } - keys = [ - "landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1", - "landsat:LC08:PRE:TOAR:meta_LC80260322016197_v1", - ] - resolution = 960 - stack, metadata = self.raster.stack( - keys, - resolution=resolution, - cutline=geom, - srs="EPSG:32615", - bounds=(277280.0, 4569600.0, 354080.0, 4646400.0), - bands=["red", "green", "blue", "alpha"], - ) - assert stack.shape == (2, 80, 80, 4) - assert stack.dtype == np.uint16 - - def test_stack_res_cutline_wgs84(self): - geom = { - "coordinates": ( - ( - (-95.66055514862535, 41.24469400862013), - (-94.74931826062456, 41.26199387228942), - (-94.76311013534223, 41.95357639323731), - (-95.69397431605952, 41.93542085595837), - (-95.66055514862535, 41.24469400862013), - ), - ), - "type": "Polygon", - } - keys = [ - "landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1", - "landsat:LC08:PRE:TOAR:meta_LC80260322016197_v1", - ] - resolution = 960 - stack, metadata = self.raster.stack( - keys, - resolution=resolution, - cutline=geom, - srs="EPSG:32615", - bounds=( - -95.69397431605952, - 41.24469400862013, - -94.74931826062456, - 41.95357639323731, - ), - bounds_srs="EPSG:4326", - bands=["red", "green", "blue", "alpha"], - ) - assert stack.shape == (2, 84, 84, 4) - assert stack.dtype == np.uint16 - - def test_cutline_dict(self): - shape = { - "geometry": { - "type": "Polygon", - "coordinates": [ - [ - [-95.2989209, 42.7999878], - [-93.1167728, 42.3858464], - [-93.7138666, 40.703737], - [-95.8364984, 41.1150618], - [-95.2989209, 42.7999878], - ] - ], - } - } - try: - data, metadata = self.raster.ndarray( - inputs=["landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1"], - bands=["red"], - resolution=960, - cutline=shape, - ) - assert data.shape == (245, 238, 1) - assert data.dtype == np.uint16 - assert len(metadata["bands"]) == 1 - except ImportError: - pass - - def test_cutline_str(self): - shape = { - "geometry": { - "type": "Polygon", - "coordinates": [ - [ - [-95.2989209, 42.7999878], - [-93.1167728, 42.3858464], - [-93.7138666, 40.703737], - [-95.8364984, 41.1150618], - [-95.2989209, 42.7999878], - ] - ], - } - } - try: - data, metadata = self.raster.ndarray( - inputs=["landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1"], - bands=["red"], - resolution=960, - cutline=json.dumps(shape), - ) - assert data.shape == (245, 238, 1) - assert data.dtype == np.uint16 - assert len(metadata["bands"]) == 1 - except ImportError: - pass - - def test_thumbnail(self): - filename, metadata = self.raster.raster( - inputs=["landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1"], - bands=["red", "green", "blue", "alpha"], - dimensions=[256, 256], - scales=[[0, 4000]] * 4, - output_format="PNG", - data_type="Byte", - ) - assert os.path.exists(filename) - try: - assert metadata is not None - finally: - os.unlink(filename) - - def test_geotiff_simple(self): - input_id = "landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1" - - filename, metadata = self.raster.raster( - inputs=[input_id], - bands=["red"], - resolution=960, - output_format="GTiff", - ) - - assert os.path.exists(filename) - - try: - # Note that this checksum will change if we make any alterations to - # .tif files; need to manually inspect them before updating the - # checksum - with open(filename, "rb") as f: - d = f.read() - checksum = hashlib.md5(d).hexdigest() - assert checksum == "93534fe93ca3b0da1ca95ec60edd6bc3" - - # check geotiff metadata - - # filename should just be input + output type (tif in this case) - self.assertEqual(filename, input_id + ".tif") - - # ensure the bands metadata is generated properly - expected_band_metadata = [ - { - "band": 1, - "block": [128, 128], - "colorInterpretation": "Red", - "description": { - "color": "Red", - "data_range": [0.0, 10000.0], - "dtype": "UInt16", - "jpx_layer": 0, - "name": "red", - "name_vendor": "B4", - "res_factor": 1, - "srcband": 1, - "srcfile": 0, - "tags": ["spectral", "red", "15m", "landsat"], - "type": "spectral", - "vendor_order": 4, - }, - "metadata": {}, - "overviews": [ - {"size": [7848, 7980]}, - {"size": [3924, 3990]}, - {"size": [1962, 1995]}, - {"size": [981, 998]}, - {"size": [490, 499]}, - {"size": [245, 250]}, - {"size": [122, 125]}, - ], - "type": "UInt16", - } - ] - - self.assertEqual(metadata["bands"], expected_band_metadata) - - # check other misc metadata - self.assertEqual(input_id, metadata["id"]) - self.assertEqual(metadata["driverLongName"], "Virtual Raster") - self.assertEqual(metadata["driverShortName"], "VRT") - self.assertEqual( - metadata["geoTransform"], [258292.5, 960, 0, 4743307.5, 0, -960] - ) - self.assertEqual( - metadata["metadata"], - { - "GEOGCS": "WGS 84", - "GEOGCS|DATUM": "WGS_1984", - "GEOGCS|PRIMEM": "Greenwich", - "GEOGCS|SPHEROID": "WGS 84", - "PROJCS": "WGS 84 / UTM zone 15N", - }, - ) - self.assertEqual(metadata["size"], [15696, 15960]) - self.assertEqual( - metadata["wgs84Extent"], - { - "coordinates": [ - [ - [-95.9559596, 42.8041728], - [-95.858773, 40.650654], - [-93.0741722, 40.6860344], - [-93.0766981, 42.8423173], - [-95.9559596, 42.8041728], - ] - ], - "type": "Polygon", - }, - ) - finally: - os.unlink(filename) - - def test_geotiff_multiband(self): - input_id = "landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1" - - filename, metadata = self.raster.raster( - inputs=[input_id], - bands=["red", "green", "blue", "alpha"], - resolution=960, - output_format="GTiff", - ) - - # filename should just be input + output type (tif in this case) - self.assertEqual(filename, input_id + ".tif") - - self.assertEqual(len(metadata["bands"]), 4) - - try: - # Note that this checksum will change if we make any alterations to - # .tif files; need to manually inspect them before updating the - # checksum - with open(filename, "rb") as f: - d = f.read() - checksum = hashlib.md5(d).hexdigest() - assert checksum == "7e480ee328b4afcead376dbc1ee68706" - finally: - os.unlink(filename) - - # see #9000 - # https://github.com/descarteslabs/monorepo/pull/9000 - def test_adjacent_mosiac(self): - scene_ids = [ - "landsat:LC08:01:RT:TOAR:meta_LC08_L1TP_191031_20180130_20180130_01_RT_v1", - "landsat:LC08:01:RT:TOAR:meta_LC08_L1TP_190031_20180123_20180123_01_RT_v1", - ] - - mosiac, metadata = self.raster.ndarray( - scene_ids, - resolution=150, - bands=["alpha"], - ) - - # note: ndarray returns a noncontiguous MaskedArray which we can't - # compute a checksum on. need to clone it before calculating the - # checksum - self.assertEqual( - hashlib.md5(np.ascontiguousarray(mosiac)).hexdigest(), - "691e464eab5bb80eb746a4ccfe05332f", - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/descarteslabs/core/client/services/raster/tests/test_geotiff_utils.py b/descarteslabs/core/client/services/raster/tests/test_geotiff_utils.py deleted file mode 100644 index aedc3218..00000000 --- a/descarteslabs/core/client/services/raster/tests/test_geotiff_utils.py +++ /dev/null @@ -1,204 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 tempfile -import numpy as np -import pytest -import sys - -from ..geotiff_utils import make_rasterio_geotiff - - -def source_band(bidx, dtype="Float32"): - return { - "band": bidx, - "block": [128, 128], - "colorInterpretation": "Undefined", - "description": { - "data_range": [0.0, 1_000_000.0], - "dtype": dtype, - "jpx_layer": 0, - "name": f"band{bidx}", - "nodata": -99.0, - "srcband": 7, - "srcfile": 0, - "tags": [], - "type": "other", - }, - "metadata": {}, - "noDataValue": -99.0, - "overviews": [ - {"size": [180, 161]}, - {"size": [45, 41]}, - {"size": [12, 11]}, - ], - "type": dtype, - } - - -def simulate_npz_data(chunk_shapes, rowcol, bands, dtype): - rows, cols = rowcol - - metadata = { - "bands": list(source_band(x + 1, dtype) for x in range(bands)), - "coordinateSystem": { - "dataAxisToSRSAxisMapping": [2, 1], - "epsg": 4326, - "proj4": "+proj=longlat +datum=WGS84 +no_defs", - "wkt": 'GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563,AUTHORITY["EPSG","7030"]],AUTHORITY["EPSG","6326"]],PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],UNIT["degree",0.0174532925199433,AUTHORITY["EPSG","9122"]],AXIS["Latitude",NORTH],AXIS["Longitude",EAST],AUTHORITY["EPSG","4326"]]', # noqa - }, - "cornerCoordinates": { - "center": [-95.02000000000001, 35.620000000000005], - "lowerLeft": [-99.98, 30.11], - "lowerRight": [-90.06, 30.11], - "upperLeft": [-99.98, 41.13], - "upperRight": [-90.06, 41.13], - }, - "driverLongName": "Virtual Raster", - "driverShortName": "VRT", - "geoTransform": [-99.98, 0.0125, 0.0, 41.13, 0.0, -0.0125], - "metadata": { - "GEOGCS": "WGS 84", - "GEOGCS|DATUM": "WGS_1984", - "GEOGCS|PRIMEM": "Greenwich", - "GEOGCS|SPHEROID": "WGS 84", - }, - "size": [720, 641], # the source image size - "wgs84Extent": { - "coordinates": [ - [ - [-180.0, 80.0], - [-180.0, -80.25], - [0.0, -80.25], - [0.0, 80.0], - [-180.0, 80.0], - ] - ], - "type": "Polygon", - }, - "id": "e", - } - - blosc_meta = { - "chunks": len(chunk_shapes), - "dtype": dtype, - "shape": [bands, rows, cols], - } - - def gen_chunks(): - for shape in chunk_shapes: - yield (np.random.rand(*shape) * 100.0).astype(dtype) - - return gen_chunks(), metadata, blosc_meta - - -# Block shapes are (rows, cols, bands) and -# are always returned in Left-to-right, Bottom-to-top order -CHUNK_SHAPES_MULTIBLOCK = [(512, 512), (512, 282), (370, 512), (370, 282)] -CHUNK_SHAPES_SINGLEBLOCK = [(400, 400)] - -GOOD_COMBINATIONS = ( - (CHUNK_SHAPES_SINGLEBLOCK, (400, 400), 1, "float32"), - (CHUNK_SHAPES_MULTIBLOCK, (882, 794), 3, "float32"), - (CHUNK_SHAPES_SINGLEBLOCK, (400, 400), 3, "float32"), - (CHUNK_SHAPES_MULTIBLOCK, (882, 794), 4, "float32"), - (CHUNK_SHAPES_SINGLEBLOCK, (400, 400), 4, "float32"), - (CHUNK_SHAPES_SINGLEBLOCK, (400, 400), 1, "uint8"), - (CHUNK_SHAPES_MULTIBLOCK, (882, 794), 3, "uint8"), - (CHUNK_SHAPES_SINGLEBLOCK, (400, 400), 3, "uint8"), - (CHUNK_SHAPES_MULTIBLOCK, (882, 794), 4, "uint8"), - (CHUNK_SHAPES_SINGLEBLOCK, (400, 400), 4, "uint8"), -) - -# These don't work with tifffile for some reason -BAD_TIFFFILE_COMBINATIONS = ( - (CHUNK_SHAPES_MULTIBLOCK, (882, 794), 1, "float32"), - (CHUNK_SHAPES_MULTIBLOCK, (882, 794), 1, "uint8"), -) - -if "rasterio" in sys.modules: - import rasterio - - @pytest.mark.parametrize("chunk_shapes,rowcol,bands,dtype", GOOD_COMBINATIONS) - def test_geotiff_rasterio_lzw(chunk_shapes, rowcol, bands, dtype): - nodata = 0 - compress = None # default is LZW - chunk_shapes_3d = [(cs[0], cs[1], bands) for cs in chunk_shapes] - chunk_iter, metadata, blosc_meta = simulate_npz_data( - chunk_shapes_3d, rowcol, bands, dtype - ) - - with tempfile.NamedTemporaryFile(mode="wb", suffix=".tif") as tmp: - make_rasterio_geotiff( - tmp.name, chunk_iter, metadata, blosc_meta, compress, nodata - ) - - with rasterio.open(tmp.name) as dst: - assert list(dst.read().shape) == blosc_meta["shape"] - assert dst.profile["compress"].lower() == "lzw" - - @pytest.mark.parametrize("chunk_shapes,rowcol,bands,dtype", GOOD_COMBINATIONS) - def test_geotiff_rasterio_deflate(chunk_shapes, rowcol, bands, dtype): - nodata = 0 - compress = "DEFLATE" - chunk_shapes_3d = [(cs[0], cs[1], bands) for cs in chunk_shapes] - chunk_iter, metadata, blosc_meta = simulate_npz_data( - chunk_shapes_3d, rowcol, bands, dtype - ) - - with tempfile.NamedTemporaryFile(mode="wb", suffix=".tif") as tmp: - make_rasterio_geotiff( - tmp.name, chunk_iter, metadata, blosc_meta, compress, nodata - ) - - with rasterio.open(tmp.name) as dst: - assert list(dst.read().shape) == blosc_meta["shape"] - assert dst.profile["compress"].lower() == "deflate" - - @pytest.mark.parametrize("chunk_shapes,rowcol,bands,dtype", GOOD_COMBINATIONS) - def test_geotiff_rasterio_jpeg(chunk_shapes, rowcol, bands, dtype): - nodata = 0 - compress = "JPEG" - chunk_shapes_3d = [(cs[0], cs[1], bands) for cs in chunk_shapes] - chunk_iter, metadata, blosc_meta = simulate_npz_data( - chunk_shapes_3d, rowcol, bands, dtype - ) - - with tempfile.NamedTemporaryFile(mode="wb", suffix=".tif") as tmp: - make_rasterio_geotiff( - tmp.name, chunk_iter, metadata, blosc_meta, compress, nodata - ) - - with rasterio.open(tmp.name) as dst: - assert list(dst.read().shape) == blosc_meta["shape"] - assert dst.profile["compress"].lower() == "jpeg" - - -# @pytest.mark.parametrize("chunk_shapes,rowcol,bands,dtype", GOOD_COMBINATIONS) -# def test_geotiff_tifffile(chunk_shapes, rowcol, bands, dtype): -# nodata = 0 -# compress = None -# chunk_shapes_3d = [(cs[0], cs[1], bands) for cs in chunk_shapes] -# chunk_iter, metadata, blosc_meta = simulate_npz_data( -# chunk_shapes_3d, rowcol, bands, dtype -# ) - -# with tempfile.NamedTemporaryFile(mode="wb", suffix=".tif") as tmp: -# make_tifffile_geotiff( -# tmp.name, chunk_iter, metadata, blosc_meta, compress, nodata -# ) - -# with rasterio.open(tmp.name) as dst: -# assert list(dst.read().shape) == blosc_meta["shape"] -# assert "compress" not in dst.profile diff --git a/descarteslabs/core/client/services/raster/tests/test_raster.py b/descarteslabs/core/client/services/raster/tests/test_raster.py deleted file mode 100644 index c689ee42..00000000 --- a/descarteslabs/core/client/services/raster/tests/test_raster.py +++ /dev/null @@ -1,212 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 base64 -import json -import re -import time -import unittest -from unittest.mock import patch - -import blosc -import numpy as np -import pytest -import responses -from descarteslabs.auth import Auth -from descarteslabs.exceptions import ServerError - -from ..raster import Raster, as_json_string -from .. import raster as raster_module - -a_geometry = { - "coordinates": ( - ( - (-95.66055514862535, 41.24469400862013), - (-94.74931826062456, 41.26199387228942), - (-94.76311013534223, 41.95357639323731), - (-95.69397431605952, 41.93542085595837), - (-95.66055514862535, 41.24469400862013), - ), - ), - "type": "Polygon", -} - - -class RasterTest(unittest.TestCase): - def setUp(self): - payload = ( - base64.b64encode( - json.dumps( - { - "aud": "client-id", - "exp": time.time() + 3600, - } - ).encode() - ) - .decode() - .strip("=") - ) - public_token = f"header.{payload}.signature" - - self.url = "http://example.com/raster" - self.raster = Raster( - url=self.url, auth=Auth(jwt_token=public_token, token_info_path=None) - ) - self.match_url = re.compile(self.url) - - def mock_response(self, method, json, status=200, **kwargs): - responses.add(method, self.match_url, json=json, status=status, **kwargs) - - def create_blosc_response(self, metadata, *arrays): - narray = len(arrays) - shape = arrays[0].shape - array_meta = { - "shape": (shape[0] * narray, shape[1], shape[2]), - "dtype": arrays[0].dtype.name, - "chunks": narray, - } - - parts = [ - json.dumps(metadata) + "\n", - json.dumps(array_meta) + "\n", - ] - - for i, array in enumerate(arrays): - # special stopping key to emulate failure - if isinstance(array, str): - parts.append(array) - break - - chunk_meta = {"offset": [i * shape[0], 0, 0], "shape": list(array.shape)} - - array_ptr = array.__array_interface__["data"][0] - blosc_data = blosc.compress_ptr( - array_ptr, array.size, array.dtype.itemsize - ).decode("utf-8") - - mask = np.zeros(array.shape[1:]).astype(bool) - mask_ptr = mask.__array_interface__["data"][0] - mask_data = blosc.compress_ptr( - mask_ptr, mask.size, mask.dtype.itemsize - ).decode("utf-8") - - parts.append(json.dumps(chunk_meta) + "\n") - parts.append(blosc_data + mask_data) - - return "".join(parts) - - @responses.activate - def test_ndarray_blosc(self): - expected_metadata = {"foo": "bar"} - expected_array = np.zeros((1, 2, 2)) - content = self.create_blosc_response(expected_metadata, expected_array) - self.mock_response(responses.POST, json=None, body=content, stream=True) - array, meta = self.raster.ndarray(["fakeid"], bands=["red"]) - assert expected_metadata == meta - np.testing.assert_array_equal(expected_array.transpose((1, 2, 0)), array) - - @responses.activate - def test_ndarray_multi_blosc(self): - expected_metadata = {"foo": "bar"} - expected_array = np.zeros((2, 2, 2)) - content = self.create_blosc_response( - expected_metadata, expected_array[0:1, :, :], expected_array[1:2, :, :] - ) - self.mock_response(responses.POST, json=None, body=content, stream=True) - array, meta = self.raster.ndarray(["fakeid"], bands=["red"]) - assert expected_metadata == meta - np.testing.assert_array_equal(expected_array.transpose((1, 2, 0)), array) - - @responses.activate - @patch.object(raster_module, "DEFAULT_MAX_RETRIES", 1) - def test_ndarray_multi_blosc_failure(self): - expected_metadata = {"foo": "bar"} - expected_array = np.zeros((2, 2, 2)) - - content = self.create_blosc_response( - expected_metadata, expected_array[0:1, :, :], "" - ) - self.mock_response(responses.POST, json=None, body=content, stream=True) - with self.assertRaises(ServerError): - self.raster.ndarray(["fakeid"], bands=["red"]) - - content = self.create_blosc_response( - expected_metadata, expected_array[0:1, :, :], "{" - ) - self.mock_response(responses.POST, json=None, body=content, stream=True) - with self.assertRaises(ServerError): - self.raster.ndarray(["fakeid"], bands=["red"]) - - @responses.activate - def do_stack(self, **stack_args): - expected_metadata = {"foo": "bar"} - expected_array = np.zeros((1, 2, 2)) - content = self.create_blosc_response(expected_metadata, expected_array) - self.mock_response(responses.POST, json=None, body=content, stream=True) - stack, meta = self.raster.stack( - [["fakeid"], ["fakeid2"]], order="gdal", **stack_args - ) - - np.testing.assert_array_equal(expected_array, stack[0, :]) - np.testing.assert_array_equal(expected_array, stack[1, :]) - assert [expected_metadata] * 2 == meta - - def test_stack_threaded_blosc(self): - self.do_stack( - resolution=60, - srs="EPSG:32615", - bounds=(277280.0, 4569600.0, 354080.0, 4646400.0), - bands=["red"], - ) - - def test_stack_dltile_blosc(self): - self.do_stack(dltile="128:16:960.0:15:-2:37", bands=["red"]) - - def test_stack_underspecified(self): - keys = ["landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1"] - place = "north-america_united-states_iowa" - bounds = ( - -95.69397431605952, - 41.24469400862013, - -94.74931826062456, - 41.95357639323731, - ) - resolution = 960 - dimensions = (128, 128) - - with pytest.raises(ValueError): - self.raster.stack(keys, bands=["red"]) - with pytest.raises(ValueError): - self.raster.stack(keys, bands=["red"], resolution=resolution) - with pytest.raises(ValueError): - self.raster.stack(keys, bands=["red"], dimensions=dimensions) - with pytest.raises(ValueError): - self.raster.stack(keys, bands=["red"], bounds=bounds) - with pytest.raises(ValueError): - self.raster.stack(keys, bands=["red"], resolution=resolution, place=place) - - -class UtilitiesTest(unittest.TestCase): - def test_as_json_string(self): - d = {"a": "b"} - truth = json.dumps(d) - - assert as_json_string(d) == truth - s = '{"a": "b"}' - assert as_json_string(s) == truth - assert as_json_string(None) is None - - -if __name__ == "__main__": - unittest.main() diff --git a/descarteslabs/core/client/services/raster/tests/test_raster_rasterio.py b/descarteslabs/core/client/services/raster/tests/test_raster_rasterio.py deleted file mode 100644 index e12045d4..00000000 --- a/descarteslabs/core/client/services/raster/tests/test_raster_rasterio.py +++ /dev/null @@ -1,163 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 base64 -import json -import re -import time -import unittest - -import blosc -import numpy as np -import pytest -import responses -from descarteslabs.auth import Auth - -from ..raster import Raster, as_json_string - -a_geometry = { - "coordinates": ( - ( - (-95.66055514862535, 41.24469400862013), - (-94.74931826062456, 41.26199387228942), - (-94.76311013534223, 41.95357639323731), - (-95.69397431605952, 41.93542085595837), - (-95.66055514862535, 41.24469400862013), - ), - ), - "type": "Polygon", -} - - -class RasterTest(unittest.TestCase): - def setUp(self): - payload = ( - base64.b64encode( - json.dumps( - { - "aud": "client-id", - "exp": time.time() + 3600, - } - ).encode() - ) - .decode() - .strip("=") - ) - public_token = f"header.{payload}.signature" - - self.url = "http://example.com/raster" - self.raster = Raster( - url=self.url, auth=Auth(jwt_token=public_token, token_info_path=None) - ) - self.match_url = re.compile(self.url) - - def mock_response(self, method, json, status=200, **kwargs): - responses.add(method, self.match_url, json=json, status=status, **kwargs) - - def create_blosc_response(self, metadata, array): - array_meta = {"shape": array.shape, "dtype": array.dtype.name, "chunks": 1} - chunk_meta = {"offset": [0, 0, 0], "shape": list(array.shape)} - - array_ptr = array.__array_interface__["data"][0] - blosc_data = blosc.compress_ptr( - array_ptr, array.size, array.dtype.itemsize - ).decode("utf-8") - - mask = np.zeros(array.shape[1:]).astype(bool) - mask_ptr = mask.__array_interface__["data"][0] - mask_data = blosc.compress_ptr(mask_ptr, mask.size, mask.dtype.itemsize).decode( - "utf-8" - ) - - return "\n".join( - [ - json.dumps(metadata), - json.dumps(array_meta), - json.dumps(chunk_meta), - blosc_data + mask_data, - ] - ) - - @responses.activate - def test_ndarray_blosc(self): - expected_metadata = {"foo": "bar"} - expected_array = np.zeros((1, 2, 2)) - content = self.create_blosc_response(expected_metadata, expected_array) - self.mock_response(responses.POST, json=None, body=content, stream=True) - array, meta = self.raster.ndarray(["fakeid"], bands=["red"]) - assert expected_metadata == meta - np.testing.assert_array_equal(expected_array.transpose((1, 2, 0)), array) - - @responses.activate - def do_stack(self, **stack_args): - expected_metadata = {"foo": "bar"} - expected_array = np.zeros((1, 2, 2)) - content = self.create_blosc_response(expected_metadata, expected_array) - self.mock_response(responses.POST, json=None, body=content, stream=True) - stack, meta = self.raster.stack( - [["fakeid"], ["fakeid2"]], order="gdal", **stack_args - ) - - np.testing.assert_array_equal(expected_array, stack[0, :]) - np.testing.assert_array_equal(expected_array, stack[1, :]) - assert [expected_metadata] * 2 == meta - - def test_stack_threaded_blosc(self): - self.do_stack( - resolution=60, - srs="EPSG:32615", - bounds=(277280.0, 4569600.0, 354080.0, 4646400.0), - bands=["red"], - ) - - def test_stack_dltile_blosc(self): - self.do_stack(dltile="128:16:960.0:15:-2:37", bands=["red"]) - - def test_stack_underspecified(self): - keys = ["landsat:LC08:PRE:TOAR:meta_LC80270312016188_v1"] - place = "north-america_united-states_iowa" - bounds = ( - -95.69397431605952, - 41.24469400862013, - -94.74931826062456, - 41.95357639323731, - ) - resolution = 960 - dimensions = (128, 128) - - with pytest.raises(ValueError): - self.raster.stack(keys, bands=["red"]) - with pytest.raises(ValueError): - self.raster.stack(keys, bands=["red"], resolution=resolution) - with pytest.raises(ValueError): - self.raster.stack(keys, bands=["red"], dimensions=dimensions) - with pytest.raises(ValueError): - self.raster.stack(keys, bands=["red"], bounds=bounds) - with pytest.raises(ValueError): - self.raster.stack(keys, bands=["red"], resolution=resolution, place=place) - - -class UtilitiesTest(unittest.TestCase): - def test_as_json_string(self): - d = {"a": "b"} - truth = json.dumps(d) - - assert as_json_string(d) == truth - s = '{"a": "b"}' - assert as_json_string(s) == truth - assert as_json_string(None) is None - - -if __name__ == "__main__": - unittest.main() diff --git a/descarteslabs/core/client/services/service/__init__.py b/descarteslabs/core/client/services/service/__init__.py deleted file mode 100644 index ff5b9de2..00000000 --- a/descarteslabs/core/client/services/service/__init__.py +++ /dev/null @@ -1,39 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 descarteslabs.exceptions import NotFoundError - -from .api_service import ApiService, ApiSession -from .service import ( - HttpHeaderKeys, - HttpRequestMethod, - JsonApiService, - JsonApiSession, - Service, - Session, - ThirdPartyService, -) - -__all__ = [ - "ApiService", - "ApiSession", - "HttpHeaderKeys", - "HttpRequestMethod", - "JsonApiService", - "JsonApiSession", - "NotFoundError", - "Service", - "Session", - "ThirdPartyService", -] diff --git a/descarteslabs/core/client/services/service/api_service.py b/descarteslabs/core/client/services/service/api_service.py deleted file mode 100644 index f1754b6e..00000000 --- a/descarteslabs/core/client/services/service/api_service.py +++ /dev/null @@ -1,233 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 -from typing import Optional - -from descarteslabs.exceptions import ClientError, ServerError - -from .service import HttpHeaderKeys, HttpHeaderValues, Service, Session - - -class ApiSession(Session): - def __init__(self, *args, **kwargs): - self.rewrite_errors = True - super().__init__(*args, **kwargs) - - def request(self, *args, **kwargs): - """Sends an HTTP request and emits Descartes Labs specific errors. - - Parameters - ---------- - method: str - The HTTP method to use. - url: str - The URL to send the request to. - kwargs: dict - Additional arguments. See `requests.request - `_. - - Returns - ------- - Response - A :py:class:`request.Response` object. - - Raises - ------ - BadRequestError - Either a 400 or 422 HTTP response status code was encountered. - ~descarteslabs.exceptions.NotFoundError - A 404 HTTP response status code was encountered. - ProxyAuthenticationRequiredError - A 407 HTTP response status code was encountered indicating proxy - authentication was not handled or was invalid. - ConflictError - A 409 HTTP response status code was encountered. - ValidationError - A 422 HTTP response status code was encountered. - ValidationError extends BadRequestError for backward compatibility. - RateLimitError - A 429 HTTP response status code was encountered. - GatewayTimeoutError - A 504 HTTP response status code was encountered. - ~descarteslabs.exceptions.ServerError - Any HTTP response status code larger than 400 that was not covered above - is returned as a ServerError. The original HTTP response status code - can be found in the attribute :py:attr:`original_status`. - - Note - ---- - If :py:attr:`rewrite_errors` was set to ``True`` in the corresponding - :py:class:`ApiService`, the API errors will be rewritten in a more - human readable format. - """ - - try: - resp = super(ApiSession, self).request(*args, **kwargs) - except (ClientError, ServerError) as error: - if self.rewrite_errors: - self._rewrite_error(error) - raise - - return resp - - def _rewrite_error(self, exception: Exception): - """All errors contain just a `detail` key at the moment. - - Validation errors are a special case with the format: - - .. code:: - - { - "detail": "Invalid request", - "errors": { - "field1": ["error 1", "error 2"], - "field2": ["some error"] - } - } - """ - indent = " " - message = "" - - for error in exception.args: - try: - json_error = json.loads(error) - - if "detail" in json_error: - message += "\n" + json_error["detail"] - - if "errors" in json_error: - message += ":\n" - - for field, errors in json_error["errors"].items(): - message += f"{indent}{field}\n" - - for field_error in errors: - message += f"{indent * 2}{field_error}\n" - else: - message += "\n" - except Exception: - return - - message = message.rstrip() - - if message: - exception.args = (message,) - - -class ApiService(Service): - """A FastAPI oriented default Descartes Labs HTTP Service. - - For details see the :py:class:`Service`. - - This service uses the :py:class:`ApiSession` which provides some optional - functionality. - - This functionality currently rewrites JSON errors to a human readable format. - - Parameters - ---------- - url: str - The URL prefix to use for communication with the Descartes Labs servers. - session_class: class - The session class to use when instantiating the session. This must be a derived - class from :py:class:`ApiSession`. If not provided, the default session - class is used. You can register a default session class with - :py:meth:`ApiService.set_default_session_class`. - rewrite_errors: bool - When set to ``True``, errors are rewritten to be more readable. Each API - error becomes it's own line. - auth: Auth, optional - A Descartes Labs :py:class:`~descarteslabs.auth.Auth` instance. If not - provided, a default one will be instantiated. - retries: int or urllib3.util.retry.Retry If a number, it's the number of retries - that will be attempted. If a :py:class:`urllib3.util.retry.Retry` instance, - it will determine the retry behavior. If not provided, the default retry - policy as described above will be used. - """ - - _session_class = ApiSession - - def __init__(self, url, session_class=None, rewrite_errors=True, **kwargs): - if not (session_class is None or issubclass(session_class, ApiSession)): - raise TypeError( - "The session class must be a subclass of {}.".format(ApiSession) - ) - - self.rewrite_errors = rewrite_errors - super(ApiService, self).__init__(url, session_class=session_class, **kwargs) - - @classmethod - def set_default_session_class(cls, session_class): - """Set the default session class for :py:class:`ApiService`. - - The default session is used for any :py:class:`ApiService` that is - instantiated without specifying the session class. - - Parameters - ---------- - session_class: class - The session class to use when instantiating the session. This must be the - class :py:class:`ApiSession` itself or a derived class from - :py:class:`ApiSession`. - """ - - if not issubclass(session_class, ApiSession): - raise TypeError( - "The session class must be a subclass of {}.".format(ApiSession) - ) - - cls._session_class = session_class - - @classmethod - def get_default_session_class(cls): - """Get the default session class for :py:class:`ApiService`. - - Returns - ------- - ApiService - The default session class, which is :py:class:`ApiService` itself or - a derived class from :py:class:`ApiService`. - """ - - return cls._session_class - - def _build_session(self): - session: ApiSession = super(ApiService, self)._build_session() - session.rewrite_errors = self.rewrite_errors - session.headers.update( - { - HttpHeaderKeys.ContentType: HttpHeaderValues.ApplicationJson, - HttpHeaderKeys.Accept: HttpHeaderValues.ApplicationJson, - } - ) - return session - - def iter_pages(self, url: str, params: Optional[dict] = None): - if params is None: - params = {} - - while True: - response = self.session.get(url, params=params) - response_json = response.json() - data = response_json["data"] - next_page = response_json["meta"]["page_cursor"] - - for item in data: - yield item - - if not next_page: - break - - params = {"page_cursor": next_page} diff --git a/descarteslabs/core/client/services/service/service.py b/descarteslabs/core/client/services/service/service.py deleted file mode 100644 index f61b0893..00000000 --- a/descarteslabs/core/client/services/service/service.py +++ /dev/null @@ -1,852 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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. - -try: - import builtins -except ImportError: - # Until we get rid of Python2 tests... - builtins = __builtins__ - -import itertools -import json -import os -import platform -import random -import sys -import uuid -from http import HTTPStatus -from warnings import warn - -from descarteslabs.auth import Auth -from descarteslabs.exceptions import ClientError, ServerError - -from ....common.http import ( - HttpHeaderKeys as CommonHttpHeaderKeys, - Retry, - Session, -) -from ....common.http.authorization import add_bearer -from ....common.threading.local import ThreadLocalWrapper -from ...version import __version__ - - -class HttpMountProtocol: - HTTP = "http://" - HTTPS = "https://" - - -class HttpRequestMethod: - DELETE = "DELETE" - GET = "GET" - HEAD = "HEAD" - OPTIONS = "OPTIONS" - PATCH = "PATCH" - POST = "POST" - PUT = "PUT" - TRACE = "TRACE" - - -class HttpHeaderKeys(CommonHttpHeaderKeys): - Accept = "Accept" - Authorization = "Authorization" - ClientSession = "X-Client-Session" - Conda = "X-Conda" - ContentType = "Content-Type" - Notebook = "X-Notebook" - OnBehalfOf = "X-On-Behalf-Of" - Platform = "X-Platform" - Python = "X-Python" - RequestGroup = "X-Request-Group" - UserAgent = "User-Agent" - - -class HttpHeaderValues: - ApplicationJson = "application/json" - ApplicationVndApiJson = "application/vnd.api+json" - ApplicationOctetStream = "application/octet-stream" - DlPython = "dl-python" - - -# For backward compatibility -WrappedSession = Session - - -class Service: - """The default Descartes Labs HTTP Service used to communicate with its servers. - - This service has a default timeout and retry policy that retries HTTP requests - depending on the timeout and HTTP status code that was returned. This is based - on the `requests timeouts - `_ - and the `urllib3 retry object - `_. - - The default timeouts are set to 9.5 seconds for establishing a connection (slightly - larger than a multiple of 3, which is the TCP default packet retransmission window), - and 30 seconds for reading a response. - - The default retry logic retries up to 3 times total, a maximum of 2 for establishing - a connection, 2 for reading a response, and 2 for unexpected HTTP status codes. - The backoff_factor is a random number between 1 and 3, but will never be more - than 2 minutes. The unexpected HTTP status codes that will be retried are ``500``, - ``502``, ``503``, and ``504`` for any of the HTTP requests. Note that 429s are - retried automatically according to their retry-after headers without us - specifying anything here (see the source for urllib3.util.Retry as the API - documentation doesn't make this clear). - - Parameters - ---------- - url: str - The URL prefix to use for communication with the Descartes Labs server. - token: str, optional - Deprecated. - auth: Auth, optional - A Descartes Labs :py:class:`~descarteslabs.auth.Auth` instance. If not - provided, a default one will be instantiated. - retries: int or urllib3.util.retry.Retry - If a number, it's the number of retries that will be attempted. If a - :py:class:`urllib3.util.retry.Retry` instance, it will determine the retry - behavior. If not provided, the default retry policy as described above will - be used. - session_class: class - The session class to use when instantiating the session. This must be a derived - class from :py:class:`Session`. If not provided, the default session class - is used. You can register a default session class with - :py:meth:`Service.set_default_session_class`. - - Raises - ------ - TypeError - If you try to use a session class that is not derived from :py:class:`Session`. - """ - - # https://requests.readthedocs.io/en/master/user/advanced/#timeouts - CONNECT_TIMEOUT = 9.5 - READ_TIMEOUT = 30 - - TIMEOUT = (CONNECT_TIMEOUT, READ_TIMEOUT) - - RETRY_CONFIG = Retry( - total=3, - connect=2, - read=2, - status=2, - backoff_factor=random.uniform(1, 3), - allowed_methods=frozenset( - [ - HttpRequestMethod.HEAD, - HttpRequestMethod.TRACE, - HttpRequestMethod.GET, - HttpRequestMethod.POST, - HttpRequestMethod.PUT, - HttpRequestMethod.PATCH, - HttpRequestMethod.OPTIONS, - HttpRequestMethod.DELETE, - ] - ), - status_forcelist=[ - HTTPStatus.INTERNAL_SERVER_ERROR, - HTTPStatus.BAD_GATEWAY, - HTTPStatus.SERVICE_UNAVAILABLE, - HTTPStatus.GATEWAY_TIMEOUT, - ], - remove_headers_on_redirect=[], - ) - - _session_class = Session - - # List of attributes that will be included in state for pickling. - # Subclasses can extend this attribute list. - __attrs__ = ["auth", "base_url", "_session_class", "RETRY_CONFIG"] - - @classmethod - def set_default_session_class(cls, session_class): - """Set the default session class for :py:class:`Service`. - - The default session is used for any :py:class:`Service` that is instantiated - without specifying the session class. - - Parameters - ---------- - session_class: class - The session class to use when instantiating the session. This must be the - class :py:class:`Session` itself or a derived class from - :py:class:`Session`. - """ - - if not issubclass(session_class, Session): - raise TypeError( - "The session class must be a subclass of {}.".format(Session) - ) - - cls._session_class = session_class - - @classmethod - def get_default_session_class(cls): - """Get the default session class for :py:class:`Service`. - - Returns - ------- - Session - The default session class, which is :py:class:`Session` itself or a derived - class from :py:class:`Session`. - """ - - return cls._session_class - - def __init__(self, url, token=None, auth=None, retries=None, session_class=None): - if auth is None: - auth = Auth.get_default_auth() - - if token is not None: - warn( - "setting token at service level will be removed in future", - FutureWarning, - ) - auth._token = token - - self.auth = auth - self.base_url = url - - if retries is None: - retries = Service.RETRY_CONFIG - self._retry_config = retries - - if session_class is not None: - # Overwrite the default session class - if not issubclass(session_class, Session): - raise TypeError( - "The session class must be a subclass of {}.".format(Session) - ) - - self._session_class = session_class - - self._init_session() - - def _init_session(self): - # Sessions can't be shared across threads or processes because the underlying - # SSL connection pool can't be shared. We create them thread-local to avoid - # intractable exceptions when users naively share clients e.g. when using - # multiprocessing. - self._session = ThreadLocalWrapper(self._build_session) - - @property - def token(self): - """str: The bearer token used in the requests.""" - return self.auth.token - - @token.setter - def token(self, token): - """str: Deprecated""" - self.auth._token = token - - @property - def session(self) -> Session: - """Session: The session instance used by this service.""" - session = self._session.get() - auth = add_bearer(self.token) - if session.headers.get(HttpHeaderKeys.Authorization) != auth: - session.headers[HttpHeaderKeys.Authorization] = auth - - return session - - def _build_session(self): - session = self._session_class( - self.base_url, timeout=self.TIMEOUT, retries=self._retry_config - ) - session.initialize() - session.headers.update( - { - HttpHeaderKeys.ContentType: HttpHeaderValues.ApplicationJson, - HttpHeaderKeys.UserAgent: "{}/{}".format( - HttpHeaderValues.DlPython, __version__ - ), - } - ) - - try: - session.headers.update( - { - # https://github.com/easybuilders/easybuild/wiki/OS_flavor_name_version - HttpHeaderKeys.Platform: platform.platform(), - HttpHeaderKeys.Python: platform.python_version(), - # https://stackoverflow.com/questions/47608532/how-to-detect-from-within-python-whether-packages-are-managed-with-conda - HttpHeaderKeys.Conda: str( - os.path.exists( - os.path.join(sys.prefix, "conda-meta", "history") - ) - ), - # https://stackoverflow.com/questions/15411967/how-can-i-check-if-code-is-executed-in-the-ipython-notebook - HttpHeaderKeys.Notebook: str("ipykernel" in sys.modules), - HttpHeaderKeys.ClientSession: uuid.uuid4().hex, - } - ) - except Exception: - pass - - return session - - def __getstate__(self): - return dict((attr, getattr(self, attr)) for attr in self.__attrs__) - - def __setstate__(self, state): - for name, value in state.items(): - setattr(self, name, value) - - self._init_session() - - -class JsonApiSession(Session): - """The HTTP Session that performs the actual JSONAPI HTTP request. - - You cannot control its instantiation, but you can derive from this class - and pass it as the class to use when you instantiate a :py:class:`JsonApiService` - or register it as the default session class using - :py:meth:`JsonApiService.set_default_session_class`. - - Parameters - ---------- - base_url: str - The URL prefix to use for communication with the Descartes Labs servers. - timeout: int or tuple(int, int) - See `requests timeouts - `_. - """ - - # Warning keys - KEY_CATEGORY = "category" - KEY_MESSAGE = "message" - KEY_META = "meta" - KEY_WARNINGS = "warnings" - - # Error keys - KEY_ABOUT = "about" - KEY_DETAIL = "detail" - KEY_ERRORS = "errors" - KEY_HREF = "href" - KEY_ID = "id" - KEY_LINKS = "links" - KEY_PARAMETER = "parameter" - KEY_POINTER = "pointer" - KEY_SOURCE = "source" - KEY_STATUS = "status" - KEY_TITLE = "title" - - def __init__(self, *args, **kwargs): - self.rewrite_errors = False # This may be changed by the JsonApiService - super(JsonApiSession, self).__init__(*args, **kwargs) - - def initialize(self): - """Initialize the :py:class:`Session` instance - - You can override this method in a derived class to add your own initialization. - This method does nothing in the base class. - """ - - pass - - def request(self, *args, **kwargs): - """Sends an HTTP request and emits Descartes Labs specific errors. - - Parameters - ---------- - method: str - The HTTP method to use. - url: str - The URL to send the request to. - kwargs: dict - Additional arguments. See `requests.request - `_. - - Returns - ------- - Response - A :py:class:`request.Response` object. - - Raises - ------ - BadRequestError - Either a 400 or 422 HTTP response status code was encountered. - ~descarteslabs.exceptions.NotFoundError - A 404 HTTP response status code was encountered. - ProxyAuthenticationRequiredError - A 407 HTTP response status code was encountered indicating proxy - authentication was not handled or was invalid. - ConflictError - A 409 HTTP response status code was encountered. - ValidationError - A 422 HTTP response status code was encountered. - ValidationError extends BadRequestError for backward compatibility. - RateLimitError - A 429 HTTP response status code was encountered. - GatewayTimeoutError - A 504 HTTP response status code was encountered. - ~descarteslabs.exceptions.ServerError - Any HTTP response status code larger than 400 that was not covered above - is returned as a ServerError. The original HTTP response status code - can be found in the attribute :py:attr:`original_status`. - - Note - ---- - If :py:attr:`rewrite_errors` was set to ``True`` in the corresponding - :py:class:`JsonApiService`, the JSONAPI errors will be rewritten in a more - human readable format. - """ - - try: - resp = super(JsonApiSession, self).request(*args, **kwargs) - except (ClientError, ServerError) as error: - if self.rewrite_errors: - self._rewrite_error(error) - raise - - try: - self._emit_warnings(resp.json()) - except Exception: - # Really don't want to raise anything here - pass - - return resp - - def _emit_warnings(self, json_response): - if ( - self.KEY_META not in json_response - or self.KEY_WARNINGS not in json_response[self.KEY_META] - ): - return - - for warning in json_response[self.KEY_META][self.KEY_WARNINGS]: - if self.KEY_MESSAGE not in warning: # Mandatory - continue - - message = warning[self.KEY_MESSAGE] - category = UserWarning - - if self.KEY_CATEGORY in warning: - category = getattr(builtins, warning[self.KEY_CATEGORY], None) - - if category is None: - # Couldn't find this category; add it to the message instead - category = UserWarning - message = "{}: {}".format(warning[self.KEY_CATEGORY], message) - - warn(message, category) - - def _rewrite_error(self, client_error): - """Rewrite JSON ClientErrors that are returned to make them easier to read""" - message = "" - - for arg in client_error.args: - try: - errors = json.loads(arg)[self.KEY_ERRORS] - - for error in errors: - line = "" - separator = "" - - if self.KEY_TITLE in error: - line += error[self.KEY_TITLE] - separator = ": " - elif self.KEY_STATUS in error: - line += error[self.KEY_STATUS] - separator = ": " - - if self.KEY_DETAIL in error: - line += separator + error[self.KEY_DETAIL].strip(".") - separator = ": " - - if self.KEY_SOURCE in error: - source = error[self.KEY_SOURCE] - if self.KEY_POINTER in source: - source = source[self.KEY_POINTER].split("/")[-1] - elif self.KEY_PARAMETER in source: - source = source[self.KEY_PARAMETER] - line += separator + source - - if self.KEY_ID in error: - line += " ({})".format(error[self.KEY_ID]) - - if line: - message += "\n " + line - - if self.KEY_LINKS in error: - links = error[self.KEY_LINKS] - - if self.KEY_ABOUT in links: - link = links[self.KEY_ABOUT] - - if isinstance(link, str): - message += "\n {}".format(link) - elif isinstance(link, dict) and self.KEY_HREF in link: - message += "\n {}".format(link[self.KEY_HREF]) - except Exception: - return - - if message: - client_error.args = (message,) - - -class JsonApiService(Service): - """A JsonApi oriented default Descartes Labs HTTP Service. - - For details see the :py:class:`Service`. This service adheres to the `JsonApi - standard `_ and interprets responses as needed. - - This service uses the :py:class:`JsonApiSession` which provides some optional - functionality. - - Parameters - ---------- - url: str - The URL prefix to use for communication with the Descartes Labs servers. - session_class: class - The session class to use when instantiating the session. This must be a derived - class from :py:class:`JsonApiSession`. If not provided, the default session - class is used. You can register a default session class with - :py:meth:`JsonApiService.set_default_session_class`. - rewrite_errors: bool - When set to ``True``, errors are rewritten to be more readable. Each JsonApi - error becomes a single line of error information without tags. - auth: Auth, optional - A Descartes Labs :py:class:`~descarteslabs.auth.Auth` instance. If not - provided, a default one will be instantiated. - retries: int or urllib3.util.retry.Retry If a number, it's the number of retries - that will be attempted. If a :py:class:`urllib3.util.retry.Retry` instance, - it will determine the retry behavior. If not provided, the default retry - policy as described above will be used. - - Raises - ------ - TypeError - If you try to use a session class that is not derived from - :py:class:`JsonApiSession`. - """ - - KEY_ATTRIBUTES = "attributes" - KEY_DATA = "data" - KEY_ID = "id" - KEY_TYPE = "type" - - _session_class = JsonApiSession - - @classmethod - def set_default_session_class(cls, session_class): - """Set the default session class for :py:class:`JsonApiService`. - - The default session is used for any :py:class:`JsonApiService` that is - instantiated without specifying the session class. - - Parameters - ---------- - session_class: class - The session class to use when instantiating the session. This must be the - class :py:class:`JsonApiSession` itself or a derived class from - :py:class:`JsonApiSession`. - """ - - if not issubclass(session_class, JsonApiSession): - raise TypeError( - "The session class must be a subclass of {}.".format(JsonApiSession) - ) - - cls._session_class = session_class - - @classmethod - def get_default_session_class(cls): - """Get the default session class for :py:class:`JsonApiService`. - - Returns - ------- - JsonApiService - The default session class, which is :py:class:`JsonApiService` itself or - a derived class from :py:class:`JsonApiService`. - """ - - return cls._session_class - - def __init__(self, url, session_class=None, rewrite_errors=False, **kwargs): - if not (session_class is None or issubclass(session_class, JsonApiSession)): - raise TypeError( - "The session class must be a subclass of {}.".format(JsonApiSession) - ) - - self.rewrite_errors = rewrite_errors - super(JsonApiService, self).__init__(url, session_class=session_class, **kwargs) - - def _build_session(self): - session = super(JsonApiService, self)._build_session() - - session.rewrite_errors = self.rewrite_errors - session.headers.update( - { - HttpHeaderKeys.ContentType: HttpHeaderValues.ApplicationVndApiJson, - HttpHeaderKeys.Accept: HttpHeaderValues.ApplicationVndApiJson, - } - ) - return session - - @staticmethod - def jsonapi_document(type, attributes, id=None): - """Return a JsonApi document with a single resource. - - A JsonApi document has the following structure: - - .. code:: - - { - "data": { - "type": "...", - "id": "...", // Optional - "attributes": { - "...": "...", - ... - } - } - } - - Parameters - ---------- - type: str - The type of resource; this becomes the ``type`` key in the ``data`` element. - attributes: dict - The attributes for this resource; this becomes the ``attributes`` key in - the ``data`` element. - id: str, optional - The optional id for the resource; if provided this becomes the ``id`` key - in the ``data`` element. - - Returns - ------- - dict - A dictionary representing the JsonApi document with ``data`` as the - top-level key, which itself contains a single resource. - """ - - resource = { - JsonApiService.KEY_DATA: { - JsonApiService.KEY_TYPE: type, - JsonApiService.KEY_ATTRIBUTES: attributes, - } - } - if id is not None: - resource[JsonApiService.KEY_DATA][JsonApiService.KEY_ID] = id - return resource - - @staticmethod - def jsonapi_collection(type, attributes_list, ids_list=None): - """Return a JsonApi document with a collection of resources. - - The number of elements in the ``attributes_list`` must be identical to the - number of elements in the ``ids_list``. - - A JsonApi collection has the following structure: - - .. code:: - - { - "data": [ - { - "type": "...", - "id": "...", // Optional - "attributes": { - "...": "...", - ... - } - }, { - ... - }, { - ... - ] - } - - Parameters - ---------- - type: str - The type of resource; this becomes the ``type`` key for each resource in - the collection. The JsonApi collection contains resources of the same - type. - attributes: list(dict) - A list of attributes for each resource; this becomes the ``attributes`` - key for each resource in the collection. - id: list(str), optional - The optional id for the resource; if provided this becomes the ``id`` key - for each resource in the collection. - - Returns - ------- - dict - A dictionary representing the JsonApi document with ``data`` as the - top-level key, which itself contains a list of resources. - - Raises - ------ - ValueError - If the number of elements in ``attributes_list`` differs from the number - of elements in ``ids_list``. - """ - - if ids_list is None: - ids_list = itertools.repeat(None) - else: - if len(ids_list) != len(attributes_list): - raise ValueError( - "Different number of resources given than IDs: {} vs {}".format( - len(attributes_list), len(ids_list) - ) - ) - resources = [] - for attributes, id in zip(attributes_list, ids_list): - resource = { - JsonApiService.KEY_TYPE: type, - JsonApiService.KEY_ATTRIBUTES: attributes, - } - if id is not None: - resource[JsonApiService.KEY_ID] = id - resources.append(resource) - return {JsonApiService.KEY_DATA: resources} - - -class ThirdPartyService: - """The default Descartes Labs HTTP Service used for 3rd party servers. - - This service has a default timeout and retry policy that retries HTTP requests - depending on the timeout and HTTP status code that was returned. This is based - on the `requests timeouts - `_ - and the `urllib3 retry object - `_. - - The default timeouts are set to 9.5 seconds for establishing a connection (slightly - larger than a multiple of 3, which is the TCP default packet retransmission window), - and 30 seconds for reading a response. - - The default retry logic retries up to 10 times total, a maximum of 2 for - establishing a connection. The backoff_factor is a random number between 1 and - 3, but will never be more than 2 minutes. The unexpected HTTP status codes that - will be retried are ``429``, ``500``, ``502``, ``503``, and ``504`` for any of the - HTTP requests. Here we specify 429s explicitly (unlike for the Service class) - because we have no guarantee that third party services are consistent about - providing a retry-after header. - - Parameters - ---------- - url: str - The URL prefix to use for communication with the 3rd party server. - session_class: class - The session class to use when instantiating the session. This must be a derived - class from :py:class:`Session`. If not provided, the default session class - is used. You can register a default session class with - :py:meth:`ThirdPartyService.set_default_session_class`. - - Raises - ------ - TypeError - If you try to use a session class that is not derived from :py:class:`Session`. - """ - - CONNECT_TIMEOUT = 9.5 - READ_TIMEOUT = 30 - TIMEOUT = (CONNECT_TIMEOUT, READ_TIMEOUT) - - RETRY_CONFIG = Retry( - total=10, - read=2, - backoff_factor=random.uniform(1, 3), - allowed_methods=frozenset( - [ - HttpRequestMethod.HEAD, - HttpRequestMethod.TRACE, - HttpRequestMethod.GET, - HttpRequestMethod.POST, - HttpRequestMethod.PUT, - HttpRequestMethod.OPTIONS, - HttpRequestMethod.DELETE, - ] - ), - status_forcelist=[ - HTTPStatus.TOO_MANY_REQUESTS, - HTTPStatus.INTERNAL_SERVER_ERROR, - HTTPStatus.BAD_GATEWAY, - HTTPStatus.SERVICE_UNAVAILABLE, - HTTPStatus.GATEWAY_TIMEOUT, - ], - ) - - _session_class = Session - - @classmethod - def set_default_session_class(cls, session_class=None): - """Set the default session class for :py:class:`ThirdPartyService`. - - The default session is used for any :py:meth:`ThirdPartyService` that is - instantiated without specifying the session class. - - Parameters - ---------- - session_class: class - The session class to use when instantiating the session. This must be the - class :py:class:`Session` itself or a derived class from - :py:class:`Session`. - """ - - if not issubclass(session_class, Session): - raise TypeError( - "The session class must be a subclass of {}.".format(Session) - ) - - cls._session_class = session_class - - @classmethod - def get_default_session_class(cls): - """Get the default session class for the :py:class:`ThirdPartyService`. - - Returns - ------- - Session - The default session class, which is :py:class:`Session` itself or a derived - class from :py:class:`Session`. - """ - - return cls._session_class - - def __init__(self, url="", session_class=None): - self.base_url = url - - if session_class is not None: - if not issubclass(session_class, Session): - raise TypeError( - "The session class must be a subclass of {}.".format(Session) - ) - - self._session_class = session_class - - self._session = ThreadLocalWrapper(self._build_session) - - @property - def session(self) -> Session: - return self._session.get() - - def _build_session(self): - session = self._session_class(self.base_url, timeout=self.TIMEOUT) - session.initialize() - session.headers.update( - { - # HttpHeaderKeys.ContentType: HttpHeaderValues.ApplicationOctetStream, - HttpHeaderKeys.UserAgent: "{}/{}".format( - HttpHeaderValues.DlPython, __version__ - ), - } - ) - - return session diff --git a/descarteslabs/core/client/services/service/tests/__init__.py b/descarteslabs/core/client/services/service/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/descarteslabs/core/client/services/service/tests/test_service.py b/descarteslabs/core/client/services/service/tests/test_service.py deleted file mode 100644 index 35e076d3..00000000 --- a/descarteslabs/core/client/services/service/tests/test_service.py +++ /dev/null @@ -1,574 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 pickle -import unittest -from http import HTTPStatus -from io import BytesIO - -from unittest import mock -import requests -import responses -import urllib3 -from descarteslabs.exceptions import BadRequestError, ProxyAuthenticationRequiredError - -from .....common.http.authorization import add_bearer -from .....common.http import ProxyAuthentication -from ....version import __version__ -from .. import ( - JsonApiService, - JsonApiSession, - Service, - Session, - ThirdPartyService, - service, -) -from ..service import HttpHeaderKeys, HttpHeaderValues, WrappedSession - -FAKE_URL = "http://localhost" -FAKE_TOKEN = "foo.bar.sig" - - -class TestService(unittest.TestCase): - def test_session_token(self): - service = Service("foo", auth=mock.MagicMock(token=FAKE_TOKEN)) - assert service.session.headers.get("Authorization") == add_bearer(FAKE_TOKEN) - - def test_client_session_header(self): - service = Service("foo", auth=mock.MagicMock(token=FAKE_TOKEN)) - assert "X-Client-Session" in service.session.headers - assert ( - service.session.headers[HttpHeaderKeys.ContentType] - == HttpHeaderValues.ApplicationJson - ) - assert service.session.headers[HttpHeaderKeys.UserAgent] == "{}/{}".format( - HttpHeaderValues.DlPython, __version__ - ) - - -class TestJsonApiService(unittest.TestCase): - def test_session_token(self): - service = JsonApiService("foo", auth=mock.MagicMock(token=FAKE_TOKEN)) - assert service.session.headers.get("Authorization") == add_bearer(FAKE_TOKEN) - - def test_client_session_header(self): - service = JsonApiService("foo", auth=mock.MagicMock(token=FAKE_TOKEN)) - assert "X-Client-Session" in service.session.headers - assert ( - service.session.headers[HttpHeaderKeys.ContentType] - == HttpHeaderValues.ApplicationVndApiJson - ) - assert service.session.headers[HttpHeaderKeys.UserAgent] == "{}/{}".format( - HttpHeaderValues.DlPython, __version__ - ) - - -class TestThirdParyService(unittest.TestCase): - def test_client_session_header(self): - service = ThirdPartyService() - assert "User-Agent" in service.session.headers - - -class TestWrappedSession(unittest.TestCase): - def test_pickling(self): - session = WrappedSession(FAKE_URL, timeout=10) - assert 10 == session.timeout - unpickled = pickle.loads(pickle.dumps(session)) - assert 10 == unpickled.timeout - - @mock.patch.object(requests.Session, "request") - def test_request_group_header_none(self, request): - request.return_value.status_code = 200 - - session = WrappedSession("") - session.request("POST", FAKE_URL) - - request.assert_called_once() - assert "X-Request-Group" in request.call_args[1]["headers"] - - @mock.patch.object(requests.Session, "request") - def test_request_group_header_conflict(self, request): - request.return_value.status_code = HTTPStatus.OK - - args = "POST", FAKE_URL - kwargs = dict(headers={"X-Request-Group": "f00"}) - - session = WrappedSession("") - session.request(*args, **kwargs) - request.assert_called_once_with(*args, **kwargs) # we do nothing here - - @mock.patch.object(requests.Session, "request") - def test_request_group_header_no_conflict(self, request): - request.return_value.status_code = HTTPStatus.OK - - session = WrappedSession("") - session.request("POST", FAKE_URL, headers={"foo": "bar"}) - - request.assert_called_once() - assert "X-Request-Group" in request.call_args[1]["headers"] - - -class TestSessionClass(unittest.TestCase): - def test_bad_session(self): - class MySession: - pass - - with self.assertRaises(TypeError): - Service( - "foo", auth=mock.MagicMock(token=FAKE_TOKEN), session_class=MySession - ) - - @mock.patch.object(requests.Session, "request") - def test_good_session(self, request): - request.return_value.status_code = HTTPStatus.OK - - class MySession(Session): - pass - - service = Service( - "foo", auth=mock.MagicMock(token=FAKE_TOKEN), session_class=MySession - ) - service.session.get("bar") - - request.assert_called() - - @mock.patch.object(requests.Session, "request") - def test_bad_json_session(self, request): - request.return_value.status_code = HTTPStatus.OK - - class MySession(Session): - pass - - with self.assertRaises(TypeError): - JsonApiService( - "foo", auth=mock.MagicMock(token=FAKE_TOKEN), session_class=MySession - ) - - @mock.patch.object(requests.Session, "request") - def test_good_json_session(self, request): - request.return_value.status_code = HTTPStatus.OK - - class MySession(JsonApiSession): - pass - - service = JsonApiService( - "foo", auth=mock.MagicMock(token=FAKE_TOKEN), session_class=MySession - ) - service.session.get("bar") - - request.assert_called() - - -class TestJsonApiSession(unittest.TestCase): - # A JSONAPI error can contain, amongst others, the following fields: - # status, title, detail, source - # The source field can contain: - # pointer, parameter - # When rewriting the error, it looks like - # [title or status: ][description: ][source or parameter][ (id)][ - # link] - - @mock.patch.object(requests.Session, "request") - def test_jsonapi_error(self, request): - error_title = "Title" - error_status = "Status" # Should be ignored - - request.return_value.status_code = HTTPStatus.BAD_REQUEST - request.return_value.text = ( - '{{"errors": [{{"title": "{}", "status": "{}"}}]}}' - ).format(error_title, error_status) - service = JsonApiService( - "foo", auth=mock.MagicMock(token=FAKE_TOKEN), rewrite_errors=True - ) - - try: - service.session.get("bar") - except BadRequestError as e: - assert e.args == ("\n {}".format(error_title),) - - @mock.patch.object(requests.Session, "request") - def test_jsonapi_error_with_detail(self, request): - error_title = "Title" - error_detail = "Description" - - request.return_value.status_code = HTTPStatus.BAD_REQUEST - request.return_value.text = ( - '{{"errors": [{{"title": "{}", "detail": "{}"}}]}}' - ).format(error_title, error_detail) - service = JsonApiService( - "foo", auth=mock.MagicMock(token=FAKE_TOKEN), rewrite_errors=True - ) - - try: - service.session.get("bar") - except BadRequestError as e: - assert e.args == ("\n {}: {}".format(error_title, error_detail),) - - @mock.patch.object(requests.Session, "request") - def test_jsonapi_error_no_title(self, request): - error_status = "Status" # Should be used instead of the title - error_detail = "Description" - - request.return_value.status_code = HTTPStatus.BAD_REQUEST - request.return_value.text = ( - '{{"errors": [{{"status": "{}", "detail": "{}"}}]}}' - ).format(error_status, error_detail) - service = JsonApiService( - "foo", auth=mock.MagicMock(token=FAKE_TOKEN), rewrite_errors=True - ) - - try: - service.session.get("bar") - except BadRequestError as e: - assert e.args == ("\n {}: {}".format(error_status, error_detail),) - - @mock.patch.object(requests.Session, "request") - def test_jsonapi_error_with_source(self, request): - error_title = "Title" - error_detail = "Detail" - error_field = "Field" - - request.return_value.status_code = HTTPStatus.BAD_REQUEST - request.return_value.text = ( - '{{"errors": [{{"title": "{}", "detail": "{}", "source": ' - '{{"pointer": "/path/to/{}"}}}}]}}' - ).format(error_title, error_detail, error_field) - service = JsonApiService( - "foo", auth=mock.MagicMock(token=FAKE_TOKEN), rewrite_errors=True - ) - - try: - service.session.get("bar") - except BadRequestError as e: - assert e.args == ( - "\n {}: {}: {}".format(error_title, error_detail, error_field), - ) - - @mock.patch.object(requests.Session, "request") - def test_jsonapi_error_with_id(self, request): - error_title = "Title" - error_detail = "Detail" - error_id = "123" - - request.return_value.status_code = HTTPStatus.BAD_REQUEST - request.return_value.text = ( - '{{"errors": [{{"title": "{}", "detail": "{}", "id": {}}}]}}' - ).format(error_title, error_detail, error_id) - service = JsonApiService( - "foo", auth=mock.MagicMock(token=FAKE_TOKEN), rewrite_errors=True - ) - - try: - service.session.get("bar") - except BadRequestError as e: - assert e.args == ( - "\n {}: {} ({})".format(error_title, error_detail, error_id), - ) - - @mock.patch.object(requests.Session, "request") - def test_jsonapi_error_with_link(self, request): - error_title = "Title" - error_detail = "Detail" - error_href = "Href" - - request.return_value.status_code = HTTPStatus.BAD_REQUEST - request.return_value.text = ( - '{{"errors": [{{"title": "{}", "detail": "{}", "links": ' - '{{"about": "{}"}}}}]}}' - ).format(error_title, error_detail, error_href) - service = JsonApiService( - "foo", auth=mock.MagicMock(token=FAKE_TOKEN), rewrite_errors=True - ) - - try: - service.session.get("bar") - except BadRequestError as e: - assert e.args == ( - "\n {}: {}\n {}".format( - error_title, error_detail, error_href - ), - ) - - request.return_value.text = ( - '{{"errors": [{{"title": "{}", "detail": "{}", "links": ' - '{{"about": {{"href": "{}"}}}}}}]}}' - ).format(error_title, error_detail, error_href) - service = JsonApiService( - "foo", auth=mock.MagicMock(token=FAKE_TOKEN), rewrite_errors=True - ) - - try: - service.session.get("bar") - except BadRequestError as e: - assert e.args == ( - "\n {}: {}\n {}".format( - error_title, error_detail, error_href - ), - ) - - -class TestWarningsClass(unittest.TestCase): - @mock.patch.object(service, "warn") - @mock.patch.object(requests.Session, "request") - def test_session_deprecation_warning(self, request, warn): - message = "Warning" - cls = FutureWarning - - class result: - status_code = HTTPStatus.OK - - def json(self): - return { - "meta": { - "warnings": [{"message": message, "category": cls.__name__}] - } - } - - request.side_effect = lambda *args, **kw: result() - service = JsonApiService("foo", auth=mock.MagicMock(token=FAKE_TOKEN)) - service.session.get("bar") - warn.assert_called_once_with(message, cls) - - @mock.patch.object(service, "warn") - @mock.patch.object(requests.Session, "request") - def test_session_my_warning(self, request, warn): - message = "Warning" - category = "MyCategory" - - class result: - status_code = HTTPStatus.OK - - def json(self): - return { - "meta": {"warnings": [{"message": message, "category": category}]} - } - - request.side_effect = lambda *args, **kw: result() - service = JsonApiService("foo", auth=mock.MagicMock(token=FAKE_TOKEN)) - service.session.get("bar") - warn.assert_called_once_with("{}: {}".format(category, message), UserWarning) - - @mock.patch.object(service, "warn") - @mock.patch.object(requests.Session, "request") - def test_session_warning(self, request, warn): - message = "Warning" - - class result: - status_code = HTTPStatus.OK - - def json(self): - return {"meta": {"warnings": [{"message": message}]}} - - request.side_effect = lambda *args, **kw: result() - service = JsonApiService("foo", auth=mock.MagicMock(token=FAKE_TOKEN)) - service.session.get("bar") - warn.assert_called_once_with(message, UserWarning) - - -class TestInitialize(unittest.TestCase): - @mock.patch.object(requests.Session, "request") - def test_initialize_session(self, request): - request.return_value.status_code = HTTPStatus.OK - - class MySession(Session): - initialize_called = 0 - - def initialize(self): - MySession.initialize_called += 1 - - service = Service( - "foo", auth=mock.MagicMock(token=FAKE_TOKEN), session_class=MySession - ) - service.session.get("bar") - - assert MySession.initialize_called == 1 - - @mock.patch.object(requests.Session, "request") - def test_initialize_json_api_session(self, request): - request.return_value.status_code = HTTPStatus.OK - - class MySession(JsonApiSession): - initialize_called = 0 - - def initialize(self): - MySession.initialize_called += 1 - - service = JsonApiService( - "foo", auth=mock.MagicMock(token=FAKE_TOKEN), session_class=MySession - ) - service.session.get("bar") - - assert MySession.initialize_called == 1 - - @mock.patch.object(requests.Session, "request") - def test_initialize_third_party_session(self, request): - request.return_value.status_code = HTTPStatus.OK - - class MySession(Session): - initialize_called = 0 - - def initialize(self): - MySession.initialize_called += 1 - - service = ThirdPartyService(session_class=MySession) - service.session.get("bar") - - assert MySession.initialize_called == 1 - - -class TestProxyAuthHTTPS(unittest.TestCase): - url = "https://fake-service" - protocol = ProxyAuthentication.Protocol.HTTPS - - def tearDown(self): - ProxyAuthentication.unregister() - ProxyAuthentication.clear_proxy() - - @responses.activate - def test_requires_proxy_auth(self): - responses.add("GET", self.url + "/bar", status=407) - - service = Service(self.url, auth=mock.MagicMock(token=FAKE_TOKEN)) - - with self.assertRaises(ProxyAuthenticationRequiredError): - service.session.get("/bar") - - # responses hijacks the connection pool and bypasses our HTTPAdapter - # unfortunately we have to mock the pool manager here instead. - @mock.patch( - "urllib3.poolmanager.PoolManager._new_pool", - ) - def test_no_proxy_headers_if_proxy_not_set(self, mock_conn): - mock_conn.return_value.urlopen.side_effect = [ - urllib3.response.HTTPResponse( - status=200, - reason=None, - body=BytesIO(), - headers=[], - preload_content=False, - ), - ] - - class MyProxyAuth(ProxyAuthentication): - def authorize(self, proxy: str, protocol: str) -> dict: - MyProxyAuth.proxy = proxy - MyProxyAuth.protocol = protocol - - return {"header-1": "uh oh"} - - ProxyAuthentication.register(MyProxyAuth) - - service = Service(self.url, auth=mock.MagicMock(token=FAKE_TOKEN)) - service.session.get("/bar") - - assert not hasattr(MyProxyAuth, "proxy") - assert not hasattr(MyProxyAuth, "protocol") - assert mock_conn.called - - _, kwargs = mock_conn.call_args - - assert "_proxy" not in kwargs["request_context"] - assert "_proxy_headers" not in kwargs["request_context"] - - @mock.patch( - "urllib3.poolmanager.PoolManager._new_pool", - ) - def test_proxy_authentication(self, mock_conn): - mock_conn.return_value.urlopen.side_effect = [ - urllib3.response.HTTPResponse( - status=200, - reason=None, - body=BytesIO(), - headers=[], - preload_content=False, - ), - ] - - class MyProxyAuth(ProxyAuthentication): - def authorize(self, proxy: str, protocol: str) -> dict: - MyProxyAuth.proxy = proxy - MyProxyAuth.protocol = protocol - - return { - "Proxy-Authorization": "proxy-auth-value", - "X-Test-Header": "another test header", - } - - ProxyAuthentication.register(MyProxyAuth) - ProxyAuthentication.set_proxy("http://some-proxy.test") - - service = Service(self.url, auth=mock.MagicMock(token=FAKE_TOKEN)) - service.session.get("/bar") - - assert MyProxyAuth.proxy == "http://some-proxy.test" - assert MyProxyAuth.protocol == self.protocol - assert mock_conn.called - - args, kwargs = mock_conn.call_args - assert str(kwargs["request_context"]["_proxy"]) == "http://some-proxy.test:80" - assert kwargs["request_context"]["_proxy_headers"] == { - "Proxy-Authorization": "proxy-auth-value", - "X-Test-Header": "another test header", - } - assert kwargs["request_context"]["scheme"] == self.protocol - assert ( - kwargs["request_context"]["port"] == 80 - if self.protocol == ProxyAuthentication.Protocol.HTTP - else 443 - ) - - # The request is tunneling it should be directed at the real service instead of - # the proxy - if self.protocol == ProxyAuthentication.Protocol.HTTPS: - assert kwargs["request_context"]["host"] == "fake-service" - else: - assert kwargs["request_context"]["host"] == "some-proxy.test" - - def test_validates_authorize(self): - class MyProxyAuth(ProxyAuthentication): - def authorize(self, proxy: str, protocol: str) -> dict: - MyProxyAuth.called = True - return 10 - - ProxyAuthentication.register(MyProxyAuth) - ProxyAuthentication.set_proxy("http://some-proxy.test:8888") - - with self.assertRaisesRegex(TypeError, "must return a dictionary"): - service = Service(self.url, auth=mock.MagicMock(token=FAKE_TOKEN)) - service.session.get("/bar") - assert MyProxyAuth.called - - -class TestProxyAuthHTTP(TestProxyAuthHTTPS): - url = "http://fake-service" - protocol = ProxyAuthentication.Protocol.HTTP - - @responses.activate - def test_proxy_auth_required_headers(self): - responses.add( - "GET", - self.url + "/bar", - status=407, - headers={ - "Proxy-Authenticate": "Basic", - }, - ) - - service = Service(self.url, auth=mock.MagicMock(token=FAKE_TOKEN)) - - with self.assertRaises(ProxyAuthenticationRequiredError) as ctx: - service.session.get("/bar") - - assert ctx.exception.status == 407 - assert ctx.exception.proxy_authenticate == "Basic" diff --git a/descarteslabs/core/client/tests/__init__.py b/descarteslabs/core/client/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/descarteslabs/core/client/tests/test_clear_client_state.py b/descarteslabs/core/client/tests/test_clear_client_state.py deleted file mode 100644 index 23a4f545..00000000 --- a/descarteslabs/core/client/tests/test_clear_client_state.py +++ /dev/null @@ -1,37 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 unittest - -from .. import clear_client_state -from descarteslabs.auth import Auth -from ..services.raster import Raster - - -class ClientStateTests(unittest.TestCase): - def test_clear_client_state(self): - clear_client_state() - - auth = Auth.get_default_auth() - assert auth - assert Auth._instance is auth - - raster = Raster.get_default_client() - assert raster - assert Raster._instance is raster - - clear_client_state() - - assert Auth._instance is None - assert Raster._instance is None diff --git a/descarteslabs/core/client/tests/test_deprecation.py b/descarteslabs/core/client/tests/test_deprecation.py deleted file mode 100644 index 982de459..00000000 --- a/descarteslabs/core/client/tests/test_deprecation.py +++ /dev/null @@ -1,145 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 pytest -import unittest -import warnings - -from ..deprecation import deprecate - - -class RequiredParametersTests(unittest.TestCase): - @deprecate(renamed={"arg1": "arg2", "kwarg1": "kwarg2"}) - def rename(self, arg2, kwarg2=None): - return (arg2, kwarg2) - - @deprecate(deprecated=["kwarg1"]) - def rename_deprecate(self, kwarg1=None): - return kwarg1 - - @deprecate(removed=["kwarg1"]) - def rename_deprecate_completely(self): - return True - - @deprecate(required=["kwarg2"]) - def required(self, arg1, kwarg1=None, kwarg2=None): - return True - - @deprecate(required=["kwarg2"], renamed={"kwarg1": "kwarg2"}) - def rename_required(self, kwarg2=None): - return kwarg2 - - @deprecate(required=["kwarg2"], renamed={"kwarg1": "kwarg2"}) - def rename_required_with_args(self, arg, kwarg2=None): - return (arg, kwarg2) - - @deprecate(renamed={"kwarg1": "kwarg2"}) - def rename_name_clash(self, kwarg2=None): - return True - - def test_rename(self): - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - assert ("value1", "value2") == self.rename("value1", "value2") - assert len(w) == 0 - - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - assert ("value1", "value2") == self.rename(arg1="value1", kwarg1="value2") - assert len(w) == 2 - assert ( - "Parameter `arg1` has been renamed to `arg2`, and will " - "be removed in future versions. Use `arg2` instead." in w[0].message.args[0] - ) - assert ( - "Parameter `kwarg1` has been renamed to `kwarg2`, and " - "will be removed in future versions. Use `kwarg2` instead." - in w[1].message.args[0] - ) - - def test_required(self): - assert self.required("value1", kwarg2="value2") - assert self.required("value1", "value3", "value2") - with pytest.raises(SyntaxError): - self.required("value1") - - def test_rename_required(self): - assert "value" == self.rename_required(kwarg2="value") - assert "value" == self.rename_required(kwarg1="value") - assert "value" == self.rename_required("value") - - def test_rename_required_with_args(self): - assert ("value1", "value2") == self.rename_required_with_args( - "value1", kwarg2="value2" - ) - assert ("value1", "value2") == self.rename_required_with_args( - "value1", kwarg1="value2" - ) - assert ("value1", "value2") == self.rename_required_with_args( - "value1", "value2" - ) - with pytest.raises(SyntaxError): - self.rename_required_with_args("value") - - def test_rename_name_clash(self): - with pytest.raises(SyntaxError): - self.rename_name_clash(kwarg1=None, kwarg2=None) - - def test_rename_deprecate(self): - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - assert self.rename_deprecate(kwarg1="value") == "value" - - assert len(w) == 1 - assert ( - "Parameter `kwarg1` has been deprecated and will be removed completely " - "in future versions." in w[0].message.args[0] - ) - - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - assert self.rename_deprecate("value") == "value" - - assert len(w) == 1 - assert ( - "Parameter `kwarg1` has been deprecated and will be removed completely " - "in future versions." in w[0].message.args[0] - ) - - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - self.rename_deprecate_completely(kwarg1="value") - - assert len(w) == 1 - assert ( - "Parameter `kwarg1` has been deprecated and is no longer supported." - in w[0].message.args[0] - ) - - # positional calls without a specific arg definition will fail - # it's unknown what the user was specifying - with pytest.raises(TypeError): - self.rename_deprecate_completely("value") - - @deprecate(required=["arg2", "arg3"], removed=["arg1"]) - def rename_deprecate_complex(self, arg1=None, arg2=None, arg3=None): - # assumed the original signature was (self, arg1, arg2, arg3) - return (arg2, arg3) - - def test_rename_deprecate_complex(self): - assert ("arg2", "arg3") == self.rename_deprecate_complex("arg1", "arg2", "arg3") - - -if __name__ == "__main__": - unittest.main() diff --git a/descarteslabs/core/client/version.py b/descarteslabs/core/client/version.py deleted file mode 100644 index 588cafe2..00000000 --- a/descarteslabs/core/client/version.py +++ /dev/null @@ -1,15 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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. - -__version__ = "4.0.0" diff --git a/descarteslabs/core/common/__init__.py b/descarteslabs/core/common/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/descarteslabs/core/common/client/__init__.py b/descarteslabs/core/common/client/__init__.py deleted file mode 100644 index aa611665..00000000 --- a/descarteslabs/core/common/client/__init__.py +++ /dev/null @@ -1,26 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 .attributes import Attribute, DatetimeAttribute, ListAttribute -from .document import Document, DocumentState -from .search import Search - -__all__ = [ - "Attribute", - "DatetimeAttribute", - "Document", - "DocumentState", - "ListAttribute", - "Search", -] diff --git a/descarteslabs/core/common/client/attributes.py b/descarteslabs/core/common/client/attributes.py deleted file mode 100644 index ece60245..00000000 --- a/descarteslabs/core/common/client/attributes.py +++ /dev/null @@ -1,494 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 collections import UserList -from datetime import datetime, timezone -from typing import TYPE_CHECKING, Any, Callable, Iterable, Type, TypeVar, Union - -from ..property_filtering import Property -from .sort import Sort - -if TYPE_CHECKING: - from .document import Document - -T = TypeVar("T") - - -class Attribute(Property): - """An attribute defined on a Document.""" - - def __init__( - self, - type: Type[T] = None, - default: Union[T, Callable] = None, - doc: str = None, - filterable: bool = False, - mutable: bool = True, - readonly: bool = False, - sortable: bool = False, - sticky: bool = False, - ): - """Defines a document attribute. - - Examples - -------- - .. code:: - - class MyDocument(Document): - id: id = Attribute(readonly=True) - name: str = Attribute(str) - set_once: str = Attribute(str, mutable=False) - - doc = MyDocument(name="test", set_once="can only be set once") - doc.set_once = "error" - - Parameters - ---------- - default : Any, Callable, None - The default value for the attribute when no value is defined. - If a callable is provided, it will be called once when the attribute is first - fetched. - doc : str, None - Sets the doc string for the attribute. - filterable: bool, False - If set, the attribute can be used as a filter. - mutable : bool, True - If not set, the attribute will be immutable and can only be set once. - readonly : bool, False - If set, the attribute cannot be modified by the user. - This is designed for attributes set and managed exclusively by the server. - sortable : bool, False - If set, the attribute can be used to sort results. - sticky : bool, False - If set, the attribute exists on the client only. - This attribute will be ignored when set by the server. - """ - super().__init__(None) - - if sticky and readonly: - raise ValueError("Using sticky and readonly together does not make sense.") - - self.type = type - self.default = default - self.filterable = filterable - self.mutable = mutable - self.readonly = readonly - self.sortable = sortable - self.sticky = sticky - - if doc is None and type: - doc = type.__doc__ - - doc_modifiers = [] - - if not self.mutable: - doc_modifiers.append( - "The attribute is `immutable` and cannot be modified once set." - ) - - if self.readonly: - doc_modifiers.append("The attribute is `readonly` and cannot be modified.") - - doc = "{}: {}".format(self._doc_type, doc) - - if doc_modifiers: - doc += "\n\n" + "\n\n".join(doc_modifiers) - - self.__doc__ = doc - - @property - def _doc_type(self) -> str: - return "{} or {}".format(self.type.__name__, self.default) - - def __set_name__(self, owner: "Document", name: "str"): - """Called when an attribute is defined on a document.""" - if not hasattr(owner, "_attributes"): - setattr(owner, "_attributes", dict()) - - if not hasattr(owner, "_modified"): - setattr(owner, "_modified", set()) - - self.name = name - - def __get__(self, instance: "Document", owner) -> T: - """Called when an attribute value is accessed. - - If no value is defined for the attribute, the default will be applied. - """ - # Instance will be None if accessed as a class property - # this occurs when generating documentation with Sphinx. - # In this case, return the attribute instance for documentation. - if instance is None: - return self - - if self.name not in instance._attributes: - if callable(self.default): - default = self.default() - else: - default = self.default - - instance._attributes[self.name] = self.deserialize( - default, instance=instance - ) - - return instance._attributes.get(self.name) - - def __set__(self, instance: "Document", value, force: bool = False): - """Called when attribute is set to a given value. - - Values will be deserialized to the type defined in the attribute. - Additionally, the attribute will be marked as modified. - - Parameters - ---------- - force : bool, False - When force is set, the value is assumed to be from the server. - In this case, `mutable` and `readonly` are ignored and `sticky` is respected. - """ - if force and self.sticky: - return - - if not force: - self._raise_immutable("set", instance) - - if self.type and value is not None: - value = self.deserialize(value, instance, force=force) - - # Only update the value if it has changed - if (self.name not in instance._attributes and value is None) or ( - instance._attributes.get(self.name) == value - ): - return - - # It is being set by the server, it is no longer modified - if force: - instance._modified.discard(self.name) - else: - instance._modified.add(self.name) - - instance._attributes[self.name] = value - - def __delete__(self, instance: "Document", force: bool = False): - """Called when an attribute is deleted.""" - if not force: - self._raise_immutable("delete", instance) - - instance._attributes.pop(self.name, None) - - def __neg__(self): - return self._to_sort(ascending=False) - - def _to_sort(self, ascending: bool = True): - if not self.sortable: - raise ValueError(f"Cannot sort on property: {self.name}") - - return Sort(self.name, ascending) - - def _raise_immutable(self, operation: str, instance: "Document"): - """Raises an error when an attribute cannot be modified.""" - if self.readonly: - raise ValueError( - "Unable to {} readonly attribute '{}'".format(operation, self.name) - ) - - if not self.mutable and ( - instance is None or instance._attributes.get(self.name, None) - ): - raise ValueError( - "Unable to {} immutable attribute '{}'".format(operation, self.name) - ) - - def _set_modified(self, instance: "Document", changed: bool = True): - """Marks the attribute as modified.""" - if changed: - instance._modified.add(self.name) - - def _serialize_to_filter(self, value: Any): - """Serializes a value to a filter expression value.""" - return self.serialize(value) - - def deserialize( - self, value: Any, instance: "Document" = None, force: bool = False - ) -> T: - """Deserializes a value to the type in the attribute. - - Parameters - ---------- - value : Any - The value to deserialize into a native Python type. - instance : Document, None - The document instance the value is being deserialized for. - When a value is set on a document, the instance will not be None. - """ - if value is None or isinstance(value, self.type): - return value - - from .document import Document - - try: - if issubclass(self.type, Document): - # Support nested documents - if isinstance(value, dict): - return self.type(**value, saved=force) - elif isinstance(value, Iterable): - return self.type(*value, saved=force) - else: - return self.type(value, saved=force) - else: - # Support single or native values - return self.type(value) - except (ValueError, TypeError) as e: - raise ValueError(f"Unable to assign {type(value)} to type {self.type}: {e}") - - def serialize(self, value): - """Serializes a value to a JSON encodable type.""" - return value - - def __repr__(self): - return ( - "".format( - repr(self.name), - self.filterable, - self.mutable, - self.readonly, - self.sticky, - ) - ) - - -class DatetimeAttribute(Attribute): - """Represents a datetime attribute on a document.""" - - def __init__( - self, - timezone=None, - remote_timezone=timezone.utc, - default: Union[T, Callable] = None, - mutable: bool = True, - readonly: bool = False, - sticky: bool = False, - **extra, - ): - """Defines a datetime attribute. - - Parameters - ---------- - timezone : timezone, None - The timezone the client would like dates to be in. - By default, this will used the timezone defined by the user's machine. - remote_timezone : timezone, timezone.utc - The timezone the server will return dates in. - By default, this is assumed to be UTC. - default : Any, Callable, None - The default value for the attribute when no value is defined. - If a callable is provided, it will be called once when the attribute is first - fetched. - mutable : bool, True - If not set, the attribute will be immutable and can only be set once. - readonly : bool, False - If set, the attribute cannot be modified by the user. - This is designed for attributes set and managed exclusively by the server. - sticky : bool, False - If set, the attribute exists on the client only. - This attribute will be ignored when set by the server. - """ - self.timezone = timezone - self.remote_timezone = remote_timezone - - super().__init__( - type=datetime, - default=default, - mutable=mutable, - readonly=readonly, - sticky=sticky, - **extra, - ) - - def deserialize( - self, value: str, instance: "Document" = None, force: bool = False - ) -> T: - """Deserialize a server datetime.""" - if value is None: - return None - - if isinstance(value, (int, float)): - value = datetime.fromtimestamp(value, tz=timezone.utc) - elif isinstance(value, str): - if value.endswith("Z"): - value = value[:-1] + "+00:00" - - value = datetime.fromisoformat(value) - - if isinstance(value, datetime): - if not value.tzinfo: - value.replace(tzinfo=self.remote_timezone) - - return value.astimezone(tz=self.timezone) - else: - raise ValueError("Expected datetime, iso formatted date or unix timestamp") - - def serialize(self, value: datetime): - """Serialize a datetime in local time to server time in iso format.""" - if value is None: - return value - - # any value which is not a datetime must be coming from e.g. a filter expression - # so we need to convert it to a datetime. - if not isinstance(value, datetime): - value = self.deserialize(value) - - if isinstance(value, datetime): - return value.astimezone(tz=self.remote_timezone).isoformat() - else: - raise ValueError("Expected datetime, iso formatted date or unix timestamp") - - -class ListAttribute(Attribute): - """Represents a list attribute on a document.""" - - def __init__( - self, - type: Type[T] = None, - default: Union[T, Callable] = None, - mutable: bool = True, - readonly: bool = False, - sticky: bool = False, - **extra, - ): - """Defines a list attribute. - - Parameters - ---------- - type : Type[T], None - The type of the items in the list. - default : Any, Callable, None - The default value for the attribute when no value is defined. - If a callable is provided, it will be called once when the attribute is first - fetched. - mutable : bool, True - If not set, the attribute will be immutable and can only be set once. - readonly : bool, False - If set, the attribute cannot be modified by the user. - This is designed for attributes set and managed exclusively by the server. - sticky : bool, False - If set, the attribute exists on the client only. - This attribute will be ignored when set by the server. - """ - super().__init__( - type=type, - default=default or [], - mutable=mutable, - readonly=readonly, - sticky=sticky, - **extra, - ) - - def deserialize( - self, value: Any, instance: "Document" = None, force: bool = False - ) -> T: - """Deserialize a list of values.""" - if value is None: - return None - - if not isinstance(value, Iterable): - raise ValueError("Expected a list of values") - - if isinstance(self.type, Attribute): - return MutableList( - self, instance, [self.type.deserialize(v, instance) for v in value] - ) - - return MutableList(self, instance, [self.type(v) for v in value]) - - def serialize(self, value): - """Serialize a list of values.""" - if isinstance(self.type, Attribute): - return [v.serialize(v) for v in value] - - if isinstance(value, MutableList): - return value.data - - return value - - def _serialize_to_filter(self, value: Any): - if isinstance(self.type, Attribute): - return self.type._serialize_to_filter(value) - - return self.type(value) - - -class MutableList(UserList): - """A mutable list that tracks changes and notifies the document.""" - - def __init__(self, attribute: Attribute, document: "Document", data: Iterable): - super().__init__(data) - self._attribute = attribute - self._document = document - - def __delitem__(self, key): - self._attribute._raise_immutable("delete", self._document) - super().__delitem__(key) - self._attribute._set_modified(self._document) - - def __iadd__(self, other: Iterable): - self._attribute._raise_immutable("add", self._document) - other = [self._attribute.type(o) for o in other] - result = super().__iadd__(other) - self._attribute._set_modified(self._document, changed=bool(other)) - return result - - def __setitem__(self, key, value): - self._attribute._raise_immutable("set", self._document) - value = self._attribute.type(value) - changed = self.__getitem__(key) != value - super().__setitem__(key, value) - self._attribute._set_modified(self._document, changed=changed) - - def append(self, item): - self._attribute._raise_immutable("append", self._document) - item = self._attribute.type(item) - super().append(item) - self._attribute._set_modified(self._document) - - def clear(self): - self._attribute._raise_immutable("clear", self._document) - super().clear() - self._attribute._set_modified(self._document) - - def extend(self, other: Iterable): - self._attribute._raise_immutable("extend", self._document) - other = [self._attribute.type(o) for o in other] - result = super().extend(other) - self._attribute._set_modified(self._document, changed=bool(other)) - return result - - def insert(self, i, item): - self._attribute._raise_immutable("insert", self._document) - item = self._attribute.type(item) - super().insert(i, item) - self._attribute._set_modified(self._document) - - def pop(self, i=-1): - self._attribute._raise_immutable("pop", self._document) - result = super().pop(i) - self._attribute._set_modified(self._document) - return result - - def remove(self, item): - self._attribute._raise_immutable("remove", self._document) - super().remove(item) - self._attribute._set_modified(self._document) - - def __repr__(self): - return repr(self.data) diff --git a/descarteslabs/core/common/client/document.py b/descarteslabs/core/common/client/document.py deleted file mode 100644 index 5ce503f7..00000000 --- a/descarteslabs/core/common/client/document.py +++ /dev/null @@ -1,222 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 enum import auto -from typing import Any, Dict, Tuple - -from strenum import LowercaseStrEnum - -from .attributes import Attribute - - -class DocumentState(LowercaseStrEnum): - MODIFIED = auto() - NEW = auto() - SAVED = auto() - DELETED = auto() - - -class Document(object): - """An Object or Document in a Descartes Labs service.""" - - def __init__(self, saved=False, **kwargs) -> None: - self._attributes = dict() - self._modified = set() - - if saved: - self._load_from_remote(kwargs) - else: - self._fill(kwargs, remote=saved) - - self._saved = saved - self._deleted = False - - def __getattribute__(self, name: str) -> Any: - try: - deleted = object.__getattribute__(self, "_deleted") - except AttributeError: - deleted = False - - if deleted and name != "state" and not name.startswith("_"): - class_name = object.__getattribute__(self, "__class__").__name__ - raise AttributeError(f"{class_name} has been deleted") - - return object.__getattribute__(self, name) - - def _clear_modified(self): - """Clears the list of modified attributes.""" - self._modified = set() - - def _get_attributes(self) -> Dict[str, Attribute]: - """Returns all of the Attributes in the document. - - Returns - ------- - Dict[str, Attribute] - The attribute instances in the document. - """ - return { - name: instance - for name, instance in vars(self.__class__).items() - if isinstance(instance, Attribute) - } - - def _fill(self, data: dict, ignore_missing: bool = False, remote: bool = False): - """Sets document attributes from a dictionary of data. - - Parameters - ---------- - ignore_missing : bool, False - If set, unknown attributes will be ignored. - remote : bool, False - If set, the data is from the remote server. - Data provided in this way will set immutable and readonly attributes. - - Additionally, the document will be forced into a `saved` status and - all modified fields will be cleared. - """ - attributes = self._get_attributes() - - for key, value in data.items(): - if ignore_missing and key not in attributes: - continue - - attributes[key].__set__(self, value, force=remote) - - if remote: - self._clear_modified() - self._saved = True - - def _load_from_remote(self, data: dict): - """Populates the document instance with data from the remote server. - - Parameters - ---------- - data : dict - The response json to populate the document with. - """ - self._fill(data, ignore_missing=True, remote=True) - - @classmethod - def _serialize_filter_attribute(cls, name: str, value: Any) -> Tuple[str, Any]: - """Serializes a filter attribute. - - Parameters - ---------- - name : str - The name of the attribute. - value : Any - The value of the attribute. - - Returns - ------- - Tuple[str, Any] - The serialized attribute name and value. - """ - attribute: Attribute = getattr(cls, name) - return (attribute.name, attribute._serialize_to_filter(value)) - - def update(self, ignore_missing=False, **kwargs): - """Updates the document setting multiple attributes at a time. - - Parameters - ---------- - ignore_missing : bool, False - If set, unknown attributes will be ignored. - """ - self._fill(kwargs, ignore_missing=ignore_missing) - - @property - def state(self) -> DocumentState: - """Returns the state of the current document instance. - - Returns - ------- - :py:class:`~descarteslabs.common.client.DocumentState` - """ - - if self._deleted: - return DocumentState.DELETED - - if not self._saved: - return DocumentState.NEW - if self.is_modified: - return DocumentState.MODIFIED - else: - return DocumentState.SAVED - - @property - def is_modified(self) -> bool: - """Determines if the document has been modified.""" - return bool(self._modified) - - def to_dict( - self, - only_modified: bool = False, - exclude_readonly: bool = False, - exclude_none: bool = False, - ) -> Dict[str, Any]: - """Converts the document to a dictionary. - - Attributes will be serialized to JSON encodable types. - - Parameters - ---------- - only_modified : bool, False - If set, only modified attributes and their values will be included. - exclude_readonly : bool, False - If set, readonly attributes and their values are excluded. - exclude_none : bool, False - If set, attributes with a value of None will be excluded. - - Returns - ------- - Dict[str, Any] - The attributes matching the call parameters. The result of this function - can be json encoded without modification. - """ - attributes = self._get_attributes() - data = {} - - for key, attribute in attributes.items(): - if exclude_readonly and attribute.readonly: - continue - - if only_modified and key not in self._modified: - continue - - value = getattr(self, key) - - if exclude_none and value is None: - continue - - if isinstance(value, Document): - value = value.to_dict( - only_modified=only_modified, - exclude_readonly=exclude_readonly, - exclude_none=exclude_none, - ) - else: - value = attribute.serialize(value) - - data[key] = value - - return data - - def __repr__(self) -> str: - attributes = self._get_attributes() - pairs = [ - "{}={}".format(key, repr(getattr(self, key))) for key in attributes.keys() - ] - return "{}({})".format(self.__class__.__name__, ", ".join(pairs)) diff --git a/descarteslabs/core/common/client/search.py b/descarteslabs/core/common/client/search.py deleted file mode 100644 index 1693dd0e..00000000 --- a/descarteslabs/core/common/client/search.py +++ /dev/null @@ -1,299 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 inspect -import json -from typing import TYPE_CHECKING, Generic, Iterator, List, TypeVar, Union - -from ..collection import Collection -from ..property_filtering.filtering import AndExpression, Expression, LogicalExpression -from .attributes import Attribute -from .sort import Sort - -if TYPE_CHECKING: - from ...client.services.service import ApiService - -AnySearch = TypeVar("AnySearch", bound="Search") -T = TypeVar("T") - - -class Search(Generic[T]): - """A search request that iterates over its search results. - - The search can be narrowed by using the methods on the search object. - - Example - ------- - >>> search = Search(Model).filter(Model.name == "test") - >>> list(search) # doctest: +SKIP - >>> search.collect() # doctest: +SKIP - """ - - def __init__(self, document: T, client: "ApiService", url: str = None, **params): - self._document = document - self._client = client - self._url = url or document._url - - self._filters: Expression = None - self._limit: int = None - self._sort: List[Sort] = [] - self._params: dict = params - - def __deepcopy__(self, memo): - """Override to avoid deep copying the client""" - cls = self.__class__ - result = cls.__new__(cls) - memo[id(self)] = result - - for k, v in self.__dict__.items(): - if k in ["_client"]: - setattr(result, k, v) - else: - setattr(result, k, copy.deepcopy(v, memo)) - - return result - - def __iter__(self: AnySearch) -> Iterator[T]: - """ - Execute the search query and make a generator for iterating through the returned results - - Returns - ------- - generator - Generator of objects that match the type of document being searched. - Empty if no matching documents found. - - Raises - ------ - BadRequestError - If any of the query parameters or filters are invalid - ~descarteslabs.exceptions.ClientError or ~descarteslabs.exceptions.ServerError - :ref:`Spurious exception ` that can occur during a - network request. - - Example - ------- - >>> from descarteslabs.compute import Function - >>> search = Function.search().filter(Function.status == "success") - >>> list(search) # doctest: +SKIP - """ - accepts_client = ( - "client" in inspect.signature(self._document.__init__).parameters - ) - documents = self._client.iter_pages(self._url, params=self._serialize()) - - for document in documents: - if accepts_client: - yield self._document(**document, client=self._client, saved=True) - else: - yield self._document(**document, saved=True) - - def collect(self: AnySearch, **kwargs) -> Collection[T]: - """ - Execute the search query and return the appropriate collection. - - Returns - ------- - ~descarteslabs.common.collection.Collection - Collection of objects that match the type of document beng searched. - - Raises - ------ - BadRequestError - If any of the query parameters or filters are invalid - ~descarteslabs.exceptions.ClientError or ~descarteslabs.exceptions.ServerError - :ref:`Spurious exception ` that can occur during a - network request. - """ - return Collection(self, item_type=self._document) - - def count(self: AnySearch) -> int: - """Fetch the number of documents that match the search. - - Returns - ------- - int - Number of matching records - - Raises - ------ - BadRequestError - If any of the query parameters or filters are invalid - ~descarteslabs.exceptions.ClientError or ~descarteslabs.exceptions.ServerError - :ref:`Spurious exception ` that can occur during a - network request. - - Example - ------- - >>> from descarteslabs.compute import Function - >>> search = Function.search().filter(Function.status == "building") - >>> count = search.count() # doctest: +SKIP - """ - instance = self.limit(0) - response = self._client.session.get(self._url, params=instance._serialize()) - return response.json()["meta"]["total"] - - def filter( - self: AnySearch, expression: Union[Expression, LogicalExpression] - ) -> AnySearch: - """Filter results by the values of various fields. - - Successive calls to `filter` will add the new filter(s) using the - ``and`` Boolean operator (``&``). - - Parameters - ---------- - expression : Expression - Expression used to filter objects in the search by their attributes, built - from class :class:`attributes - ` ex. Job.id == 'some-id'. - You can construct filter expressions using the ``==``, ``!=``, ``<``, - ``>``, ``<=`` and ``>=`` operators as well as the - :meth:`~descarteslabs.common.client.attributes.Attribute.in_` - or - :meth:`~descarteslabs.common.client.attributes.Attribute.any_of` - method. You cannot use the boolean keywords ``and`` and ``or`` because - of Python language limitations; instead combine filter expressions using - ``&`` (boolean "and") and ``|`` (boolean "or"). - - Returns - ------- - Search - A new :py:class:`~descarteslabs.common.client.Search` instance with the - new filter(s) applied (using ``and`` if there were existing filters) - - Raises - ------ - ValueError - If the filter expression provided is not supported. - - Example - ------- - >>> from descarteslabs.compute import Job - >>> search = Job.search().filter( - ... (Job.runtime > 60) | (Job.status == "failure") - ... ) - >>> list(search) # doctest: +SKIP - """ - instance = copy.deepcopy(self) - - if not isinstance(expression, (Expression, LogicalExpression)): - raise TypeError( - f"Expected an Expression not: {expression.__class__.__name__}" - ) - - if instance._filters is None: - instance._filters = expression - else: - instance._filters = instance._filters & expression - - return instance - - def limit(self: AnySearch, limit: int) -> AnySearch: - """Limit the number of search results returned by the search execution. - - Successive calls to `limit` will overwrite the previous limit parameter. - - Parameters - ---------- - limit : int - The maximum number of records to return. - - Returns - ------- - Search - """ - instance = copy.deepcopy(self) - instance._limit = limit - return instance - - def param(self: AnySearch, **params) -> AnySearch: - """Add additional parameters to the search request. - - Parameters - ---------- - params : dict - The parameters to add to the search request. - - Returns - ------- - Search - """ - instance = copy.deepcopy(self) - instance._params.update(params) - return instance - - def sort(self: AnySearch, *sorts: List[Union[Attribute, Sort]]) -> AnySearch: - """Sort the returned results by the given fields. - - Parameters - ---------- - sorts : List[Union[Attribute, Sort]] - The attributes and direction to sort by. - - Returns - ------- - Search - - Example - ------- - >>> from descarteslabs.compute import Function - >>> Function.search().sort(Function.id, -Function.creation_date) # doctest: +SKIP - >>> list(search) # doctest: +SKIP - """ - instance = copy.deepcopy(self) - - for sort in sorts: - if isinstance(sort, Attribute): - sort = sort._to_sort() - elif not isinstance(sort, Sort): - raise TypeError( - f"Expected an Attribute or Sort not: {sort.__class__.__name__}" - ) - - instance._sort.append(sort) - - return instance - - def _serialize(self, json_encode: bool = True) -> dict: - params = self._params.copy() - - if self._filters: - filters = [] - filter = self._filters.jsonapi_serialize(self._document) - - if type(self._filters) is AndExpression: - for f in filter["and"]: - filters.append(f) - else: - filters.append(filter) - - if json_encode: - params["filter"] = json.dumps( - filters, separators=(",", ":"), sort_keys=True - ) - else: - params["filter"] = filters - - if self._limit is not None: - params["limit"] = self._limit - - if self._sort: - params["sort"] = [sort.to_string() for sort in self._sort] - - return params - - def __repr__(self) -> str: - return f"" diff --git a/descarteslabs/core/common/client/sort.py b/descarteslabs/core/common/client/sort.py deleted file mode 100644 index fea6003d..00000000 --- a/descarteslabs/core/common/client/sort.py +++ /dev/null @@ -1,26 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 Sort(object): - def __init__(self, name: str, ascending: bool): - self.name = name - self.ascending = ascending - - def to_string(self): - return "{}{}".format("-" if not self.ascending else "", self.name) - - def __repr__(self) -> str: - direction = "asc" if self.ascending else "desc" - return f"Sort({repr(self.name)}, {repr(direction)})" diff --git a/descarteslabs/core/common/client/tests/__init__.py b/descarteslabs/core/common/client/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/descarteslabs/core/common/client/tests/test_attributes.py b/descarteslabs/core/common/client/tests/test_attributes.py deleted file mode 100644 index 2a20c2e1..00000000 --- a/descarteslabs/core/common/client/tests/test_attributes.py +++ /dev/null @@ -1,331 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 unittest -from datetime import datetime, timezone - -from zoneinfo import ZoneInfo - -from .. import Attribute, DatetimeAttribute, Document, DocumentState, ListAttribute - - -class MyDocument(Document): - id: int = Attribute(int, readonly=True) - name: str = Attribute(str) - local: str = Attribute(str, default="local", sticky=True) - once: int = Attribute(int, mutable=False) - default: datetime = DatetimeAttribute( - default=lambda: datetime.now(timezone.utc).replace(tzinfo=None) - ) - created_at: datetime = DatetimeAttribute(readonly=True) - - -class TestDocument(unittest.TestCase): - def test_attribute(self): - doc = MyDocument(name="testing") - assert doc.name == "testing" - assert doc.state == DocumentState.NEW - - def test_default(self): - doc = MyDocument() - assert doc.id is None - assert doc.name is None - assert doc.local == "local" - assert doc.once is None - - date = doc.default - assert date is not None - assert doc.default == date - assert doc.created_at is None - - def test_modified(self): - doc = MyDocument(name="test") - doc.name = "something new" - assert doc.name == "something new" - assert doc.is_modified - assert doc._modified == {"name"} - - doc.name = None - assert doc.is_modified - assert doc._modified == {"name"} - assert doc.name is None - - def test_coerce(self): - doc = MyDocument(once="1") - assert doc.once == 1 - - with self.assertRaises(ValueError) as ctx: - doc = MyDocument(once="1blguoaw") - assert "Unable to assign" in str(ctx.exception) - - def test_attribute_immutable(self): - # Should be able to set the value once even if it's None - doc = MyDocument(once=None) - doc.once == 1 - - doc = MyDocument(once="1") - doc.once == 1 - - with self.assertRaises(ValueError) as ctx: - doc.once = 2 - assert "Unable to set immutable attribute 'once'" == str(ctx.exception) - - with self.assertRaises(ValueError) as ctx: - doc.once = None - assert "Unable to set immutable attribute 'once'" == str(ctx.exception) - - def test_attribute_readonly(self): - with self.assertRaises(ValueError) as ctx: - MyDocument(id="123") - assert "Unable to set readonly attribute 'id'" == str(ctx.exception) - - doc = MyDocument() - with self.assertRaises(ValueError) as ctx: - doc.id = "123" - assert "Unable to set readonly attribute 'id'" == str(ctx.exception) - - def test_init_from_server(self): - now = datetime.now(timezone.utc) - # 2000-01-01, if set to 0 astimezone on windows in python 3.8 will error - timestamp = 946710000 - data = { - "id": 1, - "name": "server", - "local": "server", - "once": 2, - "default": datetime.fromtimestamp(timestamp).isoformat(), - "created_at": now.isoformat(), - "extra": "should be ignored", - } - - doc = MyDocument(**data, saved=True) - assert doc.id == 1 - assert doc.name == "server" - assert doc.local == "local" - assert doc.once == 2 - assert doc.default == datetime.fromtimestamp(timestamp, tz=timezone.utc) - assert doc.created_at == now - with self.assertRaises(AttributeError): - doc.extra - - def test_set_from_server(self): - now = datetime.now(timezone.utc) - doc = MyDocument(name="local", once="1", default=now) - # 2000-01-01, if set to 0 astimezone on windows in python 3.8 will error - timestamp = 946710000 - assert doc.once == 1 - - data = { - "id": 1, - "name": "server", - "local": "server", - "once": 2, - "default": datetime.fromtimestamp(timestamp).isoformat(), - "created_at": now.isoformat(), - } - doc._load_from_remote(data) - assert doc.id == 1 - assert doc.name == "server" - assert doc.local == "local" - assert doc.once == 2 - assert doc.default == datetime.fromtimestamp(timestamp, tz=timezone.utc) - assert doc.created_at == now - - def test_to_dict(self): - doc = MyDocument(name="local", once="1") - assert doc.to_dict() == { - "id": None, - "name": "local", - "local": "local", - "once": 1, - "default": doc.default.isoformat(), - "created_at": None, - } - - def test_deleted(self): - doc = MyDocument(name="local", once="1") - doc._deleted = True - - with self.assertRaises(AttributeError) as ctx: - doc.name - assert "MyDocument has been deleted" == str(ctx.exception) - - -class TestDatetimeAttribute(unittest.TestCase): - def test_local_time(self): - class TzTest(Document): - date: datetime = DatetimeAttribute(timezone=ZoneInfo("MST")) - - now = datetime.now(timezone.utc) - doc = TzTest(date=now.isoformat()) - assert doc.date.tzinfo == ZoneInfo("MST") - assert doc.date.astimezone(tz=timezone.utc) == now.replace(tzinfo=timezone.utc) - - assert doc.to_dict()["date"] == now.isoformat() - - def test_trailing_z(self): - class TrailingTest(Document): - date: datetime = DatetimeAttribute() - - now = datetime.now(timezone.utc) - doc = TrailingTest(date=now.isoformat()[:-6] + "Z") - assert doc.date == now - - def test_assign_instance(self): - tz = ZoneInfo("MST") - - class InstanceTest(Document): - date: datetime = DatetimeAttribute(timezone=tz) - - now = datetime.now(timezone.utc) - doc = InstanceTest(date=now) - assert doc.date == now.astimezone(tz=tz) - - def test_validation(self): - class ValidationTest(Document): - date: datetime = DatetimeAttribute() - - with self.assertRaises(ValueError) as ctx: - doc = ValidationTest(date={}) - assert "Expected datetime, iso formatted date or unix timestamp" in str( - ctx.exception - ) - - now = datetime.now(timezone.utc) - doc = ValidationTest(date=now.timestamp()) - assert doc.date == now - - def test_serialize_filter(self): - with self.assertRaises(ValueError) as ctx: - DatetimeAttribute()._serialize_to_filter({}) - assert "Expected datetime, iso formatted date or unix timestamp" in str( - ctx.exception - ) - - value = DatetimeAttribute()._serialize_to_filter("2023-01-01") - assert value == datetime(2023, 1, 1, tzinfo=timezone.utc).isoformat() - - -class TestListAttribute(unittest.TestCase): - def test_append(self): - class ListTest(Document): - items: list = ListAttribute(int) - - doc = ListTest(items=[1, 2], saved=True) - doc.items.append(3) - assert doc.items == [1, 2, 3] - assert doc.is_modified - assert doc.to_dict()["items"] == [1, 2, 3] - - def test_append_readonly(self): - class ListTest(Document): - items: list = ListAttribute(int, readonly=True) - - doc = ListTest(items=[1, 2], saved=True) - with self.assertRaises(ValueError) as ctx: - doc.items.append(3) - assert "Unable to append readonly attribute 'items'" == str(ctx.exception) - assert doc.items == [1, 2] - - def test_delete(self): - class ListTest(Document): - items: list = ListAttribute(int) - - doc = ListTest(items=[1, 2], saved=True) - del doc.items[0] - assert doc.items == [2] - assert doc.is_modified - assert doc.to_dict()["items"] == [2] - - def test_add_assign(self): - class ListTest(Document): - items: list = ListAttribute(int) - - doc = ListTest(items=[1, 2], saved=True) - doc.items += [3] - assert doc.items == [1, 2, 3] - assert doc.is_modified - assert doc.to_dict()["items"] == [1, 2, 3] - - doc._clear_modified() - doc.items += [] - assert doc.items == [1, 2, 3] - assert doc.is_modified is False - assert doc.to_dict()["items"] == [1, 2, 3] - - def test_clear(self): - class ListTest(Document): - items: list = ListAttribute(int) - - doc = ListTest(items=[1, 2], saved=True) - doc.items.clear() - assert doc.items == [] - assert doc.is_modified - assert doc.to_dict()["items"] == [] - - def test_extend(self): - class ListTest(Document): - items: list = ListAttribute(int) - - doc = ListTest(items=[1, 2], saved=True) - doc.items.extend([3, 4]) - assert doc.items == [1, 2, 3, 4] - assert doc.is_modified - assert doc.to_dict()["items"] == [1, 2, 3, 4] - - def test_insert(self): - class ListTest(Document): - items: list = ListAttribute(int) - - doc = ListTest(items=[1, 2], saved=True) - doc.items.insert(0, 0) - assert doc.items == [0, 1, 2] - assert doc.is_modified - assert doc.to_dict()["items"] == [0, 1, 2] - - def test_pop(self): - class ListTest(Document): - items: list = ListAttribute(int) - - doc = ListTest(items=[1, 2, 3], saved=True) - assert doc.items.pop() == 3 - assert doc.items == [1, 2] - assert doc.is_modified - assert doc.to_dict()["items"] == [1, 2] - - doc._clear_modified() - assert doc.items.pop(0) == 1 - assert doc.items == [2] - assert doc.is_modified - assert doc.to_dict()["items"] == [2] - - def test_remove(self): - class ListTest(Document): - items: list = ListAttribute(int) - - doc = ListTest(items=[1, 2, 3], saved=True) - doc.items.remove(2) - assert doc.items == [1, 3] - assert doc.is_modified - assert doc.to_dict()["items"] == [1, 3] - - def test_serializes_type(self): - class ListTest(Document): - items: list = ListAttribute(str) - - doc = ListTest(items=[1, 2, 3], saved=True) - assert doc.to_dict()["items"] == ["1", "2", "3"] - doc.items.append(4) - assert doc.is_modified - assert doc.to_dict()["items"] == ["1", "2", "3", "4"] diff --git a/descarteslabs/core/common/client/tests/test_search.py b/descarteslabs/core/common/client/tests/test_search.py deleted file mode 100644 index 8141ecba..00000000 --- a/descarteslabs/core/common/client/tests/test_search.py +++ /dev/null @@ -1,65 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 unittest -from unittest import mock - -from ..attributes import Attribute -from ..document import Document -from ..search import Search - - -class DocumentTest(Document): - name = Attribute(str, filterable=True) - num = Attribute(int, sortable=True) - order = Attribute(int, sortable=True) - - -class TestSearch(unittest.TestCase): - def test_search_immutable(self): - mock_client = mock.Mock() - search = Search(DocumentTest, mock_client, "/test_url") - search.filter(DocumentTest.name == "test") - search.sort(DocumentTest.order, -DocumentTest.num) - search.param(test="blah") - search.limit(10) - assert search._serialize() == {} - - def test_build_search(self): - mock_client = mock.Mock() - search = Search(DocumentTest, mock_client, "/test_url") - search = ( - search.filter(DocumentTest.name == "test") - .sort(DocumentTest.order, -DocumentTest.num) - .param(test="blah") - .limit(10) - ) - assert search._serialize() == { - "filter": '[{"name":"name","op":"eq","val":"test"}]', - "sort": ["order", "-num"], - "limit": 10, - "test": "blah", - } - - def test_search_not_allowed(self): - mock_client = mock.Mock() - search = Search(DocumentTest, mock_client, "/test_url") - - with self.assertRaises(ValueError) as ctx: - search.filter(DocumentTest.num == 123) - assert "Cannot filter on property: num" in str(ctx.exception) - - with self.assertRaises(ValueError) as ctx: - search.sort(DocumentTest.name) - assert "Cannot sort on property: name" in str(ctx.exception) diff --git a/descarteslabs/core/common/collection/__init__.py b/descarteslabs/core/common/collection/__init__.py deleted file mode 100644 index a3078da7..00000000 --- a/descarteslabs/core/common/collection/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 .collection import Collection, Eacher - -__all__ = ["Collection", "Eacher"] diff --git a/descarteslabs/core/common/collection/collection.py b/descarteslabs/core/common/collection/collection.py deleted file mode 100644 index 35d1f54a..00000000 --- a/descarteslabs/core/common/collection/collection.py +++ /dev/null @@ -1,468 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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. - -""" -A list-based sequence with helper methods, which serves as the base class for `SceneCollection`. -""" - -import collections -import itertools -from typing import Generic, TypeVar - -from ...client.deprecation import deprecate -from ...common.property_filtering.filtering import Expression - -T = TypeVar("T") - - -# TODO: maybe subclass collections.UserList? -class Collection(Generic[T]): - """ - List-based sequence with convenience methods for mapping and filtering, - and NumPy-style fancy indexing - """ - - def __init__(self, iterable=None, item_type=None): - if iterable is None: - self._list = [] - else: - self._list = list(iterable) - if item_type is not None: - self._item_type = item_type - else: - item_type = getattr(self, "_item_type", None) - - if item_type is not None: - if not all(map(lambda i: isinstance(i, item_type), self._list)): - raise ValueError( - f"item is not of required type {item_type.__name__}" - ) - - def __getitem__(self, idx): - """ - self[start:stop:step] <--> Collection(list(self[start:stop:step])) - self[] <--> Collection(self[i] for i in ) - Can slice like a normal list, or with a list of indices to select - """ - - if isinstance(idx, list) or type(idx).__name__ == "ndarray": - subset = [self._list[i] for i in idx] - return self._cast_and_copy_attrs_to(subset) - else: - if isinstance(idx, slice): - return self._cast_and_copy_attrs_to(self._list[idx]) - else: - return self._list[idx] - - def __setitem__(self, idx, item): - """ - Can assign a scalar, or a list of equal length to the slice, - to any valid slice (including an list of indices) - """ - item_type = getattr(self, "_item_type", None) - if isinstance(idx, (list, slice)) or type(idx).__name__ == "ndarray": - if isinstance(idx, slice): - idx = list(range(*idx.indices(len(self)))) - if isinstance(item, str) or not isinstance( - item, collections.abc.Sequence - ): # if scalar - item = [item] * len(idx) - item = list(item) - if len(idx) != len(item): - raise ValueError( - "Cannot assign {} items to a slice {} items long".format( - len(item), len(idx) - ) - ) - for i, x in zip(idx, item): - if item_type is not None and not isinstance(x, item_type): - raise ValueError( - f"item is not of required type {item_type.__name__}" - ) - self._list[i] = x - else: - if item_type is not None and not isinstance(x, item_type): - raise ValueError(f"item is not of required type {item_type.__name__}") - self._list[idx] = item - - def __iter__(self): - return iter(self._list) - - def __reversed__(self): - return reversed(self._list) - - def __contains__(self, other): - return other in self._list - - def __len__(self): - return len(self._list) - - def __eq__(self, other): - return self._list == other - - def __repr__(self): - return "{}({})".format(self.__class__.__name__, repr(self._list)) - - def _cast_and_copy_attrs_to(self, other): - # used to copy over any attrs a subclass may have set - other = self.__class__(other) - for k, v in self.__dict__.items(): - if k != "_list": - setattr(other, k, v) - return other - - @property - def each(self): - """ - Any operations chained onto - :attr:`~descarteslabs.common.collection.Collection.each` (attribute access, - item access, and calls) are applied to each item in the - :class:`~descarteslabs.common.collection.Collection`. - - Yields - ------ - Any - The result of an item with all operations following - :attr:`~descarteslabs.common.collection.Collection.each` applied to it. - - Notes - ----- - * Add :meth:`~descarteslabs.common.collection.Eacher.combine` - at the end of the operations chain to combine the results into a - list by default, or any container type passed into - :meth:`~descarteslabs.common.collection.Eacher.combine` - * Use - :meth:`pipe(f, *args, **kwargs) ` - to yield ``f(x, *args, **kwargs)`` for each item ``x`` yielded by the - preceeding operations chain - - Examples - -------- - >>> c = Collection(["one", "two", "three", "four"]) - >>> for x in c.each.capitalize(): - ... print(x) - One - Two - Three - Four - >>> c.each.capitalize()[:2] - 'On' - 'Tw' - 'Th' - 'Fo' - >>> c.each.capitalize().pipe(len) - 3 - 3 - 5 - 4 - >>> list(c.each.capitalize().pipe(len).combine(set)) - [3, 4, 5] - """ - return Eacher(iter(self._list)) - - def map(self, f): - """Returns a :class:`~descarteslabs.common.collection.Collection` of ``f`` applied to each item. - - Parameters - ---------- - f : callable - Apply function ``f`` to each element of the collection and return the result - as a collection. - - Returns - ------- - Collection - A collection with the results of the function ``f`` applied to each element - of the original collection. - """ - - res = (f(x) for x in self._list) - item_type = getattr(self, "_item_type", None) - if item_type is None or all(map(lambda i: isinstance(i, item_type), res)): - return self._cast_and_copy_attrs_to(res) - else: - return Collection(res) - - def filter(self, predicate): - """Returns a :class:`~descarteslabs.common.collection.Collection` filtered by predicate. - - Predicate can either be a ``callable`` or an - :py:class:`~descarteslabs.common.property_filtering.filtering.Expression` - from :ref:`property_filtering`. - - If the predicate is a ``callable``, :py:meth:`filter` will return all items - for which ``predicate(item)`` is ``True``. - - If the predicate is an - :py:class:`~descarteslabs.common.property_filtering.filtering.Expression`, - :py:meth:`filter` will return all items - for which ``predicate.evaluate(item)`` is ``True``. - - Parameters - ---------- - predicate : callable or Expression - Either a callable or a :ref:`property_filtering` `Expression` which is - called or evaluated for each item in the list. - - Returns - ------- - Collection - A new collection with only those items for which the predicate returned - or evaluated to ``True``. - """ - - if isinstance(predicate, Expression): - res = (x for x in self._list if predicate.evaluate(x)) - else: - res = (x for x in self._list if predicate(x)) - - return self._cast_and_copy_attrs_to(res) - - def sorted(self, *predicates, **reverse): - """ - Returns a :class:`~descarteslabs.common.collection.Collection`, - sorted by predicates in ascending order. - - Each predicate can be a key function, or a string of dot-chained attributes - to use as sort keys. The reverse flag returns results in descending order. - - Parameters - ---------- - predicates : callable or str - Any positional arguments are predicates. If the predicate is a string, - it denotes an attribute for each element, potentially with levels separated - by a dot. If the predicate is a callable, it must return the value to sort - by for each given element. - reverse : bool - The sort is ascending by default, by setting ``reverse`` to - ``True``, the sort will be descending. - - Returns - ------- - Collection - The sorted collection. - - Examples - -------- - >>> import collections - >>> FooBar = collections.namedtuple("FooBar", ["foo", "bar"]) - >>> X = collections.namedtuple("X", "x") - >>> c = Collection([FooBar(1, X("one")), FooBar(2, X("two")), FooBar(3, X("three"))]) - - >>> c.sorted("foo") - Collection([FooBar(foo=1, bar=X(x='one')), FooBar(foo=2, bar=X(x='two')), FooBar(foo=3, bar=X(x='three'))]) - >>> c.sorted("bar.x") - Collection([FooBar(foo=1, bar=X(x='one')), FooBar(foo=3, bar=X(x='three')), FooBar(foo=2, bar=X(x='two'))]) - """ - - if len(predicates) == 0: - raise TypeError("No predicate(s) given to sorted") - predicates = [ - self._str_to_predicate(p) if isinstance(p, str) else p for p in predicates - ] - if len(predicates) == 1: - predicate = predicates[0] - else: - - def predicate(v): - return tuple(p(v) for p in predicates) - - res = sorted(self, key=predicate, **reverse) - return self._cast_and_copy_attrs_to(res) - - def sort(self, field, ascending=True): - """ - Returns a :class:`~descarteslabs.common.collection.Collection`, - sorted by the given field and direction. - - Parameters - ---------- - field : str - The name of the field to sort by - ascending : bool - Sorts results in ascending order if True (the default), - and in descending order if False. - - Returns - ------- - Collection - The sorted collection. - - Example - ------- - >>> from descarteslabs.catalog import Product - >>> collection = Product.search().collect() # doctest: +SKIP - >>> sorted_collection = collection.sort("created", ascending=False) # doctest: +SKIP - >>> sorted_collection # doctest: +SKIP - """ - return self.sorted(field, reverse=not ascending) - - def groupby(self, *predicates): - """Groups items by predicates. - - Groups items by predicates and yields tuple of ``(group, items)`` - for each group, where ``items`` is a - :class:`~descarteslabs.common.collection.Collection`. - - Each predicate can be a key function, or a string of dot-chained attributes - to use as sort keys. - - Parameters - ---------- - predicates : callable or str - Any positional arguments are predicates. If the predicate is a string, - it denotes an attribute for each element, potentially with levels separated - by a dot. If the predicate is a callable, it must return the value to sort - by for each given element. - - Yields - ------ - Tuple[str, Collection] - A tuple of ``(group, Collection)`` for each group. - - Examples - -------- - >>> import collections - >>> FooBar = collections.namedtuple("FooBar", ["foo", "bar"]) - >>> c = Collection([FooBar("a", True), FooBar("b", False), FooBar("a", False)]) - - >>> for group, items in c.groupby("foo"): - ... print(group) - ... print(items) - a - Collection([FooBar(foo='a', bar=True), FooBar(foo='a', bar=False)]) - b - Collection([FooBar(foo='b', bar=False)]) - >>> for group, items in c.groupby("bar"): - ... print(group) - ... print(items) - False - Collection([FooBar(foo='b', bar=False), FooBar(foo='a', bar=False)]) - True - Collection([FooBar(foo='a', bar=True)]) - """ - - if len(predicates) == 0: - raise TypeError("No predicate(s) given to groupby") - predicates = [ - self._str_to_predicate(p) if isinstance(p, str) else p for p in predicates - ] - if len(predicates) == 1: - predicate = predicates[0] - else: - - def predicate(v): - return tuple(p(v) for p in predicates) - - ordered = self.sorted(predicate) - for group, items in itertools.groupby(ordered, predicate): - yield group, self._cast_and_copy_attrs_to(items) - - def append(self, x): - """Append x to the end of this :class:`~descarteslabs.common.collection.Collection`. - - The type of the item must match the type of the collection. - - Parameters - ---------- - x : Any - Add an item to the collection - """ - - item_type = getattr(self, "_item_type", None) - if item_type is not None and not isinstance(x, item_type): - raise ValueError(f"item is not of required type {item_type.__name__}") - - self._list.append(x) - - def extend(self, x): - """Extend this :class:`~descarteslabs.common.collection.Collection` by appending elements from the iterable. - - The type of the items in the list must all match the type of the collection. - - Parameters - ---------- - x : List[Any] - Extend a collection with the items from the list. - """ - - item_type = getattr(self, "_item_type", None) - if item_type is not None and not all( - map(lambda i: isinstance(i, item_type), x) - ): - raise ValueError(f"item is not of required type {item_type.__name__}") - - self._list.extend(x) - - @staticmethod - def _str_to_predicate(string): - attrs = string.split(".") - - def predicate(x): - result = x - for attr in attrs: - result = getattr(result, attr) - return result - - return predicate - - -class Eacher(object): - "Applies operations chained onto it to each item in an iterator" - - __slots__ = "_iterable" - - def __init__(self, iterable): - self._iterable = iterable - - def __iter__(self): - return iter(self._iterable) - - def __getattr__(self, attr): - return Eacher(getattr(x, attr) for x in self) - - def __getitem__(self, idx): - return Eacher(x[idx] for x in self) - - def __call__(self, *args, **kwargs): - return Eacher(x(*args, **kwargs) for x in self) - - @deprecate(renamed={"collection": "op"}) - def combine(self, op=list): - "self.combine(collection) <--> op(iter(self))" - - return op(iter(self)) - - def collect(self, collection=Collection): - "self.collect(collection) <--> collection(iter(self))" - - return collection(iter(self)) - - def pipe(self, callable, *args, **kwargs): - "self.pipe(f, *args, **kwargs) <--> f(x, *args, **kwargs) for x in self" - - return Eacher(callable(x, *args, **kwargs) for x in self) - - def __repr__(self): - max_length = 8 - objs = list(itertools.islice(self, max_length)) - try: - head = [repr(x) for x in objs] - except Exception: - return "<%s instance at %#x>" % (self.__class__.__name__, id(self)) - else: - s = "\n".join(head) - if len(head) == max_length: - s = s + "\n..." - return s diff --git a/descarteslabs/core/common/collection/tests/__init__.py b/descarteslabs/core/common/collection/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/descarteslabs/core/common/collection/tests/test_collection.py b/descarteslabs/core/common/collection/tests/test_collection.py deleted file mode 100644 index 9cf976d1..00000000 --- a/descarteslabs/core/common/collection/tests/test_collection.py +++ /dev/null @@ -1,164 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 collections -import pytest -import unittest - -from .. import Collection - - -class SubCollection(Collection): - def __init__(self, iterable=None, foo=1): - super(SubCollection, self).__init__(iterable) - self.foo = foo - self._secret = True - - -class TestCollection(unittest.TestCase): - def test_init_and_overloads(self): - c = Collection() - assert len(c) == 0 - - c = Collection([0, 1, 2]) - assert len(c) == 3 - for i, x in enumerate(c): - assert i == x - assert 1 in c - assert c == c - # self.assertEqual(reversed(reversed(c)), c) - - def test_slicing_and_setting(self): - c = Collection(range(10)) - assert c[4] == 4 - assert c[:2] == [0, 1] - assert c[:] == list(range(10)) - - slice = [0, 3, 5] - shouldbe = [c[i] for i in slice] - sliced = c[slice] - assert shouldbe == sliced - - c[0] = True - assert c[0] is True - c[-2:] = "foo" - assert c[-2:] == ["foo", "foo"] - c[-2:] = ["foo", "bar"] - assert c[-2:] == ["foo", "bar"] - with pytest.raises(ValueError): - c[:5] = [1, 2, 3] - - c = Collection(range(10)) - shouldbe = list(c) - for i in slice: - shouldbe[i] = True - c[slice] = True - assert c == shouldbe - - shouldbe = list(c) - for i, v in zip(slice, "abc"): - shouldbe[i] = v - c[slice] = list("abc") - assert c == shouldbe - - def test_each(self): - c = Collection([{"index": i} for i in range(10)]) - - assert c.each.combine() == c - assert c.each["index"].combine() == list(range(10)) - assert c.each.get("index").combine() == list(range(10)) - assert c.each["index"].pipe(str).zfill(2).combine() == [ - str(i).zfill(2) for i in range(10) - ] - - def test_cast_and_copy_attrs_to(self): - c = Collection([]) - c_copy = c._cast_and_copy_attrs_to([]) - assert isinstance(c_copy, Collection) - - subc = SubCollection([], foo="bar") - subc_copied = subc._cast_and_copy_attrs_to([]) - assert isinstance(subc_copied, SubCollection) - assert subc_copied.foo == "bar" - assert subc_copied._secret is True - - def test_str_to_predicate(self): - nt = collections.namedtuple("FooBar", "foo bar") - obj1 = nt(foo=True, bar=None) - obj2 = nt(foo=None, bar=obj1) - - assert obj2.bar.foo is True - - pred = Collection._str_to_predicate("bar.foo") - assert pred(obj2) is True - - pred = Collection._str_to_predicate("bar") - assert pred(obj2) == obj1 - - pred = Collection._str_to_predicate("bar.xyz") - with pytest.raises(AttributeError): - pred(obj2) - - def test_sorted(self): - nt = collections.namedtuple("FooBar", "foo bar") - orig = [nt(i, "baz") for i in range(10)] - orig_rev = list(reversed(orig)) - c = Collection(orig) - c.attr = -1 - - with pytest.raises(TypeError): - c.sorted() - - s = c.sorted(lambda x: x.foo) - assert s.attr == -1 - assert s == orig - - s = c.sorted(lambda x: x.foo, reverse=True) - assert s.attr == -1 - assert s == orig_rev - - s = c.sorted("foo", reverse=True) - assert s == orig_rev - - s = c.sorted("foo", "bar", reverse=True) - assert s == orig_rev - - def test_groupby(self): - nt = collections.namedtuple("FooBar", "foo bar") - c = Collection(nt(i, i % 2) for i in range(10)) - c.attr = "baz" - - with pytest.raises(TypeError): - list(c.groupby()) - - grouped = list(c.groupby(lambda x: x.bar)) - assert len(grouped) == 2 - for group, items in grouped: - assert isinstance(items, Collection) - assert list(items.each.bar) == [group] * len(items) - assert items.attr == "baz" - - groups, items = zip(*c.groupby("bar", "foo")) - assert groups == ( - (0, 0), - (0, 2), - (0, 4), - (0, 6), - (0, 8), - (1, 1), - (1, 3), - (1, 5), - (1, 7), - (1, 9), - ) diff --git a/descarteslabs/core/common/display/__init__.py b/descarteslabs/core/common/display/__init__.py deleted file mode 100644 index 265bb16a..00000000 --- a/descarteslabs/core/common/display/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 ._display import LayoutDirection, display, save_image - -__all__ = ["LayoutDirection", "display", "save_image"] diff --git a/descarteslabs/core/common/display/_display.py b/descarteslabs/core/common/display/_display.py deleted file mode 100644 index 931d0847..00000000 --- a/descarteslabs/core/common/display/_display.py +++ /dev/null @@ -1,305 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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. - -""" -Displays ndarrays as images, but is easier to use and more flexible than matplotlib's ``imshow``. -""" - -from __future__ import division - -import math - -import numpy as np -from strenum import StrEnum - - -def _import_matplotlib_pyplot(): - try: - import matplotlib - except ImportError: - raise ImportError("The matplotlib package is required for displaying images.") - try: - # a change in matplotlib at some point causes certain runtime failures - # unless these are previously imported - import matplotlib.backends - import matplotlib.backends.backend_agg # noqa F401 - except ImportError: - pass - try: - import matplotlib.pyplot - except RuntimeError as e: - if matplotlib.get_backend() == "MacOSX": - raise RuntimeError( - "Python is not installed as a framework; the Mac OS X backend will not work.\n" - "To resolve this, *before* calling dl.utils.display(), execute this code:\n\n" - "import matplotlib\n" - "matplotlib.use('TkAgg')\n" - "import matplotlib.pyplot as plt\n\n" - "In an interactive session, you'll have to restart your Python interpreter first." - ) - else: - raise e - return matplotlib - - -class LayoutDirection(StrEnum): - left_to_right = "left-to-right" - top_to_bottom = "top-to-bottom" - - @classmethod - def directions(cls): - return [attr for attr in dir(cls) if not attr.startswith("_")] - - -def display(*imgs, **kwargs): - """ - Display 2D and 3D ndarrays as images with matplotlib. - - The ndarrays must either be 2D, or 3D with 1 or 3 bands. - If they are 3D masked arrays, the mask will be used as an alpha channel. - - Unlike matplotlib's ``imshow``, arrays can be any dtype; - internally, each is normalized to the range [0..1]. - - Parameters - ---------- - *imgs: 1 or more ndarrays - When multiple images are given, each is displayed on its own row by default. - bands_axis: int, default 0 - Axis which contains bands in each array. - title: str, or sequence of str; optional - Title for each image. If a sequence, must be the same length as ``imgs``. - size: int, default 10 - Length, in inches, to display the longer side of each image. - robust: bool, default True - Use the 2nd and 98th percentiles to compute color limits. - Otherwise, the minimum and maximum values in each array are used. - interpolation: str, default "bilinear" - Interpolation method for matplotlib to use when scaling images for display. - - Bilinear is the default, since it produces smoother results when scaling - down continuously-valued data (i.e. images). For displaying discrete data, - however, choose 'nearest' to prevent values not existing in the input - from appearing in the output. - - Acceptable values are 'none', 'nearest', 'bilinear', 'bicubic', 'spline16', - 'spline36', 'hanning', 'hamming', 'hermite', 'kaiser', 'quadric', 'catrom', - 'gaussian', 'bessel', 'mitchell', 'sinc', 'lanczos' - colormap: str, default None - The name of a Colormap registered with matplotlib. Some commonly used - built-in options are 'plasma', 'magma', 'viridis', 'inferno'. See - https://matplotlib.org/users/colormaps.html for more options. - - To use a Colormap, the input images must have a single band. The Colormap - will be ignored for images with more than one band. - figsize: tuple(int), default (size, (size / ncols) * nrows) - Width, height in inches. - nrows: int, default is the number of images - Number of rows if there are multiple images. - ncols: int, default 1 - Number of columns if there are multiple images. - layout_direction: str, default "left-to-right" - If ncols is greated than 1, it determines whether the layout is left-to-right - for the images, or top-to-bottom. - - Raises - ------ - ImportError - If matplotlib is not installed. - """ - _display_or_save(None, *imgs, **kwargs) - - -def save_image(filename, *imgs, **kwargs): - """ - Save 2D and 3D ndarrays as images with matplotlib. - - For an explanation of the rest of the arguments, please look under :func:`display`. - For an explanation of valid extension types, please look under matplotlib - :func:'savefig'. - - Parameters - ---------- - filename: str - The name and extension of the image to be saved. - - """ - _display_or_save(filename, *imgs, **kwargs) - - -def _flatten(axs, left_to_right=True): - # Flatten the images into a single stream - if left_to_right: - for axcol in axs: - for ax in axcol: - yield ax - else: - # top to bottom - for col in range(len(axs[0])): - for axcol in axs: - yield axcol[col] - - -def _display_or_save(filename, *imgs, **kwargs): - if len(imgs) == 0: - return - - bands_axis = kwargs.pop("bands_axis", 0) - titles = kwargs.pop("title", None) - size = kwargs.pop("size", 10) - robust = kwargs.pop("robust", True) - interpolation = kwargs.pop("interpolation", "bilinear") - colormap_name = kwargs.pop("colormap", None) - figsize = kwargs.pop("figsize", None) - nrows = kwargs.pop("nrows", None) - ncols = kwargs.pop("ncols", 1) - layout_direction = LayoutDirection( - kwargs.pop("layoutdirection", LayoutDirection.left_to_right) - ) - - if len(kwargs) > 0: - raise TypeError( - "Unexpected keyword arguments for display: {}".format( - ", ".join(kwargs.keys()) - ) - ) - - matplotlib = _import_matplotlib_pyplot() - plt = matplotlib.pyplot - - if len(imgs) == 1: - if isinstance(imgs[0], (list, tuple)): - raise TypeError( - "To display a sequence of images, unpack it: `display(*images_list)`" - ) - elif isinstance(imgs[0], np.ndarray) and len(imgs[0].shape) == 4: - raise TypeError( - "To display a 4D ndarray (image stack), unpack it: `display(*stack)`" - ) - - # TODO: facet grid - # TODO: leaves huge gaps between images that aren't very square - # would need to calculate figsize better based on shapes of each img - # or use seaborn? - if nrows is None: - nrows = math.ceil(len(imgs) / ncols) - - if figsize is None: - figsize = size, (size / ncols) * nrows - - fig, axs = plt.subplots(nrows, ncols, figsize=figsize, squeeze=False) - - if isinstance(titles, (list, tuple, np.ndarray)): - if len(titles) != len(imgs): - raise ValueError("Different number of titles given than images") - else: - titles = [titles] * len(imgs) - - colormap = None - if colormap_name: - colormap = plt.cm.get_cmap(colormap_name) - - for ax, img, title in zip( - _flatten(axs, layout_direction == LayoutDirection.left_to_right), imgs, titles - ): - if not isinstance(img, np.ndarray): - raise TypeError("Expected ndarray, instead got {}".format(type(img))) - - if len(img.shape) not in (2, 3): - raise NotImplementedError( - "Can only display 2D or 3D arrays, not shape {}".format(img.shape) - ) - - if len(img.shape) == 2: - # expand 2d image to 3d with 1 band - slicer = [slice(None)] * 3 - slicer[bands_axis] = np.newaxis - img = img[tuple(slicer)] - - nbands = img.shape[bands_axis] - if nbands not in (1, 3, 4): - raise NotImplementedError( - "Can only display images with 1 or 3 bands currently, not {}. " - "Is axis {} actually your bands axis?".format(nbands, bands_axis) - ) - - if nbands == 4: - # don't include alpha band in min max - slicer = [slice(None)] * 3 - slicer[bands_axis] = slice(None, 3) - spectrals = img[tuple(slicer)] - else: - spectrals = img - - # calculate min and max - if robust: - if hasattr(spectrals, "mask"): - spectrals = ( - spectrals.compressed() - ) # don't include masked values in percentile - vmin, vmax = np.nanpercentile(spectrals, 2), np.nanpercentile(spectrals, 98) - if vmin == vmax: - robust = False - - if not robust: - vmin, vmax = np.ma.min(spectrals), np.ma.max(spectrals) - - # matplotlib requires shape (n, m, band) - disp = np.moveaxis(img, bands_axis, -1).astype(np.float64) - - # rescale - disp -= vmin - disp /= vmax - vmin - np.clip(disp, 0, 1, out=disp) # to coerce away any floating-point errors - - if hasattr(disp, "mask"): - # turn mask into alpha - if nbands == 4: - raise NotImplementedError( - "Currently can't supply an image with an explicit alpha band as well as a mask" - ) - if np.isscalar(disp.mask): - alpha = np.ones(disp.shape[:2]) * (not disp.mask) - else: - alpha = (~disp.mask.any(axis=-1)).astype(disp.dtype) - if nbands == 1: - if colormap: - disp = colormap(disp[:, :, 0]) - disp = disp[ - :, :, :3 - ] # Removes the alpha channel the color map always adds - else: - # to use an alpha channel, matplotlib must have a 4-band image, - # so just duplicate the 1 band for r, g, and b - disp = np.concatenate( - [disp] * 3, axis=-1 - ) # TODO: unnecessary copy of disp's mask - disp = np.concatenate([disp, alpha[:, :, np.newaxis]], axis=-1) - - if disp.shape[-1] == 1: - # matplotlib takes 1 band images as (n, m), not (n, m, 1) - disp = disp[:, :, 0] - if colormap: - disp = colormap(disp) - - ax.grid(False) # just to be sure - ax.imshow(disp, aspect="equal", interpolation=interpolation) - if title is not None: - ax.set_title(str(title)) - fig.tight_layout() - - if filename is not None: - plt.savefig(filename) - else: - plt.show() diff --git a/descarteslabs/core/common/display/tests/__init__.py b/descarteslabs/core/common/display/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/descarteslabs/core/common/display/tests/test_display.py b/descarteslabs/core/common/display/tests/test_display.py deleted file mode 100644 index 7f17099a..00000000 --- a/descarteslabs/core/common/display/tests/test_display.py +++ /dev/null @@ -1,138 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 __future__ import division - -import pytest -import unittest -from unittest import mock - -import numpy as np -from .. import _display -from .. import display - - -class TestDisplay(unittest.TestCase): - @staticmethod - def make_mock_subplots(mock_matplotlib_importer, n_imgs): - mock_matplotlib = mock.Mock() - mock_matplotlib_importer.return_value = mock_matplotlib - - mock_plt = mock_matplotlib.pyplot - mock_fig = mock.Mock() - mock_axs = [[mock.Mock()] for i in range(n_imgs)] - - mock_plt.subplots.return_value = (mock_fig, mock_axs) - return mock_plt, mock_fig, mock_axs - - @mock.patch.object(_display, "_import_matplotlib_pyplot") - def test_display_2d(self, mock_matplotlib_importer): - mock_plt, mock_fig, mock_axs = self.make_mock_subplots( - mock_matplotlib_importer, 1 - ) - - img = np.arange(6).reshape((3, 2)) - - img_normed = img / (img.size - 1) - display(img, size=5, title="foo", robust=False) - - mock_plt.subplots.assert_called_with(1, 1, figsize=(5, 5), squeeze=False) - - ax = mock_axs[0][0] - imshow_args, imshow_kwargs = ax.imshow.call_args - assert (imshow_args[0] == img_normed).all() - ax.set_title.assert_called_with("foo") - - @mock.patch.object(_display, "_import_matplotlib_pyplot") - def test_display_multi_cols(self, mock_matplotlib_importer): - mock_plt, mock_fig, mock_axs = self.make_mock_subplots( - mock_matplotlib_importer, 5 - ) - - img = np.arange(6).reshape((3, 2)) - display(img, img, img, img, img, ncols=2) - - mock_plt.subplots.assert_called_with(3, 2, figsize=(10, 15), squeeze=False) - - @mock.patch.object(_display, "_import_matplotlib_pyplot") - def test_display_3d_masked(self, mock_matplotlib_importer): - mock_plt, mock_fig, mock_axs = self.make_mock_subplots( - mock_matplotlib_importer, 1 - ) - - img = np.arange(3 * 3 * 2).reshape((3, 3, 2)) - mask = np.zeros_like(img).astype(bool) - mask[0, 0, 0] = True - mask[2, 0, 0] = True - mask[:, -1, -1] = True - img = np.ma.MaskedArray(img, mask=mask) - - alpha = np.ones((3, 2), dtype=float) - alpha[0, 0] = 0 - alpha[-1, -1] = 0 - - display(img) - - ax = mock_axs[0][0] - imshow_args, imshow_kwargs = ax.imshow.call_args - called_arr = imshow_args[0] - assert called_arr.shape == (3, 2, 4) - assert (called_arr[:, :, -1] == alpha).all() - - ax.set_title.assert_not_called() - - @mock.patch.object(_display, "_import_matplotlib_pyplot") - def test_display_3d_multiple(self, mock_matplotlib_importer): - mock_plt, mock_fig, mock_axs = self.make_mock_subplots( - mock_matplotlib_importer, 5 - ) - - img = np.arange(len(mock_axs) * 3 * 3 * 2).reshape((len(mock_axs), 3, 3, 2)) - with pytest.raises(TypeError, match="To display a 4D ndarray"): - display(img) - - display(*img, title=list(range(len(img)))) - - for i, ax in enumerate(mock_axs): - ax = ax[0] - imshow_args, imshow_kwargs = ax.imshow.call_args - called_arr = imshow_args[0] - assert called_arr.shape == (3, 2, 3) - ax.set_title.assert_called_with(str(i)) - - @mock.patch.object(_display, "_import_matplotlib_pyplot") - def test_fails_2band(self, mock_matplotlib_importer): - mock_plt, mock_fig, mock_axs = self.make_mock_subplots( - mock_matplotlib_importer, 1 - ) - - img = np.arange(2 * 4 * 2).reshape((2, 4, 2)) - - with pytest.raises(NotImplementedError): - display(img) - - @mock.patch.object(_display, "_import_matplotlib_pyplot") - def test_fails_wrong_num_titles(self, mock_matplotlib_importer): - mock_plt, mock_fig, mock_axs = self.make_mock_subplots( - mock_matplotlib_importer, 5 - ) - - img = np.arange(len(mock_axs) * 3 * 3 * 2).reshape((len(mock_axs), 3, 3, 2)) - with pytest.raises(ValueError, match="titles"): - display(*img, title=[1, 2]) - - @mock.patch.object(_display, "_import_matplotlib_pyplot") - def test_fails_wrong_kwargs(self, mock_matplotlib_importer): - with pytest.raises(TypeError, match="what"): - display(None, title="foo", what="bar") diff --git a/descarteslabs/core/common/dltile/__init__.py b/descarteslabs/core/common/dltile/__init__.py deleted file mode 100644 index 26d65df3..00000000 --- a/descarteslabs/core/common/dltile/__init__.py +++ /dev/null @@ -1,22 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 .tile import Tile, Grid -from .rasterize import rasterize_shape - -__all__ = [ - "Tile", - "Grid", - "rasterize_shape", -] diff --git a/descarteslabs/core/common/dltile/_tiling.py b/descarteslabs/core/common/dltile/_tiling.py deleted file mode 100644 index 3bb8878f..00000000 --- a/descarteslabs/core/common/dltile/_tiling.py +++ /dev/null @@ -1,239 +0,0 @@ -"""Implementation details for tile.Grid.tiles_from_shape""" - -# © 2025 EarthDaily Analytics Corp. -# -# 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 numpy as np -import shapely.geometry as geo - -from .exceptions import InvalidShapeError -from .utm import ( - UTM_MIN_LAT, - UTM_MAX_LAT, - UTM_MIN_EAST, - UTM_MAX_EAST, - FALSE_EASTING, - lonlat_to_utm, -) -from .utils import utm_box_to_lonlat - - -def _get_next_tiling(polygon, grid_width): - """This function yields (zone, path, row) tuples corresponding to - all tiles over a shape, given grid_width (resolution*tilesize) in meters.""" - - min_lon, min_lat, max_lon, max_lat = polygon.bounds - - if min_lon < -180.0: - raise InvalidShapeError("Polygon goes beyond -180deg longitude") - - if max_lon > 180.0: - raise InvalidShapeError("Polygon goes beyond +180deg longitude") - - if min_lat < UTM_MIN_LAT: - raise InvalidShapeError("Polygon goes beyond -90deg latitude") - - if max_lat > UTM_MAX_LAT: - raise InvalidShapeError("Polygon goes beyond +90deg latitude") - - yield from _tiling_method_appropriate_zones(polygon, grid_width) - - -def _tiling_method_appropriate_zones(polygon, grid_width): - """Chooses the most appropriate zones to tile a shape.""" - min_lon, _, max_lon, _ = polygon.bounds - min_zone = max(1, 1 + np.floor((min_lon + 180.0) / 6.0).astype(int)) - max_zone = 1 + min( - 60, 1 + np.floor((max_lon + 180.0) / 6.0).astype(int) - ) # exclusive range - - for zone in range(min_zone, max_zone): - zone_min_lon = 6 * zone - 186.0 - zone_max_lon = 6 * zone - 180.0 - zone_box = geo.box(zone_min_lon, UTM_MIN_LAT, zone_max_lon, UTM_MAX_LAT) - - polygon_in_zone = polygon.intersection(zone_box) - - if ( - isinstance(polygon_in_zone, (geo.Polygon, geo.MultiPolygon)) - and not polygon_in_zone.is_empty - ): - yield from _tile_zone( - polygon_in_zone, grid_width, zone, zone_min_lon, zone_max_lon - ) - - if isinstance(polygon_in_zone, geo.GeometryCollection): - for shape in polygon_in_zone: - if ( - isinstance(shape, (geo.Polygon, geo.MultiPolygon)) - and not shape.is_empty - ): - yield from _tile_zone( - shape, grid_width, zone, zone_min_lon, zone_max_lon - ) - - -def _tile_zone(polygon, grid_width, zone, zone_min_lon, zone_max_lon): - # buffer for utm zones curving away from latlon - _, quad_min_lat, _, quad_max_lat = polygon.bounds - b = 2000 * abs(quad_max_lat - quad_min_lat) - - polygon_utm = lonlat_to_utm(polygon, zone=zone).buffer(b) - min_east, min_north, max_east, max_north = polygon_utm.bounds - - min_east = max(min_east, UTM_MIN_EAST) - max_east = min(max_east, UTM_MAX_EAST) - - min_path = int(np.floor((min_east - FALSE_EASTING) / grid_width)) - min_row = int(np.floor(min_north / grid_width)) - max_path = int(np.ceil((max_east - FALSE_EASTING) / grid_width)) # exclusive - max_row = int(np.ceil(max_north / grid_width)) # exclusive - - quads = [(min_path, min_row, max_path, max_row, False)] - - # Traverse a quadtree division of tiles to efficiently find which ones - # intersect with the given shape - while len(quads) > 0: - ( - quad_min_path, - quad_min_row, - quad_max_path, - quad_max_row, - certainly_within_zone, - ) = quads.pop() - quadbox = geo.box( - FALSE_EASTING + quad_min_path * grid_width, - quad_min_row * grid_width, - FALSE_EASTING + quad_max_path * grid_width, - quad_max_row * grid_width, - ) - - quadbox_lonlat = utm_box_to_lonlat(quadbox, zone) - quad_min_lon, _, quad_max_lon, _ = quadbox_lonlat.bounds - if quad_min_lon > zone_max_lon or quad_max_lon < zone_min_lon: - continue - - quad_h = quad_max_row - quad_min_row - quad_w = quad_max_path - quad_min_path - - if quad_h <= 3 and quad_w <= 3: - # Just do an exhaustive check when both dimensions are - # less than 4 tiles across - for row in range(quad_min_row, quad_max_row): - for path in range(quad_min_path, quad_max_path): - quadbox = geo.box( - FALSE_EASTING + path * grid_width, - row * grid_width, - FALSE_EASTING + (path + 1) * grid_width, - (row + 1) * grid_width, - ) - quadbox_lonlat = utm_box_to_lonlat(quadbox, zone) - if quadbox_lonlat.intersects(polygon): - yield zone, path, row - - elif quadbox_lonlat.within(polygon): - # If the quadbox is entirely within our polygon, - # return all tiles in it - for row in range(quad_min_row, quad_max_row): - for path in range(quad_min_path, quad_max_path): - yield zone, path, row - - elif quadbox.intersects(polygon_utm): - quad_mid_path = int(np.floor((quad_max_path + quad_min_path) / 2)) - quad_mid_row = int(np.floor((quad_max_row + quad_min_row) / 2)) - - if quad_h <= 1: - # Split the quad into two quads to check independently - quads.append( - ( - quad_min_path, - quad_min_row, - quad_mid_path, - quad_max_row, - certainly_within_zone, - ) - ) - quads.append( - ( - quad_mid_path, - quad_min_row, - quad_max_path, - quad_max_row, - certainly_within_zone, - ) - ) - - elif quad_w <= 1: - # Split the quad into two quads to check independently - quads.append( - ( - quad_min_path, - quad_min_row, - quad_max_path, - quad_mid_row, - certainly_within_zone, - ) - ) - quads.append( - ( - quad_min_path, - quad_mid_row, - quad_max_path, - quad_max_row, - certainly_within_zone, - ) - ) - - else: - # Split the quad into four quads to check independently - quads.append( - ( - quad_min_path, - quad_mid_row, - quad_mid_path, - quad_max_row, - certainly_within_zone, - ) - ) - quads.append( - ( - quad_mid_path, - quad_mid_row, - quad_max_path, - quad_max_row, - certainly_within_zone, - ) - ) - quads.append( - ( - quad_min_path, - quad_min_row, - quad_mid_path, - quad_mid_row, - certainly_within_zone, - ) - ) - quads.append( - ( - quad_mid_path, - quad_min_row, - quad_max_path, - quad_mid_row, - certainly_within_zone, - ) - ) - - else: - # This quad doesn't intersect our polygon, continue - pass diff --git a/descarteslabs/core/common/dltile/conversions.py b/descarteslabs/core/common/dltile/conversions.py deleted file mode 100644 index 3ed96457..00000000 --- a/descarteslabs/core/common/dltile/conversions.py +++ /dev/null @@ -1,135 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 numbers -import numpy as np -import shapely.geometry as geo -from typing import List, Union - -from .exceptions import InvalidShapeError - -AnyShapes = Union[List[geo.base.BaseGeometry], geo.base.BaseGeometry, dict, str] - -AnyPoints = Union[List[geo.Point], geo.Point, dict, str, np.ndarray] - - -def normalize_polygons(shape_or_shapes: AnyShapes) -> List[geo.base.BaseGeometry]: - """Given a collection of shapes in some format, try to make it into a - list of shapely polygons.""" - if isinstance(shape_or_shapes, str): - shape_or_shapes = json.loads(shape_or_shapes) - - if isinstance(shape_or_shapes, list): - out = list() - for item in shape_or_shapes: - out.extend(normalize_polygons(item)) - return out - - if isinstance(shape_or_shapes, dict): - if "geometry" in shape_or_shapes: - shape = geo.shape(shape_or_shapes["geometry"]) - elif "features" in shape_or_shapes: - return [ - geo.shape(feature["geometry"]) - for feature in shape_or_shapes["features"] - ] - else: - shape = geo.shape(shape_or_shapes) - return normalize_polygons(shape) - - elif isinstance(shape_or_shapes, geo.MultiPolygon): - return [shape_or_shapes] - - elif isinstance(shape_or_shapes, geo.Polygon): - return [shape_or_shapes] - - elif isinstance(shape_or_shapes, geo.base.BaseGeometry): - raise InvalidShapeError( - "Geometries must be polygon or multipolygon, got %s" % type(shape_or_shapes) - ) - - elif hasattr(shape_or_shapes, "__geo_interface__"): - return normalize_polygons(shape_or_shapes.__geo_interface__) - - raise InvalidShapeError( - "Could not normalize shape or shapes of type %s" % type(shape_or_shapes) - ) - - -def normalize_points(point_or_points: AnyPoints) -> np.ndarray: - """Given a collection of points in some format, try to make it into a - numpy array.""" - if isinstance(point_or_points, list): - if isinstance(point_or_points, numbers.Number): - return np.array([point_or_points]) - out = list() - for item in point_or_points: - out.extend(normalize_points(item)) - return np.array(out) - - if isinstance(point_or_points, str): - point_or_points = json.loads(point_or_points) - - if isinstance(point_or_points, dict): - if "geometry" in point_or_points: - return np.array(point_or_points["geometry"]).reshape((-1, 2)) - elif "features" in point_or_points: - return np.array( - [ - np.array(feature["geometry"]).reshape((-1, 2)) - for feature in point_or_points["features"] - ] - ) - else: - raise InvalidShapeError( - "Could not normalize point or points of type dict without " - "geometry or features" - ) - - if isinstance(point_or_points, geo.Point): - x, y = geo.Point - return np.array([[x, y]]) - - elif isinstance(point_or_points, np.ndarray): - if len(point_or_points.shape) != 2: - raise InvalidShapeError( - "Incorrect number of dimensions for point_or_points array, " - "expected 2, got %i" % len(point_or_points.shape) - ) - if point_or_points.shape[-1] != 2: - raise InvalidShapeError( - "Incorrect size of last dimension for point_or_points array, " - "expected 2, got %i" % point_or_points.shape[-1] - ) - return point_or_points - - raise InvalidShapeError( - "Could not normalize point or points of type %s" % type(point_or_points) - ) - - -def points_from_polygon(polygon: geo.Polygon) -> List[np.array]: - """Get the exterior and interior points of a polygon from shapely""" - if not isinstance(polygon, geo.Polygon): - raise InvalidShapeError( - "Expected a shapely Polygon object, got %s" % type(polygon) - ) - if not polygon.exterior.coords: - return np.array([[], []]) - - points_list = [np.array(polygon.exterior.coords.xy).T[:-1, :]] - for interior in polygon.interiors: - points_list.append(np.array(interior.coords.xy).T[:-1, :]) - return points_list diff --git a/descarteslabs/core/common/dltile/exceptions.py b/descarteslabs/core/common/dltile/exceptions.py deleted file mode 100644 index 55b34d46..00000000 --- a/descarteslabs/core/common/dltile/exceptions.py +++ /dev/null @@ -1,29 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 InvalidTileError(ValueError): - pass - - -class InvalidLatLonError(ValueError): - pass - - -class InvalidRowColError(ValueError): - pass - - -class InvalidShapeError(ValueError): - pass diff --git a/descarteslabs/core/common/dltile/rasterize.py b/descarteslabs/core/common/dltile/rasterize.py deleted file mode 100644 index 3f29d831..00000000 --- a/descarteslabs/core/common/dltile/rasterize.py +++ /dev/null @@ -1,216 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 collections import namedtuple -import numpy as np -import shapely.geometry as geo -from typing import Sequence - -from .conversions import normalize_polygons, AnyShapes -from .tile import Tile -from .utm import utm_to_rowcol, lonlat_to_utm - - -def rasterize_shape( - tile: Tile, - shapes: AnyShapes, - values: Sequence[int] = None, - out: np.ndarray = None, - mode="burn", - dtype=np.byte, - shape_coords="lonlat", - all_touched=False, -) -> np.ndarray: - """Rasterize a collection of lon,lat shapes onto a DLTile. - - Parameters - ---------- - - tile : Tile - Defines the output raster geocontext - shapes : AnyShapes - Vector shapes to rasterize - values : Sequence[int], optional - If given, burns/adds from this sequence of values for the respective - shape in order. Must have the same length as `shapes`. - If not given, we burn the shape's index or add 1 by default. - out : np.ndarray, optional - If given, writes the output to this array - mode : str - 'burn' or 'add' - dtype : np.dtype, optional - dtype to use if creating `out` array - shape_coords : str - 'lonlat', 'utm', or 'rowcol' - all_touched : bool, optional - If True, affect all pixels where the shapes touch their neighborhood. - Otherwise, only affect pixels which are contained within each shape. - - Returns - ------- - - out: np.ndarray - An array containing raster data according to mode. This will be the - same array as the `out` parameter, if given. - - Raises - ------ - - ValueError - If invalid parameters are given - - """ - shapes = normalize_polygons(shapes) - - if values is None: - if mode == "burn": - values = range(1, len(shapes) + 1) - elif mode == "add": - values = (1 for _ in range(len(shapes))) - else: - raise ValueError("Expected mode of 'burn' or 'add', got %s" % mode) - elif len(values) != len(shapes): - raise ValueError( - "Expected parameter 'values' to have the same length as parameter " - "'shapes', got %i and %i." % (len(values), len(shapes)) - ) - - if out is None: - out = np.zeros((tile.tile_extent, tile.tile_extent), dtype=dtype) - - # Convert shapes to pixel coordinates - tilebox = geo.box(0, 0, tile.tile_extent, tile.tile_extent) - if shape_coords == "lonlat": - shapes_rowcol = [ - utm_to_rowcol(lonlat_to_utm(shape, zone=tile.zone), tile=tile).intersection( - tilebox - ) - for shape in shapes - ] - elif shape_coords == "utm": - shapes_rowcol = [ - utm_to_rowcol(shape, tile=tile).intersection(tilebox) for shape in shapes - ] - elif shape_coords == "rowcol": - shapes_rowcol = [shape.intersection(tilebox) for shape in shapes] - else: - raise ValueError( - "Parameter shape_coords of function rasterize_shape() must be one " - "of 'lonlat', 'rowcol', or 'utm'." - ) - - # We use a quadtree algorithm to rasterize. - TreeNode = namedtuple("TreeNode", ("min_col", "min_row", "max_col", "max_row")) - for shape_i, (shape, value) in enumerate(zip(shapes_rowcol, values)): - nodes = [TreeNode(0, 0, tile.tile_extent, tile.tile_extent)] - while len(nodes) > 0: - node = nodes.pop() - # "min_nodebox" is the smallest box we need to contain to cover all - # pixels in the box. "max_nodebox" is the larger box we would need - # to miss entirely in order to cover no pixels in the box. - if all_touched: - min_nodebox = geo.box( - node.min_row + 1, - node.min_col + 1, - node.max_row - 1, - node.max_col - 1, - ) - max_nodebox = geo.box( - node.min_row, node.min_col, node.max_row, node.max_col - ) - else: - min_nodebox = geo.box( - node.min_row + 0.5, - node.min_col + 0.5, - node.max_row - 0.5, - node.max_col - 0.5, - ) - max_nodebox = min_nodebox - node_w = node.max_col - node.min_col - node_h = node.max_row - node.min_row - - if node_w <= 3 and node_h <= 3: - # Check each pixel for being within shape - for row in range(node.min_row, node.max_row): - for col in range(node.min_col, node.max_col): - if all_touched: - pixelbox = geo.box(row, col, row + 1.0, col + 1.0) - condition = shape.intersects(pixelbox) - else: - pixel = geo.Point(row + 0.5, col + 0.5) - condition = shape.intersects(pixel) - if condition: - if mode == "burn": - out[row, col] = value - elif mode == "add": - out[row, col] += value - else: - raise ValueError( - "Expected mode of 'burn' or 'add', got %s" % mode - ) - elif shape.contains(min_nodebox): - # Apply to all pixels in box - if mode == "burn": - out[ - node.min_row : node.max_row, - node.min_col : node.max_col, - ] = value - elif mode == "add": - out[ - node.min_row : node.max_row, - node.min_col : node.max_col, - ] += value - else: - raise ValueError("Expected mode of 'burn' or 'add', got %s" % mode) - elif max_nodebox.disjoint(shape): - # No intersection, do nothing. - pass - else: - # There is some intersection. - # Split node into child nodes - node_mid_row = int((node.max_row + node.min_row) / 2) - node_mid_col = int((node.max_col + node.min_col) / 2) - - if node_h <= 1: - left_node = TreeNode( - node.min_col, node.min_row, node_mid_col, node.max_row - ) - right_node = TreeNode( - node_mid_col, node.min_row, node.max_col, node.max_row - ) - nodes.extend([left_node, right_node]) - elif node_w <= 1: - upper_node = TreeNode( - node.min_col, node.min_row, node.max_col, node_mid_row - ) - lower_node = TreeNode( - node.min_col, node_mid_row, node.max_col, node.max_row - ) - nodes.extend([upper_node, lower_node]) - else: - ul_node = TreeNode( - node.min_col, node.min_row, node_mid_col, node_mid_row - ) - ur_node = TreeNode( - node_mid_col, node.min_row, node.max_col, node_mid_row - ) - ll_node = TreeNode( - node.min_col, node_mid_row, node_mid_col, node.max_row - ) - lr_node = TreeNode( - node_mid_col, node_mid_row, node.max_col, node.max_row - ) - nodes.extend([ul_node, ur_node, ll_node, lr_node]) - - return out diff --git a/descarteslabs/core/common/dltile/tests/__init__.py b/descarteslabs/core/common/dltile/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/descarteslabs/core/common/dltile/tests/test_conversions.py b/descarteslabs/core/common/dltile/tests/test_conversions.py deleted file mode 100644 index 9a41be24..00000000 --- a/descarteslabs/core/common/dltile/tests/test_conversions.py +++ /dev/null @@ -1,117 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 pytest -import shapely.geometry as geo - -from ..conversions import normalize_polygons -from ..exceptions import InvalidShapeError - - -@pytest.fixture -def feature(): - return { - "type": "Feature", - "properties": {"id": 0}, - "geometry": { - "type": "Polygon", - "coordinates": [ - [ - [-67.13734, 45.13745], - [-66.96466, 44.8097], - [-68.03252, 44.3252], - [-67.13734, 45.13745], - ] - ], - }, - } - - -def test_normalize_polygons_jsonfeature(feature): - inshape = json.dumps(feature) - expected = [geo.shape(feature["geometry"])] - result = normalize_polygons(inshape) - assert result == expected - - -def test_normalize_polygons_jsonfeaturelist(feature): - inshape = json.dumps([feature]) - expected = [geo.shape(feature["geometry"])] - result = normalize_polygons(inshape) - assert result == expected - - -def test_normalize_polygons_geometry(feature): - inshape = feature["geometry"] - expected = [geo.shape(feature["geometry"])] - result = normalize_polygons(inshape) - assert result == expected - - -def test_normalize_polygons_feature(feature): - inshape = feature - expected = [geo.shape(feature["geometry"])] - result = normalize_polygons(inshape) - assert result == expected - - -def test_normalize_polygons_featurecollection(feature): - inshape = {"type": "FeatureCollection", "features": [feature]} - expected = [geo.shape(feature["geometry"])] - result = normalize_polygons(inshape) - assert result == expected - - -def test_normalize_polygons_poly(feature): - inshape = geo.shape(feature["geometry"]) - expected = [geo.shape(feature["geometry"])] - result = normalize_polygons(inshape) - assert result == expected - - -def test_normalize_polygons_multipoly(feature): - original_geom = feature["geometry"] - feature["geometry"]["coordinates"] = [original_geom["coordinates"]] - feature["geometry"]["type"] = "MultiPolygon" - inshape = geo.shape(feature["geometry"]) - expected = [geo.shape(original_geom)] - result = normalize_polygons(inshape) - assert result == expected - - -def test_normalize_polygons_geo_interface(feature): - class MockGeo(object): - @property - def __geo_interface__(self): - return feature - - inshape = MockGeo() - expected = [geo.shape(feature["geometry"])] - result = normalize_polygons(inshape) - assert result == expected - - -def test_normalize_polygons_badshape(feature): - feature["geometry"]["coordinates"] = [0, 0] - feature["geometry"]["type"] = "Point" - inshape = geo.shape(feature["geometry"]) - with pytest.raises(InvalidShapeError): - normalize_polygons(inshape) - - -def test_normalize_polygons_error(feature): - with pytest.raises(InvalidShapeError): - normalize_polygons([0, "a", None]) diff --git a/descarteslabs/core/common/dltile/tests/test_dltiles.py b/descarteslabs/core/common/dltile/tests/test_dltiles.py deleted file mode 100644 index f48890b1..00000000 --- a/descarteslabs/core/common/dltile/tests/test_dltiles.py +++ /dev/null @@ -1,354 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 pytest -from unittest import TestCase -import numpy as np - -from ..tile import Tile, Grid -from ..utm import lonlat_to_utm -from ..exceptions import ( - InvalidLatLonError, - InvalidRowColError, - InvalidTileError, - InvalidShapeError, -) - - -class TileTest(TestCase): - """Tests Tile class""" - - def test_from_key_1(self): - key = "2048:16:30.2:15:3:80" - tile = Tile.from_key(key) - assert tile.key == key - assert tile.zone == 15 - assert tile.resolution == 30.2 - assert tile.tilesize == 2048 - assert tile.path == 3 - assert tile.row == 80 - - def test_from_key_2(self): - key = "2048:16:30:15:-5:15" # no decimal in key - tile = Tile.from_key(key) - assert tile.key == "2048:16:30.0:15:-5:15" # decimal included - assert tile.resolution == 30 - assert tile.path == -5 - assert tile.row == 15 - - def test_get_invalid_dlkey_1(self): - invalid_key = "2048:16:30.0:0:3:80" # tilesize must be greater than zero - with pytest.raises(InvalidTileError): - Tile.from_key(invalid_key) - - def test_get_invalid_dlkey_2(self): - invalid_key = "blah:16:30.0:1:3:80" # invalid type - with pytest.raises(InvalidTileError): - Tile.from_key(invalid_key) - - def test_get_invalid_dlkey_3(self): - invalid_key = "2048:16.4:30.0:15:3:80" # pad must be int - with pytest.raises(InvalidTileError): - Tile.from_key(invalid_key) - - def test_dlkeys_subtile(self): - params = { - "resolution": 1, - "tilesize": 1024, - "pad": 0, - } - sub = 8 - lat, lon = 35.691544, -105.944183 - - tile = Grid(**params).tile_from_lonlat(lon, lat) - tiles = [t for t in tile.subtile(sub)] - assert len(tiles) == sub * sub - for t in tiles: - assert t.tilesize == params["tilesize"] // sub - - def test_dlkeys_subtile_with_params(self): - params = { - "resolution": 1, - "tilesize": 1024, - "pad": 0, - } - new_resolution = 2 - new_pad = 13 - sub = 4 - lat, lon = 35.691544, -105.944183 - - tile = Grid(**params).tile_from_lonlat(lon, lat) - tiles = [ - t for t in tile.subtile(sub, new_resolution=new_resolution, new_pad=new_pad) - ] - assert len(tiles) == sub * sub - for t in tiles: - assert np.allclose( - t.tilesize * new_resolution * sub, - params["tilesize"] * params["resolution"], - ) - assert t.pad == new_pad - assert t.resolution == new_resolution - - def test_dlkeys_subtile_error_1(self): - params = { - "resolution": 1, - "tilesize": 1024, - "pad": 0, - } - sub = 11 # does not evenly divide tilesize - lat, lon = 35.691544, -105.944183 - - tile = Grid(**params).tile_from_lonlat(lon, lat) - with pytest.raises(InvalidTileError): - [t for t in tile.subtile(sub)] - - def test_dlkeys_subtile_error_2(self): - params = { - "resolution": 1, - "tilesize": 1024, - "pad": 0, - } - sub = 8 - lat, lon = 35.691544, -105.944183 - - tile = Grid(**params).tile_from_lonlat(lon, lat) - with pytest.raises(InvalidTileError): - [t for t in tile.subtile(sub, new_resolution=13)] # does not divide - - def test_rowcol_conversions(self): - # get a polar tile - tile = Grid(tilesize=1000, resolution=1000, pad=0).tile_from_lonlat( - lon=0.0, lat=90.0 - ) - x, y = 567, 133 - lon, lat = tile.rowcol_to_lonlat(x, y) - row, col = tile.lonlat_to_rowcol(lon, lat) - assert row == x - assert col == y - - def test_invalid_rowcol(self): - tile = Grid(tilesize=1000, resolution=1000, pad=0).tile_from_lonlat( - lon=0.0, lat=90.0 - ) - x, y = [1, 1, 2, 3, 5], [42] - with pytest.raises(InvalidRowColError): - lon, lat = tile.rowcol_to_lonlat(x, y) - - def test_assign(self): - tile1 = Tile.from_key("2048:16:0.2:15:3:80") - assert tile1.resolution == 0.2 - tile2 = tile1.assign(resolution=1) - assert tile2.resolution == 1 - assert tile1.pad == tile2.pad - assert tile1.tilesize == tile2.tilesize - - def test_bad_assign(self): - tile1 = Tile.from_key("2048:16:0.2:15:3:80") - with pytest.raises(InvalidTileError): - # incompatible resolution and tilesize - tile1.assign(resolution=1, tilesize=512) - - -class GridTest(TestCase): - """Tests Grid class""" - - def test_make_invalid_grid(self): - with pytest.raises(InvalidTileError): - Grid(tilesize=0, resolution=1000, pad=0) - - def test_from_latlon(self): - params = {"tilesize": 1, "resolution": 1.5, "pad": 99} - lat, lon = (61.91, 5.26) - tile = Grid(**params).tile_from_lonlat(lon, lat) - assert tile.tilesize == params["tilesize"] - assert tile.pad == params["pad"] - assert tile.tile_extent == params["tilesize"] + 2 * params["pad"] - assert np.allclose( - [ - tile.polygon.centroid.xy[0][0], - tile.polygon.centroid.xy[1][0], - ], - [lon, lat], - ) - - def test_dlkeys_from_invalid_latlon(self): - lat, lon = -97.635, 212.723 - params = {"resolution": 60.0, "tilesize": 512, "pad": 0} - with pytest.raises(InvalidLatLonError): - Grid(**params).tile_from_lonlat(0, lat) - with pytest.raises(InvalidLatLonError): - Grid(**params).tile_from_lonlat(lon, 0) - - def test_tiles_from_shape_1(self): - params = { - "resolution": 10, - "tilesize": 2048, - "pad": 16, - } - shape = """{"coordinates": - [[[-90.1897158, 44.2267595], - [-87.9570052, 43.8067829], - [-88.5766841, 42.1269533], - [-90.7457357, 42.5435965], - [-90.1897158, 44.2267595]]], - "type": "Polygon"}""" - - grid = Grid(**params) - gen = grid.tiles_from_shape(shape) - tiles = [tile for tile in gen] - assert len(tiles) == len(set(tiles)) - assert len(tiles) == 116 - - est_ntiles = grid._estimate_ntiles_from_shape(shape) - assert len(tiles) > (est_ntiles // 2) - assert len(tiles) < (est_ntiles * 2) - - def test_tiles_from_shape_2(self): - params = { - "resolution": 1, - "tilesize": 128, - "pad": 8, - } - shape = { - "type": "Feature", - "geometry": { - "type": "Polygon", - "coordinates": [ - [ - [-122.51140471760839, 37.77130087547876], - [-122.45475646845254, 37.77475476721895], - [-122.45303985468301, 37.76657207194229], - [-122.51057242081689, 37.763446782666094], - [-122.51140471760839, 37.77130087547876], - ] - ], - }, - "properties": None, - } - - grid = Grid(**params) - gen = Grid(**params).tiles_from_shape(shape) - tiles = [tile for tile in gen] - assert len(tiles) == len(set(tiles)) - assert len(tiles) == 325 - - est_ntiles = grid._estimate_ntiles_from_shape(shape) - assert len(tiles) > (est_ntiles // 2) - assert len(tiles) < (est_ntiles * 2) - - def test_tiles_from_shape_3(self): - params = { - "resolution": 1, - "tilesize": 128, - "pad": 8, - } - feature = { - "type": "Feature", - "geometry": { - "type": "Polygon", - "coordinates": [ - [ - [-122.51140471760839, 37.77130087547876], - [-122.45475646845254, 37.77475476721895], - [-122.45303985468301, 37.76657207194229], - [-122.51057242081689, 37.763446782666094], - [-122.51140471760839, 37.77130087547876], - ] - ], - }, - "properties": None, - } - - # Any object with a __geo_interface__ property - # e.g. wf.map.geocontext() - class MockWfContext(object): - @property - def __geo_interface__(self): - return feature - - mock_context = MockWfContext() - - grid = Grid(**params) - gen = Grid(**params).tiles_from_shape(mock_context) - tiles = [tile for tile in gen] - assert len(tiles) == len(set(tiles)) - assert len(tiles) == 325 - - est_ntiles = grid._estimate_ntiles_from_shape(mock_context) - assert len(tiles) > (est_ntiles // 2) - assert len(tiles) < (est_ntiles * 2) - - def test_dlkeys_from_invalid_shape(self): - params = { - "resolution": 30, - "tilesize": 2048, - "pad": 16, - } - shape = {"type": "Point", "coordinates": [-105.01621, 39.57422]} - with pytest.raises(InvalidShapeError): - for t in Grid(**params).tiles_from_shape(shape): - pass - - def test_dltiles_utm_buffering(self): - params = { - "resolution": 10.0, - "tilesize": 4096, - "pad": 0, - } - - # extremely long and narrow strip which should cover - # most of the the known world - y_min, y_max = -66, 66 - x_min, x_max = -98, -90 - - feature = { - "type": "FeatureCollection", - "features": [ - { - "type": "Feature", - "geometry": { - "type": "Polygon", - "coordinates": [ - [ - [x_min, y_min], - [x_max, y_min], - [x_max, y_max], - [x_min, y_max], - [x_min, y_min], - ] - ], - }, - "properties": {}, - } - ], - } - - gen = Grid(**params).tiles_from_shape(feature) - - counter = 0 - for tile in gen: - counter += 1 - # simple check that all the tiles are in bounds - tx_min, ty_min, tx_max, ty_max = tile.polygon.bounds - - if tx_max < x_min or tx_min > x_max or ty_min > y_max or ty_max < y_min: - raise InvalidLatLonError("tile outside bounds") - - # simple check that coverage is greater than 100% - p = lonlat_to_utm([(x_min, y_min), (x_max, y_max)], ref_lon=x_min) - tile_area = (params["resolution"] * params["tilesize"]) ** 2 - est_total_area = (p[1][0] - p[0][0]) * (p[1][1] - p[0][1]) - ratio = (counter * tile_area) / est_total_area - assert ratio > 1 diff --git a/descarteslabs/core/common/dltile/tile.py b/descarteslabs/core/common/dltile/tile.py deleted file mode 100644 index b5491ee2..00000000 --- a/descarteslabs/core/common/dltile/tile.py +++ /dev/null @@ -1,577 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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. - -"""In this file we define two classes: Grid, which describes a way to -divide UTM zones in a grid, and Tile, which specifies a particular -element in a grid.""" - -import collections.abc -import numpy as np -import re -import shapely.geometry as geo -from typing import Generator, Sequence, Tuple, Union - -from . import _tiling as _tiling -from .conversions import normalize_polygons, AnyShapes -from .exceptions import ( - InvalidTileError, - InvalidLatLonError, - InvalidRowColError, -) -from .utm import ( - rowcol_to_utm, - utm_to_rowcol, - utm_to_lonlat, - lonlat_to_utm, - lon_to_zone, - FALSE_EASTING, - UTM_MIN_LON, - UTM_MAX_LON, - UTM_MIN_LAT, - UTM_MAX_LAT, -) - - -class Grid: - """A grid specifies for any given UTM zone, an origin-centered regular - division into square tiles, as well as the resolution and padding used - when those tiles represent the extent of raster data.""" - - gridkey_pattern = ( - r"(?P\d+):" r"(?P-?\d+):" r"(?P\d+.?\d+)" - ) - gridkey_regex = re.compile(gridkey_pattern) - - def __init__(self, resolution: float, tilesize: int, pad: int): - self._tilesize = int(tilesize) - self._pad = int(pad) - self._resolution = float(resolution) - - if self._tilesize <= 0: - raise InvalidTileError("Tile size must be greater than zero") - if self._pad < 0: - raise InvalidTileError("Pad value must be non-negative") - if self._resolution <= 0: - raise InvalidTileError("Resolution must be greater than zero") - - @classmethod - def from_key(cls, key) -> "Grid": - """ - Create a grid object from its string representation - """ - match = cls.gridkey_regex.match(key) - if match is None: - raise InvalidTileError("Invalid tile parameters") - - kwargs = match.groupdict() - return cls( - tilesize=int(kwargs["tilesize"]), - resolution=float(kwargs["resolution"]), - pad=int(kwargs["pad"]), - ) - - def __repr__(self) -> str: - res_string = ("%f" % self.resolution).rstrip("0") - if res_string[-1] == ".": - res_string += "0" - return "%i:%i:%s" % (self.tilesize, self.pad, res_string) - - @property - def tilesize(self) -> int: - """Get the tilesize parameter in meters""" - return self._tilesize - - @property - def pad(self) -> int: - """Get the pad value of the tile in meters""" - return self._pad - - @property - def resolution(self) -> float: - """Get the resolution in meters""" - return self._resolution - - @property - def tile_extent(self) -> int: - """Get the pixel size of a tile raster, including padding.""" - return self.tilesize + 2 * self.pad - - @property - def utm_tile_extent(self) -> float: - """Get the size of a tile in meters, including padding.""" - return self.tile_extent * self.resolution - - @property - def utm_tilesize(self) -> float: - """Get the size of a tile in meters, not including padding.""" - return self.tilesize * self.resolution - - def tile_from_lonlat(self, lon: float, lat: float) -> "Tile": - if lon < UTM_MIN_LON or lon > UTM_MAX_LON: - raise InvalidLatLonError("Longitude must be between -180.0 and 180.0") - if lat < UTM_MIN_LAT or lat > UTM_MAX_LAT: - raise InvalidLatLonError("Latitude must be between -90 and 90") - - zone = lon_to_zone(lon) - x, y = lonlat_to_utm(np.stack((lon, lat), axis=-1), zone=zone)[0] - x -= FALSE_EASTING - path = int(x // self.utm_tilesize) - row = int(y // self.utm_tilesize) - return Tile(self, zone=zone, path=path, row=row) - - def _estimate_ntiles_from_shape(self, shape: AnyShapes) -> int: - shape = normalize_polygons(shape) - ntiles = 0 - for s in shape: - lat = s.centroid.xy[1][-1] - m2 = 12321000000 * np.cos(lat * np.pi / 180) - ntiles += (s.area * m2) // ((self.resolution * self.tilesize) ** 2) - return int(ntiles) - - def tiles_from_shape( - self, shape: AnyShapes, keys_only=False - ) -> Generator[Union["Tile", str], None, None]: - """Yields tiles which cover the given shape. If zone is given, all - tiles will come from one UTM zone. This puts everything on the same - UTM grid, but comes with the trade-off of more area distortion the - further from the given zone your shape is.""" - shape = normalize_polygons(shape) - - key_set = set() # remove duplicate tiles - for polygon in shape: - for zone, path, row in _tiling._get_next_tiling( - polygon, self.tilesize * self.resolution - ): - tile = Tile(self, zone=zone, path=path, row=row) - tile_key = str(tile) - if tile_key not in key_set: - key_set.add(tile_key) - if keys_only: - yield tile_key - else: - yield tile - - -class Tile: - """A tile specifies a particular element of a grid system. Each tile is - uniquely specified by its parameters.""" - - tilekey_pattern = "%s:%s" % ( - Grid.gridkey_pattern, - r"(?P\d+):(?P-?\d+):(?P-?\d+)", - ) - tilekey_regex = re.compile(tilekey_pattern) - - def __init__(self, grid: Grid, zone: int, path: int, row: int): - self._grid = grid - self._path = int(path) - self._row = int(row) - self._zone = int(zone) - if self._zone <= 0 or self._zone > 60: - raise InvalidTileError("Invalid zone") - - @classmethod - def from_key(cls, key) -> "Tile": - match = cls.tilekey_regex.match(key) - if match is None: - raise InvalidTileError("Invalid DLTile key") - - kwargs = match.groupdict() - resolution = float(kwargs["resolution"]) - tilesize = int(kwargs["tilesize"]) - pad = int(kwargs["pad"]) - zone = int(kwargs["zone"]) - path = int(kwargs["path"]) - row = int(kwargs["row"]) - - if tilesize <= 0: - raise InvalidTileError("Invalid tile size") - if pad < 0: - raise InvalidTileError("Invalid padding") - if resolution <= 0: - raise InvalidTileError("Invalid resolution") - if zone <= 0 or zone > 60: - raise InvalidTileError("Invalid zone") - - grid = Grid( - resolution=resolution, - tilesize=tilesize, - pad=pad, - ) - return Tile( - grid, - zone=zone, - path=path, - row=row, - ) - - def __repr__(self) -> str: - zone = str(self.zone) - if len(zone) == 1: - zone = "0" + zone - - return "%s:%s:%i:%i" % ( - repr(self.grid), - zone, - self.path, - self.row, - ) - - def assign( - self, resolution: float = None, tilesize: int = None, pad: int = None - ) -> "Tile": - """Returns a new Tile with new resolution, tilesize, and / or pad (thus changing grid) - while keeping the tile region (ignoring pad) the same. If both resolution - and tilesize are specified, they must multiply to the same value as before.""" - new_grid = Grid( - tilesize=self.tilesize if tilesize is None else tilesize, - resolution=self.resolution if resolution is None else resolution, - pad=self.pad if pad is None else pad, - ) - - if resolution is None: - if tilesize is None and pad is None: - return self # change nothing - else: - if tilesize is not None: # change resolution and tilesize, must check - if resolution * tilesize != self.resolution * self.tilesize: - raise InvalidTileError( - "New resolution and tilesize are not compatible" - "With the old resolution and tilesize" - ) - - return Tile(new_grid, self.zone, self.path, self.row) - - @property - def tilesize(self) -> int: - return self.grid.tilesize - - @property - def tile_extent(self) -> int: - return self.grid.tile_extent - - @property - def utm_tilesize(self) -> float: - return self.grid.utm_tilesize - - @property - def utm_tile_extent(self) -> float: - return self.grid.utm_tile_extent - - @property - def pad(self) -> int: - return self.grid.pad - - @property - def resolution(self) -> float: - return self.grid.resolution - - @property - def row(self) -> int: - return self._row - - @property - def path(self) -> int: - return self._path - - @property - def zone(self) -> int: - return self._zone - - @property - def grid(self) -> Grid: - return self._grid - - @property - def key(self) -> str: - return str(self) - - @property - def utm_bounds_unpadded(self) -> Tuple[float, float, float, float]: - # Get the size in meters of the tile (not including pad) - utm_size = self.resolution * self.tilesize - - # Get the location in meters of the tile. - x_min = FALSE_EASTING + self.path * utm_size - y_min = self.row * utm_size - x_max = x_min + utm_size - y_max = y_min + utm_size - - return x_min, y_min, x_max, y_max - - @property - def utm_bounds(self) -> Tuple[float, float, float, float]: - x_min, y_min, x_max, y_max = self.utm_bounds_unpadded - utm_pad = self.pad * self.resolution - return ( - x_min - utm_pad, - y_min - utm_pad, - x_max + utm_pad, - y_max + utm_pad, - ) - - @property - def utm_polygon_unpadded(self) -> geo.Polygon: - x_min, y_min, x_max, y_max = self.utm_bounds_unpadded - utm_points = np.array( - [ - (x_min, y_min), - (x_max, y_min), - (x_max, y_max), - (x_min, y_max), - (x_min, y_min), - ] - ) - return geo.Polygon(utm_points) - - @property - def utm_polygon(self) -> geo.Polygon: - x_min, y_min, x_max, y_max = self.utm_bounds - utm_points = np.array( - [ - (x_min, y_min), - (x_max, y_min), - (x_max, y_max), - (x_min, y_max), - (x_min, y_min), - ] - ) - return geo.Polygon(utm_points) - - @property - def polygon(self) -> geo.Polygon: - """Shapely polygon""" - x_min, y_min, x_max, y_max = self.utm_bounds - utm_points = np.array( - [ - (x_min, y_min), - (x_max, y_min), - (x_max, y_max), - (x_min, y_max), - (x_min, y_min), - ] - ) - lonlat_points = utm_to_lonlat(utm_points, zone=self.zone) - return geo.Polygon(lonlat_points) - - @property - def center(self) -> geo.Point: - """Shapely centroid""" - return self.polygon.centroid - - @property - def epsg(self) -> int: - """Returns the coordinate system's European Petroleum Survey Group - geodetic parameter database's standard code, as an integer.""" - return 32600 + self.zone - - @property - def proj4(self) -> str: - return "+proj=utm +zone={} +datum=WGS84 +units=m +no_defs ".format(self.zone) - - @property - def srs(self) -> str: - """spatial reference system (srs) in well-known text (wkt)""" - return ( - """PROJCS["WGS 84 / UTM zone {zone}N",""" - """GEOGCS["WGS 84",""" - """DATUM["WGS_1984",""" - """SPHEROID["WGS 84",6378137,298.257223563,""" - """AUTHORITY["EPSG","7030"]],""" - """AUTHORITY["EPSG","6326"]],""" - """PRIMEM["Greenwich",0,""" - """AUTHORITY["EPSG","8901"]],""" - """UNIT["degree",0.0174532925199433,""" - """AUTHORITY["EPSG","9122"]],""" - """AUTHORITY["EPSG","4326"]],""" - """PROJECTION["Transverse_Mercator"],""" - """PARAMETER["latitude_of_origin",0],""" - """PARAMETER["central_meridian",{central_meridian}],""" - """PARAMETER["scale_factor",0.9996],""" - """PARAMETER["false_easting",500000],""" - """PARAMETER["false_northing",0],""" - """UNIT["metre",1,""" - """AUTHORITY["EPSG","9001"]],""" - """AXIS["Easting",EAST],""" - """AXIS["Northing",NORTH],""" - """AUTHORITY["EPSG","{epsg}"]]""" - ).format(zone=self.zone, central_meridian=(6 * self.zone - 183), epsg=self.epsg) - - @property - def geotransform(self) -> Tuple[float, float, float, float, float, float]: - """Returns the affine geotransform parameters in GDAL order""" - x_min, y_min, x_max, y_max = self.utm_bounds - left, top = x_min, y_max - res = self.resolution - return (left, res, 0.0, top, 0.0, -res) - - @property - def polygon_unpadded(self) -> geo.Polygon: - """Returns a shapely polygon object, lonlat coordinates, - of the unpadded tile extent""" - x_min, y_min, x_max, y_max = self.utm_bounds_unpadded - utm_points = [ - (x_min, y_min), - (x_max, y_min), - (x_max, y_max), - (x_min, y_max), - (x_min, y_min), - ] - lonlat_points = utm_to_lonlat(utm_points, zone=self.zone) - return geo.Polygon(lonlat_points) - - @property - def geometry(self) -> dict: - """Returns a geojson polygon geometry, lonlat coordinates""" - return geo.mapping(self.polygon) - - @property - def geocontext(self): - """For compatibility with the current descarteslabs module""" - properties = dict( - coordinateSystem={"wkt": self.srs}, - geometry=self.geometry, - key=str(self), - resolution=self.resolution, - tilesize=self.tilesize, - pad=self.pad, - cs_code="EPSG:%i" % self.epsg, - outputBounds=self.utm_bounds, - size=[self.tile_extent, self.tile_extent], - zone=self.zone, - ti=self.path, - tj=self.row, - geotrans=self.geotransform, - geoTransform=self.geotransform, - wkt=self.srs, - proj4=self.proj4, - ) - feature = self.feature - feature["properties"] = properties - return feature - - @property - def feature(self) -> dict: - """GeoJSON Feature""" - return dict( - type="Feature", - geometry=self.polygon, - properties={"tilekey": str(self)}, - ) - - def lonlat_to_rowcol( - self, - lon: Union[float, Sequence[float]], - lat: Union[float, Sequence[float]], - ): - """Convert lonlat coordinates to pixel coordinates""" - if type(lon) is not type(lat): - raise InvalidLatLonError("lat and lon should have compatible types") - - if isinstance(lon, (collections.abc.Sequence, np.ndarray)): - if len(lon) != len(lat): - raise InvalidLatLonError("lat and lon must be the same length") - - utm_coordinates = lonlat_to_utm( - np.stack((lon, lat), axis=-1), zone=self.zone - ) - return np.floor(utm_to_rowcol(utm_coordinates, tile=self)).astype(int) - else: - utm_coordinates = lonlat_to_utm(np.array([(lon, lat)]), zone=self.zone) - return np.floor(utm_to_rowcol(utm_coordinates, tile=self)[0]).astype(int) - - def rowcol_to_lonlat( - self, - row: Union[int, Sequence[int]], - col: Union[int, Sequence[int]], - ): - """Convert pixel coordinates to lonlat coordinates""" - if type(row) is not type(col): - raise InvalidRowColError("row and col should have compatible types") - - if isinstance(row, (collections.abc.Sequence, np.ndarray)): - if len(row) != len(col): - raise InvalidRowColError("row and col must be the same length") - - utm_coordinates = rowcol_to_utm(np.stack((row, col), axis=-1), tile=self) - return utm_to_lonlat(utm_coordinates, zone=self.zone) - else: - utm_coordinates = rowcol_to_utm(np.array([(row, col)]), tile=self) - return utm_to_lonlat(utm_coordinates, zone=self.zone)[0] - - def subtile( - self, - subdivide: int, - row: int = None, - col: int = None, - new_resolution: float = None, - new_pad: float = None, - ) -> Union[Generator["Tile", None, None], "Tile"]: - """Divide this tile into subdivide^2 total tiles. - If row,col is given, returns just the tile in that position - counting from the upper-left corner.""" - - if row is not None and col is None: - raise InvalidRowColError("col is None but row is not") - elif row is None and col is not None: - raise InvalidRowColError("row is None but col is not") - - if row is not None and not (0 <= row < subdivide): - raise IndexError( - "row is %i but should be between 0 and %i" % (row, subdivide) - ) - if col is not None and not (0 <= col < subdivide): - raise IndexError( - "col is %i but should be between 0 and %i" % (col, subdivide) - ) - - if new_resolution is None: - new_resolution = self.resolution - if new_pad is None: - new_pad = self.pad - - if not np.allclose(subdivide % 1, 0.0): - raise InvalidTileError("subdivide ratio must be an integer") - subdivide = int(subdivide) - - if not np.allclose(self.tilesize % subdivide, 0): - raise InvalidTileError( - "The subdivide ratio must evenly divide the original tilesize" - ) - - new_tilesize = (self.resolution * self.tilesize) / (subdivide * new_resolution) - - if not np.allclose(new_tilesize % 1, 0.0): - raise InvalidTileError( - "The tile can only be subdivided if the subdivide * new tilesize * new resolution is " - "equal to the original tilesize * original resolution" - ) - new_tilesize = int(new_tilesize) - - grid = Grid(resolution=new_resolution, tilesize=new_tilesize, pad=new_pad) - - # Get the path, row of the lower-left corner subtile - ll_path = self.path * subdivide - ll_row = self.row * subdivide - - # Get the path, row of the upper-left corner subtile - ul_path = ll_path - ul_row = ll_row + subdivide - 1 - - if row is not None: - return Tile(grid=grid, zone=self.zone, path=ul_path + col, row=ul_row - row) - - for j in range(subdivide): - for i in range(subdivide): - yield Tile(grid=grid, zone=self.zone, path=ul_path + i, row=ul_row - j) diff --git a/descarteslabs/core/common/dltile/utils.py b/descarteslabs/core/common/dltile/utils.py deleted file mode 100644 index 3be8020a..00000000 --- a/descarteslabs/core/common/dltile/utils.py +++ /dev/null @@ -1,108 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 numpy as np -import shapely.geometry as geo - -from .conversions import points_from_polygon -from . import utm as utm - - -def utm_box_to_lonlat(polygon: geo.Polygon, zone: int) -> geo.base.BaseGeometry: - """Given a box (polygon with four corners) in UTM coordinates, return - a box in lonlat coordinates which behaves appropriately at the prime - antimeridian and polar regions. This is used in tile.py to construct - appropriate tile boundaries.""" - min_x, min_y, max_x, max_y = polygon.bounds - - polygon_lonlat = utm.utm_to_lonlat(polygon, zone) - min_lon, min_lat, max_lon, max_lat = polygon_lonlat.bounds - - points_lonlat = np.array(points_from_polygon(polygon_lonlat)[0]) - - # First we check if the polygon touches or contains a pole when - # transforming it. If it does, we need to include points at the pole to - # cover the correct area in lonlat coordinates. - north_pole = geo.Point((utm.FALSE_EASTING, utm.UTM_MAX_NORTH)) - south_pole = geo.Point((utm.FALSE_EASTING, utm.UTM_MIN_NORTH)) - - wrap_north = polygon.touches(north_pole) or polygon.contains(north_pole) - wrap_south = polygon.touches(south_pole) or polygon.contains(south_pole) - - if wrap_north or wrap_south: - n_points, _ = points_lonlat.shape - - points_by_lon = np.sort(points_lonlat, axis=0) - wrapforward = points_by_lon[0, :][np.newaxis] + np.array([(360.0, 0.0)]) - wrapback = points_by_lon[-1, :][np.newaxis] - np.array([(360.0, 0.0)]) - - # Create a polygon that goes beyond 90deg latitude, then cut it down - # to the real range of lonlat. - if wrap_north: - excessive_polygon = geo.Polygon( - np.concatenate( - ( - wrapback, - points_by_lon, - wrapforward, - np.array([(wrapforward[0, 0], 91.0), (wrapback[0, 0], 91.0)]), - ), - axis=0, - ) - ) - else: # wrap_south - excessive_polygon = geo.Polygon( - np.concatenate( - ( - wrapforward, - points_by_lon[::-1], - wrapback, - np.array( - [ - (wrapback[0, 0], -91.0), - (wrapforward[0, 0], -91.0), - ] - ), - ), - axis=0, - ) - ) - return excessive_polygon.intersection(geo.box(-180.0, -90.0, 180.0, 90.0)) - - # A square in UTM coordinates which does not contain a pole can never - # map to a polygon with longitude range greater than 180deg. - # So because this polygon crosses the prime antimeridian, we know it - # doesn't cross the prime meridian, and we can split it accordingly. - - wraps_prime_antimeridian = ( - np.max(points_lonlat[:, 0]) - np.min(points_lonlat[:, 0]) > 180.0 - ) - if not wraps_prime_antimeridian: - return polygon_lonlat - - lons = points_lonlat[:, 0] - eastern_lons_mask = lons >= 0.0 - western_lons_mask = lons < 0.0 - - western_points = points_lonlat.copy() - western_points[eastern_lons_mask, 0] -= 360.0 - western_hemisphere = geo.box(-180.0, -90.0, 0.0, 90.0) - western_polygon = geo.Polygon(western_points).intersection(western_hemisphere) - - eastern_points = points_lonlat.copy() - eastern_points[western_lons_mask, 0] += 360.0 - eastern_hemisphere = geo.box(0.0, -90.0, 180.0, 90.0) - eastern_polygon = geo.Polygon(eastern_points).intersection(eastern_hemisphere) - - return geo.MultiPolygon([western_polygon, eastern_polygon]) diff --git a/descarteslabs/core/common/dltile/utm.py b/descarteslabs/core/common/dltile/utm.py deleted file mode 100644 index c95a97b1..00000000 --- a/descarteslabs/core/common/dltile/utm.py +++ /dev/null @@ -1,355 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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. - -""" Descartes Labs utilities for our Universal Transverse Mercator (UTM)-based -projection system. """ - -# The Descartes Labs projection system is slightly different from the -# canonical UTM standard. Only North UTM zones are used, including for the -# southern hemisphere; so there are no false northings. Also, the latitude -# range is extended to the full +/-90 (instead of -80 to +84). - -from collections.abc import Sequence -import json -import numpy as np -import shapely.geometry as geo - -from .conversions import points_from_polygon -from .exceptions import InvalidLatLonError - -# WGS84 constants: - -# usually written 'a' -EARTH_MAJOR_AXIS = 6378137 # in meters -# usually written 'f' -FLATTENING = 1.0 / 298.257223563 - -# UTM constants -# usually written 'k0' -POINT_SCALE_FACTOR = 0.9996 -# usually written 'E0' -FALSE_EASTING = 500000 # in meters -# Note that we do not use a false northing. - -# usually written 'n' -THIRD_FLATTENING = FLATTENING / (2 - FLATTENING) -# Usually written 'A' -RECTIFYING_RADIUS = ( - EARTH_MAJOR_AXIS - / (1 + THIRD_FLATTENING) - * (1.0 + 1.0 / 4.0 * THIRD_FLATTENING**2 + 1.0 / 64.0 * THIRD_FLATTENING**4) -) - -# Numbers outside these ranges are surely outside their UTM zone -# (but not strictly invalid) -UTM_MIN_EAST = FALSE_EASTING - 334000 -UTM_MAX_EAST = FALSE_EASTING + 334000 - -# Distances from equator to south/north poles, according to our transformation -# of points of latitude -90.0 and 90.0 respectively -UTM_MIN_NORTH = -9997964.943 -UTM_MAX_NORTH = 9997964.943 - -# Numbers outside these ranges are not supported by our UTM system -UTM_MIN_LON = -180.0 -UTM_MAX_LON = 180.0 -UTM_MIN_LAT = -90.0 -UTM_MAX_LAT = 90.0 - -# The width of a zone, in degrees longitude -ZONE_WIDTH_LON = 6 - - -def zone_to_lon(zone: int): - """Returns the middle longitude of a zone""" - if zone < 1 or zone > 60: - raise ValueError("Zones must be between 1 and 60 (inclusive)") - return zone * ZONE_WIDTH_LON - 183.0 - - -def lon_to_zone(lon: float): - if lon < UTM_MIN_LON or lon > UTM_MAX_LON: - raise InvalidLatLonError( - "Longitude must be between -180.0 and 180.0 " "(inclusive)" - ) - return max(1, 1 + np.floor((lon + 180.0) / 6.0).astype(int)) - - -def coordinate_transform(function): - """Decorate a function which accepts numpy arrays of shape (?, 2), and - optionally other arguments, and returns numpy arrays of the same shape; - then the function will work for shapes and non-numpy sequences as well as - numpy arrays, and will attempt to return arguments of the same type as its - points parameter. - """ - - def _transform(points, *args, axis=-1, **kwargs): - if isinstance(points, np.ndarray): - pass - - elif isinstance(points, str): - points = json.loads(points) - points = geo.shape(points) - transformed_points = _transform(points, *args, **kwargs) - return json.dumps(geo.mapping(transformed_points)) - - elif isinstance(points, dict): - points = geo.shape(points) - transformed_points = _transform(points, *args, **kwargs) - return geo.mapping(transformed_points) - - elif isinstance(points, geo.MultiPoint): - return geo.MultiPoint( - [_transform(geom, *args, **kwargs) for geom in points.geoms] - ) - - elif isinstance(points, geo.MultiLineString): - return geo.MultiLineString( - [_transform(geom, *args, **kwargs) for geom in points.geoms] - ) - - elif isinstance(points, geo.MultiPolygon): - return geo.MultiPolygon( - [_transform(geom, *args, **kwargs) for geom in points.geoms] - ) - - elif isinstance(points, geo.GeometryCollection): - return geo.GeometryCollection( - [_transform(geom, *args, **kwargs) for geom in points.geoms] - ) - - elif isinstance(points, Sequence): - try: - if np.isfinite(points).all(): - points = np.array(points, dtype=np.double) - else: - raise TypeError # Catch - except TypeError: - # The elements of this sequence could not become a numpy array, - # try instead to see if this is a list of polygons. - return [_transform(polygon, *args, **kwargs) for polygon in points] - - elif isinstance(points, geo.Polygon): - exterior_points, *interiors_points = points_from_polygon(points) - return geo.Polygon( - _transform(exterior_points, *args, **kwargs), - holes=[ - _transform(interior_points, *args, **kwargs) - for interior_points in interiors_points - ] - or None, - ) - - else: - raise TypeError( - "Could not interpret points of type %s, " - "try passing an ndarray or shape" % type(points) - ) - - points = points.swapaxes(axis, -1).reshape((-1, 2)).astype(np.double) - shape = list(points.shape) - shape[axis] = points.shape[-1] - shape[-1] = points.shape[axis] - transformed_points = function(points, *args, **kwargs) - return transformed_points.reshape(shape).swapaxes(axis, -1) - - return _transform - - -@coordinate_transform -def lonlat_to_utm(points, zone=None, ref_lon=None): - """Convert lon,lat points in a numpy array or shapely shape to UTM - coordinates in the given zone. - - Parameters - ---------- - - points: numpy array, shapely polygon/multipolygon, geojson, or array-like - Points of WGS84 lon,lat coordinates - zone: int, optional - UTM zone from 1 to 60 inclusive, must be specified if ref_lon is not - ref_lon: float, optional - Reference longitude to determine zone from - axis: int, default=-1 - The given axis should have size 2, with lon,lat pairs. - - Returns - ------- - - utm_points: tries to be the same type as points, or numpy array - - Raises - ------ - - ValueError - When UTM zone is outside of 1 to 60 inclusive, or the numpy array - axis does not have size==2. - """ - - if zone is None: - if ref_lon is None: - raise TypeError("Either `zone` or `ref_lon` must be specified") - zone = lon_to_zone(ref_lon) - - # These series expansion coefficients are sufficient to approximate the UTM - # projection system to a precision of millimeters. - n = THIRD_FLATTENING - N = 2 * np.sqrt(n) / (1.0 + n) - - a1 = 1.0 / 2.0 * n - 2.0 / 3.0 * n**2 + 5.0 / 16.0 * n**3 - a2 = 13.0 / 48.0 * n**2 - 3.0 / 5.0 * n**3 - a3 = 61.0 / 240.0 * n**3 - - lon = points[:, 0] - lat = points[:, 1] - radlon = np.deg2rad(lon - 6.0 * zone + 183.0) - radlat = np.deg2rad(lat) - - sinlat = np.sin(radlat) - t = np.sinh(np.arctanh(sinlat) - N * np.arctanh(N * sinlat)) - etap = np.arctanh(np.sin(radlon) / np.sqrt(1 + t**2)) - xip = np.arctan(t / np.cos(radlon)) - - easting = FALSE_EASTING + POINT_SCALE_FACTOR * RECTIFYING_RADIUS * ( - etap - + a1 * np.cos(2 * xip) * np.sinh(2 * etap) - + a2 * np.cos(4 * xip) * np.sinh(4 * etap) - + a3 * np.cos(6 * xip) * np.sinh(6 * etap) - ) - - northing = ( - POINT_SCALE_FACTOR - * RECTIFYING_RADIUS - * ( - xip - + a1 * np.sin(2 * xip) * np.cosh(2 * etap) - + a2 * np.sin(4 * xip) * np.cosh(4 * etap) - + a3 * np.sin(6 * xip) * np.cosh(6 * etap) - ) - ) - - return np.stack((easting, northing), axis=-1) - - -@coordinate_transform -def utm_to_lonlat(points, zone): - """Convert UTM points in a numpy array or shapely shape to lon,lat - coordinates in the given zone. - - Parameters - ---------- - - points: numpy array, shapely polygon/multipolygon, geojson, or array-like - Points of x,y coordinates in the given UTM north zone - zone: int - UTM north zone from 1 to 60 inclusive - axis: int, default=-1 - The given axis should have size 2, with UTM x,y pairs. - - Returns - ------- - - lonlat_points: tries to be the same type as points, or numpy array - - Raises - ------ - - ValueError - When UTM zone is outside of 1 to 60 inclusive, or the numpy array - axis does not have size==2. - """ - # These series expansion coefficients are sufficient to approximate the UTM - # projection system to a precision of millimeters. - n = THIRD_FLATTENING - - b1 = 1.0 / 2.0 * n - 2.0 / 3.0 * n**2 + 37.0 / 96.0 * n**3 - b2 = 1.0 / 48.0 * n**2 + 1.0 / 15.0 * n**3 - b3 = 17.0 / 480.0 * n**3 - - d1 = 2.0 * n - 2.0 / 3.0 * n**2 - 2.0 * n**3 - d2 = 7.0 / 3.0 * n**2 - 8.0 / 5.0 * n**3 - d3 = 56.0 / 15.0 * n**3 - - easting = points[:, 0] - northing = points[:, 1] - - xi = northing / (POINT_SCALE_FACTOR * RECTIFYING_RADIUS) - eta = (easting - FALSE_EASTING) / (POINT_SCALE_FACTOR * RECTIFYING_RADIUS) - - xip = xi - ( - b1 * np.sin(2 * xi) * np.cosh(2 * eta) - + b2 * np.sin(4 * xi) * np.cosh(4 * eta) - + b3 * np.sin(6 * xi) * np.cosh(6 * eta) - ) - - etap = eta - ( - b1 * np.cos(2 * xi) * np.sinh(2 * eta) - + b2 * np.cos(4 * xi) * np.sinh(4 * eta) - + b3 * np.cos(6 * xi) * np.sinh(6 * eta) - ) - - chi = np.arcsin(np.sin(xip) / np.cosh(etap)) - - lat = np.rad2deg( - chi + d1 * np.sin(2 * chi) + d2 * np.sin(4 * chi) + d3 * np.sin(6 * chi) - ) - lon = 6.0 * zone - 183.0 + np.rad2deg(np.arctan(np.sinh(etap) / np.cos(xip))) - - # Return all longitude outputs within the range -180.0, +180.0 - lon = (lon + 180.0) % 360.0 - 180.0 - - return np.stack((lon, lat), axis=-1) - - -@coordinate_transform -def utm_to_rowcol(utm_points, tile): - """Convert UTM points in an array of shape (?, 2) to row,col array indices - given a tile.""" - if not utm_points.shape[1] == 2: - raise ValueError( - "Expected array of utm points of shape (?, 2), got %s" - % str(utm_points.shape) - ) - - min_col = tile.tilesize * tile.path - tile.pad - max_row = tile.tilesize * (tile.row + 1) + tile.pad - - east = utm_points[:, 0] - FALSE_EASTING - north = utm_points[:, 1] - - row = max_row - north / tile.resolution - col = east / tile.resolution - min_col - - return np.stack((row, col), axis=-1) - - -@coordinate_transform -def rowcol_to_utm(indices, tile): - """Convert row,col array indices in an array of shape (?, 2) to UTM - coordinates given a tile.""" - if not indices.shape[1] == 2: - raise ValueError( - "Expected array of utm points of shape (?, 2), got %s" % str(indices.shape) - ) - - min_col = tile.tilesize * tile.path - tile.pad - max_row = tile.tilesize * (tile.row + 1) + tile.pad - - row = indices[:, 0] - col = indices[:, 1] - - east = (col + min_col + 0.5) * tile.resolution + FALSE_EASTING - north = (max_row - row - 0.5) * tile.resolution - - return np.stack((east, north), axis=-1) diff --git a/descarteslabs/core/common/dotdict/__init__.py b/descarteslabs/core/common/dotdict/__init__.py deleted file mode 100644 index 93ff355b..00000000 --- a/descarteslabs/core/common/dotdict/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 .dotdict import DotDict, DotList, DotDict_items, DotDict_values # noqa: F401 - -__all__ = ["DotDict", "DotList"] diff --git a/descarteslabs/core/common/dotdict/dotdict.py b/descarteslabs/core/common/dotdict/dotdict.py deleted file mode 100644 index 1a1889f5..00000000 --- a/descarteslabs/core/common/dotdict/dotdict.py +++ /dev/null @@ -1,515 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 reprlib -from itertools import islice - - -class DotDict(dict): - """ - Subclass of dict, with "dot" (attribute) access to keys, - a pretty-printed repr, which indents and truncates large containers, - and a JSON repr for Jupyter Lab. - - Any dicts stored in DotDict are returned as DotDicts, to allow chained attribute access. - Any lists stored in DotDict are returned as DotLists, which return any contained dict items as DotDicts, - allowing chained attribute access past list indexing. - - The repr() of a DotDict is truncated for readability, but str() is not. - - Example - ------- - - >>> d = DotDict(a=1, b=[{"foo": "bar"}]) - >>> d.a - 1 - >>> d["a"] - 1 - >>> d.b - [ - { - 'foo': 'bar' - } - ] - >>> d.b[0].foo - 'bar' - """ - - __slots__ = () # no need for a namespace __dict__ when DotDict is a dict already - - def _repr_json_(self): - return self, {"expanded": False} - - @classmethod - def _box(cls, value): - "If value is a dict or list, return it as a DotDict or DotList, otherwise return unmodified value." - if type(value) is dict: - return cls(value) - elif type(value) is list: - return DotList(value) - else: - return value - - def __getitem__(self, key): - """ - x.__getitem__(y) <==> x[y] - If x[y] is a dict or list, it is returned as a DotDict or DotList. - """ - try: - v = dict.__getitem__(self, key) - except KeyError: - raise KeyError(key) from None - - v = self._box(v) - self[key] = v - return v - - def __getattr__(self, attr): - """ - self.attr <==> self[attr] - If x[y] is a dict or list, it is returned as a DotDict or DotList. - """ - try: - return self[attr] - except KeyError: - try: - return object.__getattribute__(self, attr) - except AttributeError: - raise AttributeError(attr) from None - - def __setattr__(self, attr, val): - "self.attr = x <==> self[attr] = x" - self[attr] = val - - def __delattr__(self, attr): - "del self.attr <==> del self[attr]" - try: - del self[attr] - except KeyError: - raise AttributeError(attr) from None - - def __dir__(self): - return list(self.keys()) + dir(dict) - - def __repr__(self): - return idr.repr(self) - - def __str__(self): - return untruncated_idr.repr(self) - - def items(self): - """ - Equivalent to dict.items. - Values that are plain dicts or lists are returned as DotDicts or DotLists. - """ - return DotDict_items(self) - - def values(self): - """ - Equivalent to dict.values. - Values that are plain dicts or lists are returned as DotDicts or DotLists. - """ - return DotDict_values(self) - - def get(self, key, default=None): - """ - D.get(k[,d]) -> D[k] if k in D, else d. d defaults to None. - Values that are dicts or lists are cast to DotDicts and DotLists. - """ - try: - return self[key] - except KeyError: - return self._box(default) - - def pop(self, key, default=None): - """ - D.pop(k[,d]) -> v, remove specified key and return the corresponding value. - If key is not found, d is returned if given, otherwise KeyError is raised. - If v is a dict or list, it is returned as a DotDict or DotList. - """ - result = dict.pop(self, key, default) - return self._box(result) - - def popitem(self): - """ - D.popitem() -> (k, v), remove and return some (key, value) pair as a - 2-tuple; but raise KeyError if D is empty. - If v is a dict or list, it is returned as a DotDict or DotList. - """ - k, v = dict.popitem(self) - return k, self._box(v) - - def setdefault(self, key, default=None): - """ - D.setdefault(k[,d]) -> D.get(k,d), also set D[k]=d if k not in D - If d is a dict or list, it is returned as a DotDict or DotList. - """ - try: - return self[key] - except KeyError: - default = self._box(default) - self[key] = default - return default - - def asdict(self): - """ - D.asdict() -> a deep copy of D, where any DotDicts or DotLists contained are converted to plain types. - Raises RuntimeError if the container is recursive (contains itself as a value). - """ - # TODO: does not handle recursive structures - - # note: we're assuming here that any plain dict/list doesn't contain Dot-types - # within it. this is safe for normal usage of DotDict: any assignment to a - # DotDict will cause all the levels in the hierarchy to be converted to - # Dot-types However, if someone creates a plain dict, assigns DotDicts as its - # values, then assigns *that* plain dict to a value in a DotDict, the asdict - # of the containing DotDict will stop when it hits the plain dict. The most - # probable case here is assigning Dot-types as values in a dictionary or list - # comprehension. - - unboxed = {} - iterator = dict.items - for k, v in iterator(self): - if isinstance(v, DotDict): - v = v.asdict() - if isinstance(v, DotList): - v = v.aslist() - unboxed[k] = v - return unboxed - - -class DotDict_view(object): - """Wrapper around a dictionary view object that yields dicts and lists as DotDicts and DotLists when iterated.""" - - __slots__ = ("_view",) - - def __init__(self, dotdict): - self._view = dict.items(dotdict) - self._dict = dotdict - - def __iter__(self): - """Implement iter(self).""" - for k, v in self._view: - boxed = DotDict._box(v) - if boxed is not v: - self._dict[k] = boxed - yield k, boxed - - def __len__(self): - """Return len(self).""" - return self._view.__len__() - - def __repr__(self): - """Return repr(self).""" - return "{}({})".format(self.__class__.__name__, list(self)) - - -class DotDict_values(DotDict_view): - """Wrapper around a dict_values object that yields dicts and lists as DotDicts and DotLists when iterated.""" - - def __iter__(self): - for k, v in super(DotDict_values, self).__iter__(): - yield v - - -class DotDict_items(DotDict_view): - """Wrapper around a dict_values object that yields dicts and lists as DotDicts and DotLists when iterated.""" - - def __and__(self, value): - """Return self&value.""" - return self._view.__and__(value) - - def __contains__(self, key): - """Return key in self.""" - return self._view.__contains__(key) - - def __eq__(self, value): - """Return self==value.""" - return self._view.__eq__(value) - - def __ge__(self, value): - """Return self>=value.""" - return self._view.__ge__(value) - - def __gt__(self, value): - """Return self>value.""" - return self._view.__gt__(value) - - def __le__(self, value): - """Return self<=value.""" - return self._view.__le__(value) - - def __lt__(self, value): - """Return self x[y] - If x[y] is a dict or list, it is returned as a DotDict or DotList. - """ - try: - item = list.__getitem__(self, i) - except IndexError: - raise IndexError("list index out of range") from None - - item = DotDict._box(item) - self[i] = item - return item - - def __getslice__(self, i, j): - return self.__getitem__(slice(i, j)) - - def __iter__(self): - for i in range(0, len(self)): - yield self[i] - - def __repr__(self): - return idr.repr(self) - - def __str__(self): - return untruncated_idr.repr(self) - - def pop(self, i=-1): - """ - L.pop([index]) -> item -- remove and return item at index (default last). - If item is a dict or list, it is returned as a DotDict or DotList. - Raises IndexError if list is empty or index is out of range. - """ - result = list.pop(self, i) - return DotDict._box(result) - - def aslist(self): - """ - L.aslist() -> a deep copy of L, where any DotDicts or DotLists contained are converted to plain types. - Raises RuntimeError if the container is recursive (contains itself as a value). - """ - unboxed = list(self) - for i, obj in enumerate(unboxed): - if isinstance(obj, DotList): - unboxed[i] = obj.aslist() - elif isinstance(obj, DotDict): - unboxed[i] = obj.asdict() - return unboxed - - -def _possibly_sorted(x): - # Since not all sequences of items can be sorted and comparison - # functions may raise arbitrary exceptions, return an unsorted - # sequence in that case. - try: - return sorted(x) - except Exception: - return list(x) - - -class IndentedRepr(reprlib.Repr, object): - def __init__(self): - super(IndentedRepr, self).__init__() - - self.maxstring = 90 # about the maximum width of a Jupyter Notebook - self.maxlevel = 6 - self.maxlist = 4 - self.maxdict = None - - self.indent = 2 - - def repr_DotDict(self, x, level): - return self.repr_dict(x, level) - - def repr_DotList(self, x, level): - return self.repr_list(x, level) - - def repr_unicode(self, x, level): - return self.repr_str(x, level) - - def repr1(self, x, level): - # repr1 is explicity defined rather than inherited, - # because py2 and py3 have different implementations---py2 inlines repr_instance, basically - typename = type(x).__name__ - if " " in typename: - parts = typename.split() - typename = "_".join(parts) - if hasattr(self, "repr_" + typename): - return getattr(self, "repr_" + typename)(x, level) - else: - return self.repr_instance(x, level) - - def repr_dict(self, x, level): - n = len(x) - if n == 0: - return "{}" - if self.maxlevel is not None: - depth = self.maxlevel - level - if level <= 0: - return "{...}" - newlevel = level - 1 - else: - if level is None: - level = 0 - depth = level - newlevel = level + 1 - repr1 = self.repr1 - pieces = [] - for key in islice( - _possibly_sorted(x), - self.maxdict if self.maxdict is not None and depth > 0 else None, - ): - keyrepr = repr1(key, newlevel) - valrepr = repr1(x[key], newlevel) - pieces.append("%s: %s" % (keyrepr, valrepr)) - if self.maxdict is not None and n > self.maxdict and depth > 0: - pieces.append("...") - - outer_indent = " " * (self.indent * depth) - inner_indent = outer_indent + " " * self.indent - s = (",\n%s" % inner_indent).join(pieces) - return "{\n%s%s\n%s}" % (inner_indent, s, outer_indent) - - def _repr_iterable(self, x, level, left, right, maxiter, trail=""): - n = len(x) - if self.maxlevel is not None: - depth = self.maxlevel - level - newlevel = level - 1 - else: - if level is None: - level = 0 - depth = level - newlevel = level + 1 - - outer_indent = " " * (self.indent * depth) - inner_indent = outer_indent + " " * self.indent - - if self.maxlevel is not None and level <= 0 and n: - s = "..." - else: - repr1 = self.repr1 - pieces = [ - repr1(elem, newlevel) - for elem in islice( - x, maxiter if maxiter is not None and depth > 0 else None - ) - ] - - has_multiline_pieces = any("\n" in piece for piece in pieces) - - if maxiter is not None and n > maxiter and depth > 0: - pieces.append("...") - - if has_multiline_pieces or maxiter is not None and n > maxiter: - # multiline if long list, or components have line breaks (prevents weird closing bracket indentation) - s = (",\n%s" % inner_indent).join(pieces) - s = "\n%s%s\n%s" % (inner_indent, s, outer_indent) - else: - # single line if short - s = ", ".join(pieces) - - if n == 1 and trail: - right = trail + right - - return "%s%s%s" % (left, s, right) - - def repr_str(self, x, level): - s = repr(x[: self.maxstring]) - if self.maxstring is not None: - if len(s) > self.maxstring: - i = max(0, (self.maxstring - 3) // 2) - j = max(0, self.maxstring - 3 - i) - s = repr(x[:i] + x[len(x) - j :]) - s = s[:i] + "..." + s[len(s) - j :] - return s - - def repr_int(self, x, level): - return self.repr_long(x, level) - - def repr_long(self, x, level): - s = repr(x) # XXX Hope this isn't too slow... - if self.maxlong is not None and len(s) > self.maxlong: - i = max(0, (self.maxlong - 3) // 2) - j = max(0, self.maxlong - 3 - i) - s = s[:i] + "..." + s[len(s) - j :] - return s - - def repr_instance(self, x, level): - try: - s = repr(x) - # Bugs in x.__repr__() can cause arbitrary - # exceptions -- then make up something - except Exception: - return "<%s instance at %#x>" % (x.__class__.__name__, id(x)) - if self.maxother is not None and len(s) > self.maxother: - i = max(0, (self.maxother - 3) // 2) - j = max(0, self.maxother - 3 - i) - s = s[:i] + "..." + s[len(s) - j :] - return s - - -idr = IndentedRepr() -untruncated_idr = IndentedRepr() -untruncated_idr.maxlevel = None -untruncated_idr.maxdict = None -untruncated_idr.maxlist = None -untruncated_idr.maxtuple = None -untruncated_idr.maxset = None -untruncated_idr.maxfrozenset = None -untruncated_idr.maxdeque = None -untruncated_idr.maxarray = None -untruncated_idr.maxlong = None -untruncated_idr.maxstring = None -untruncated_idr.maxother = None diff --git a/descarteslabs/core/common/dotdict/tests/__init__.py b/descarteslabs/core/common/dotdict/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/descarteslabs/core/common/dotdict/tests/test_dotdict.py b/descarteslabs/core/common/dotdict/tests/test_dotdict.py deleted file mode 100644 index 8132a8b1..00000000 --- a/descarteslabs/core/common/dotdict/tests/test_dotdict.py +++ /dev/null @@ -1,466 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 unittest -import pytest -import textwrap -import random -import string -import ast -import json - -from .. import DotDict, DotList, DotDict_items, DotDict_values -from ..dotdict import IndentedRepr, idr, untruncated_idr - - -class TestDotDict(unittest.TestCase): - def test_from_dict(self): - template = {"a": 1, "b": 2, "c": [0, -1]} - d = DotDict(template) - assert d == template - assert isinstance(d, DotDict) - assert isinstance(d, dict) - - def test_getitem_access(self): - d = DotDict(alpha=1, beta=2) - assert d["alpha"] == 1 - with pytest.raises(KeyError): - d["nonexistent"] - - def test_getattr_access(self): - d = DotDict(alpha=1, beta=2) - assert d.alpha == 1 - with pytest.raises((KeyError, AttributeError)): - d["nonexistent"] - - def test_setattr(self): - d = DotDict() - d.key = "value" - assert d.key == "value" - assert d["key"] == "value" - - def test_delattr(self): - d = DotDict(delete=0) - del d.delete - assert "delete" not in d - with pytest.raises(AttributeError): - del d.delete - pass - - def test_mutable(self): - d = DotDict({"a": 1, "subdict": {"x": 0}}) - d.subdict.y = 4 - assert d["subdict"]["y"] == 4 - d.subdict.x = -1 - assert d["subdict"]["x"] == -1 - - def test_dir(self): - d = DotDict({"a": 1, "b": 2, "c": [0, -1]}) - _dir = dir(d) - properDir = sorted(dir(dict) + list(d.keys())) - assert properDir == _dir - - def test_repr(self): - d = DotDict(long=list(range(100))) - # long lists should be truncated with "..." - assert "..." in repr(d) - - d = DotDict({i: i for i in range(100)}) - # a long top-level dict should not be truncated - assert d == {i: i for i in range(100)} - - # short lists and dicts should not be truncated - d = DotDict(short=list(range(2)), other_key=list(range(3))) - assert d == ast.literal_eval(repr(d)) - - def test_str(self): - d = DotDict({"a": 1, "b": 2, "c": [0, -1]}) - assert ast.literal_eval(str(d)) == d - - def test_untruncated_str(self): - d = DotDict(long=[[list(range(100))]]) - _ = d.long[0][0][0] # force list to be converted to DotList # noqa: F841 - assert d == ast.literal_eval(str(d)) - - def test_str_none(self): - d = DotDict({"none": None}) - assert "{\n 'none': None\n}" == str(d) - - def test_getattr_returns_dotdict(self): - d = DotDict({"a": 1, "subdict": {"x": 0}}) - subdict = d.subdict - assert isinstance(subdict, DotDict) - assert subdict.x == 0 - - def test_getitem_returns_dotdict(self): - d = DotDict({"a": 1, "subdict": {"x": 0}}) - subdict = d["subdict"] - assert isinstance(subdict, DotDict) - assert subdict.x == 0 - - def test_getattr_returns_dotlist(self): - d = DotDict({"a": 1, "sublist": [{"x": 0}]}) - sublist = d.sublist - assert isinstance(sublist, DotList) - assert sublist[0].x == 0 - - def test_getitem_returns_dotlist(self): - d = DotDict({"a": 1, "sublist": [{"x": 0}]}) - sublist = d["sublist"] - assert isinstance(sublist, DotList) - assert sublist[0].x == 0 - - def test_nested_lists(self): - d = DotDict(x=[[{"sublist": [{"key": "value"}]}]]) - assert d.x[0][0]["sublist"][0].key == "value" - - def test_jsonable(self): - d = DotDict(long=[[list(range(100))]]) - _ = d.long[0][0][0] # force list to be converted to DotList # noqa: F841 - from_json = json.loads(json.dumps(d)) - assert from_json == d - - def test_items(self): - d = DotDict({"a": 1, "subdict": {"x": 0, "z": -1}, "sublist": [{"y": "foo"}]}) - items = d.items() - assert isinstance(items, DotDict_items) - for k, v in items: - if isinstance(v, dict): - assert isinstance(v, DotDict) - v.foo = "bar" - if isinstance(v, list): - assert isinstance(v, DotList) - v.append(None) - assert d.subdict.foo == "bar" - assert d.sublist[1] is None - - def test_DotDict_view(self): - d1 = DotDict({"a": 0, "b": 1}) - d2 = DotDict({"a": 0, "c": 2}) - - items1 = d1.items() - items2 = d2.items() - assert len(items1) == 2 - assert ("a", 0) in items1 - assert ("c", 2) not in items1 - assert not items1.isdisjoint(items2) - assert items1.isdisjoint([]) - - assert items1 & items2 == {("a", 0)} - assert items1 | items2 == {("a", 0), ("b", 1), ("c", 2)} - assert items1 ^ items2 == {("b", 1), ("c", 2)} - assert items1 - items2 == {("b", 1)} - assert {("b", 1)} - items1 == set() - assert items1 == items1 - assert items1 != items2 - - with pytest.raises(TypeError): - hash(items1) - - with pytest.raises(AttributeError): - items1.foo() - - def test_values(self): - d = DotDict({"subdictA": {"x": 0}, "subdictB": {"x": 1}}) - values = d.values() - assert isinstance(values, DotDict_values) - for v in values: - assert isinstance(v.x, int) - v.foo = "bar" - assert d.subdictA.foo == "bar" - assert d.subdictB.foo == "bar" - - def test_get(self): - d = DotDict({"subdict": {"x": 0}}) - subdict = d.get("subdict") - assert subdict.x == 0 - subdict.foo = "bar" - assert d.subdict.foo == "bar" - default = d.get("not_here", {"foo": 1}) - assert default.foo == 1 - - def test_pop(self): - d = DotDict({"subdict": {"x": 0}}) - subdict = d.pop("subdict") - assert subdict.x == 0 - default = d.pop("subdict", {"foo": 1}) - assert default.foo == 1 - - def test_popitem(self): - d = DotDict({"subdict": {"x": 0}}) - k, v = d.popitem() - assert k == "subdict" - assert v.x == 0 - assert len(d) == 0 - - def test_setdefault(self): - d = DotDict({"subdict": {"x": 0}}) - default = d.setdefault("subdict", {}) - assert default.x == 0 - default.foo = "bar" - assert d.subdict.foo == "bar" - missing = d.setdefault("missing", {"foo": 1}) - assert missing.foo == 1 - assert d.missing.foo == 1 - - -class TestUnbox(unittest.TestCase): - @classmethod - def random_container(cls, height=6): - make_dict = random.choice((True, False, None)) - if height == 0 or make_dict is None: - return random.randint(0, 100) - if make_dict: - return DotDict( - { - i: cls.random_container(height - 1) - for i in range(random.randint(1, 5)) - } - ) - else: - return DotList( - cls.random_container(height - 1) for i in range(random.randint(1, 5)) - ) - - @classmethod - def is_unboxed(cls, obj): - if not isinstance(obj, (dict, list)): - return True - if isinstance(obj, dict): - return type(obj) is dict and all(cls.is_unboxed(x) for x in obj.values()) - if isinstance(obj, list): - return type(obj) is list and all(cls.is_unboxed(x) for x in obj) - - def test_basic(self): - d = DotDict({"a": 1, "b": 2}) - - unboxed = d.asdict() - assert type(unboxed) is dict - assert unboxed == d - - obj = DotDict(a=d) - unboxed = obj.asdict() - assert type(unboxed["a"]) is dict - - obj = DotList(DotDict(i=i) for i in range(10)) - unboxed = obj.aslist() - assert type(unboxed) is list - assert all(type(x) is dict for x in unboxed) - - obj = DotDict({i: DotList(range(i)) for i in range(10)}) - unboxed = obj.asdict() - assert all(type(x) is list for x in unboxed.values()) - - def test_random_nested_container(self): - obj = 0 - while isinstance(obj, int): - obj = self.random_container() - - unboxed = obj.asdict() if isinstance(obj, DotDict) else obj.aslist() - - assert not self.is_unboxed(obj) - assert self.is_unboxed(unboxed) - assert obj == unboxed - - @unittest.expectedFailure - def test_dottype_within_plain(self): - # see note in DotDict.asdict - # unsure if this is an important case to handle - sub = {i: DotDict(val=i) for i in range(5)} - obj = DotDict(sub=sub) - - unboxed = obj.asdict() - assert self.is_unboxed(unboxed) - - @unittest.skip("results in RuntimeError since recursive structures are not handled") - def test_recursive_container(self): - d = DotDict({"a": 1, "b": 2}) - - d.loop = d - unboxed = d.asdict() - assert type(unboxed["loop"]) is dict - - -class TestDotList(unittest.TestCase): - def test_from_list(self): - template = list(range(10)) - dotlist = DotList(template) - assert dotlist == template - assert isinstance(dotlist, DotList) - assert isinstance(dotlist, list) - - def test_slice(self): - d = DotList([[1, 2], {"foo": "bar"}]) - sliced = d[0:2] - assert isinstance(sliced, DotList) - assert 2 == len(sliced) - - def test_iterate(self): - d = DotList([{"foo": "bar"}, {"foo": "baz"}]) - foos = [foo.foo for foo in d] - assert ["bar", "baz"] == foos - - def test_pop(self): - d = DotList([{"foo": "bar"}, {"foo": "baz"}]) - item = d.pop() - assert item.foo == "baz" - - -class TestIndentedRepr(unittest.TestCase): - def test_idr_short(self): - assert ( - idr.indent == 2 - ), "indented repr indent has changed, other tests will fail" - obj = [{"key": 1.01, "bool": False, (1, (2, 3)): {"a", "b", "c"}}, [4, 5, 6]] - unicode_prefix = "" - set_start = "{" - set_end = "}" - - output = idr.repr(obj) - - # The order is not guaranteed. Look for individual lines. - # Since the comma depends on the position, skip those. - assert "[\n" in output - assert " {\n" in output - assert "\n {}'key': 1.01".format(unicode_prefix) in output - assert "\n 'bool': False" in output - assert ( - "\n (1, (2, 3)): {}'a', 'b', 'c'{}".format(set_start, set_end) in output - ) - assert "\n }" in output - assert "\n [4, 5, 6]" in output - assert "\n]" in output - - def test_idr_truncates_str(self): - idr = IndentedRepr() - idr.maxstring = 8 - s = "abcdefghijkl" - assert idr.repr(s) == "'a...kl'" - - def test_idr_toplevel_untruncated(self): - idr = IndentedRepr() - idr.maxlist = 5 - obj = list(range(10)) - expected = """\ - [ - 0, - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9 - ]""" - expected = textwrap.dedent(expected) - assert idr.repr(obj) == expected - - idr.maxdict = 5 - obj = {i: i for i in range(10)} - - expected = """\ - { - 0: 0, - 1: 1, - 2: 2, - 3: 3, - 4: 4, - 5: 5, - 6: 6, - 7: 7, - 8: 8, - 9: 9 - }""" - expected = textwrap.dedent(expected) - assert idr.repr(obj) == expected - - def test_idr_truncates_list(self): - idr = IndentedRepr() - idr.maxlist = 5 - obj = [list(range(6))] - expected = """\ - [ - [ - 0, - 1, - 2, - 3, - 4, - ... - ] - ]""" - expected = textwrap.dedent(expected) - assert idr.repr(obj) == expected - - def test_idr_doesnt_truncate_dict(self): - obj = [{i: i for i in range(10)}] - expected = """\ - [ - { - 0: 0, - 1: 1, - 2: 2, - 3: 3, - 4: 4, - 5: 5, - 6: 6, - 7: 7, - 8: 8, - 9: 9 - } - ]""" - expected = textwrap.dedent(expected) - assert idr.repr(obj) == expected - - def test_idr_truncates_level(self): - idr = IndentedRepr() - idr.maxlevel = 3 - obj = [[[[True]]]] - expected = "[[[[...]]]]" - assert idr.repr(obj) == expected - - def test_untruncated(self): - long_idr = IndentedRepr() - long_idr.maxlevel = 100 - long_idr.maxdict = 100 - long_idr.maxlist = 100 - long_idr.maxtuple = 100 - long_idr.maxset = 100 - long_idr.maxfrozenset = 100 - long_idr.maxdeque = 100 - long_idr.maxarray = 100 - long_idr.maxlong = 100 - long_idr.maxstring = 100 - long_idr.maxother = 100 - - # dict - obj = [{i: i for i in range(10)}] - assert untruncated_idr.repr(obj) == long_idr.repr(obj) - # list - obj = [list(range(50))] - assert untruncated_idr.repr(obj) == long_idr.repr(obj) - # tuple - obj = [tuple(range(50))] - assert untruncated_idr.repr(obj) == long_idr.repr(obj) - # set - obj = [frozenset(range(50))] - assert untruncated_idr.repr(obj) == long_idr.repr(obj) - # long - obj = int("".join(str(x) for x in range(50))) - assert untruncated_idr.repr(obj) == long_idr.repr(obj) - # string - obj = "".join(random.choice(string.ascii_letters) for i in range(50)) - assert untruncated_idr.repr(obj) == long_idr.repr(obj) diff --git a/descarteslabs/core/common/geo/__init__.py b/descarteslabs/core/common/geo/__init__.py deleted file mode 100644 index ebc76ea5..00000000 --- a/descarteslabs/core/common/geo/__init__.py +++ /dev/null @@ -1,22 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 .geocontext import GeoContext, AOI, DLTile, XYZTile - -__all__ = [ - "GeoContext", - "AOI", - "DLTile", - "XYZTile", -] diff --git a/descarteslabs/core/common/geo/geocontext.py b/descarteslabs/core/common/geo/geocontext.py deleted file mode 100644 index 9268849a..00000000 --- a/descarteslabs/core/common/geo/geocontext.py +++ /dev/null @@ -1,1436 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 warnings -import math - -import reprlib -import mercantile -import shapely.geometry - -from .. import shapely_support -from ..dltile import Tile, Grid - -from .utils import ( - is_geographic_crs, - is_wgs84_crs, - polygon_from_bounds, - valid_latlon_bounds, -) - -EARTH_CIRCUMFERENCE_WGS84 = 2 * math.pi * 6378137 - - -class GeoContext(object): - """ - Specifies spatial parameters to use when loading a raster - from the Descartes Labs catalog. - - Two Images loaded with the same GeoContext will result in images - with the same shape (in pixels), covering the same spatial extent, - regardless of the dimensions or projection of the original data. - - Specifically, a fully-defined GeoContext specifies: - - * geometry to use as a cutline (WGS84), and/or bounds - * resolution (m) or a shape defining the extent in pixels - * EPSG code of the output coordinate reference system - * whether to align pixels to the output CRS - (see docstring for `AOI.align_pixels` for more information) - - GeoContexts are immutable. - """ - - __slots__ = ("_all_touched",) - # slots *suffixed* with an underscore will be ignored by `__eq__` and `__repr__`. - # a double-underscore prefix would be more conventional, but that actually breaks as a slot name. - - def __init__(self, all_touched=False): - """ - Parameters - ---------- - all_touched: bool, default False - If True, this ensures that any source pixel which intersects the - AOI GeoContext contributes to the raster result. Normally this mode is - not enabled, and its use is strongly discouraged. However, it can be - useful when the AOI is smaller than a source pixel, which under many - situations will return no result at all (i.e. entirely masked). - """ - - self._all_touched = bool(all_touched) - - def __getstate__(self): - return { - attr: getattr(self, attr) - for s in self.__class__.__mro__ - for attr in getattr(s, "__slots__", tuple()) - } - - def __setstate__(self, state): - for attr, val in state.items(): - setattr(self, attr, val) - - @property - def all_touched(self): - """ - bool: If True, this ensures that any source pixel which intersects the - GeoContext contributes to the raster result. - - Normally this mode is not enabled, and its use is strongly discouraged. - However, it can be useful when the AOI is smaller than a source pixel, - which under many situations will return no result at all (i.e. entirely - masked). - """ - return self._all_touched - - @property - def raster_params(self): - """ - dict: The properties of this GeoContext, - as keyword arguments to use for `Raster.ndarray` or `Raster.raster`. - """ - - raster_params = {} - if self.all_touched: - raster_params["cutline_all_touched"] = True - - return raster_params - - def __eq__(self, other): - """ - Two GeoContexts are equal only if they are the same type, - and every property is equal. - """ - if not isinstance(other, self.__class__): - return False - for attr in self.__slots__: - if getattr(self, attr) != getattr(other, attr): - return False - return True - - def __repr__(self): - classname = self.__class__.__name__ - delim = ",\n" + " " * (len(classname) + 1) - props = delim.join( - "{}={}".format(attr.lstrip("_"), reprlib.repr(getattr(self, attr))) - for s in self.__class__.__mro__ - for attr in getattr(s, "__slots__", tuple()) - ) - return "{}({})".format(classname, props) - - -class AOI(GeoContext): - """ - A GeoContext that clips imagery to a geometry, and/or to square bounds, - with any output resolution and CRS. - - Examples - -------- - - .. code-block:: python - - cutline_aoi = dl.geo.AOI(my_geometry, resolution=40) - aoi_with_cutline_disabled = cutline_aoi.assign(geometry=None) - no_cutline_aoi = dl.geo.AOI(geometry=None, resolution=15, bounds=(-40, 35, -39, 36)) - aoi_without_auto_bounds = dl.geo.AOI(geometry=my_geometry, resolution=15, bounds=(-40, 35, -39, 36)) - aoi_with_specific_pixel_dimensions = dl.geo.AOI(geometry=my_geometry, shape=(200, 400)) - """ - - __slots__ = ( - "_geometry", - "_resolution", - "_crs", - "_align_pixels", - "_bounds", - "_bounds_crs", - "_shape", - ) - - def __init__( - self, - geometry=None, - resolution=None, - crs=None, - align_pixels=None, - bounds=None, - bounds_crs="EPSG:4326", - shape=None, - all_touched=False, - ): - """ - Parameters - ---------- - geometry: GeoJSON-like dict, object with ``__geo_interface__``; optional - When searching, filter for elements which intersect this geometry. - When rastering, clip imagery to this geometry. - Coordinates must be WGS84 (lat-lon). - If :const:`None`, imagery will just be clipped to - :py:attr:`~descarteslabs.common.gecontext.AOI.bounds`. - resolution: float, optional - Distance, in native units of the CRS, that the edge of each pixel - represents on the ground. Do not assume this to always be either - degrees or meters. - Can only specify one of `resolution` and `shape`. - crs: str, optional - Coordinate Reference System into which imagery will be projected, - expressed as an EPSG code (like :const:`EPSG:4326`), a PROJ.4 definition, - or an OGC CRS Well-Known Text string. - align_pixels: bool, optional, default True if resolution is not None - If :const:`True`, this ensures that, in different images rasterized - with this same AOI GeoContext, pixels ``(i, j)`` correspond - to the same area in space. This is accomplished by snapping the - coordinates of the origin (top-left corner of top-left pixel) - to a non-fractional interval of `resolution`. Note that in cases - where `shape` has been specified, this may lead to the resulting - image being one pixel larger in each dimension, so the the entire - bounds is included. - - If `align_pixels` is :const:`False`, when using imagery with different - native resolutions and/or projections, pixels at the same indices - can be misaligned by a fraction of `resolution` - (i.e. correspond to *slighly* different coordinates in space). - - However, this requires warping of the original image, which can be - undesireable when you want to work with the original data in its - native resolution and projection. - bounds: 4-tuple, optional - Clip imagery to these ``(min_x, min_y, max_x, max_y)`` bounds, - expressed in :py:attr:`~descarteslabs.common.geo.geocontext.AOI.bounds_crs` - (which defaults to WGS84 lat-lon). - :py:attr:`~descarteslabs.common.geo.geocontext.AOI.bounds` - are automatically computed from `geometry` if not specified. - Otherwise, - :py:attr:`~descarteslabs.common.geo.geocontext.AOI.bounds` are required. - bounds_crs: str, optional, default "EPSG:4326" - The Coordinate Reference System of the - :py:attr:`~descarteslabs.common.geo.geocontext.AOI.bounds`, - given as an EPSG code (like :const:`EPSG:4326`), a PROJ.4 definition, - or an OGC CRS Well-Known Text string. - shape: 2-tuple, optional - ``(rows, columns)``, in pixels, the output raster should fit within; - the longer side of the raster will be min(shape). - Can only specify one of `resolution` and `shape`. Note that when - `align_pixels` is :const:`True`, the actual resulting raster may - be one pixel larger in each direction. - all_touched: bool, default False - If True, this ensures that any source pixel which intersects the - AOI GeoContext contributes to the raster result. Normally this mode is - not enabled, and its use is strongly discouraged. However, it can be - useful when the AOI is smaller than a source pixel, which under many - situations will return no result at all (i.e. entirely masked). - """ - - super(AOI, self).__init__(all_touched=all_touched) - - # If no bounds were given, use the bounds of the geometry - if bounds is None and geometry is not None: - bounds = "update" - - self._assign( - geometry, - resolution, - crs, - align_pixels, - bounds, - bounds_crs, - shape, - "unchanged", - ) - self._validate() - - @property - def geometry(self): - """ - shapely geometry: Clip imagery to this geometry - Coordinates must be WGS84 (lat-lon). - If :const:`None`, imagery will just be clipped to - :py:attr:`~descarteslabs.common.geo.geocontext.AOI.bounds`. - """ - - return self._geometry - - @property - def resolution(self): - """ - float: Distance, in units of the CRS, that the edge of each pixel - represents on the ground. - """ - - return self._resolution - - @property - def crs(self): - """ - str: Coordinate reference system into which imagery will be projected, - expressed as an EPSG code (like :const:`EPSG:4326`), a PROJ.4 definition, - or an OGC CRS Well-Known Text string. - """ - - return self._crs - - @property - def align_pixels(self): - """ - bool: If True, this ensures that, in different images rasterized with - this same AOI GeoContext, pixels ``(i, j)`` correspond to the - same area in space. This is accomplished by snapping the coordinates of - the origin (top-left corner of top-left pixel) to a non-fractional - interval of `resolution`. Note that in cases where `shape` has been - specified, this may lead to the resulting image being one pixel larger - in each dimension, so the the entire bounds is included. - - If `align_pixels` is False, when using imagery with different native - resolutions and/or projections, pixels at the same indicies can be - misaligned by a fraction of ``resolution`` (i.e. correspond to *slighly* - different coordinates in space). - - However, this requires warping of the original image, which can be - undesireable when you want to work with the original data in its native - resolution and projection. - """ - - if self._align_pixels is None: - return self._resolution is not None - else: - return self._align_pixels - - @property - def bounds(self): - """ - tuple: Clip imagery to these ``(min_x, min_y, max_x, max_y)`` bounds, - expressed in the coordinate reference system in - :py:attr:`~descarteslabs.common.geo.geocontext.AOI.bounds_crs`. - """ - - return self._bounds - - @property - def bounds_crs(self): - """ - str: The coordinate reference system of the - :py:attr:`~descarteslabs.common.geo.geocontext.AOI.bounds`, - given as an EPSG code (like :const:`EPSG:4326`), a PROJ.4 definition, - or an OGC CRS Well-Known Text string. - """ - - return self._bounds_crs - - @property - def shape(self): - """ - tuple: ``(rows, columns)``, in pixels, the output raster should fit within; - the longer side of the raster will be min(shape). - """ - - return self._shape - - @property - def raster_params(self): - """ - dict: The properties of this `AOI`, - as keyword arguments to use for - :class:`~descarteslabs.client.services.raster.raster.Raster.ndarray` or - :class:`~descarteslabs.client.services.raster.raster.Raster.raster`. - - Raises ValueError if - :py:attr:`~descarteslabs.common.geo.geocontext.AOI.bounds`, `crs`, - :py:attr:`~descarteslabs.common.geo.geocontext.AOI.bounds_crs`, - `resolution`, or `align_pixels` is :const:`None`. - """ - - # Ensure that there can be no ambiguity: every parameter must be specified, - # so every raster call using this context will return spatially equivalent data - if self._bounds is None: - raise ValueError("AOI must have bounds specified") - if self._bounds_crs is None: - raise ValueError("AOI must have bounds_crs specified") - if self._crs is None: - raise ValueError("AOI must have CRS specified") - if self._resolution is None and self._shape is None: - raise ValueError("AOI must have one of resolution or shape specified") - # align_pixels will always be True or False based on resolution - # all_touched doesn't affect the spatial equivalence - - cutline = ( - self._geometry.__geo_interface__ if self._geometry is not None else None - ) - - dimensions = ( - (self._shape[1], self._shape[0]) if self._shape is not None else None - ) - - return { - **super().raster_params, - "cutline": cutline, - "resolution": self._resolution, - "srs": self._crs, - "bounds_srs": self._bounds_crs, - "align_pixels": self.align_pixels, - "bounds": self._bounds, - "dimensions": dimensions, - } - - @property - def __geo_interface__(self): - """ - dict: :py:attr:`~descarteslabs.common.geo.geocontext.AOI.geometry` as a GeoJSON Geometry dict, - otherwise - :py:attr:`~descarteslabs.common.geo.geocontext.AOI.bounds` - as a GeoJSON Polygon dict if - :py:attr:`~descarteslabs.common.geo.geocontext.AOI.geometry` is - :const:`None` and - :py:attr:`~descarteslabs.common.geo.geocontext.AOI.bounds_crs` - is :const:`EPSG:4326`, otherwise - raises :exc:`RuntimeError`. - """ - - if self._geometry is not None: - return self._geometry.__geo_interface__ - elif self._bounds is not None and is_wgs84_crs(self._bounds_crs): - return polygon_from_bounds(self._bounds) - else: - raise RuntimeError( - "AOI GeoContext must have a geometry set, or bounds set and a WGS84 `bounds_crs`, " - "to have a __geo_interface__" - ) - - def assign( - self, - geometry="unchanged", - resolution="unchanged", - crs="unchanged", - align_pixels="unchanged", - bounds="unchanged", - bounds_crs="unchanged", - shape="unchanged", - all_touched="unchanged", - ): - """ - Return a copy of the AOI with the given values assigned. - - Note - ---- - If you are assigning a new geometry and want bounds to updated as - well, use ``bounds="update"``. This will also change - :py:attr:`~descarteslabs.common.geo.geocontext.AOI.bounds_crs` - to :const:`EPSG:4326`, since the geometry's coordinates are in WGS84 - decimal degrees, so the new bounds determined from those coordinates - must be in that CRS as well. - - If you assign - :py:attr:`~descarteslabs.common.geo.geocontext.AOI.geometry` - without changing - :py:attr:`~descarteslabs.common.geo.geocontext.AOI.bounds`, - the new AOI GeoContext will produce rasters with the same - shape and covering the same spatial area as the old one, just with - pixels masked out that fall outside your new geometry. - - Returns - ------- - new : `AOI` - """ - - new = copy.deepcopy(self) - new._assign( - geometry, - resolution, - crs, - align_pixels, - bounds, - bounds_crs, - shape, - all_touched, - ) - new._validate() - return new - - def _validate(self): - # validate shape - if self._shape is not None: - if not isinstance(self._shape, (list, tuple)) or len(self._shape) != 2: - raise TypeError("Shape must be a tuple of (rows, columns) in pixels") - - # validate resolution - if self._resolution is not None: - if not isinstance(self._resolution, (int, float)): - raise TypeError( - "Resolution must be an int or float, got type '{}'".format( - type(self._resolution).__name__ - ) - ) - if self._resolution <= 0: - raise ValueError("Resolution must be greater than zero") - - # can't set both resolution and shape - if self._resolution is not None and self._shape is not None: - raise ValueError("Cannot set both resolution and shape") - - # test that bounds are sane - if self._bounds is not None: - shapely_support.check_valid_bounds(self._bounds) - - # rough check that bounds values actually make sense for bounds_crs - if self._bounds_crs is not None and self._bounds is not None: - is_geographic, lon_wrap = is_geographic_crs( - self._bounds_crs, with_lon_wrap=True - ) - if is_geographic: - # some whole-globe products are funky around the dateline. Try - # to allow up to a 1/2 pixel slop there. This will generally only - # occur with AOIs created automatically from Image properties. - if self._resolution and self._crs and is_geographic_crs(self._crs): - tol = self._resolution / 2 - elif self._shape is not None: - tol = ( - max( - (self._bounds[2] - self._bounds[0]) / self._shape[1], - (self._bounds[3] - self._bounds[1]) / self._shape[0], - ) - / 2 - ) - else: - tol = 0.001 - if not valid_latlon_bounds(self._bounds, tol, lon_wrap=lon_wrap): - raise ValueError( - "Bounds must be in lat-lon coordinates, " - "but the given bounds are outside [-90, 90] for y or [-180, 180] for x." - ) - else: - if valid_latlon_bounds(self._bounds): - # Warn that bounds are probably in the wrong CRS. - # But we can't be sure without a proper tool for working with CRSs, - # since bounds that look like valid lat-lon coords - # *could* be valid in a different CRS, though unlikely. - warnings.warn( - "You might have the wrong `bounds_crs` set.\n" - "Bounds appear to be in lat-lon decimal degrees, but the `bounds_crs` " - "does not seem to be a geographic coordinate reference system " - "(i.e. its units are not degrees, but meters, feet, etc.).\n\n" - "If this is unexpected, set `bounds_crs='EPSG:4326'`." - ) - - # check that bounds and geometry actually intersect (if bounds in wgs84) - if ( - self._geometry is not None - and self._bounds is not None - and is_wgs84_crs(self._bounds_crs) - ): - bounds_shp = shapely.geometry.box(*self._bounds) - if not bounds_shp.intersects(self._geometry): - raise ValueError( - "Geometry and bounds do not intersect. This would result in all data being masked. " - "If you're assigning new geometry, assign new bounds as well " - "(use `bounds='update'` to use the bounds of the new geometry)." - ) - - # Helpful warning about a common mistake: resolution < width - # The CRS of bounds and CRS of resolution must be the same to compare between those values - - # This most often happens when switching from a projected to a geodetic CRS (i.e. UTM to WGS84) - # and not updating the (units of the) resolution accordingly, so you now have, say, - # 30 decimal degrees as your resolution. Probably not what you meant. - - # TODO: better way of checking equivalence between CRSs than string equality - if ( - not self._all_touched - and self._crs is not None - and self._resolution is not None - and self._bounds is not None - and self._bounds_crs == self._crs - ): - crs_width = self._bounds[2] - self._bounds[0] - crs_height = self._bounds[3] - self._bounds[1] - msg = ( - "Output raster's {dim} ({dim_len:.4f}) is smaller than its resolution " - "({res:.4f}), meaning it would be less than one pixel {dim_adj}.\n" - "Remember that resolution is specified in units of the output CRS, " - "which are not necessarily meters." - ) - if is_geographic_crs(self._crs): - msg += "\nSince your CRS is in lat-lon coordinates, resolution must be given in decimal degrees." - msg += ( - "\nIf you are intending to raster an area smaller than the source imagery resolution, then you" - "should set an appropriate value of resolution, shape, or all_touched=True on the supplied AOI" - " to signal your intentions." - ) - - if crs_width < self._resolution: - raise ValueError( - msg.format( - dim="width", - dim_len=crs_width, - res=self._resolution, - dim_adj="wide", - ) - ) - if crs_height < self._resolution: - raise ValueError( - msg.format( - dim="height", - dim_len=crs_height, - res=self._resolution, - dim_adj="tall", - ) - ) - - def _assign( - self, - geometry, - resolution, - crs, - align_pixels, - bounds, - bounds_crs, - shape, - all_touched, - ): - # we use "unchanged" as a sentinel value, because None is a valid thing to set attributes to. - if geometry is not None and geometry != "unchanged": - geometry = shapely_support.geometry_like_to_shapely(geometry) - - if bounds is not None and bounds != "unchanged": - if bounds == "update": - if bounds_crs not in (None, "unchanged", "EPSG:4326"): - raise ValueError( - "Can't compute bounds from a geometry while also explicitly setting a `bounds_crs`.\n\n" - "To resolve: don't set `bounds_crs`. It will be set to 'EPSG:4326' for you. " - "(Though you can do so explicitly if you'd like.)\n\n" - "Explanation: the coordinates in a geometry are latitudes and longitudes " - "in decimal degrees, defined in the WGS84 coordinate reference system " - "(referred to by the code EPSG:4326). When we infer `bounds` from a `geometry`, " - "those bounds will be in the same coordinate reference system as the geometry---i.e., WGS84. " - "Therefore, setting `bounds_crs` to anything besides 'EPSG:4326' doesn't make sense." - ) - bounds_crs = "EPSG:4326" - if geometry is not None and geometry != "unchanged": - bounds = geometry.bounds - else: - raise ValueError( - "A geometry must be given with which to update the bounds" - ) - else: - bounds = tuple(bounds) - - if geometry != "unchanged": - self._geometry = geometry - if resolution != "unchanged": - # To avoid breaking existing code, avoid a conflict with shape. - # getattr() to handle pre-init cases. - if ( - getattr(self, "_resolution", None) is None - and getattr(self, "_shape", None) is not None - ): - self._shape = None - self._resolution = resolution - if crs != "unchanged": - self._crs = crs - if align_pixels != "unchanged": - self._align_pixels = align_pixels - if bounds != "unchanged": - self._bounds = bounds - if bounds_crs != "unchanged": - self._bounds_crs = bounds_crs - if shape != "unchanged": - self._shape = shape - if all_touched != "unchanged": - self._all_touched = bool(all_touched) - - -class DLTile(GeoContext): - """ - A GeoContext that clips and projects imagery to a single DLTile. - - DLTiles allow you to define a grid of arbitrary spacing, resolution, - and overlap that can cover the globe. - - DLTiles are always in a UTM projection. - - Example - ------- - >>> import descarteslabs as dl - >>> from descarteslabs.geo import DLTile - >>> tile = DLTile.from_latlon( - ... lat=35.691, - ... lon=-105.944, - ... tilesize=512, - ... resolution=10, - ... pad=0 - ... ) - >>> product = dl.catalog.Product.get("usgs:landsat:oli-tirs:c2:l2:v0") # doctest: +SKIP - >>> images = product.images().intersects(tile).collect() # doctest: +SKIP - >>> images # doctest: +SKIP - ImageCollection of 558 images - * Dates: Mar 18, 2013 to Sep 14, 2023 - * Products: usgs:landsat:oli-tirs:c2:l2:v0: 558 - >>> images.geocontext # doctest: +SKIP - DLTile(key='512:0:10.0:13:-17:771', - resolution=10.0, - tilesize=512, - pad=0, - crs='EPSG:32613', - bounds=(412960.0, 3947520.0, 418080.0, 3952640.0), - bounds_crs='EPSG:32613', - geometry=, - zone=13, - ti=-17, - tj=771, - geotrans=(412960.0, 10.0, 0.0, 3952640.0, 0.0, -10.0), - proj4='+proj=utm +z...s=m +no_defs ', - wkt='PROJCS["WGS ...SG","32613"]]', - all_touched=False) - """ - - __slots__ = ( - "_key", - "_resolution", - "_tilesize", - "_pad", - "_crs", - "_bounds", - "_bounds_crs", - "_geometry", - "_zone", - "_ti", - "_tj", - "_geotrans", - "_proj4", - "_wkt", - ) - - def __init__(self, dltile_dict, all_touched=False): - """ - Constructs a DLTile from a parameter dictionary. - It is preferred to use the - :meth:`DLTile.from_latlon, :meth:`DLTile.from_shape`, or :meth:`DLTile.from_key` - class methods to construct a DLTile GeoContext. - - Parameters - ---------- - dltile_dict: Dict[Str, Any] - Dictionary for the tile. - all_touched: bool, default False - If True, this ensures that any source pixel which intersects the - AOI GeoContext contributes to the raster result. Normally this mode is - not enabled, and its use is strongly discouraged. However, it can be - useful when the AOI is smaller than a source pixel, which under many - situations will return no result at all (i.e. entirely masked). - """ - - super(DLTile, self).__init__(all_touched=all_touched) - - if isinstance(dltile_dict["geometry"], shapely.geometry.polygon.Polygon): - self._geometry = dltile_dict["geometry"] - else: - self._geometry = shapely.geometry.shape(dltile_dict["geometry"]) - - properties = dltile_dict["properties"] - self._key = properties["key"] - self._resolution = properties["resolution"] - self._tilesize = properties["tilesize"] - self._pad = properties["pad"] - self._crs = properties["cs_code"] - self._bounds = tuple(properties["outputBounds"]) - self._bounds_crs = properties["cs_code"] - self._zone = properties["zone"] - self._ti = properties["ti"] - self._tj = properties["tj"] - - # these properties may not be present - self._geotrans = properties.get("geotrans", None) - self._proj4 = properties.get("proj4", None) - self._wkt = properties.get("wkt", None) - - @classmethod - def from_latlon(cls, lat, lon, resolution, tilesize, pad, all_touched=False): - """ - Return a DLTile GeoContext that covers a latitude/longitude. - - Where the point falls within the tile will vary, depending on the point - and tiling parameters. - - Parameters - ---------- - lat : float - Latitude (WGS84) - lon : float - Longitude (WGS84) - resolution : float - Distance, in meters, that the edge of each pixel represents on the ground - tilesize : int - Length of each side of the tile, in pixels - pad : int - Number of extra pixels by which each side of the tile is buffered. - This determines the number of pixels by which two tiles overlap. - all_touched: bool, default False - If True, this ensures that any source pixel which intersects the - AOI GeoContext contributes to the raster result. Normally this mode is - not enabled, and its use is strongly discouraged. However, it can be - useful when the AOI is smaller than a source pixel, which under many - situations will return no result at all (i.e. entirely masked). - - Returns - ------- - tile : DLTile - - Example - ------- - >>> from descarteslabs.geo import DLTile - >>> # make a tile with total size 100, centered on lat, lon - >>> # total tilesize == tilesize + 2 * pad - >>> params = { - ... "lat": 30.0131, - ... "lon": 31.2089, - ... "resolution": 10, - ... "tilesize": 2, - ... "pad": 49, - ... } - >>> tile = DLTile.from_latlon(**params) - >>> tile.key - '2:49:10.0:36:-8637:166079' - >>> tile.geometry.centroid.xy # doctest: +SKIP - (array('d', [31.20899205942612]), array('d', [30.013121672688087])) - """ - - grid = Grid(resolution=resolution, tilesize=tilesize, pad=pad) - tile = grid.tile_from_lonlat(lat=lat, lon=lon) - return cls(tile.geocontext, all_touched=all_touched) - - @classmethod - def from_shape( - cls, shape, resolution, tilesize, pad, keys_only=False, all_touched=False - ): - """ - Return a list of DLTiles that intersect the given geometry. - - Parameters - ---------- - shape : GeoJSON-like - A GeoJSON dict, or object with a ``__geo_interface__``. Must be in - :const:`EPSG:4326` (WGS84 lat-lon) projection. - resolution : float - Distance, in meters, that the edge of each pixel represents on the ground. - tilesize : int - Length of each side of the tile, in pixels. - pad : int - Number of extra pixels by which each side of the tile is buffered. - This determines the number of pixels by which two tiles overlap. - keys_only : bool, default False - Whether to return DLTile objects or only DLTile keys. Set to True when - returning a large number of tiles and you do not need the full objects. - all_touched: bool, default False - If True, this ensures that any source pixel which intersects the - AOI GeoContext contributes to the raster result. Normally this mode is - not enabled, and its use is strongly discouraged. However, it can be - useful when the AOI is smaller than a source pixel, which under many - situations will return no result at all (i.e. entirely masked). - - Returns - ------- - tiles : List[DLTile] or List[Str] - - Example - ------- - >>> from descarteslabs.geo import DLTile - >>> shape = { - ... "type":"Feature", - ... "geometry":{ - ... "type":"Polygon", - ... "coordinates":[[ - ... [-122.51140471760839,37.77130087547876], - ... [-122.45475646845254,37.77475476721895], - ... [-122.45303985468301,37.76657207194229], - ... [-122.51057242081689,37.763446782666094], - ... [-122.51140471760839,37.77130087547876]]] - ... },"properties": None - ... } - >>> tiles = DLTile.from_shape( - ... shape=shape, - ... resolution=1, - ... tilesize=500, - ... pad=0, - ... ) - >>> len(tiles) - 31 - """ - - grid = Grid(resolution=resolution, tilesize=tilesize, pad=pad) - - if grid._estimate_ntiles_from_shape(shape) > 50000: - warnings.warn( - "DLTile.from_shape will return a large number of tiles. " - "Consider using DLTile.iter_from_shape instead." - ) - - tiles = grid.tiles_from_shape(shape=shape, keys_only=keys_only) - if keys_only: - result = [tile for tile in tiles] - else: - result = [cls(tile.geocontext, all_touched=all_touched) for tile in tiles] - return result - - @classmethod - def iter_from_shape( - cls, shape, resolution, tilesize, pad, keys_only=False, all_touched=False - ): - """ - Return a iterator for DLTiles that intersect the given geometry. - - Parameters - ---------- - shape : GeoJSON-like - A GeoJSON dict, or object with a ``__geo_interface__``. Must be in - :const:`EPSG:4326` (WGS84 lat-lon) projection. - resolution : float - Distance, in meters, that the edge of each pixel represents on the ground. - tilesize : int - Length of each side of the tile, in pixels. - pad : int - Number of extra pixels by which each side of the tile is buffered. - This determines the number of pixels by which two tiles overlap. - keys_only : bool, default False - Whether to return DLTile objects or only DLTile keys. Set to True when - returning a large number of tiles and you do not need the full objects. - all_touched: bool, default False - If True, this ensures that any source pixel which intersects the - AOI GeoContext contributes to the raster result. Normally this mode is - not enabled, and its use is strongly discouraged. However, it can be - useful when the AOI is smaller than a source pixel, which under many - situations will return no result at all (i.e. entirely masked). - - Returns - ------- - Iterator of DLTiles or str - - Example - ------- - >>> from descarteslabs.geo import DLTile - >>> shape = { - ... "type":"Feature", - ... "geometry":{ - ... "type":"Polygon", - ... "coordinates":[[ - ... [-122.51140471760839,37.77130087547876], - ... [-122.45475646845254,37.77475476721895], - ... [-122.45303985468301,37.76657207194229], - ... [-122.51057242081689,37.763446782666094], - ... [-122.51140471760839,37.77130087547876]]] - ... },"properties": None - ... } - >>> gen = DLTile.from_shape( - ... shape=shape, - ... resolution=1, - ... tilesize=500, - ... pad=0, - ... keys_only=True - ... ) - >>> tiles = [tile for tile in gen] # doctest: +SKIP - >>> tiles[0] # doctest: +SKIP - '500:0:1.0:10:94:8359' - """ - - grid = Grid(resolution=resolution, tilesize=tilesize, pad=pad) - tiles = grid.tiles_from_shape(shape=shape, keys_only=keys_only) - for tile in tiles: - if keys_only: - yield tile - else: - yield cls(tile.geocontext, all_touched=all_touched) - - @classmethod - def from_key(cls, dltile_key, all_touched=False): - """ - Return a DLTile GeoContext from a DLTile key. - - Parameters - ---------- - dltile_key : str - DLTile key, e.g. '128:16:960.0:15:-1:37' - all_touched: bool, default False - If True, this ensures that any source pixel which intersects the - AOI GeoContext contributes to the raster result. Normally this mode is - not enabled, and its use is strongly discouraged. However, it can be - useful when the AOI is smaller than a source pixel, which under many - situations will return no result at all (i.e. entirely masked). - - Returns - ------- - tile: DLTile - - Example - ------- - >>> from descarteslabs.geo import DLTile - >>> tile = DLTile.from_key("2048:16:30.0:15:3:80") - >>> tile # doctest: +SKIP - DLTile(key='2048:16:30.0:15:3:80', - resolution=30.0, - tilesize=2048, - pad=16, - crs='EPSG:32615', - bounds=(683840.0, 4914720.0, 746240.0, 4977120.0), - bounds_crs='EPSG:32615', - geometry=, - zone=15, - ti=3, - tj=80, - geotrans=[ - ... - """ - - tile = Tile.from_key(dltile_key) - return cls(tile.geocontext, all_touched=all_touched) - - def subtile(self, subdivide, resolution=None, pad=None, keys_only=False): - """ - Return an iterator for new DLTiles that subdivide this tile. - - The DLtile will be sub-divided into subdivide^2 total sub-tiles each with a side length - of tile_size / subdivide. The resulting sub-tile size must be an integer. - Each sub-tile will by default inherit the same resolution and pad as the orginal tile. - - Parameters - ---------- - subdivide : int - The value to subdivide the tile. The total number of sub-tiles will be the - square of this value. This value must evenly divide the original tilesize. - resolution : None, float - A new resolution for the sub-tiles. None defaults to the original DLTile resolution. - The new resolution must evenly divide the the original tilesize divided by - the subdivide ratio. - pad : None, int - A new pad value for the sub-tiles. None defaults to the original DLTile pad value. - keys_only : bool, default False - Whether to return DLTile objects or only DLTile keys. Set to True when returning a large number of tiles - and you do not need the full objects. - - Returns - ------- - Iterator over DLTiles or str - - Example: - ------- - >>> from descarteslabs.geo import DLTile - >>> tile = DLTile.from_key("2048:0:30.0:15:3:80") - >>> tiles = [tile for tile in tile.subtile(8)] - >>> len(tiles) - 64 - >>> tiles[0].tilesize - 256 - """ - - subtiles = Tile.from_key(self.key).subtile( - subdivide=subdivide, - new_resolution=resolution, - new_pad=pad, - ) - - for tile in subtiles: - if keys_only: - yield tile.key - else: - yield DLTile(tile.geocontext, all_touched=self.all_touched) - - def rowcol_to_latlon(self, row, col): - """ - Convert pixel coordinates to lat, lon coordinates - - Parameters - ---------- - row : int or List[int] - Pixel row coordinate or coordinates - col : int or List[int] - Pixel column coordinate or coordinates - - Returns - ------- - coords : List[Tuple[float], Tuple[float]] - List with the first element the latitude values and the second element longitude values - - Example - ------- - >>> from descarteslabs.geo import DLTile - >>> tile = DLTile.from_key("2048:0:30.0:15:3:80") - >>> tile.rowcol_to_latlon(row=56, col=1111) - [(44.894653081367544,), (-90.24334206726267,)] - """ - - lonlat = Tile.from_key(self.key).rowcol_to_lonlat(row=row, col=col) - lonlat = lonlat.tolist() - if isinstance(lonlat[0], (int, float)): - result = [(lonlat[1],), (lonlat[0],)] - else: - result = list(zip(*lonlat)) - result[0], result[1] = result[1], result[0] - return result - - def latlon_to_rowcol(self, lat, lon): - """ - Convert lat, lon coordinates to pixel coordinates - - Parameters - ---------- - lat: float or List[float] - Latitude coordinate or coordinates - lon: float or List[float] - Longitude coordinate or coordinates - - Returns - ------- - coords: List[Tuple[int] Tuple[int]] - Tuple with the first element the row values and the second element column values - - Example - ------- - >>> from descarteslabs.geo import DLTile - >>> tile = DLTile.from_key("2048:0:30.0:15:3:80") - >>> tile.latlon_to_rowcol(lat=44.8, lon=-90.2) - [(403,), (1237,)] - """ - - rowcol = Tile.from_key(self.key).lonlat_to_rowcol(lat=lat, lon=lon) - rowcol = rowcol.tolist() - if isinstance(rowcol[0], (int, float)): - result = [(rowcol[0],), (rowcol[1],)] - else: - result = list(zip(*rowcol)) - return result - - def assign(self, pad="unchanged", all_touched="unchanged"): - """ - Return a copy of the DLTile with the pad and/or all_touched value modified. - - Parameters - ---------- - pad : int, default "unchanged" - New pad value - all_touched : bool, default "unchanged" - New all_touched value - - Returns - ------- - tile : DLTile - - Example: - -------- - >>> from descarteslabs.geo import DLTile - >>> tile = DLTile.from_key("2048:16:30.0:15:3:80") - >>> tile.pad - 16 - >>> tile = tile.assign(123) - >>> tile.pad - 123 - """ - - tile = Tile.from_key(self.key) - if pad != "unchanged": - tile = tile.assign(pad=pad) - if all_touched == "unchanged": - all_touched = self.all_touched - return DLTile(tile.geocontext, all_touched=all_touched) - - @property - def key(self): - """ - str: The DLTile's key, which encodes the tiling parameters, - and which number in the grid this tile is. - """ - - return self._key - - @property - def resolution(self): - """float: Distance, in meters, that the edge of each pixel represents on the ground""" - - return self._resolution - - @property - def tilesize(self): - """ - int: Length of each side of the tile, in pixels. - Note that the total number of pixels along each side of an image is - ``tile_size + 2 * padding`` - """ - - return self._tilesize - - @property - def tile_extent(self): - """ - int: total extent of geocontext length in pixels, including pad. - Size is ``tile_size + 2 * pad``. - """ - - return self._tilesize + 2 * self._pad - - @property - def pad(self): - """ - int: Number of extra pixels by which each side of the tile is buffered. - This determines the number of pixels by which two tiles overlap. - """ - - return self._pad - - @property - def crs(self): - """ - str: Coordinate reference system into which imagery will be projected. - For DLTiles, this is always a UTM projection, given as an EPSG code. - """ - - return self._crs - - @property - def bounds(self): - """ - tuple: The ``(min_x, min_y, max_x, max_y)`` of the area covered by - this DLTile, in the UTM coordinate reference system given in - :py:attr:`~descarteslabs.common.geo.geocontext.DLTile.bounds_crs`. - """ - - return self._bounds - - @property - def bounds_crs(self): - """ - str: The coordinate reference system of the - :py:attr:`~descarteslabs.common.geo.geocontext.DLTile.bounds`, - given as an EPSG code (like :const:`EPSG:32615`). - A DLTile's CRS is always UTM. - """ - - return self._bounds_crs - - @property - def geometry(self): - """ - shapely.geometry.Polygon: The polygon covered by this DLTile - in WGS84 (lat-lon) coordinates - """ - - return self._geometry - - @property - def zone(self): - """int: The UTM zone of this tile""" - - return self._zone - - @property - def ti(self): - """int: The y-index of this tile in its grid""" - - return self._ti - - @property - def tj(self): - """int: The x-index of this tile in its grid""" - - return self._tj - - @property - def raster_params(self): - """ - dict: The properties of this DLTile, - as keyword arguments to use for `Raster.ndarray` or `Raster.raster`. - """ - - return { - **super().raster_params, - "dltile": self._key, - # QUESTION: shouldn't align_pixels be True? - # based on the GDAL documentation for `-tap`, seems like that should be true - # to ensure that pixels of images with different resolutions/projections - # are aligned with the same dltile. otherwise, pixel (0,0) in 1 image could be at - # different coordinates than the other - "align_pixels": False, - } - - @property - def geotrans(self): - """ - tuple: The 6-tuple GDAL geotrans for this DLTile in the shape - ``(a, b, c, d, e, f)`` where - - | a is the top left pixel's x-coordinate - | b is the west-east pixel resolution - | c is the row rotation, always 0 for DLTiles - | d is the top left pixel's y-coordinate - | e is the column rotation, always 0 for DLTiles - | f is the north-south pixel resolution, always a negative value - """ - - if self._geotrans is None: - return None - - return tuple(self._geotrans) - - @property - def proj4(self): - """str: PROJ.4 definition for this DLTile's coordinate reference system""" - - return self._proj4 - - @property - def wkt(self): - """str: OGC Well-Known Text definition for this DLTile's coordinate reference system""" - - return self._wkt - - @property - def __geo_interface__(self): - """dict: :py:attr:`~descarteslabs.common.geo.geocontext.DLTile.geometry` as a GeoJSON Polygon""" - - return self._geometry.__geo_interface__ - - -class XYZTile(GeoContext): - """ - A GeoContext for XYZ tiles, such as those used in web maps. - - The tiles are always 256x256 pixels, in the spherical Mercator - or "Web Mercator" coordinate reference system (:const:`EPSG:3857`). - """ - - __slots__ = ("_x", "_y", "_z") - - def __init__(self, x, y, z, all_touched=False): - """ - Parameters - ---------- - x: int - X-index of the tile (increases going east) - y: int - Y-index of the tile (increases going south) - z: int - Zoom level of the tile - all_touched: bool, default False - If True, this ensures that any source pixel which intersects the - AOI GeoContext contributes to the raster result. Normally this mode is - not enabled, and its use is strongly discouraged. However, it can be - useful when the AOI is smaller than a source pixel, which under many - situations will return no result at all (i.e. entirely masked). - """ - - self._x = x - self._y = y - self._z = z - super(XYZTile, self).__init__(all_touched=all_touched) - - @property - def x(self): - "int: X-index of the tile (increases going east)" - - return self._x - - @property - def y(self): - "int: Y-index of the tile (increases going south)" - - return self._y - - @property - def z(self): - "int: Zoom level of the tile" - - return self._z - - def parent(self): - "The parent XYZTile enclosing this one" - - return self.__class__(*mercantile.parent(self._x, self._y, self._z)) - - def children(self): - "List of child XYZTiles contained within this one" - - return [ - self.__class__(*t) for t in mercantile.children(self._x, self._y, self._z) - ] - - @property - def geometry(self): - """ - shapely.geometry.Polygon: The polygon covered by this XYZTile - in :const:`WGS84` (lat-lon) coordinates - """ - - return shapely.geometry.box(*mercantile.bounds(self._x, self._y, self._z)) - - @property - def bounds(self): - """ - tuple: The ``(min_x, min_y, max_x, max_y)`` of the area covered by - this XYZTile, in spherical Mercator coordinates (EPSG:3857). - """ - - return tuple(mercantile.xy_bounds(self._x, self._y, self._z)) - - @property - def crs(self): - """ - str: Coordinate reference system into which common.geo will be projected. - Always :const:`EPSG:3857` (spherical Mercator, aka "Web Mercator") - """ - - return "EPSG:3857" - - @property - def bounds_crs(self): - """ - str: The coordinate reference system of the - :py:attr:`~descarteslabs.common.geo.geocontext.XYZTile.bounds`. - Always :const:`EPSG:3857` (spherical Mercator, aka "Web Mercator") - """ - - return "EPSG:3857" - - @property - def tilesize(self): - """ - int: Length of each side of the tile, in pixels. Always 256. - """ - - return 256 - - @property - def resolution(self): - """ - float: Distance, in meters, that the edge of each pixel represents in the - spherical Mercator ("Web Mercator", EPSG:3857) projection. - """ - num_tiles = 1 << self.z - return EARTH_CIRCUMFERENCE_WGS84 / num_tiles / self.tilesize - - @property - def __geo_interface__(self): - "dict: :py:attr:`~descarteslabs.common.geo.geocontext.XYZTile.geometry` as a GeoJSON Polygon" - - return self.geometry.__geo_interface__ - - @property - def raster_params(self): - """ - dict: The properties of this XYZTile, - as keyword arguments to use for `Raster.ndarray` or `Raster.raster`. - """ - - return { - **super().raster_params, - "bounds": self.bounds, - "srs": self.crs, - "bounds_srs": self.bounds_crs, - "align_pixels": False, - "resolution": self.resolution, - } diff --git a/descarteslabs/core/common/geo/tests/__init__.py b/descarteslabs/core/common/geo/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/descarteslabs/core/common/geo/tests/test_geocontext.py b/descarteslabs/core/common/geo/tests/test_geocontext.py deleted file mode 100644 index 0c69ba54..00000000 --- a/descarteslabs/core/common/geo/tests/test_geocontext.py +++ /dev/null @@ -1,477 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 pytest -import unittest -import copy -import warnings - -import shapely.geometry - -try: - # shapely 2.x - from shapely import translate -except ImportError: - # shapely 1.X - from shapely.affinity import translate - -from .. import geocontext -from ..geocontext import EARTH_CIRCUMFERENCE_WGS84 - - -class SimpleContext(geocontext.GeoContext): - __slots__ = ("foo", "_bar") - - def __init__(self, foo=None, bar=None): - super(SimpleContext, self).__init__() - self.foo = foo - self._bar = bar - - -class TestGeoContext(unittest.TestCase): - def test_repr(self): - simple = SimpleContext(1, False) - r = repr(simple) - expected = """SimpleContext(foo=1, - bar=False, - all_touched=False)""" - assert r == expected - - def test_eq(self): - simple = SimpleContext(1, False) - simple2 = SimpleContext(1, False) - simple_diff = SimpleContext(1, True) - not_simple = geocontext.GeoContext() - assert simple == simple - assert simple == simple2 - assert simple != simple_diff - assert simple != not_simple - - def test_deepcopy(self): - simple = SimpleContext(1, False) - simple_copy = copy.deepcopy(simple) - assert simple == simple_copy - - -class TestAOI(unittest.TestCase): - def test_init(self): - feature = { - "type": "Feature", - "geometry": { - "coordinates": ( - ( - (-93.52300099792355, 41.241436141055345), - (-93.7138666, 40.703737), - (-94.37053769704536, 40.83098709945576), - (-94.2036617, 41.3717716), - (-93.52300099792355, 41.241436141055345), - ), - ), - "type": "Polygon", - }, - } - collection = { - "type": "FeatureCollection", - "features": [feature, feature, feature], - } - bounds_wgs84 = (-94.37053769704536, 40.703737, -93.52300099792355, 41.3717716) - resolution = 40 - ctx = geocontext.AOI(collection, resolution=resolution) - assert ctx.resolution == resolution - assert tuple(round(e, 5) for e in ctx.bounds) == tuple( - round(e, 5) for e in bounds_wgs84 - ) - assert ctx.bounds_crs == "EPSG:4326" - assert isinstance(ctx.geometry, shapely.geometry.GeometryCollection) - assert ctx.__geo_interface__["type"] == "GeometryCollection" - assert ctx.__geo_interface__["geometries"][0] == feature["geometry"] - - def test_raster_params(self): - geom = { - "coordinates": ( - ( - (-93.52300099792355, 41.241436141055345), - (-93.7138666, 40.703737), - (-94.37053769704536, 40.83098709945576), - (-94.2036617, 41.3717716), - (-93.52300099792355, 41.241436141055345), - ), - ), - "type": "Polygon", - } - bounds_wgs84 = (-94.37053769704536, 40.703737, -93.52300099792355, 41.3717716) - resolution = 40 - crs = "EPSG:32615" - - ctx = geocontext.AOI(geom, resolution=resolution, crs=crs) - raster_params = ctx.raster_params - expected = { - "cutline": geom, - "resolution": resolution, - "srs": crs, - "bounds_srs": "EPSG:4326", - "align_pixels": True, - "bounds": bounds_wgs84, - "dimensions": None, - } - assert raster_params == expected - - ctx = geocontext.AOI(geom, crs=crs, shape=(512, 512)) - raster_params = ctx.raster_params - expected = { - "cutline": geom, - "resolution": None, - "srs": crs, - "bounds_srs": "EPSG:4326", - "align_pixels": False, - "bounds": bounds_wgs84, - "dimensions": (512, 512), - } - assert raster_params == expected - - def test_assign(self): - geom = { - "coordinates": [ - [ - [-93.52300099792355, 41.241436141055345], - [-93.7138666, 40.703737], - [-94.37053769704536, 40.83098709945576], - [-94.2036617, 41.3717716], - [-93.52300099792355, 41.241436141055345], - ] - ], - "type": "Polygon", - } - ctx = geocontext.AOI(resolution=40) - ctx2 = ctx.assign(geometry=geom) - assert ( - ctx2.geometry.__geo_interface__ - == shapely.geometry.shape(geom).__geo_interface__ - ) - assert ctx2.resolution == 40 - assert ctx2.align_pixels - assert ctx2.shape is None - - ctx3 = ctx2.assign(geometry=None) - assert ctx3.geometry is None - - def test_assign_update_bounds(self): - geom = shapely.geometry.Point(-90, 30).buffer(1).envelope - ctx = geocontext.AOI(geometry=geom, resolution=40) - - geom_overlaps = translate(geom, xoff=1) - assert geom.intersects(geom_overlaps) - ctx_overlap = ctx.assign(geometry=geom_overlaps) - assert ctx_overlap.bounds == ctx.bounds - - ctx_updated = ctx.assign(geometry=geom_overlaps, bounds="update") - assert ctx_updated.bounds == geom_overlaps.bounds - - geom_doesnt_overlap = translate(geom, xoff=3) - with pytest.raises(ValueError, match="Geometry and bounds do not intersect"): - ctx.assign(geometry=geom_doesnt_overlap) - ctx_doesnt_overlap_updated = ctx.assign( - geometry=geom_doesnt_overlap, bounds="update" - ) - assert ctx_doesnt_overlap_updated.bounds == geom_doesnt_overlap.bounds - - with pytest.raises( - ValueError, match="A geometry must be given with which to update the bounds" - ): - ctx.assign(bounds="update") - - def test_assign_update_bounds_crs(self): - ctx = geocontext.AOI(bounds_crs="EPSG:32615") - assert ctx.bounds_crs == "EPSG:32615" - geom = shapely.geometry.Point(-20, 30).buffer(1).envelope - - ctx_no_update_bounds = ctx.assign(geometry=geom) - assert ctx_no_update_bounds.bounds_crs == "EPSG:32615" - - ctx_update_bounds = ctx.assign(geometry=geom, bounds="update") - assert ctx_update_bounds.bounds_crs == "EPSG:4326" - - with pytest.raises( - ValueError, - match="Can't compute bounds from a geometry while also explicitly setting", - ): - ctx = geocontext.AOI(geometry=geom, resolution=40, bounds_crs="EPSG:32615") - - def test_validate_bounds_values_for_bounds_crs__latlon(self): - # invalid latlon bounds - with pytest.raises(ValueError, match="Bounds must be in lat-lon coordinates"): - geocontext.AOI( - bounds_crs="EPSG:4326", bounds=[500000, 2000000, 501000, 2001000] - ) - # valid latlon bounds, no error should raise - geocontext.AOI(bounds_crs="EPSG:4326", bounds=[12, -41, 14, -40]) - - def test_validate_bounds_values_for_bounds_crs__non_latlon(self): - # valid latlon bounds, should warn - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - ctx = geocontext.AOI(bounds_crs="EPSG:32615", bounds=(12, -41, 14, -40)) - assert ctx.bounds_crs == "EPSG:32615" - assert ctx.bounds == (12, -41, 14, -40) - warning = w[0] - assert "You might have the wrong `bounds_crs` set." in str(warning.message) - # not latlon bounds, no error should raise - geocontext.AOI( - bounds_crs="EPSG:32615", bounds=[500000, 2000000, 501000, 2001000] - ) - - def test_validate_shape(self): - with pytest.raises(TypeError): - geocontext.AOI(shape=120) - with pytest.raises(TypeError): - geocontext.AOI(shape=(120, 0, 0)) - - def test_validate_resolution(self): - with pytest.raises(TypeError): - geocontext.AOI(resolution="foo") - with pytest.raises(ValueError): - geocontext.AOI(resolution=-1) - - def test_validate_resolution_shape(self): - with pytest.raises(ValueError): - geocontext.AOI(resolution=40, shape=(120, 280)) - - def test_validate_bound_geom_intersection(self): - # bounds don't intersect - with pytest.raises(ValueError, match="Geometry and bounds do not intersect"): - geocontext.AOI( - geometry=shapely.geometry.box(0, 0, 1, 1), - bounds=[5, 5, 6, 6], - bounds_crs="EPSG:4326", - ) - - # bounds do intersect; no error should raise - geocontext.AOI( - geometry=shapely.geometry.box(0, 0, 1, 1), - bounds=[0.5, 0.5, 3, 4], - bounds_crs="EPSG:4326", - ) - - # bounds_crs is not WGS84, so we can't check if bounds and geometry intersect or not---no error should raise - geocontext.AOI( - geometry=shapely.geometry.box(0, 0, 1, 1), - bounds_crs="EPSG:32615", - bounds=[500000, 2000000, 501000, 2001000], - ) - - def test_validate_reasonable_resolution(self): - # different CRSs --- no error - ctx = geocontext.AOI( - crs="EPSG:32615", - bounds_crs="EPSG:4326", - bounds=[0, 0, 1.5, 1.5], - resolution=15, - ) - assert ctx.crs == "EPSG:32615" - assert ctx.bounds_crs == "EPSG:4326" - assert ctx.bounds == (0, 0, 1.5, 1.5) - assert ctx.resolution == 15 - - # same CRSs, bounds < resolution --- no error - geocontext.AOI( - crs="EPSG:32615", - bounds_crs="EPSG:32615", - bounds=[200000, 5000000, 200100, 5000300], - resolution=15, - ) - - # same CRSs, width < resolution --- error - with pytest.raises(ValueError, match="less than one pixel wide"): - geocontext.AOI( - crs="EPSG:32615", - bounds_crs="EPSG:32615", - bounds=[200000, 5000000, 200001, 5000300], - resolution=15, - ) - - # same CRSs, height < resolution --- error - with pytest.raises(ValueError, match="less than one pixel tall"): - geocontext.AOI( - crs="EPSG:32615", - bounds_crs="EPSG:32615", - bounds=[200000, 5000000, 200100, 5000001], - resolution=15, - ) - - # same CRSs, width < resolution, CRS is lat-lon --- error including "decimal degrees" - with pytest.raises( - ValueError, match="resolution must be given in decimal degrees" - ): - geocontext.AOI( - crs="EPSG:4326", - bounds_crs="EPSG:4326", - bounds=[10, 10, 11, 11], - resolution=15, - ) - - -class TestDLTIle(unittest.TestCase): - @classmethod - def setUpClass(cls): - cls.key = "128:16:960.0:15:-1:37" - cls.key2 = "128:8:960.0:15:-1:37" - - def test_from_key(self): - tile = geocontext.DLTile.from_key(self.key) - - assert tile.key == self.key - assert tile.resolution == 960 - assert tile.pad == 16 - assert tile.tilesize == 128 - assert tile.crs == "EPSG:32615" - assert tile.bounds == (361760.0, 4531200.0, 515360.0, 4684800.0) - assert tile.bounds_crs == "EPSG:32615" - assert tile.raster_params == {"dltile": self.key, "align_pixels": False} - assert tile.geotrans == (361760.0, 960, 0, 4684800.0, 0, -960) - assert tile.proj4 == "+proj=utm +zone=15 +datum=WGS84 +units=m +no_defs " - assert ( - tile.wkt - == 'PROJCS["WGS 84 / UTM zone 15N",GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563,AUTHORITY["EPSG","7030"]],AUTHORITY["EPSG","6326"]],PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],UNIT["degree",0.0174532925199433,AUTHORITY["EPSG","9122"]],AUTHORITY["EPSG","4326"]],PROJECTION["Transverse_Mercator"],PARAMETER["latitude_of_origin",0],PARAMETER["central_meridian",-93],PARAMETER["scale_factor",0.9996],PARAMETER["false_easting",500000],PARAMETER["false_northing",0],UNIT["metre",1,AUTHORITY["EPSG","9001"]],AXIS["Easting",EAST],AXIS["Northing",NORTH],AUTHORITY["EPSG","32615"]]' # noqa - ) - - def test_assign(self): - tile = geocontext.DLTile.from_key(self.key) - tile = tile.assign(8) - - assert tile.key == self.key.replace(":16:", ":8:") - assert tile.resolution == 960 - assert tile.pad == 8 - assert tile.tilesize == 128 - assert tile.crs == "EPSG:32615" - assert tile.bounds == (369440.0, 4538880.0, 507680.0, 4677120.0) - assert tile.bounds_crs == "EPSG:32615" - assert tile.raster_params == {"dltile": self.key2, "align_pixels": False} - assert tile.geotrans == (369440.0, 960.0, 0, 4677120.0, 0, -960.0) - assert tile.proj4 == "+proj=utm +zone=15 +datum=WGS84 +units=m +no_defs " - assert ( - tile.wkt - == 'PROJCS["WGS 84 / UTM zone 15N",GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563,AUTHORITY["EPSG","7030"]],AUTHORITY["EPSG","6326"]],PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],UNIT["degree",0.0174532925199433,AUTHORITY["EPSG","9122"]],AUTHORITY["EPSG","4326"]],PROJECTION["Transverse_Mercator"],PARAMETER["latitude_of_origin",0],PARAMETER["central_meridian",-93],PARAMETER["scale_factor",0.9996],PARAMETER["false_easting",500000],PARAMETER["false_northing",0],UNIT["metre",1,AUTHORITY["EPSG","9001"]],AXIS["Easting",EAST],AXIS["Northing",NORTH],AUTHORITY["EPSG","32615"]]' # noqa - ) - - def latlon_conversions(self): - tile = geocontext.DLTile.from_key(self.key) - latlons = tile.rowcol_to_latlon(row=5, col=23) - rowcols = tile.latlon_to_rowcol(lat=latlons[0], lon=latlons[1]) - assert rowcols[0] == 5 - assert rowcols[1] == 23 - - def test_iter_from_shape(self): - params = {"resolution": 1.5, "tilesize": 512, "pad": 0, "keys_only": True} - shape = { - "type": "Feature", - "geometry": { - "type": "Polygon", - "coordinates": [ - [ - [-122.51140471760839, 37.77130087547876], - [-122.45475646845254, 37.77475476721895], - [-122.45303985468301, 37.76657207194229], - [-122.51057242081689, 37.763446782666094], - [-122.51140471760839, 37.77130087547876], - ] - ], - }, - "properties": None, - } - dltiles1 = [tile for tile in geocontext.DLTile.iter_from_shape(shape, **params)] - dltiles2 = geocontext.DLTile.from_shape(shape, **params) - assert type(dltiles1[0]) is str - assert type(dltiles1[1]) is str - assert len(dltiles1) == len(dltiles2) - - def test_iter_from_shape_multi(self): - params = {"resolution": 1.5, "tilesize": 512, "pad": 0, "keys_only": True} - shape = { - "type": "Feature", - "geometry": { - "type": "MultiPolygon", - "coordinates": [ - [ - [ - [-122.51140471760839, 37.77130087547876], - [-122.45475646845254, 37.77475476721895], - [-122.45303985468301, 37.76657207194229], - [-122.51057242081689, 37.763446782666094], - [-122.51140471760839, 37.77130087547876], - ] - ], - [ - [ - [-123.51140471760839, 37.77130087547876], - [-123.45475646845254, 37.77475476721895], - [-123.45303985468301, 37.76657207194229], - [-123.51057242081689, 37.763446782666094], - [-123.51140471760839, 37.77130087547876], - ] - ], - ], - }, - "properties": None, - } - dltiles1 = [tile for tile in geocontext.DLTile.iter_from_shape(shape, **params)] - dltiles2 = geocontext.DLTile.from_shape(shape, **params) - assert type(dltiles1[0]) is str - assert type(dltiles1[1]) is str - assert len(dltiles1) == len(dltiles2) - - def test_subtile(self): - tile = geocontext.DLTile.from_key("2048:0:30.0:15:3:80") - tiles = [t for t in tile.subtile(8, keys_only=True)] - assert len(tiles) == 64 - assert type(tiles[0]) is str - - -class TestXYZTile(unittest.TestCase): - def test_bounds(self): - tile = geocontext.XYZTile(1, 1, 2) - assert tile.bounds == (-10018754.171394622, 0.0, 0.0, 10018754.171394622) - - def test_geometry(self): - tile = geocontext.XYZTile(1, 1, 2) - assert tile.geometry.bounds == (-90.0, 0.0, 0.0, 66.51326044311186) - - def test_resolution(self): - tile = geocontext.XYZTile(1, 1, 0) - assert tile.resolution == EARTH_CIRCUMFERENCE_WGS84 / tile.tilesize - # resolution at zoom 0 is just the Earth's circumfrence divided by tilesize - - assert geocontext.XYZTile(1, 1, 2).resolution == ( - geocontext.XYZTile(1, 1, 3).resolution * 2 - ) - # resolution halves with each zoom level - - assert ( - geocontext.XYZTile(1, 1, 12).resolution - == geocontext.XYZTile(2048, 1024, 12).resolution - ) - # resolution is invariant to location; only depends on zoom - - def test_raster_params(self): - tile = geocontext.XYZTile(1, 1, 2) - assert tile.raster_params == { - "bounds": (-10018754.171394622, 0.0, 0.0, 10018754.171394622), - "srs": "EPSG:3857", - "bounds_srs": "EPSG:3857", - "align_pixels": False, - "resolution": 39135.75848201024, - } - - def test_children_parent(self): - tile = geocontext.XYZTile(1, 1, 2) - assert tile == tile.children()[0].parent() diff --git a/descarteslabs/core/common/geo/tests/test_utils.py b/descarteslabs/core/common/geo/tests/test_utils.py deleted file mode 100644 index d813acf4..00000000 --- a/descarteslabs/core/common/geo/tests/test_utils.py +++ /dev/null @@ -1,174 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 unittest -import textwrap - -import shapely.geometry - -from ..utils import ( - polygon_from_bounds, - valid_latlon_bounds, - is_geographic_crs, - is_wgs84_crs, -) - - -class TestSimpleHelpers(unittest.TestCase): - def test_polygon_from_bounds(self): - bounds = (-95.8364984, 39.2784859, -92.0686956, 42.7999878) - geom = { - "coordinates": ( - ( - (-92.0686956, 39.2784859), - (-92.0686956, 42.7999878), - (-95.8364984, 42.7999878), - (-95.8364984, 39.2784859), - (-92.0686956, 39.2784859), - ), - ), - "type": "Polygon", - } - assert geom == shapely.geometry.box(*bounds).__geo_interface__ - assert ( - polygon_from_bounds(bounds) - == shapely.geometry.box(*bounds).__geo_interface__ - ) - - def test_valid_latlon_bounds(self): - assert valid_latlon_bounds([-10, 5, 60, 80]) - assert valid_latlon_bounds([-180, -90, 180, 90]) - assert not valid_latlon_bounds([361760.0, 4531200.0, 515360.0, 4684800.0]) - - def test_is_geographic_crs(self): - assert is_geographic_crs("EPSG:4326") - assert is_geographic_crs("+proj=longlat +datum=NAD27 +no_defs") - assert is_geographic_crs( - textwrap.dedent( - """\ - GEOGCS["NAD27", - DATUM["North_American_Datum_1927", - SPHEROID["Clarke 1866",6378206.4,294.9786982139006, - AUTHORITY["EPSG","7008"]], - AUTHORITY["EPSG","6267"]], - PRIMEM["Greenwich",0, - AUTHORITY["EPSG","8901"]], - UNIT["degree",0.0174532925199433, - AUTHORITY["EPSG","9122"]], - AUTHORITY["EPSG","4267"]] - """ - ) - ) - - assert not is_geographic_crs("EPSG:32615") - assert not is_geographic_crs( - "+proj=utm +zone=15 +datum=WGS84 +units=m +no_defs" - ) - assert not is_geographic_crs( - textwrap.dedent( - """\ - PROJCS["WGS 84 / UTM zone 15N", - GEOGCS["WGS 84", - DATUM["WGS_1984", - SPHEROID["WGS 84",6378137,298.257223563, - AUTHORITY["EPSG","7030"]], - AUTHORITY["EPSG","6326"]], - PRIMEM["Greenwich",0, - AUTHORITY["EPSG","8901"]], - UNIT["degree",0.0174532925199433, - AUTHORITY["EPSG","9122"]], - AUTHORITY["EPSG","4326"]], - PROJECTION["Transverse_Mercator"], - PARAMETER["latitude_of_origin",0], - PARAMETER["central_meridian",-93], - PARAMETER["scale_factor",0.9996], - PARAMETER["false_easting",500000], - PARAMETER["false_northing",0], - UNIT["metre",1, - AUTHORITY["EPSG","9001"]], - AXIS["Easting",EAST], - AXIS["Northing",NORTH], - AUTHORITY["EPSG","32615"]] - """ - ) - ) - - def test_is_wgs84_crs(self): - assert is_wgs84_crs("EPSG:4326") - assert is_wgs84_crs("+proj=longlat +datum=WGS84 +no_defs") - assert is_wgs84_crs( - textwrap.dedent( - """\ - GEOGCS["WGS 84", - DATUM["WGS_1984", - SPHEROID["WGS 84",6378137,298.257223563, - AUTHORITY["EPSG","7030"]], - AUTHORITY["EPSG","6326"]], - PRIMEM["Greenwich",0, - AUTHORITY["EPSG","8901"]], - UNIT["degree",0.0174532925199433, - AUTHORITY["EPSG","9122"]], - AUTHORITY["EPSG","4326"]] - """ - ) - ) - - assert not is_wgs84_crs("+proj=longlat +datum=NAD27 +no_defs") - assert not is_wgs84_crs( - textwrap.dedent( - """\ - GEOGCS["NAD27", - DATUM["North_American_Datum_1927", - SPHEROID["Clarke 1866",6378206.4,294.9786982139006, - AUTHORITY["EPSG","7008"]], - AUTHORITY["EPSG","6267"]], - PRIMEM["Greenwich",0, - AUTHORITY["EPSG","8901"]], - UNIT["degree",0.0174532925199433, - AUTHORITY["EPSG","9122"]], - AUTHORITY["EPSG","4267"]] - """ - ) - ) - - assert not is_wgs84_crs("EPSG:32615") - assert not is_wgs84_crs("+proj=utm +zone=15 +datum=WGS84 +units=m +no_defs") - assert not is_wgs84_crs( - textwrap.dedent( - """\ - PROJCS["WGS 84 / UTM zone 15N", - GEOGCS["WGS 84", - DATUM["WGS_1984", - SPHEROID["WGS 84",6378137,298.257223563, - AUTHORITY["EPSG","7030"]], - AUTHORITY["EPSG","6326"]], - PRIMEM["Greenwich",0, - AUTHORITY["EPSG","8901"]], - UNIT["degree",0.0174532925199433, - AUTHORITY["EPSG","9122"]], - AUTHORITY["EPSG","4326"]], - PROJECTION["Transverse_Mercator"], - PARAMETER["latitude_of_origin",0], - PARAMETER["central_meridian",-93], - PARAMETER["scale_factor",0.9996], - PARAMETER["false_easting",500000], - PARAMETER["false_northing",0], - UNIT["metre",1, - AUTHORITY["EPSG","9001"]], - AXIS["Easting",EAST], - AXIS["Northing",NORTH], - AUTHORITY["EPSG","32615"]] - """ - ) - ) diff --git a/descarteslabs/core/common/geo/utils.py b/descarteslabs/core/common/geo/utils.py deleted file mode 100644 index a7e17d5d..00000000 --- a/descarteslabs/core/common/geo/utils.py +++ /dev/null @@ -1,102 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 math import floor - - -def polygon_from_bounds(bounds): - "Return a GeoJSON Polygon dict from a (minx, miny, maxx, maxy) tuple" - return { - "type": "Polygon", - "coordinates": ( - ( - (bounds[2], bounds[1]), - (bounds[2], bounds[3]), - (bounds[0], bounds[3]), - (bounds[0], bounds[1]), - (bounds[2], bounds[1]), - ), - ), - } - - -def valid_latlon_bounds(bounds, tol=0.001, lon_wrap=0): - "Return whether bounds fall within [-180, 180] for x and [-90, 90] for y" - return ( - (-180 + lon_wrap) <= round_if_close_to_int(bounds[0], tol) <= (180 + lon_wrap) - and -90 <= round_if_close_to_int(bounds[1], tol) <= 90 - and (-180 + lon_wrap) - <= round_if_close_to_int(bounds[2], tol) - <= (180 + lon_wrap) - and -90 <= round_if_close_to_int(bounds[3], tol) <= 90 - ) - - -def is_geographic_crs(crs, with_lon_wrap=False): - is_geographic = False - lon_wrap = 0 - if isinstance(crs, str): - crs = crs.lower() - if crs == "epsg:4326": - # WGS84 geodetic CRS. Other geodetic EPSG codes (e.g. NAD27) are incorrectly rejected. - is_geographic = True - elif crs.startswith("+proj=longlat"): - # PROJ.4 - is_geographic = True - for word in crs.split(): - if word.startswith("+lon_wrap"): - try: - lon_wrap = float(word.split("=", 1)[1]) - except Exception: - pass - break - elif ( - crs.startswith("geogcs[") # deprecated - or crs.startswith("geodcrs[") - or crs.startswith("geodeticcrs[") - ): - is_geographic = True - if with_lon_wrap: - return is_geographic, lon_wrap - else: - return is_geographic - - -def is_wgs84_crs(crs): - if not isinstance(crs, str): - return False - lower_crs = crs.lower() - return ( - lower_crs == "epsg:4326" # WGS84 geodetic CRS - or lower_crs.startswith("+proj=longlat +datum=wgs84") # PROJ.4 - # OGC WKT - # NOTE: this is a totally heuristic, non-robust guess at whether a WKT string is WGS84. - # The correct way would be to parse out the spheroid, prime meridian, and unit parameters - # and check their values. However, parsing WKT is outside the scope of this client, - # and this method is used only to provide more helpful error messages, so accuracy isn't essential. - # Here, we'll hope that anyone using a WGS 84 WKT generated it with a tool that sensibly - # named the CRS as "WGS 84", or with a tool that sensibly *didn't* name a non-WGS84 CRS as "WGS 84". - or lower_crs.startswith('geogcs["wgs 84"') # deprecated - or lower_crs.startswith('geodcrs["wgs 84"') - or lower_crs.startswith('geodeticcrs["wgs 84"') - ) - - -def round_if_close_to_int(value, tol=0.001): - closest_int = int(floor(value + 0.5)) - - if abs(value - closest_int) < tol: - return closest_int - else: - return value diff --git a/descarteslabs/core/common/http/__init__.py b/descarteslabs/core/common/http/__init__.py deleted file mode 100644 index 66be5dc4..00000000 --- a/descarteslabs/core/common/http/__init__.py +++ /dev/null @@ -1,24 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 .retry import Retry -from .session import HTTPAdapter, HttpHeaderKeys, ProxyAuthentication, Session - -__all__ = [ - "ProxyAuthentication", - "Session", - "HTTPAdapter", - "HttpHeaderKeys", - "Retry", -] diff --git a/descarteslabs/core/common/http/authorization.py b/descarteslabs/core/common/http/authorization.py deleted file mode 100644 index 5e8acdbd..00000000 --- a/descarteslabs/core/common/http/authorization.py +++ /dev/null @@ -1,49 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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. - - -def add_bearer(token): - """For use with Authorization headers, add "Bearer ".""" - if token: - return ("Bearer " if isinstance(token, str) else b"Bearer ") + token - else: - return token - - -def remove_bearer(token): - """For use with Authorization headers, strip any "Bearer ".""" - if isinstance(token, (str, bytes)) and token.lower().startswith( - "bearer " if isinstance(token, str) else b"bearer " - ): - return token[7:] - else: - return token - - -def add_basic(token): - """For use with Authorization headers, add "Basic ".""" - if token: - return ("Basic " if isinstance(token, str) else b"Basic ") + token - else: - return token - - -def remove_basic(token): - """For use with Authorization headers, strip any "Basic ".""" - if isinstance(token, (str, bytes)) and token.lower().startswith( - "basic " if isinstance(token, str) else b"basic " - ): - return token[6:] - else: - return token diff --git a/descarteslabs/core/common/http/proxy.py b/descarteslabs/core/common/http/proxy.py deleted file mode 100644 index f74e8712..00000000 --- a/descarteslabs/core/common/http/proxy.py +++ /dev/null @@ -1,253 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 abc -import os -from typing import Dict, Union - -from strenum import StrEnum - - -class ProxyAuthentication(abc.ABC): - """Provides a common interface to handle proxies and authentication between HTTP and GRPC. - - See :py:meth:`ProxyAuthentication.authorize` for more information. - """ - - class Protocol(StrEnum): - """Protocols supported by ProxyAuthentication. - - Attributes - ---------- - GRPC : enum - gRPC protocol. - HTTP : enum - HTTP Protocol. - HTTPS : enum - HTTPS Protocol. - """ - - GRPC = "grpc" - HTTP = "http" - HTTPS = "https" - - _instance: "ProxyAuthentication" = None - _proxies: Dict[str, str] = {} - - @classmethod - def get_registered_instance(cls) -> "ProxyAuthentication": - return cls._instance - - @classmethod - def register(cls, implementation: Union[type, "ProxyAuthentication"]): - """Registers a proxy authentication implementation. - - Parameters - ========== - implementation : Union[type, ProxyAuthentication] - An instance or subclass type of :py:class:`ProxyAuthentication`. - """ - - if isinstance(implementation, ProxyAuthentication): - cls._instance = implementation - elif issubclass(implementation, ProxyAuthentication): - cls._instance = implementation() - else: - raise TypeError( - "ProxyAuthentication implementation must be of type ProxyAuthentication" - ) - - @classmethod - def unregister(cls): - cls._instance = None - - @classmethod - def get_proxies(cls): - """Returns a dictionary of the configured proxy for each protocol. - - User defined proxies will take precedence over default environment vars. - """ - return {**cls._get_proxies_from_env(), **cls._proxies} - - @classmethod - def set_proxy(cls, proxy: str, protocol: Protocol = None): - """Configures a proxy for a given protocol. - - If no protocol is specified, all known protocols will be configured to use the - specified proxy. - - Parameters - ========== - proxy : str - The URL of the proxy. - protocol : :py:class:`Protocol` - The Protocol that should be modified to use the specified proxy. - """ - - if protocol is None: - cls._proxies = {k: proxy for k in cls.get_proxies().keys()} - else: - cls._proxies[protocol] = proxy - - @classmethod - def get_proxy(cls, protocol: Protocol) -> str: - """Determines the proxy to use for a given protocol. - - Attempts to use user defined proxies and fallsback to proxies defined by - environment variables. - - Parameters - ========== - protocol : :py:class:`Protocol` - The Protocol for which to retrieve the proxy URL. - """ - - return cls.get_proxies().get(protocol, None) - - @classmethod - def clear_proxy(cls, protocol: Protocol = None): - """Clears a proxy that was defined by ``set_proxy``. - - If no protocol is specified, all programmatically specified proxies will be - cleared. This will result in environment variables being used instead. - - Parameters - ========== - protocol : :py:class:`Protocol` - The Protocol for which to clear the proxy configuration. - """ - - if protocol: - cls._proxies.pop(protocol, None) - else: - cls._proxies.clear() - - @classmethod - def _get_proxies_from_env(cls): - """Retrieves proxies from environment variables. - - We preserve gRPC behavior by falling back to HTTPS then HTTP. - """ - - return { - cls.Protocol.HTTP: os.environ.get("HTTP_PROXY"), - cls.Protocol.HTTPS: os.environ.get("HTTPS_PROXY"), - cls.Protocol.GRPC: os.environ.get( - "GRPC_PROXY", - os.environ.get("HTTPS_PROXY", os.environ.get("HTTP_PROXY")), - ), - } - - def get_verified_headers(self, proxy: str, protocol: Protocol) -> dict: - """Calls `authorize` and verifies the returned headers. - - Intended to be used to retrieve the headers instead of calling - py:meth:`ProxyAuthentication.authorize` directly. - - Parameters - ========== - proxy : str - The URL of the proxy that was selected for the connection. - protocol : :py:class:`Protocol` - The Protocol that will be used for requests across the proxy after the - connection is established. - - Raises - ====== - TypeError - When :py:meth:`ProxyAuthentication.authorize` returns a type that is not a - dictionary. - """ - - headers = self.authorize(proxy, protocol) - - if not isinstance(headers, dict): - raise TypeError("ProxyAuthentication.authorize must return a dictionary") - - return headers - - # User Implementable methods below this line - - @abc.abstractmethod - def authorize(self, proxy: str, protocol: Protocol) -> dict: - """This method is used to authorize an HTTP, HTTPS, or GRPC connection to a service. - - If you are attempting to use basic auth, you should include your - username and password in the proxy URL. In this case, you do not need to - implement this interface. - - .. code:: - - proxy = "http://user:pass@someproxy:8080" - os.environ["HTTPS_PROXY"] = proxy - # OR - ProxyAuthentication.set_proxy(proxy, ProxyAuthentication.Protocol.HTTPS) - ProxyAuthentication.set_proxy(proxy, ProxyAuthentication.Protocol.GRPC) - # OR - ProxyAuthentication.set_proxy(proxy) # sets all known protocols - - If you are implementing this interface, you should return a dictionary with the - headers as the keys and any string values that should be sent. - - .. code:: - - class MyProxyAuth(ProxyAuthentication) - def __init__(self, client_id: str = None, secret: str = None): - self.client_id = client_id - self.secret = secret - - def authorize(self, proxy: str, protocol: str) -> dict: - # Here you could use the instance variables to fetch a new key. - # Or whatever implementation you desire. - - return { - "some-header": "some value", - "x-api-key": "123456", - } - - ProxyAuthentication.register(MyProxyAuth) - # OR - ProxyAuthentication.register(MyProxyAuth("some-client-id", "some-secret")) - - Parameters - ========== - proxy : str - The URL of the proxy that was selected for the connection. - protocol : :py:class:`Protocol` - The Protocol that will be used for requests across the proxy after the - connection is established. - - Notes - ===== - For HTTP (urls starting with `http://`): - Authorize is called for every HTTP request. - - The returned headers are merged with the original request headers. - - For HTTPS (urls starting with `https://`) and GRPC: - Authorize is called for the initial CONNECT request for the underlying - socket to the proxy server. - - The returned headers will not be present on any requests through the - established connection to prevent data leaking. - - For HTTPS Proxies (proxy urls starting with `https://`) and GRPC: - Unless your proxy is signed by a third party Certificate Authority, you will - need to configure a CA certificate. - - This can be done at the system level or through the `CURL_CA_BUNDLE` or - `REQUESTS_CA_BUNDLE` environment variable. - """ - - pass diff --git a/descarteslabs/core/common/http/retry.py b/descarteslabs/core/common/http/retry.py deleted file mode 100644 index 5f24b349..00000000 --- a/descarteslabs/core/common/http/retry.py +++ /dev/null @@ -1,46 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 urllib3.util.retry import Retry as Urllib3Retry - - -class Retry(Urllib3Retry): - """Retry configuration that allows configuration of retry-after support. - - This retry configuration class derives from - `urllib3.util.retry.Retry - `_. - - Parameters - ---------- - retry_after_status_codes : list - The http status codes that should support the - `Retry-After `_ - header. This is in lieu of the hardwired urllib3 Retry.RETRY_AFTER_STATUS_CODES. - """ - - DEFAULT_RETRY_AFTER_STATUS_CODES = frozenset([403, 413, 429, 503]) - - def __init__(self, *args, retry_after_status_codes=None, **kwargs): - super().__init__(*args, **kwargs) - - if retry_after_status_codes is None: - retry_after_status_codes = self.DEFAULT_RETRY_AFTER_STATUS_CODES - - if not isinstance(retry_after_status_codes, frozenset): - retry_after_status_codes = frozenset(retry_after_status_codes) - - # Overrides the urllib3.util.retry.Retry.RETRY_AFTER_STATUS_CODES - # class variable. - self.RETRY_AFTER_STATUS_CODES = retry_after_status_codes diff --git a/descarteslabs/core/common/http/service.py b/descarteslabs/core/common/http/service.py deleted file mode 100644 index 1ad2b896..00000000 --- a/descarteslabs/core/common/http/service.py +++ /dev/null @@ -1,56 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 DefaultClientMixin: - """ - Provides common service functionality to HTTP and Grpc clients. - """ - - @classmethod - def get_default_client(cls): - """Retrieve the default client. - - This client is used whenever you don't explicitly set the client. - """ - - instance = getattr(cls, "_instance", None) - - if not isinstance(instance, cls): - instance = cls() - cls._instance = instance - - return instance - - @classmethod - def set_default_client(cls, client): - """Change the default client to the given client. - - This is the client that will be used whenever you don't explicitly set the - client - """ - - if not isinstance(client, cls): - raise ValueError(f"client must be an instance of {cls.__name__}") - - cls._instance = client - - @classmethod - def clear_all_default_clients(cls): - """Clear all default clients of this class and all its subclasses.""" - - cls._instance = None - - for subclass in cls.__subclasses__(): - subclass.clear_all_default_clients() diff --git a/descarteslabs/core/common/http/session.py b/descarteslabs/core/common/http/session.py deleted file mode 100644 index 50bcfbef..00000000 --- a/descarteslabs/core/common/http/session.py +++ /dev/null @@ -1,448 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 uuid -from http import HTTPStatus -import warnings - -import requests -import requests.adapters -import requests.compat -import requests.utils -import urllib3 -import urllib3.exceptions - -try: - from urllib3.contrib.socks import SOCKSProxyManager -except ImportError: - - def SOCKSProxyManager(*args, **kwargs): - raise requests.exceptions.InvalidSchema( - "Missing dependencies for SOCKS support." - ) - - -from descarteslabs.exceptions import ( - BadRequestError, - ClientError, - ConflictError, - ForbiddenError, - GatewayTimeoutError, - GoneError, - MethodNotAllowedError, - NotFoundError, - ProxyAuthenticationRequiredError, - RateLimitError, - RequestCancellationError, - ServerError, - UnauthorizedError, - ValidationError, -) - -from .proxy import ProxyAuthentication - -# Disable warnings for retries etc -logging.getLogger("urllib3").setLevel(logging.ERROR) -logging.getLogger("urllib3").propagate = False - - -class HttpHeaderKeys: - RequestGroup = "X-Request-Group" - RetryAfter = "Retry-After" - ProxyAuthenticate = "Proxy-Authenticate" - - -class HTTPAdapter(requests.adapters.HTTPAdapter): - """Custom HTTPAdapter to integrate ProxyAuthentication with requests.""" - - def proxy_headers(self, proxy: str, url: str = None): - """Sets headers used for the connection to the proxy. - - Parameters - ========== - proxy : str - The proxy URL. - url : Optional[str] - The request URL. - - Note - ==== - If the URL starts with `http://`, the headers are merged with the request - headers. - """ - - if url is None: - protocol = ProxyAuthentication.Protocol.HTTPS - else: - if url.startswith(ProxyAuthentication.Protocol.HTTPS): - protocol = ProxyAuthentication.Protocol.HTTPS - elif url.startswith(ProxyAuthentication.Protocol.HTTP): - protocol = ProxyAuthentication.Protocol.HTTP - else: - raise ValueError( - "Protocol must be either {} or {}".format( - ProxyAuthentication.Protocol.HTTP, - ProxyAuthentication.Protocol.HTTPS, - ) - ) - - proxy_auth = ProxyAuthentication.get_registered_instance() - if proxy_auth is None: - return super().proxy_headers(proxy) - - return proxy_auth.get_verified_headers(proxy, protocol) - - def send( - self, request: requests.PreparedRequest, proxies=None, **kwargs - ) -> requests.Response: - """Override the base send method of the adapter to be able to specify proxies from ProxyAuthentication. - - Parameters - ========== - request: requests.PreparedRequest - The prepared request to send. - proxies : dict - The proxies to use for the request. - kwargs : dict - Additional request arguments see - :py:meth:`requests.adapter.HTTPAdapter.send`. - """ - - # proxies defaults to {} by the time it gets here - if not proxies: - protocol = request.url.split(":")[0] - proxy = ProxyAuthentication.get_proxy(protocol) - - if proxy: - proxies = {protocol: proxy} - - try: - return super().send(request, proxies=proxies, **kwargs) - except urllib3.exceptions.ProxyError as ex: - # Unfortunately response headers are pretty low level and require a lot of overriding. - # If this is really desired look at https://stackoverflow.com/questions/39068998/reading-connect-headers - # or https://gist.github.com/bpartridge/9c758c5e70222bac6ce6e1db7bb4d8ea - - # Since we use retry, MaxRetryException converts the underlying exception - # into a string giving us no choice but to search for the string - if "407 Proxy Authentication Required" in ex: - response = requests.Response() - response.status_code = HTTPStatus.PROXY_AUTHENTICATION_REQUIRED - return response - else: - raise - - def proxy_manager_for(self, proxy, url=None, **proxy_kwargs): - """Copied from requests.adapters.HTTPAdapter to pass request url to proxy_headers() - - Parameters - ========== - proxy : str - The selected proxy for the request. - url : str - The URL of the request. - proxy_kwargs : dict - Additional request arguments see - :py:meth:`requests.adapter.HTTPAdapter.proxy_manager_for`. - """ - - if proxy in self.proxy_manager: - manager = self.proxy_manager[proxy] - elif proxy.lower().startswith("socks"): - username, password = requests.utils.get_auth_from_url(proxy) - manager = self.proxy_manager[proxy] = SOCKSProxyManager( - proxy, - username=username, - password=password, - num_pools=self._pool_connections, - maxsize=self._pool_maxsize, - block=self._pool_block, - **proxy_kwargs, - ) - else: - proxy_headers = self.proxy_headers(proxy, url) - manager = self.proxy_manager[proxy] = urllib3.poolmanager.proxy_from_url( - proxy, - proxy_headers=proxy_headers, - num_pools=self._pool_connections, - maxsize=self._pool_maxsize, - block=self._pool_block, - **proxy_kwargs, - ) - - return manager - - def get_connection_with_tls_context(self, request, verify, proxies=None, cert=None): - """Returns a urllib3 connection for the given request and TLS settings. - This should not be called from user code, and is only exposed for use - when subclassing the :class:`HTTPAdapter `. - - :param request: - The :class:`PreparedRequest ` object to be sent - over the connection. - :param verify: - Either a boolean, in which case it controls whether we verify the - server's TLS certificate, or a string, in which case it must be a - path to a CA bundle to use. - :param proxies: - (optional) The proxies dictionary to apply to the request. - :param cert: - (optional) Any user-provided SSL certificate to be used for client - authentication (a.k.a., mTLS). - :rtype: - urllib3.ConnectionPool - """ - proxy = requests.utils.select_proxy(request.url, proxies) - try: - host_params, pool_kwargs = self.build_connection_pool_key_attributes( - request, - verify, - cert, - ) - except ValueError as e: - raise requests.exceptions.InvalidURL(e, request=request) - if proxy: - proxy = requests.utils.prepend_scheme_if_needed(proxy, "http") - proxy_url = urllib3.util.parse_url(proxy) - if not proxy_url.host: - raise requests.exceptions.InvalidProxyURL( - "Please check proxy URL. It is malformed " - "and could be missing the host." - ) - proxy_manager = self.proxy_manager_for(proxy, request.url) - conn = proxy_manager.connection_from_host( - **host_params, pool_kwargs=pool_kwargs - ) - else: - # Only scheme should be lower case - conn = self.poolmanager.connection_from_host( - **host_params, pool_kwargs=pool_kwargs - ) - - return conn - - def get_connection(self, url, proxies=None): - """Copied from requests.adapters.HTTPAdapter to pass request url to proxy_manager_for() - - Parameters - ========== - url : str - The request URL. - proxies : Optional[dict] - The proxies configured for this request. - """ - - warnings.warn( - ( - "`get_connection` has been deprecated in favor of " - "`get_connection_with_tls_context`. Custom HTTPAdapter subclasses " - "will need to migrate for Requests>=2.32.2. Please see " - "https://github.com/psf/requests/pull/6710 for more details." - ), - DeprecationWarning, - ) - proxy = requests.utils.select_proxy(url, proxies) - - if proxy: - proxy = requests.utils.prepend_scheme_if_needed(proxy, "http") - proxy_url = urllib3.util.parse_url(proxy) - if not proxy_url.host: - raise requests.exceptions.InvalidProxyURL( - "Please check proxy URL. It is malformed" - " and could be missing the host." - ) - proxy_manager = self.proxy_manager_for(proxy, url) - conn = proxy_manager.connection_from_url(url) - else: - # Only scheme should be lower case - parsed = requests.compat.urlparse(url) - url = parsed.geturl() - conn = self.poolmanager.connection_from_url(url) - - return conn - - -class Session(requests.Session): - """The HTTP Session that performs the actual HTTP request. - - This is the base session that is used for all Descartes Labs HTTP calls which - itself is derived from `requests.Session - `_. - - You cannot control its instantiation, but you can derive from this class - and pass it as the class to use when you instantiate a - :py:class:`~descarteslabs.client.services.service.Service` or register it as the - default session class using - :py:meth:`~descarteslabs.client.services.service.Service.set_default_session_class`. - - Notes - ===== - Session is not thread safe due to the Adapter and the connection pool which it uses. - Instead, you should ensure that each thread is using it's own session instead of - trying to share one. - - Parameters - ---------- - base_url: str - The URL prefix to use for communication with the Descartes Labs servers. - timeout: int or tuple(int, int) - See `requests timeouts - `_. - """ - - ATTR_BASE_URL = "base_url" - ATTR_TIMEOUT = "timeout" - - # Adapts the custom pickling protocol of requests.Session - __attrs__ = requests.Session.__attrs__ + [ATTR_BASE_URL, ATTR_TIMEOUT] - - def __init__(self, base_url="", timeout=None, retries=None): - self.base_url = base_url - self.timeout = timeout - - super(Session, self).__init__() - - self.mount("http://", HTTPAdapter(max_retries=retries)) - self.mount("https://", HTTPAdapter(max_retries=retries)) - - def initialize(self): - """Initialize the :py:class:`Session` instance - - You can override this method in a derived class to add your own initialization. - This method does nothing in the base class. - """ - - pass - - def request(self, method, url, headers=None, **kwargs): - """Sends an HTTP request and emits Descartes Labs specific errors. - - Parameters - ---------- - method: str - The HTTP method to use. - url: str - The URL to send the request to. - headers: dict - The Headers to set on the request. - kwargs: dict - Additional arguments. See `requests.request - `_. - - Returns - ------- - Response - A :py:class:`request.Response` object. - - Raises - ------ - BadRequestError - Either a 400 or 422 HTTP response status code was encountered. - NotFoundError - A 404 HTTP response status code was encountered. - ProxyAuthenticationRequiredError - A 407 HTTP response status code was encountered indicating proxy - authentication was not handled or was invalid. - ConflictError - A 409 HTTP response status code was encountered. - GoneError - A 410 HTTP response status code was encountered. - ValidationError - A 422 HTTP response status code was encountered. - ValidationError extends BadRequestError for backward compatibility. - RateLimitError - A 429 HTTP response status code was encountered. - GatewayTimeoutError - A 504 HTTP response status code was encountered. - ~descarteslabs.exceptions.ServerError - Any HTTP response status code larger than 400 that was not covered above - is returned as a ServerError. The original HTTP response status code - can be found in the attribute :py:attr:`original_status`. - """ - - if self.timeout and self.ATTR_TIMEOUT not in kwargs: - kwargs[self.ATTR_TIMEOUT] = self.timeout - - if headers is None: - headers = {} - - headers[HttpHeaderKeys.RequestGroup] = uuid.uuid4().hex - request_url = self.base_url + url - - try: - resp = super(Session, self).request( - method, - request_url, - headers=headers, - **kwargs, - ) - except IndexError: - # self._read_status() in http/client returns an IndexError when the request - # is cancelled. - raise RequestCancellationError() - - if ( - resp.status_code >= HTTPStatus.OK - and resp.status_code < HTTPStatus.BAD_REQUEST - ): - return resp - elif resp.status_code == HTTPStatus.BAD_REQUEST: - raise BadRequestError(resp.text) - elif resp.status_code == HTTPStatus.UNAUTHORIZED: - raise UnauthorizedError(resp.text) - elif resp.status_code == HTTPStatus.FORBIDDEN: - raise ForbiddenError(resp.text) - elif resp.status_code == HTTPStatus.NOT_FOUND: - text = resp.text - if not text: - text = "{} {} {}".format(HTTPStatus.NOT_FOUND, method, url) - raise NotFoundError(text) - elif resp.status_code == HTTPStatus.METHOD_NOT_ALLOWED: - raise MethodNotAllowedError(resp.text) - elif resp.status_code == HTTPStatus.PROXY_AUTHENTICATION_REQUIRED: - raise ProxyAuthenticationRequiredError( - resp.text, - proxy_authenticate=resp.headers.get(HttpHeaderKeys.ProxyAuthenticate), - ) - elif resp.status_code == HTTPStatus.CONFLICT: - raise ConflictError(resp.text) - elif resp.status_code == HTTPStatus.GONE: - raise GoneError(resp.text) - elif resp.status_code == HTTPStatus.UNPROCESSABLE_ENTITY: - # For backward compatibility, ValidationError extends BadRequestError - raise ValidationError(resp.text) - elif resp.status_code == HTTPStatus.TOO_MANY_REQUESTS: - raise RateLimitError( - resp.text, retry_after=resp.headers.get(HttpHeaderKeys.RetryAfter) - ) - elif resp.status_code < HTTPStatus.INTERNAL_SERVER_ERROR: - ex = ClientError(resp.text) - ex.status = resp.status_code.value - raise ex - elif resp.status_code == HTTPStatus.GATEWAY_TIMEOUT: - raise GatewayTimeoutError( - "Your request timed out on the server. " - "Consider reducing the complexity of your request." - ) - else: - # The whole error hierarchy has some problems. Originally a ClientError - # could be thrown by our client libraries, but any HTTP error was a - # ServerError. That changed and HTTP errors below 500 became ClientErrors. - # That means that this actually should be split in ClientError for - # status < 500 and ServerError for status >= 500, but that might break - # things. So instead, we'll add the original status. - server_error = ServerError(resp.text) - server_error.original_status = resp.status_code - raise server_error diff --git a/descarteslabs/core/common/http/tests/__init__.py b/descarteslabs/core/common/http/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/descarteslabs/core/common/http/tests/test_authorization.py b/descarteslabs/core/common/http/tests/test_authorization.py deleted file mode 100644 index f9a26d32..00000000 --- a/descarteslabs/core/common/http/tests/test_authorization.py +++ /dev/null @@ -1,29 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 unittest - -from .. import authorization - - -class TestAuthorization(unittest.TestCase): - def test_add_bearer(self): - assert authorization.add_bearer("foo") == "Bearer foo" - assert authorization.add_bearer("foo") == "Bearer foo" - assert authorization.add_bearer(b"foo") == b"Bearer foo" - - def test_remove_bearer(self): - assert authorization.remove_bearer("Bearer foo") == "foo" - assert authorization.remove_bearer("Bearer foo") == "foo" - assert authorization.remove_bearer(b"Bearer foo") == b"foo" diff --git a/descarteslabs/core/common/http/tests/test_proxy.py b/descarteslabs/core/common/http/tests/test_proxy.py deleted file mode 100644 index e79733b4..00000000 --- a/descarteslabs/core/common/http/tests/test_proxy.py +++ /dev/null @@ -1,167 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import os -import unittest - -from ..proxy import ProxyAuthentication - - -class TestProxyAuth(unittest.TestCase): - def tearDown(self): - ProxyAuthentication.unregister() - ProxyAuthentication.clear_proxy() - - keys = ["HTTP_PROXY", "HTTPS_PROXY", "GRPC_PROXY"] - for key in keys: - os.environ.pop(key, None) - - def test_register_validates_type(self): - with self.assertRaises(TypeError): - ProxyAuthentication.register(dict) - - def test_requires_implementation(self): - class MyProxyAuth(ProxyAuthentication): - # unimplemented methods - pass - - with self.assertRaises(TypeError): - ProxyAuthentication.register(MyProxyAuth) - - def test_register(self): - class MyProxyAuth(ProxyAuthentication): - def authorize(self, proxy: str, protocol: str) -> dict: - return {} - - my_instance = MyProxyAuth() - ProxyAuthentication.register(MyProxyAuth) - - assert isinstance(ProxyAuthentication.get_registered_instance(), MyProxyAuth) - assert ProxyAuthentication.get_registered_instance() != my_instance - - ProxyAuthentication.register(my_instance) - assert ProxyAuthentication.get_registered_instance() == my_instance - - ProxyAuthentication.unregister() - assert ProxyAuthentication.get_registered_instance() is None - - def test_proxies_from_env(self): - os.environ["HTTP_PROXY"] = "http://some-proxy" - assert ProxyAuthentication.get_proxies() == { - "http": "http://some-proxy", - "https": None, - "grpc": "http://some-proxy", - } - - os.environ["HTTPS_PROXY"] = "https://another-proxy" - assert ProxyAuthentication.get_proxies() == { - "http": "http://some-proxy", - "https": "https://another-proxy", - "grpc": "https://another-proxy", # grpc defaults to https if not set - } - - os.environ["GRPC_PROXY"] = "https://grpc-proxy" - assert ProxyAuthentication.get_proxies() == { - "http": "http://some-proxy", - "https": "https://another-proxy", - "grpc": "https://grpc-proxy", - } - - def test_proxy_precedence(self): - for protocol in ["http", "https", "grpc"]: - env = f"{protocol.upper()}_PROXY" - os.environ[env] = "set-by-env" - - ProxyAuthentication.set_proxy("set-by-user", protocol) - - proxies = ProxyAuthentication.get_proxies() - other_protocol_proxies = [ - (k, v) for k, v in proxies.items() if k != protocol - ] - - for k, v in other_protocol_proxies: - if k == "grpc": - assert v == "set-by-env" - else: - assert v is None - - ProxyAuthentication.clear_proxy() - del os.environ[env] - - def test_set_proxy(self): - assert ProxyAuthentication.get_proxies() == { - "grpc": None, - "http": None, - "https": None, - } - - for protocol in ProxyAuthentication.get_proxies().keys(): - proxy = f"http://proxy-{protocol}" - ProxyAuthentication.set_proxy(proxy, protocol) - assert ProxyAuthentication.get_proxy(protocol) == proxy - - ProxyAuthentication.clear_proxy() - assert ProxyAuthentication.get_proxies() == { - "grpc": None, - "http": None, - "https": None, - } - - ProxyAuthentication.set_proxy("all-of-them") - assert ProxyAuthentication.get_proxies() == { - "grpc": "all-of-them", - "http": "all-of-them", - "https": "all-of-them", - } - - def test_clear_proxy(self): - assert ProxyAuthentication.get_proxies() != {} - - for protocol in ProxyAuthentication.get_proxies().keys(): - proxy = f"http://proxy-{protocol}" - - ProxyAuthentication.clear_proxy(protocol) - ProxyAuthentication.set_proxy(proxy, protocol) - assert ProxyAuthentication.get_proxy(protocol) == proxy - - def test_authorize(self): - class MyProxyAuth(ProxyAuthentication): - def authorize(self, proxy: str, protocol: str) -> dict: - MyProxyAuth.proxy = proxy - MyProxyAuth.protocol = protocol - - return {"some-header": "some-value"} - - ProxyAuthentication.register(MyProxyAuth) - proxy_auth = ProxyAuthentication.get_registered_instance() - assert isinstance(proxy_auth, ProxyAuthentication) - - headers = proxy_auth.get_verified_headers("http://some-proxy", "some-protocol") - assert headers == {"some-header": "some-value"} - assert MyProxyAuth.proxy == "http://some-proxy" - assert MyProxyAuth.protocol == "some-protocol" - - def test_authorize_validation(self): - class MyProxyAuth(ProxyAuthentication): - def authorize(self, proxy: str, protocol: str) -> dict: - MyProxyAuth.called = True - return 10 - - ProxyAuthentication.register(MyProxyAuth) - proxy_auth = ProxyAuthentication.get_registered_instance() - - with self.assertRaisesRegex(TypeError, "return a dictionary"): - proxy_auth.get_verified_headers("http://some proxy", "some protocol") - - assert MyProxyAuth.called diff --git a/descarteslabs/core/common/http/tests/test_retry.py b/descarteslabs/core/common/http/tests/test_retry.py deleted file mode 100644 index dcea8bf5..00000000 --- a/descarteslabs/core/common/http/tests/test_retry.py +++ /dev/null @@ -1,38 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 unittest - -from ..retry import Retry - - -class TestRetry(unittest.TestCase): - def test_retry_sets_status_codes(self, *mocks): - retry = Retry() - assert retry.RETRY_AFTER_STATUS_CODES == Retry.DEFAULT_RETRY_AFTER_STATUS_CODES - - for code in Retry.DEFAULT_RETRY_AFTER_STATUS_CODES: - assert retry.is_retry("GET", code, has_retry_after=True) is True - assert retry.is_retry("GET", code, has_retry_after=False) is False - - retry = Retry(retry_after_status_codes=[]) - assert retry.RETRY_AFTER_STATUS_CODES == frozenset([]) - assert retry.is_retry("GET", 403, has_retry_after=True) is False - assert retry.is_retry("GET", 403, has_retry_after=False) is False - - retry = Retry(retry_after_status_codes=[400]) - assert retry.RETRY_AFTER_STATUS_CODES == frozenset([400]) - assert retry.is_retry("GET", 403, has_retry_after=True) is False - assert retry.is_retry("GET", 400, has_retry_after=True) is True - assert retry.is_retry("GET", 400, has_retry_after=False) is False diff --git a/descarteslabs/core/common/http/tests/test_service.py b/descarteslabs/core/common/http/tests/test_service.py deleted file mode 100644 index 6456e5b7..00000000 --- a/descarteslabs/core/common/http/tests/test_service.py +++ /dev/null @@ -1,54 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 unittest - -from ..service import DefaultClientMixin - - -class TestDefaultClient(unittest.TestCase): - class BaseClient(DefaultClientMixin): - """Test base client to make sure instance type is correct when subclassing""" - - pass - - class TestClient(BaseClient): - """Test client to make sure the instance type is correct""" - - def __init__(self, url="default"): - self.url = url - - def test_get_default_client(self): - TestDefaultClient.TestClient.set_default_client( - TestDefaultClient.TestClient(url="blah") - ) - default_client = TestDefaultClient.TestClient.get_default_client() - assert isinstance(default_client, TestDefaultClient.TestClient) - assert TestDefaultClient.TestClient.get_default_client() == default_client - - def test_set_default_client(self): - url = "something" - TestDefaultClient.TestClient.set_default_client( - TestDefaultClient.TestClient(url=url) - ) - assert TestDefaultClient.TestClient.get_default_client().url == url - - def test_set_validates_type(self): - with self.assertRaisesRegex(ValueError, "client must be an instance of"): - TestDefaultClient.TestClient.set_default_client("Should Fail") - - with self.assertRaisesRegex(ValueError, "client must be an instance of"): - TestDefaultClient.TestClient.set_default_client( - TestDefaultClient.BaseClient() - ) diff --git a/descarteslabs/core/common/property_filtering/__init__.py b/descarteslabs/core/common/property_filtering/__init__.py deleted file mode 100644 index fbf31285..00000000 --- a/descarteslabs/core/common/property_filtering/__init__.py +++ /dev/null @@ -1,39 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 .filtering import ( - EqExpression, - Expression, - ILikeExpression, - LikeExpression, - NeExpression, - Properties, - Property, - RangeExpression, -) - -# for backwards compatibility -GenericProperties = Properties - -__all__ = [ - "EqExpression", - "Expression", - "GenericProperties", - "ILikeExpression", - "LikeExpression", - "NeExpression", - "Properties", - "Property", - "RangeExpression", -] diff --git a/descarteslabs/core/common/property_filtering/filtering.py b/descarteslabs/core/common/property_filtering/filtering.py deleted file mode 100644 index a79fb607..00000000 --- a/descarteslabs/core/common/property_filtering/filtering.py +++ /dev/null @@ -1,856 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 functools -import inspect -import json -import re -from typing import Any, Dict, List, Tuple, Type, TypeVar, Union - -AnyExpression = TypeVar("AnyExpression", bound="Expression") - - -class Expression(object): - """An expression is the result of a filtering operation. - - An expression can contain a :py:class:`Property`, a comparison operator, and a - value (or set of values): - - | ``property`` ``operator`` ``value`` - | or - | ``value`` ``operator`` ``property`` - - where the operator can be - - * == - * != - * < - * <= - * > - * >= - - If the operator is ``<``, ``<=``, ``>`` or ``>=``, you can construct a range using - - ``value`` ``operator`` ``property`` ``operator`` ``value`` - - Expressions can be combined using the Boolean operators ``&`` and ``|`` to form - larger expressions. Due to language limitations - the operator for ``and`` is expressed as ``&`` and the operator for ``or`` is - expressed as ``|``. Also, because of operator precedence, you must bracket - expressions with ``(`` and ``)`` to avoid unexpected behavior: - - ``(`` ``property`` ``operator`` ``value`` ``)`` ``&`` ``(`` ``value`` ``operator`` ``property`` ``)`` - - In addition there is a method-like operator that can be used on a - property. - - * :py:meth:`Property.any_of` or :meth:`Property.in_` - - And a couple of properties that allow you to verify whether a property value has - been set or not. A property value is considered ``null`` when it's either set to - ``None`` or to the empty list ``[]`` in case of a list property. These are only - available for the Catalog Service. - - * :py:attr:`Property.isnull` - * :py:attr:`Property.isnotnull` - - Examples - -------- - >>> from descarteslabs.common.property_filtering import Properties - >>> p = Properties() - >>> e = p.foo == 5 - >>> type(e) - - >>> e = p.foo.any_of([1, 2, 3, 4, 5]) - >>> type(e) - - >>> e = 5 < p.foo < 10 - >>> type(e) - - >>> e = (5 < p.foo < 10) & p.foo.any_of([1, 2, 3, 4, 5]) - >>> type(e) - - >>> e = p.foo.isnotnull - >>> type(e) - - >>> e = p.foo.isnull - >>> type(e) - - """ - - __abstract__: bool = False - _aliases: List[str] = None - _registry: Dict[str, Type[AnyExpression]] = dict() - _operator: str = None - - def __init_subclass__(cls) -> None: - # Do not register base classes - if cls.__dict__.get("__abstract__", False): - return - - operator = cls._operator - - if not operator: - operator = cls.__name__.replace("Expression", "").lower() - setattr(cls, "_operator", operator) - - to_add = cls._aliases or [] - to_add.append(operator) - - # Register the operator and all aliases - for operator in to_add: - if operator in cls._registry: - other_expression = cls._registry[operator] - - raise ValueError( - "Expression {} already exists with operator {}".format( - other_expression, operator - ) - ) - - cls._registry[operator] = cls - - def jsonapi_serialize(self, model=None): - raise NotImplementedError - - def is_same(self, other: Any) -> bool: - """Determine if two expressions are the same. This is different - from testing for equivalence (eg `a == b` and `b == a` are equivalent, - bit not the same). - """ - return type(self) is type(other) - - @classmethod - def parse( - cls, data: Union[str, Dict[str, Any], List[Dict[str, Any]]] - ) -> AnyExpression: - """Parses a serialized filter into a series of expression objects. - - Parameters - ---------- - data: str or dict - The serialized filter expression. This can be a JSON string or a dict. - """ - if isinstance(data, str): - data = json.loads(data) - - if not isinstance(data, (list, dict)): - raise ValueError("Invalid filter expression") - - if isinstance(data, list): - return AndExpression([Expression._parse_filter_part(item) for item in data]) - - return Expression._parse_filter_part(data) - - @classmethod - def _parse_filter_part(cls, data: Dict[str, Any]) -> AnyExpression: - """Parses a single filter expression.""" - op, value = cls._parse_operator(data) - - if op in cls._registry: - return cls._registry[op]._parse(value) - else: - raise ValueError(f"Unknown filter operator: {op}") - - @classmethod - def _parse_operator(self, data: Dict[str, Any]): - if not isinstance(data, dict): - raise ValueError(f"Invalid filter expected dict found: {data}") - - op = data.get("op") - - if op: - # This is a json api expression - return op, data - else: - # This is a standard expression - if len(data.keys()) == 1: - op = list(data.keys())[0] - value = data[op] - - return op, value - else: - raise ValueError(f"Invalid filter expression: {data}") - - @classmethod - def _parse(cls, *args) -> AnyExpression: - """Parse the input into a specific expression type. - - Must be implemented by subclasses. - """ - raise NotImplementedError(f"{cls.__name__}: {args}") - - -class OpExpression(Expression): - """Base class for expressions that have an operator and a value.""" - - __abstract__ = True - - def is_same(self, other: Any) -> bool: - if not super().is_same(other): - return False - - return self.name == other.name - - def __and__(self, other): - return AndExpression([self]) & other - - def __or__(self, other): - return OrExpression([self]) | other - - def __rand__(self, other): - return AndExpression([other]) & self - - def __ror__(self, other): - return OrExpression([other]) | self - - def _convert_name_value_pair(self, name, value): - if hasattr(value, "id"): - return name + "_id", value.id - else: - return name, value - - @classmethod - def _parse(cls, data: Dict[str, Any]) -> AnyExpression: - signature = inspect.signature(cls) - num_params = len(signature.parameters) - - if isinstance(data, dict) and "op" in data: - # Json api expression - name, val = cls._parse_jsonapi_filter(data) - else: - # Standard expression - name, val = cls._parse_filter(data) - - if name is None: - raise ValueError( - f"Invalid {cls._operator} expression missing field name: {data}" - ) - - # Some expressions only take the name and no value - if num_params == 1: - return cls(name) - else: - if val is None: - raise ValueError( - f"Invalid {cls._operator} expression missing value: {data}" - ) - - return cls(name, val) - - @classmethod - def _parse_filter(cls, data: Any) -> Tuple[str, Any]: - if not isinstance(data, dict): - raise ValueError( - f"Invalid {cls._operator} value expected dict found: {data}" - ) - - if len(data.keys()) != 1: - # There must be exactly one field name - raise ValueError(f"Invalid {cls._operator} expression: {data}") - - return list(data.items())[0] - - @classmethod - def _parse_jsonapi_filter(cls, data: Dict[str, Any]) -> Tuple[str, Any]: - return data.get("name"), data.get("val") - - -class LogicalExpression(Expression): - """Base class for logical expressions that have sub expressions.""" - - __abstract__ = True - - def __init__(self, parts): - self.parts = parts - - def is_same(self, other: Any) -> bool: - if not super().is_same(other): - return False - - return len(self.parts) == len(other.parts) and all( - part.is_same(other_part) - for part, other_part in zip(self.parts, other.parts) - ) - - @classmethod - def _parse(cls, data: List[Dict[str, Any]]) -> AnyExpression: - parts = [Expression.parse(expr) for expr in data] - return cls(parts) - - -# A convention was added to allow for serialization of catalog V2 attributes -# If a model is given, the model class method `_serialize_filter_attribute` will be -# called to retrieve the serialized value of an attribute. - -# A second convention was added to allow for Catalog V2 object to be used -# instead of the name for == and != operations, and to convert -# that into the `id` field of the object. - - -class EqExpression(OpExpression): - """Whether a property value is equal to the given value.""" - - def __init__(self, name, value): - self.name, self.value = self._convert_name_value_pair(name, value) - - def serialize(self): - return {"eq": {self.name: self.value}} - - def jsonapi_serialize(self, model=None): - name, value = ( - model._serialize_filter_attribute(self.name, self.value) - if model - else (self.name, self.value) - ) - return {"op": "eq", "name": name, "val": value} - - def evaluate(self, obj): - return getattr(obj, self.name) == self.value - - def is_same(self, other: Any) -> bool: - if not super().is_same(other): - return False - - return self.value == other.value - - -class NeExpression(OpExpression): - """Whether a property value is not equal to the given value.""" - - def __init__(self, name, value): - self.name, self.value = self._convert_name_value_pair(name, value) - - def serialize(self): - return {"ne": {self.name: self.value}} - - def jsonapi_serialize(self, model=None): - name, value = ( - model._serialize_filter_attribute(self.name, self.value) - if model - else (self.name, self.value) - ) - return {"op": "ne", "name": name, "val": value} - - def evaluate(self, obj): - return getattr(obj, self.name) != self.value - - def is_same(self, other: Any) -> bool: - if not super().is_same(other): - return False - - return self.value == other.value - - -class RangeExpression(OpExpression): - """Whether a property value is within the given range. - - A range can have a single value that must be ``>``, ``>=``, - ``<`` or ``<=`` than the value of the property. If the range - has two values, the property value must be between the given - range values. - """ - - _aliases = ["gt", "gte", "lt", "lte"] - - def __init__(self, name, parts): - self.name = name - self.parts = parts - - def serialize(self): - return {"range": {self.name: self.parts}} - - def jsonapi_serialize(self, model=None): - serialized = [] - for op, val in self.parts.items(): - name, value = ( - model._serialize_filter_attribute(self.name, val) - if model - else (self.name, val) - ) - serialized.append({"name": name, "op": op, "val": value}) - return serialized[0] if len(serialized) == 1 else {"and": serialized} - - def evaluate(self, obj): - result = True - - for op, val in self.parts.items(): - if op == "gte": - result = result and getattr(obj, self.name) >= val - elif op == "gt": - result = result and getattr(obj, self.name) > val - elif op == "lte": - result = result and getattr(obj, self.name) <= val - elif op == "lt": - result = result and getattr(obj, self.name) < val - else: - raise ValueError("Unknown operation") - - return result - - def is_same(self, other: Any) -> bool: - if not super().is_same(other): - return False - - return len(self.parts) == len(other.parts) and all( - part == other_part - for part, other_part in zip(self.parts.items(), other.parts.items()) - ) - - @classmethod - def _parse_jsonapi_filter(cls, data: Dict[str, Any]) -> AnyExpression: - """Override parsing to handle json api special case""" - return data.get("name"), {data["op"]: data.get("val")} - - -class IsNullExpression(OpExpression): - """Whether a property value is ``None`` or ``[]``.""" - - def __init__(self, name): - self.name = name - - def serialize(self): - return {self._operator: self.name} - - def jsonapi_serialize(self, model=None): - name = self.name - - if model: - name, _ = model._serialize_filter_attribute(self.name, None) - - return {"name": name, "op": self._operator} - - def evaluate(self, obj): - return getattr(obj, self.name) is None - - @classmethod - def _parse_filter(cls, data: Any) -> Tuple[str, Any]: - if not isinstance(data, str): - raise ValueError( - f"Invalid {cls._operator} value expected str found: {data}" - ) - - return data, None - - -class IsNotNullExpression(OpExpression): - """Whether a property value is not ``None`` or ``[]``.""" - - def __init__(self, name): - self.name = name - - def serialize(self): - return {self._operator: self.name} - - def jsonapi_serialize(self, model=None): - name = self.name - - if model: - name, _ = model._serialize_filter_attribute(self.name, None) - - return {"name": name, "op": self._operator} - - def evaluate(self, obj): - return getattr(obj, self.name) is not None - - @classmethod - def _parse_filter(cls, data: Any) -> Tuple[str, Any]: - if not isinstance(data, str): - raise ValueError( - f"Invalid {cls._operator} value expected str found: {data}" - ) - - return data, None - - -class PrefixExpression(OpExpression): - """Whether a string property value starts with the given string prefix.""" - - def __init__(self, name, value): - self.name = name - self.value = value - - def serialize(self): - return {"prefix": {self.name: self.value}} - - def jsonapi_serialize(self, model=None): - if model: - name, _ = model._serialize_filter_attribute(self.name, None) - - return {"op": "prefix", "name": name, "val": self.value} - - def evaluate(self, obj): - return getattr(obj, self.name).startswith(self.value) - - def is_same(self, other: Any) -> bool: - if not super().is_same(other): - return False - - return self.value == other.value - - -class LikeExpression(OpExpression): - """Whether a property value matches the given wildcard expression. - - The wildcard expression can contain ``%`` for zero or more characters and - ``_`` for a single character. - - This expression is not supported by the `Catalog` service. - """ - - def __init__(self, name, value): - self.name = name - self.value = value - - def serialize(self): - return {"like": {self.name: self.value}} - - def jsonapi_serialize(self, model=None): - name, value = ( - model._serialize_filter_attribute(self.name, self.value) - if model - else (self.name, self.value) - ) - return {"name": name, "op": "like", "val": value} - - def evaluate(self, obj): - expr = re.escape(self.value).replace("_", ".").replace("%", ".*") - return re.match(f"^{expr}$", getattr(obj, self.name)) is not None - - def is_same(self, other: Any) -> bool: - if not super().is_same(other): - return False - - return self.value == other.value - - -class ILikeExpression(OpExpression): - """Whether a property value matches the given case insensitive wildcard expression. - - The wildcard expression can contain ``%`` for zero or more characters and - ``_`` for a single character. - - This expression is not supported by the `Catalog` service. - """ - - def __init__(self, name, value): - self.name = name - self.value = value - - def serialize(self): - return {"ilike": {self.name: self.value}} - - def jsonapi_serialize(self, model=None): - name, value = ( - model._serialize_filter_attribute(self.name, self.value) - if model - else (self.name, self.value) - ) - return {"name": name, "op": "ilike", "val": value} - - def evaluate(self, obj): - expr = re.escape(self.value).replace("_", ".").replace("%", ".*") - return re.match(f"^{expr}$", getattr(obj, self.name), re.IGNORECASE) is not None - - def is_same(self, other: Any) -> bool: - if not super().is_same(other): - return False - - return self.value == other.value - - -class AndExpression(LogicalExpression): - """``True`` if both expressions are ``True``, ``False`` otherwise.""" - - def __and__(self, other): - if isinstance(other, AndExpression): - self.parts.extend(other.parts) - return self - if isinstance(other, (OrExpression, Expression)): - self.parts.append(other) - return self - else: - raise Exception("Invalid sub-expression") - - __rand__ = __and__ - - def __or__(self, other): - return OrExpression([self]) | other - - def __repr__(self): - return "".format(self.parts) - - def serialize(self): - return {"and": [x.serialize() for x in self.parts]} - - def jsonapi_serialize(self, model=None): - return {"and": [part.jsonapi_serialize(model=model) for part in self.parts]} - - def evaluate(self, obj): - for part in self.parts: - if not part.evaluate(obj): - return False - - return True - - def is_same(self, other: Any) -> bool: - if not super().is_same(other): - return False - - return len(self.parts) == len(other.parts) and all( - part.is_same(other_part) - for part, other_part in zip(self.parts, other.parts) - ) - - -class OrExpression(LogicalExpression): - """``True`` if either expression is ``True``, ``False`` otherwise.""" - - def __and__(self, other): - return AndExpression([self]) & other - - def __or__(self, other): - if isinstance(other, OrExpression): - self.parts.extend(other.parts) - return self - if isinstance(other, (AndExpression, Expression)): - self.parts.append(other) - return self - else: - raise Exception("Invalid sub-expression") - - __ror__ = __or__ - - def __repr__(self): - return "".format(self.parts) - - def serialize(self): - return {"or": [x.serialize() for x in self.parts]} - - def jsonapi_serialize(self, model=None): - return {"or": [part.jsonapi_serialize(model=model) for part in self.parts]} - - def evaluate(self, obj): - for part in self.parts: - if part.evaluate(obj): - return True - - return False - - def is_same(self, other: Any) -> bool: - if not super().is_same(other): - return False - - return len(self.parts) == len(other.parts) and all( - part.is_same(other_part) - for part, other_part in zip(self.parts, other.parts) - ) - - -def range_expr(op): - def f(self, other): - # This is a hack to support compound comparisons - # such as 10 < a < 20 - self.parts[op] = other - return RangeExpression(self.name, self.parts.copy()) - - return f - - -def check_can_filter(fn): - """Decorator to check whether a property can be filtered on. - - This is used by Documents in some object oriented clients. - """ - - @functools.wraps(fn) - def wrapper(self, *args, **kwargs): - if getattr(self, "filterable", True): - return fn(self, *args, **kwargs) - else: - raise ValueError(f"Cannot filter on property: {self.name}") - - return wrapper - - -class Property(object): - """A filter property that can be used in an expression. - - Although you can generate filter properties by instantiating this class, a more - convenient method is to use a - :py:class:`~descarteslabs.common.property_filtering.filtering.Properties` - instance. - By referencing any attribute of a - :py:class:`~descarteslabs.common.property_filtering.filtering.Properties` - instance the corresponding filter property - will be created. - - See :ref:`Properties Introduction ` - for a more detailed explanation. - - Examples - -------- - >>> e = Property("modified") > "2020-01-01" - """ - - def __init__(self, name, parts=None): - self.name = name - self.parts = parts or {} - - __ge__ = check_can_filter(range_expr("gte")) - __gt__ = check_can_filter(range_expr("gt")) - __le__ = check_can_filter(range_expr("lte")) - __lt__ = check_can_filter(range_expr("lt")) - - @check_can_filter - def __eq__(self, other): - return EqExpression(self.name, other) - - @check_can_filter - def __ne__(self, other): - return NeExpression(self.name, other) - - @check_can_filter - def __repr__(self): - return "".format(self.name) - - @check_can_filter - def prefix(self, prefix): - """Compare against a prefix string.""" - return PrefixExpression(self.name, prefix) - - startswith = prefix - - @check_can_filter - def like(self, wildcard): - """Compare against a wildcard string. - - This can only be used in expressions for the ``Vector`` service. - This allows for wildcards, e.g. ``like("bar%foo")`` where any - string that starts with ``'bar'`` and ends with ``'foo'`` will be - matched. - - This uses the SQL ``LIKE`` syntax with single character - wildcard ``'_'`` and arbitrary character wildcard ``'%'``. - - To escape either of these wilcard characters prepend it - with a backslash, which becomes a double backslash in the - python string, i.e. use ``like("bar\\\\%foo")`` to match exactly - ``'bar%foo'``. - """ - return LikeExpression(self.name, wildcard) - - @check_can_filter - def ilike(self, wildcard): - """Compare against a case insensitive wildcard string. - - This can only be used in expressions for the ``Vector`` service. - This allows for wildcards, e.g. ``ilike("bar%foo")`` where any - string that starts with ``'bar'`` and ends with ``'foo'`` will be - matched. - - This uses the SQL ``LIKE`` syntax with single character - wildcard ``'_'`` and arbitrary character wildcard ``'%'``. - - To escape either of these wilcard characters prepend it - with a backslash, which becomes a double backslash in the - python string, i.e. use ``like("bar\\\\%foo")`` to match exactly - ``'bar%foo'``. - """ - return ILikeExpression(self.name, wildcard) - - @check_can_filter - def any_of(self, iterable): - """The property must have any of the given values. - - Asserts that this property must have a value equal to one of the - values in the given iterable. This can be thought of as behaving - like an ``in`` expression in Python or an ``IN`` expression in SQL. - """ - exprs = [(self == item) for item in iterable] - - if len(exprs) > 1: - return OrExpression(exprs) - elif len(exprs) == 1: - return exprs[0] - else: - # technically we should return an expression that always evaluates false - # (to match python in operator on an empty sequence). But there's nothing - # we can do here to create such an expression, so instead error to the user - # since they surely didn't mean this. - raise ValueError("in_ expression requires at least one item") - - in_ = any_of - - @property - @check_can_filter - def isnull(self): - """Whether a property value is ``None`` or ``[]``. - - This can only be used in expressions for the ``Catalog`` service. - """ - return IsNullExpression(self.name) - - @property - @check_can_filter - def isnotnull(self): - """Whether a property value is not ``None`` or ``[]``. - - This can only be used in expressions for the ``Catalog`` service. - """ - return IsNotNullExpression(self.name) - - -class Properties(object): - """A wrapper object to construct filter properties by referencing instance attributes. - - By referring to any instance attribute, a corresponding property will be created. - The instance validates whether the generated property is in the list of property - names that this instance was created with. - - See :ref:`Properties Introduction ` - for a more detailed explanation. - - Parameters - ---------- - name: str - The property names that are allowed, each as a positional parameter. - - Examples - -------- - >>> p = Properties("modified", "created") - >>> e = p.modified > "2020-01-01" - >>> e = p.deleted > "2020-01-01" # doctest: +SKIP - Traceback (most recent call last): - ... - AttributeError: 'Properties' object has no attribute 'deleted' - >>>""" - - def __init__(self, *args): - self.props = args - - def __getattr__(self, attr): - # keep sphinx happy - if attr == "__qualname__": - return self.__class__.__qualname__ - - if not self.props: - # implement the old GenericProperties - return Property(attr) - - if attr in self.props: - return Property(attr) - - raise AttributeError("'Properties' object has no attribute '{}'".format(attr)) diff --git a/descarteslabs/core/common/property_filtering/tests/__init__.py b/descarteslabs/core/common/property_filtering/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/descarteslabs/core/common/property_filtering/tests/test_filtering.py b/descarteslabs/core/common/property_filtering/tests/test_filtering.py deleted file mode 100644 index 9b627594..00000000 --- a/descarteslabs/core/common/property_filtering/tests/test_filtering.py +++ /dev/null @@ -1,496 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 datetime import datetime - -import pytest - -from ....catalog import MaskBand, Product -from .. import GenericProperties, Properties, Property -from ..filtering import ( - AndExpression, - EqExpression, - Expression, - ILikeExpression, - IsNotNullExpression, - IsNullExpression, - LikeExpression, - NeExpression, - OrExpression, - PrefixExpression, - RangeExpression, -) - - -def v1_expression(expression, expected_expression): - assert expression.serialize() == expected_expression - - -def v2_expression(expression, expected_expression): - assert expression.jsonapi_serialize() == expected_expression - assert expression.jsonapi_serialize(Product) == expected_expression - - -def test_generic_properties(): - properties = Properties() - assert isinstance(properties.foo, Property) - assert isinstance(properties.bar, Property) - - properties = GenericProperties() - assert isinstance(properties.foo, Property) - assert isinstance(properties.bar, Property) - - -def test_specific_properties(): - properties = Properties("foo") - assert isinstance(properties.foo, Property) - - with pytest.raises(AttributeError): - assert isinstance(properties.bar, Property) - - -def test_isnull_expression(): - name = "modified" - - property = Property(name) - expression = property.isnull - - assert expression.name == name - - v1_expression(expression, dict(isnull=name)) - v2_expression(expression, dict(name=name, op="isnull")) - - -def test_isnull_reference(): - name = "product" - - property = Property(name) - expression = property.isnull - - expected_expression = dict(name=f"{name}_id", op="isnull") - assert expression.jsonapi_serialize(MaskBand) == expected_expression - - -def test_isnull_expression_no_attribute(): - name = "foo" - - property = Property(name) - expression = property.isnull - - with pytest.raises(AttributeError): - expression.jsonapi_serialize(Product) - - -def test_isnotnull_expression(): - name = "modified" - - property = Property(name) - expression = property.isnotnull - - assert expression.name == name - v1_expression(expression, dict(isnotnull=name)) - v2_expression(expression, dict(name=name, op="isnotnull")) - - -def test_isnotnull_reference(): - name = "product" - - property = Property(name) - expression = property.isnotnull - - expected_expression = dict(name=f"{name}_id", op="isnotnull") - assert expression.jsonapi_serialize(MaskBand) == expected_expression - - -def test_isnotnull_expression_no_attribute(): - name = "foo" - - property = Property(name) - expression = property.isnotnull - - with pytest.raises(AttributeError): - expression.jsonapi_serialize(Product) - - -def test_like_expression(): - name = "description" - value = "%scr%" - - property = Property(name) - expression = property.like(value) - - assert expression.name == name - - v1_expression(expression, dict(like=dict(description=value))) - - expected = dict(name=name, op="like", val=value) - expression.jsonapi_serialize() == expected - - -def test_any_of_expression(): - name = "tags" - value = ["one", "two"] - - property = Property(name) - expression = property.any_of(value) - - assert expression.parts[0].name == name - assert expression.parts[1].name == name - assert Property.any_of == Property.in_ - - v1_expression( - expression, {"or": [dict(eq={name: value[0]}), dict(eq={name: value[1]})]} - ) - v2_expression( - expression, - { - "or": [ - dict(name=name, op="eq", val=value[0]), - dict(name=name, op="eq", val=value[1]), - ] - }, - ) - - -def test_any_of_expression_no_attribute(): - name = "foo" - value = ["one", "two"] - - property = Property(name) - expression = property.any_of(value) - - assert expression.serialize() - assert expression.jsonapi_serialize() - - with pytest.raises(AttributeError): - expression.jsonapi_serialize(Product) - - -def test_eq_ne_expression(): - name = "tags" - value = "one" - - for op in ("eq", "ne"): - property = Property(name) - - if op == "eq": - expression = property == value - else: - expression = property != value - - assert expression.name == name - - v1_expression(expression, {op: {name: value}}) - v2_expression(expression, dict(name=name, op=op, val=value)) - - -def test_reference_attribute(): - op = "eq" - name = "product" - value = "one:two" - - property = Property(name) - expression = property == MaskBand(id=value) - - assert expression.name == f"{name}_id" - - expected_expression = dict(name=f"{name}_id", op=op, val=value) - assert expression.jsonapi_serialize(MaskBand) == expected_expression - - -def test_reference_id(): - op = "eq" - name = "product" - value = "one" - - property = Property(name) - expression = property == value - - assert expression.name == name - - expected_expression = dict(name=f"{name}_id", op=op, val=value) - assert expression.jsonapi_serialize(MaskBand) == expected_expression - - -def test_reversed_eq_ne_expression(): - name = "tags" - value = "one" - - for op in ("eq", "ne"): - property = Property(name) - - if op == "eq": - expression = value == property - else: - expression = value != property - - assert expression.name == name - - v1_expression(expression, {op: {name: value}}) - v2_expression(expression, dict(name=name, op=op, val=value)) - - -def test_eq_ne_expression_no_attribute(): - name = "foo" - value = "one" - - for op in ("eq", "ne"): - property = Property(name) - - if op == "eq": - expression = property == value - else: - expression = property != value - - assert expression.serialize() - assert expression.jsonapi_serialize() - - with pytest.raises(AttributeError): - expression.jsonapi_serialize(Product) - - -def test_range_expression_single(): - name = "created" - value = "2022-09-22" - - for op in ("lt", "gt", "lte", "gte"): - property = Property(name) - - if op == "lt": - expression = property < value - elif op == "gt": - expression = property > value - elif op == "lte": - expression = property <= value - else: - expression = property >= value - - assert expression.name == name - - v1_expression(expression, dict(range={name: {op: value}})) - v2_expression(expression, dict(name=name, op=op, val=value)) - - -def test_range_expression(): - name = "created" - value1 = "2022-08-22" - value2 = "2022-09-22" - - # Operation and opposite operation - for op, oop in (("lt", "gt"), ("gt", "lt"), ("lte", "gte"), ("gte", "lte")): - property = Property(name) - - if op == "lt": - expression = value2 < property < value1 - elif op == "gt": - expression = value2 > property > value1 - elif op == "lte": - expression = value2 <= property <= value1 - else: - expression = value2 >= property >= value1 - - assert expression.name == name - - v1_expression(expression, dict(range={name: {op: value1, oop: value2}})) - v2_expression( - expression, - { - "and": [ - dict(name=name, op=oop, val=value2), - dict(name=name, op=op, val=value1), - ] - }, - ) - - -def test_range_expression_datetime(): - name = "created" - value = datetime.now() - - for op in ("lt", "gt", "lte", "gte"): - property = Property(name) - - if op == "lt": - expression = property < value - elif op == "gt": - expression = property > value - elif op == "lte": - expression = property <= value - else: - expression = property >= value - - expected_expression = dict(name=name, op=op, val=value.isoformat()) - assert expression.jsonapi_serialize(Product) == expected_expression - - -def test_range_expression_no_attribute(): - name = "foo" - value = "2022-09-22" - - for op in ("lt", "gt", "lte", "gte"): - property = Property(name) - - if op == "lt": - expression = property < value - elif op == "gt": - expression = property > value - elif op == "lte": - expression = property <= value - else: - expression = property >= value - - assert expression.serialize() - assert expression.jsonapi_serialize() - - with pytest.raises(AttributeError): - expression.jsonapi_serialize(Product) - - -def test_parse_expression(): - filters = [ - # Eq - {"op": "eq", "name": "field", "val": "value"}, - {"eq": {"field": "value"}}, - # Ne - {"op": "ne", "name": "field", "val": "value"}, - {"ne": {"field": "value"}}, - # Range - {"range": {"field": {"gte": 1, "lte": 2}}}, - {"op": "lt", "name": "field", "val": 10}, - # Null - {"isnull": "field"}, - {"op": "isnull", "name": "field"}, - # Not Null - {"isnotnull": "field"}, - {"op": "isnotnull", "name": "field"}, - # Prefix - {"prefix": {"field": "value"}}, - {"op": "prefix", "name": "field", "val": "value"}, - # Like - {"like": {"field": "value"}}, - {"op": "like", "name": "field", "val": "value"}, - # ILike - {"ilike": {"field": "value"}}, - {"op": "ilike", "name": "field", "val": "value"}, - ] - expression = Expression.parse(filters) - assert isinstance(expression, AndExpression) - assert len(expression.parts) == len(filters) - - # Eq - assert isinstance(expression.parts[0], EqExpression) - assert expression.parts[0].name == "field" - assert expression.parts[0].value == "value" - - assert isinstance(expression.parts[1], EqExpression) - assert expression.parts[1].name == "field" - assert expression.parts[1].value == "value" - - # Ne - assert isinstance(expression.parts[2], NeExpression) - assert expression.parts[2].name == "field" - assert expression.parts[2].value == "value" - - assert isinstance(expression.parts[3], NeExpression) - assert expression.parts[3].name == "field" - assert expression.parts[3].value == "value" - - # Range - assert isinstance(expression.parts[4], RangeExpression) - assert expression.parts[4].name == "field" - assert expression.parts[4].parts == {"gte": 1, "lte": 2} - - assert isinstance(expression.parts[5], RangeExpression) - assert expression.parts[5].name == "field" - assert expression.parts[5].parts == {"lt": 10} - - # Null - assert isinstance(expression.parts[6], IsNullExpression) - assert expression.parts[6].name == "field" - - assert isinstance(expression.parts[7], IsNullExpression) - assert expression.parts[7].name == "field" - - # Not Null - assert isinstance(expression.parts[8], IsNotNullExpression) - assert expression.parts[8].name == "field" - - assert isinstance(expression.parts[9], IsNotNullExpression) - assert expression.parts[9].name == "field" - - # Prefix - assert isinstance(expression.parts[10], PrefixExpression) - assert expression.parts[10].name == "field" - assert expression.parts[10].value == "value" - - assert isinstance(expression.parts[11], PrefixExpression) - assert expression.parts[11].name == "field" - assert expression.parts[11].value == "value" - - # Like - assert isinstance(expression.parts[12], LikeExpression) - assert expression.parts[12].name == "field" - assert expression.parts[12].value == "value" - - assert isinstance(expression.parts[13], LikeExpression) - assert expression.parts[13].name == "field" - assert expression.parts[13].value == "value" - - # ILike - assert isinstance(expression.parts[14], ILikeExpression) - assert expression.parts[12].name == "field" - assert expression.parts[12].value == "value" - - assert isinstance(expression.parts[15], ILikeExpression) - assert expression.parts[13].name == "field" - assert expression.parts[13].value == "value" - - -def test_parse_nested(): - filters = [ - { - "and": [ - {"op": "eq", "name": "field1", "val": "value1"}, - { - "or": [ - {"op": "eq", "name": "field2", "val": "value2"}, - {"op": "eq", "name": "field3", "val": "value3"}, - ] - }, - ] - } - ] - expression = Expression.parse(filters) - assert isinstance(expression, AndExpression) - assert len(expression.parts) == 1 - - sub_and = expression.parts[0] - assert isinstance(sub_and, AndExpression) - assert len(sub_and.parts) == 2 - - assert isinstance(sub_and.parts[0], EqExpression) - assert sub_and.parts[0].name == "field1" - assert sub_and.parts[0].value == "value1" - - sub_or = sub_and.parts[1] - assert isinstance(sub_or, OrExpression) - assert len(sub_or.parts) == 2 - - assert isinstance(sub_or.parts[0], EqExpression) - assert sub_or.parts[0].name == "field2" - assert sub_or.parts[0].value == "value2" - - assert isinstance(sub_or.parts[1], EqExpression) - assert sub_or.parts[1].name == "field3" - assert sub_or.parts[1].value == "value3" diff --git a/descarteslabs/core/common/registry/__init__.py b/descarteslabs/core/common/registry/__init__.py deleted file mode 100644 index 3fc3a2ee..00000000 --- a/descarteslabs/core/common/registry/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 .registry import registry - -__all__ = ["registry"] diff --git a/descarteslabs/core/common/registry/registry.py b/descarteslabs/core/common/registry/registry.py deleted file mode 100644 index 86b68c87..00000000 --- a/descarteslabs/core/common/registry/registry.py +++ /dev/null @@ -1,69 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from typing import TypeVar, Optional, Mapping, Callable, Tuple - -K = TypeVar("K") -V = TypeVar("V") - - -def registry( - mapping: Optional[Mapping[K, V]] = None, error_on_overwrite: bool = True -) -> Tuple[Mapping[K, V], Callable[[K], Callable[[V], V]]]: - """ - Construct a registry and a decorator for registering functions or classes into it. - - Parameters - ---------- - mapping: mapping, optional, default None - The mapping to use as a registry. If None, creates an empty dict for you. - error_on_overwrite: bool, default True - Whether to raise a ValueError when attempting to register a new value - for a key that's already been registered. - - Returns - ------- - mapping: mapping - The mapping things will be registered to - register: function - Decorator that takes 1 argument: the key under which to register the decorated thing - - Example - ------- - >>> REGISTRY, register = registry() - >>> @register("foo") - ... def foo_func(): - ... pass - >>> @register("bar") - ... def bar_func(): - ... pass - >>> print(REGISTRY) - {'foo': Callable[[V], V]: - def deco(obj: V) -> V: - existing = mapping.setdefault(key, obj) - if error_on_overwrite and existing is not obj: - raise ValueError( - "Attempted to register {!r} to key {!r}, " - "which is already registered to {!r}".format(obj, key, existing) - ) - return obj - - return deco - - return mapping, register diff --git a/descarteslabs/core/common/retry/__init__.py b/descarteslabs/core/common/retry/__init__.py deleted file mode 100644 index b7fbf08c..00000000 --- a/descarteslabs/core/common/retry/__init__.py +++ /dev/null @@ -1,22 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 .retry import Retry, RetryError, _name_of_func, truncated_delay_generator - -__all__ = [ - "_name_of_func", - "Retry", - "RetryError", - "truncated_delay_generator", -] diff --git a/descarteslabs/core/common/retry/retry.py b/descarteslabs/core/common/retry/retry.py deleted file mode 100644 index d49b2d43..00000000 --- a/descarteslabs/core/common/retry/retry.py +++ /dev/null @@ -1,270 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 datetime -import functools -import inspect -import random -import time -from typing import Iterable - -_DEFAULT_RETRIES = 3 -_DEFAULT_DELAY_INITIAL = 0.1 -_DEFAULT_DELAY_MULTIPLIER = 2.0 -_DEFAULT_DELAY_MAXIMUM = 60 -_DEFAULT_DELAY_JITTER = (0, 1) - - -def _name_of_func(f): - module = inspect.getmodule(f) - - if module is not None: - module = module.__name__ - else: - module = "" - - return "{}.{}".format(module, getattr(f, "__name__", f)) - - -class Retry(object): - """Retry class to wrap functions as a decorator or inline. - - Example - ------- - - >>> import descarteslabs as dl - >>> retry = dl.common.retry.Retry( - ... maximum=30, - ... retries=5, - ... exceptions=(dl.exceptions.GatewayTimeoutError,) - ... ) - >>> @retry - ... def flaky(x): - ... return x - >>> flaky("test") - 'test' - >>> retry(lambda x: x)("test") - 'test' - """ - - def __init__( - self, - retries=_DEFAULT_RETRIES, - exceptions=None, - predicate=None, - blacklist=None, - deadline=None, - initial=_DEFAULT_DELAY_INITIAL, - maximum=_DEFAULT_DELAY_MAXIMUM, - jitter=_DEFAULT_DELAY_JITTER, - multiplier=_DEFAULT_DELAY_MULTIPLIER, - ): - """Instantiate a Retry object that can be used to wrap a callable. - - Parameters - ---------- - retries : int, optional - The number of retries allowed. - exceptions : tuple, optional - A tuple of Exceptions that should always be retried. - predicate : function, optional - A callable that takes an exception and returns either a bool or a Tuple[bool, int]. - If the bool value is true, the wrapped callable was determined to be retryable. - This can be used for cases with a generic exception with variable attributes. - - If the return was a Tuple[bool, int], the int value will be used as the delay. - This can be used for cases where an exception should only be retried after some - variable amount of time. - This is typically used for handling `Retry-After` headers in which the server is - requesting the client wait for a specific amount of time. - blacklist : tuple, optional - A tuple of Exceptions that should never be retried. - deadline : float, optional - The deadline in seconds for retries. - initial : float - The amount of delay for the before the first retry. - maximum : float - The maximum amount of delay between retries. - jitter : tuple, optional - The bounds for a random amount to be added to each delay. - multiplier : float, optional - The multiple by which the delay increases. - - """ - - self._retries = retries - self._exceptions = exceptions - self._predicate = predicate - self._blacklist = blacklist - self._deadline = deadline - self._initial = initial - self._maximum = maximum - self._jitter = jitter - self._multiplier = multiplier - - def __call__(self, func): - @functools.wraps(func) - def wrapper(*args, **kwargs): - target = functools.partial(func, *args, **kwargs) - delay_generator = truncated_delay_generator( - initial=self._initial, - maximum=self._maximum, - jitter=self._jitter, - multiplier=self._multiplier, - ) - - return self._retry(target, delay_generator) - - return wrapper - - def _retry(self, func, delay_generator): - deadline = self._deadline_datetime(self._deadline) - retries = self._retries - previous_exceptions = [] - - # delay generator can be a list and should - # be converted to an iterator to use with next - delay_generator = iter(delay_generator) - - while True: - try: - return func() - except Exception as e: - delay = self._handle_exception(e, previous_exceptions) - - # predicate returned no delay use the generator - if delay is None: - try: - delay = next(delay_generator) - except Exception: - raise ValueError("Bad delay generator") - - # will raise RetryError if deadline or retries exceeded - retries = self._check_retries( - retries, _name_of_func(func), deadline, previous_exceptions - ) - time.sleep(delay) - - def _handle_exception(self, exception, previous_exceptions): - delay = None - - if callable(self._predicate): - # a predicate can either return a bool - # or a an Iterable (tuple) containing a bool (if retryable) and a delay - retryable = self._predicate(exception) - if isinstance(retryable, Iterable): - retryable, delay, *_ = retryable - - if not retryable: - raise - - if self._blacklist is not None and isinstance(exception, self._blacklist): - raise - - if self._exceptions is not None and not isinstance(exception, self._exceptions): - raise - - previous_exceptions.append(exception) - - return delay - - def _check_retries(self, retries, name, deadline, previous_exceptions): - # Raise RetryError if deadline exceeded - if deadline is not None and deadline <= datetime.datetime.now( - datetime.timezone.utc - ).replace(tzinfo=None): - raise RetryError( - "Deadline of {:.1f}s exceeded while calling {}".format(deadline, name), - previous_exceptions, - ) from previous_exceptions[-1] - - # Raise RetryError if retries exhausted - if retries is not None and retries == 0: - raise RetryError( - "Maximum retry attempts calling {}".format(name), - previous_exceptions, - ) from previous_exceptions[-1] - - if retries is not None: - retries -= 1 - - return retries - - @staticmethod - def _deadline_datetime(deadline): - if deadline is None: - return None - - return datetime.datetime.now(datetime.timezone.utc).replace( - tzinfo=None - ) + datetime.timedelta(seconds=deadline) - - -class RetryError(Exception): - """Error raised when the number of retries has been exhausted or the - deadline has passed.""" - - def __init__(self, message, exceptions): - super(RetryError, self).__init__(message) - self.message = message - self._exceptions = exceptions - - @property - def exceptions(self): - """Get a list of exceptions that occurred. - - Returns - ------- - list - The list of exceptions - """ - - return self._exceptions - - def __str__(self): - return "{}, exceptions: {}".format(self.message, self.exceptions) - - -def truncated_delay_generator( - initial=None, maximum=None, jitter=None, multiplier=_DEFAULT_DELAY_MULTIPLIER -): - """A generator for truncated exponential delay. - - Parameters - ---------- - initial : float - The amount of delay for the first generated value. - maximum : float - The maximum amount of delay. - jitter : tuple, optional - The bounds for a random amount to be added to each delay. - multiplier : float, optional - The multiple by which the delay increases. - """ - - if initial is None: - initial = _DEFAULT_DELAY_INITIAL - - delay = initial - - while True: - if jitter is not None: - delay += random.uniform(*jitter) - - if maximum is not None: - delay = min(delay, maximum) - - yield delay - - delay *= multiplier diff --git a/descarteslabs/core/common/retry/tests/__init__.py b/descarteslabs/core/common/retry/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/descarteslabs/core/common/retry/tests/test_retry.py b/descarteslabs/core/common/retry/tests/test_retry.py deleted file mode 100644 index 6d544051..00000000 --- a/descarteslabs/core/common/retry/tests/test_retry.py +++ /dev/null @@ -1,208 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 import mock - -import pytest - -from .. import Retry, RetryError, truncated_delay_generator -from ..retry import _DEFAULT_DELAY_INITIAL - - -class FakeException(Exception): - pass - - -def fake_delay_generator(*args, **kwargs): - while True: - yield 0 - - -def fake_func(x): - return x - - -def fake_failing_func(): - raise Exception("error") - - -@pytest.mark.parametrize("initial", [0, None, 1, 1.0]) -def test_truncated_delay_generator_initial(initial): - if initial is None: - delay = _DEFAULT_DELAY_INITIAL - else: - delay = initial - - assert next(truncated_delay_generator(initial=initial)) == delay - - -@pytest.mark.parametrize("maximum", [0, 1, 1.0]) -def test_truncated_delay_generator_maximum(maximum): - for initial in range(0, 10): - assert ( - next(truncated_delay_generator(initial=initial, maximum=maximum)) <= maximum - ) - - -@pytest.mark.parametrize("jitter", [(0, 1.0)]) -def test_truncated_delay_generator_jitter(jitter): - initial = 0 - assert next(truncated_delay_generator(initial=initial, jitter=jitter)) > initial - - -@pytest.mark.parametrize("multiplier", [(1, 1.2)]) -def test_truncated_delay_generator_multiplier(multiplier): - delay_generator = truncated_delay_generator(initial=1, multiplier=multiplier) - - first = next(delay_generator) - second = next(delay_generator) - - assert first * multiplier == second - - -def test__retry_exceptions(): - Retry(exceptions=(Exception,))._retry( - mock.Mock(side_effect=[FakeException, True]), - fake_delay_generator(), - ) - - with pytest.raises(FakeException): - Retry(exceptions=(TypeError,))._retry( - mock.Mock(side_effect=[FakeException("")]), - fake_delay_generator(), - ) - - -def test__retry_blacklist(): - Retry(blacklist=(TypeError,))._retry( - mock.Mock(side_effect=[FakeException, ValueError, True]), - fake_delay_generator(), - ) - - with pytest.raises(FakeException): - Retry(blacklist=(FakeException,))._retry( - mock.Mock(side_effect=[FakeException("")]), - fake_delay_generator(), - ) - - -def test__retry_retries(): - Retry(retries=1, exceptions=(FakeException,))._retry( - mock.Mock(side_effect=[FakeException, True]), - fake_delay_generator(), - ) - - with pytest.raises(RetryError) as exc_info: - Retry(retries=0, exceptions=(FakeException,))._retry( - mock.Mock(side_effect=[FakeException, True]), - fake_delay_generator(), - ) - - assert len(exc_info.value.exceptions) == 1 - - with pytest.raises(RetryError) as exc_info: - Retry(retries=1, exceptions=(FakeException,))._retry( - mock.Mock(side_effect=[FakeException, FakeException, True]), - fake_delay_generator(), - ) - - assert len(exc_info.value.exceptions) == 2 - - -def test__retry_deadline(): - Retry(deadline=10, exceptions=(FakeException,))._retry( - mock.Mock(side_effect=[FakeException, True]), - fake_delay_generator(), - ) - - with pytest.raises(RetryError) as exc_info: - Retry(deadline=0, exceptions=(FakeException,))._retry( - mock.Mock(side_effect=[FakeException, True]), - fake_delay_generator(), - ) - - assert len(exc_info.value.exceptions) == 1 - - -def test__retry_delay_from_predicate(): - def noop_generator(): - assert False, "noop delay generator called" - - # this cannot be reached but yield is required to make this a generator - while True: - yield 0 - - Retry(predicate=lambda e: (True, 0))._retry( - mock.Mock(side_effect=[FakeException, FakeException, True]), noop_generator() - ) - - -def test__handle_exception_returns_delay(): - delay = Retry(predicate=lambda e: True)._handle_exception(FakeException, []) - assert delay is None - - delay = Retry(predicate=lambda e: (True, 10))._handle_exception(FakeException, []) - assert delay == 10 - - -def test_RetryError_message(): - with pytest.raises( - RetryError, - match="^Maximum retry attempts calling .*\.fake_failing_func, exceptions: ", # noqa - ): - Retry(retries=0, exceptions=(Exception,))._retry( - fake_failing_func, - fake_delay_generator(), - ) - - -def test__retry_bad_delay_generator(): - with pytest.raises(ValueError): - Retry()._retry(mock.Mock(side_effect=[FakeException]), []) - - with pytest.raises(ValueError): - Retry()._retry(mock.Mock(side_effect=[FakeException]), [0]) - - -def test__retry_predicate(): - Retry(predicate=lambda e: True)._retry( - mock.Mock(side_effect=[FakeException, True]), - fake_delay_generator(), - ) - - with pytest.raises(FakeException): - Retry(predicate=lambda e: False)._retry( - mock.Mock(side_effect=[FakeException, True]), - fake_delay_generator(), - ) - - -def test_RetryError(): - exceptions = [FakeException("")] - assert exceptions == RetryError("message", exceptions).exceptions - - -def test_Retry(): - retriable = Retry()(fake_func) - - assert retriable.__doc__ == fake_func.__doc__ - assert retriable(0) == fake_func(0) - - -def test_decorate(): - @Retry() - def foo(x): - return x - - assert foo(0) == 0 diff --git a/descarteslabs/core/common/shapely_support/__init__.py b/descarteslabs/core/common/shapely_support/__init__.py deleted file mode 100644 index 5d6520c6..00000000 --- a/descarteslabs/core/common/shapely_support/__init__.py +++ /dev/null @@ -1,154 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 collections.abc as abc -import geojson -import shapely.geometry - - -def shapely_to_geojson(geometry): - """Converts a Shapely Shape geometry to a GeoJSON geometry""" - if hasattr(geometry, "__geo_interface__"): - geometry = shapely.geometry.mapping(geometry) - return geometry - - -def geometry_like_to_shapely(geometry): - """ - Convert a GeoJSON dict, or __geo_interface__ object, to a Shapely geometry. - - Handles Features and FeatureCollections (FeatureCollections become GeometryCollections). - """ - if isinstance(geometry, shapely.geometry.base.BaseGeometry): - return geometry - - if not isinstance(geometry, abc.Mapping): - try: - geometry = geometry.__geo_interface__ - except AttributeError: - raise TypeError( - "geometry object is not a GeoJSON dict, nor has a `__geo_interface__`: {}".format( - geometry - ) - ) from None - - geoj = as_geojson_geometry(geometry) - try: - shape = shapely.geometry.shape(geoj) - except Exception: - raise ValueError( - "Could not interpret this geometry as a Shapely shape: {}".format(geometry) - ) - - # test that geometry is in WGS84 - check_valid_bounds(shape.bounds, shape.geom_type) - return shape - - -def as_geojson_geometry(geojson_dict): - """ - Return a mapping as a GeoJSON instance, converting Feature types to Geometry types. - """ - geoj = _parse_geojson_safe(geojson_dict) - - # Shapely cannot handle GeoJSON Features or FeatureCollections - if isinstance(geoj, geojson.Feature): - geoj = _parse_geojson_safe(geojson_dict["geometry"]) - elif isinstance(geoj, geojson.FeatureCollection): - features = [] - for feature in geojson_dict.get("features", []): - try: - features.append(_parse_geojson_safe(feature["geometry"])) - except (TypeError, KeyError, UnicodeEncodeError) as ex: - raise ValueError( - "feature in FeatureCollection not recognized as valid ({}): {}".format( - str(ex), feature - ) - ) - geoj = geojson.GeometryCollection(features) - return geoj - - -def _parse_geojson_safe(geojson_dict): - """ - Turns a dictionary into a GeoJSON instance in a safe way across different versions - of the geojson library, without losing precision. Version 2.5.0 introduced a - default FP precision of 6 for geometry coordinates, but we never want to lose - precision. The maintainers have said they will remove the default precision again - (https://github.com/jazzband/geojson/issues/135) but in the meantime we need to - handle 2.5.0 in the wild. - """ - try: - geojson_dict = dict(geojson_dict) - geojson_dict["precision"] = 40 - geoj = geojson.GeoJSON.to_instance(geojson_dict, strict=True) - except (TypeError, KeyError, UnicodeEncodeError) as ex: - raise ValueError( - "geometry not recognized as valid GeoJSON ({}): {}".format( - str(ex), geojson_dict - ) - ) - - # Prior to 2.5.0 this will exist as an attribute now, after 2.5.0 it won't - if hasattr(geoj, "precision"): - del geoj.precision - - return geoj - - -def check_valid_bounds(bounds, geom_type=None): - """ - Test given bounds are correct type and in correct order. - - Raises TypeError or ValueError if bounds are invalid, otherwise returns None - """ - try: - if not isinstance(bounds, (list, tuple)): - raise TypeError( - "Bounds must be a list or tuple, instead got type {}".format( - type(bounds) - ) - ) - - if len(bounds) != 4: - raise ValueError( - "Bounds must a sequence of (minx, miny, maxx, maxy), " - "got sequence of length {}".format(len(bounds)) - ) - except TypeError: - raise TypeError( - "Bounds must a sequence of (minx, miny, maxx, maxy), got {}".format( - type(bounds) - ) - ) from None - - # Only check polygons and multipolygons here - if geom_type and geom_type.endswith("Polygon"): - if bounds[0] >= bounds[2]: - raise ValueError( - "minx >= maxx in given bounds, should be (minx, miny, maxx, maxy)" - ) - if bounds[1] >= bounds[3]: - raise ValueError( - "miny >= maxy in given bounds, should be (minx, miny, maxx, maxy)" - ) - else: - if bounds[0] > bounds[2]: - raise ValueError( - "minx > maxx in given bounds, should be (minx, miny, maxx, maxy)" - ) - if bounds[1] > bounds[3]: - raise ValueError( - "miny > maxy in given bounds, should be (minx, miny, maxx, maxy)" - ) diff --git a/descarteslabs/core/common/shapely_support/tests/__init__.py b/descarteslabs/core/common/shapely_support/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/descarteslabs/core/common/shapely_support/tests/test_shapely_support.py b/descarteslabs/core/common/shapely_support/tests/test_shapely_support.py deleted file mode 100644 index 87ddc493..00000000 --- a/descarteslabs/core/common/shapely_support/tests/test_shapely_support.py +++ /dev/null @@ -1,162 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 unittest -from unittest import mock -import pytest - -import shapely.geometry -import geojson - -from .. import ( - as_geojson_geometry, - check_valid_bounds, - geometry_like_to_shapely, -) - - -class ShapelySupportTest(unittest.TestCase): - def setUp(self): - self.feature = { - "type": "Feature", - "geometry": { - "type": "Polygon", - "coordinates": [ - [ - [-93.52300099792355, 41.241436141055345], - [-93.7138666, 40.703737], - [-94.37053769704536, 40.83098709945576], - [-94.2036617, 41.3717716], - [-93.52300099792355, 41.241436141055345], - ] - ], - }, - "properties": {"foo": "bar"}, - } - - def test_check_valid_bounds(self): - bounds_wgs84 = (-94.37053769704536, 40.703737, -93.52300099792355, 41.3717716) - bounds_wrong_order = ( - bounds_wgs84[2], - bounds_wgs84[1], - bounds_wgs84[0], - bounds_wgs84[3], - ) - bounds_wrong_number = bounds_wgs84[:2] - bounds_wrong_type = dict(left=1, right=2, top=3, bottom=4) - bounds_point = (-90.0, 35.0, -90.0, 35.0) - - check_valid_bounds(bounds_wgs84) - - with pytest.raises(ValueError): - check_valid_bounds(bounds_wrong_order) - with pytest.raises(ValueError): - check_valid_bounds(bounds_wrong_order, "Polygon") - with pytest.raises(ValueError): - check_valid_bounds(bounds_wrong_number) - with pytest.raises(TypeError): - check_valid_bounds(bounds_wrong_type) - with pytest.raises(ValueError): - check_valid_bounds(bounds_point, "Polygon") - - def test_as_geojson_geometry(self): - geoj = as_geojson_geometry(self.feature["geometry"]) - assert isinstance(geoj, geojson.Polygon) - assert geoj == self.feature["geometry"] - - def test_as_geojson_geometry_feature(self): - geoj = as_geojson_geometry(self.feature) - assert isinstance(geoj, geojson.Polygon) - assert geoj == self.feature["geometry"] - - def test_as_geojson_geometry_featurecollection(self): - fc = { - "type": "FeatureCollection", - "features": [self.feature, self.feature, self.feature], - } - gc = { - "type": "GeometryCollection", - "geometries": [ - self.feature["geometry"], - self.feature["geometry"], - self.feature["geometry"], - ], - } - geoj = as_geojson_geometry(fc) - assert isinstance(geoj, geojson.GeometryCollection) - assert geoj == gc - - def test_as_geojson_geometry_invalid(self): - with pytest.raises(ValueError): - as_geojson_geometry(1.2) - with pytest.raises(ValueError): - as_geojson_geometry({}) - with pytest.raises(ValueError): - as_geojson_geometry(dict(self.feature["geometry"], type="Foo")) - with pytest.raises(ValueError): - as_geojson_geometry(dict(self.feature["geometry"], coordinates=1)) - with pytest.raises(ValueError): - as_geojson_geometry( - {"type": "FeatureCollection", "features": [self.feature, "hey"]} - ) - - def test_geometry_like_to_shapely(self): - shape = shapely.geometry.box(10, 20, 15, 30) - as_shapely = geometry_like_to_shapely(shape) - assert isinstance(as_shapely, shapely.geometry.Polygon) - assert shape == as_shapely - - def test_geometry_like_to_shapely_dict(self): - shape = shapely.geometry.box(10, 20, 15, 30) - mapping = shape.__geo_interface__ - as_shapely = geometry_like_to_shapely(mapping) - assert isinstance(as_shapely, shapely.geometry.Polygon) - assert shape == as_shapely - - def test_geometry_like_to_shapely_geo_interface(self): - shape = shapely.geometry.Point(-5, 10).buffer(5) - obj = mock.Mock() - obj.__geo_interface__ = shape.__geo_interface__ - as_shapely = geometry_like_to_shapely(obj) - assert isinstance(as_shapely, shapely.geometry.Polygon) - assert shape.__geo_interface__ == as_shapely.__geo_interface__ - - def test_geometry_like_to_shapely_not_mapping_or_geo_interface(self): - unhelpful = (1, 2, 3, 4) - with pytest.raises(TypeError): - geometry_like_to_shapely(unhelpful) - - def test_geometry_like_to_shapely_featurecollection(self): - shapes = ( - shapely.geometry.Point(-5, 10), - shapely.geometry.Point(-5, 10).buffer(5), - ) - fc = { - "type": "FeatureCollection", - "features": [ - { - "type": "Feature", - "geometry": shape.__geo_interface__, - "properties": {"foo": "bar"}, - } - for shape in shapes - ], - } - as_shapely = geometry_like_to_shapely(fc) - assert isinstance(as_shapely, shapely.geometry.GeometryCollection) - for converted, shape in zip( - list(as_shapely.geoms), - list(shapely.geometry.GeometryCollection(shapes).geoms), - ): - assert converted.__geo_interface__ == shape.__geo_interface__ diff --git a/descarteslabs/core/common/threading/__init__.py b/descarteslabs/core/common/threading/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/descarteslabs/core/common/threading/local.py b/descarteslabs/core/common/threading/local.py deleted file mode 100644 index a708f549..00000000 --- a/descarteslabs/core/common/threading/local.py +++ /dev/null @@ -1,49 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import os -import threading - - -class ThreadLocalWrapper(object): - """ - A wrapper around a thread-local object that gets created lazily in every - thread of every process via the given factory callable when it is - accessed. I.e., at most one instance per thread exists. - - In contrast to standard thread-locals this is compatible with multiple - processes. - """ - - def __init__(self, factory): - self._factory = factory - self._create_local(os.getpid()) - - def get(self): - self._init_local() - if not hasattr(self._local, "wrapped"): - self._local.wrapped = self._factory() - return self._local.wrapped - - def _init_local(self): - local_pid = os.getpid() - previous_pid = getattr(self._local, "_pid", None) - if previous_pid is None: - self._local._pid = local_pid - elif local_pid != previous_pid: - self._create_local(local_pid) - - def _create_local(self, pid): - self._local = threading.local() - self._local._pid = pid diff --git a/descarteslabs/core/common/threading/tests/__init__.py b/descarteslabs/core/common/threading/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/descarteslabs/core/common/threading/tests/test_local.py b/descarteslabs/core/common/threading/tests/test_local.py deleted file mode 100644 index 6e00919e..00000000 --- a/descarteslabs/core/common/threading/tests/test_local.py +++ /dev/null @@ -1,98 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 multiprocessing -import os -import platform -import sys -import unittest -import threading - -from ..local import ThreadLocalWrapper - -if platform.system() == "Darwin": - multiprocessing.set_start_method("fork") - - -class ThreadLocalWrapperTest(unittest.TestCase): - def setUp(self): - self.wrapper = ThreadLocalWrapper( - lambda: (os.getpid(), threading.current_thread().ident) - ) - - def _store_id(self): - self.thread_id = self.wrapper.get() - - def _send_id(self, queue): - queue.put(self.wrapper.get()) - - def test_thread_thread(self): - main_thread_id = self.wrapper.get() - assert main_thread_id == self.wrapper.get() - - thread = threading.Thread(target=self._store_id) - thread.start() - thread.join() - assert main_thread_id != self.thread_id - - # Note on Windows: fork is not available so multiprocessing pickles the multiprocessing - # function and arguments. ThreadLocalWrapper isn't picklable, so the following tests - # can't work on Windows. But the problem it solves for multiprocessing also doesn't - # exist there. - - @unittest.skipIf(sys.platform.startswith("win"), "forking not a concern on Windows") - def test_wrapper_process(self): - main_thread_id = self.wrapper.get() - thread = threading.Thread(target=self._store_id) - thread.start() - thread.join() - assert main_thread_id != self.thread_id - - queue = multiprocessing.Queue() - process = multiprocessing.Process(target=self._send_id, args=(queue,)) - process.start() - process_id = queue.get() - process.join() - assert main_thread_id != process_id - assert self.thread_id != process_id - - @unittest.skipIf(sys.platform.startswith("win"), "forking not a concern on Windows") - def test_wrapper_unused_in_main_process(self): - queue = multiprocessing.Queue() - process = multiprocessing.Process(target=self._send_id, args=(queue,)) - process.start() - process_id = queue.get() - process.join() - assert process_id != self.wrapper.get() - - @unittest.skipIf(sys.platform.startswith("win"), "forking not a concern on Windows") - def test_fork_from_fork(self): - # A gross edge case discovered by Clark: if a process is forked from a forked process - # things will go awry if we hadn't initialized the internal threading.local's pid. - def fork_another(queue): - queue.put(self.wrapper.get()) - process3 = multiprocessing.Process(target=self._send_id, args=(queue,)) - process3.start() - process3.join() - - process1_id = self.wrapper.get() - queue = multiprocessing.Queue() - process = multiprocessing.Process(target=fork_another, args=(queue,)) - process.start() - process2_id = queue.get() - process3_id = queue.get() - process.join() - assert process1_id != process2_id - assert process2_id != process3_id - assert process1_id != process3_id diff --git a/descarteslabs/core/common/vector/__init__.py b/descarteslabs/core/common/vector/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/descarteslabs/core/common/vector/models.py b/descarteslabs/core/common/vector/models.py deleted file mode 100644 index bdb2362a..00000000 --- a/descarteslabs/core/common/vector/models.py +++ /dev/null @@ -1,54 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from typing import Any, Dict -from uuid import uuid4 - -from pydantic import BaseModel, Field - - -class VectorBaseModel(BaseModel): - uuid: str = Field( - default_factory=uuid4, - json_schema_extra={"primary_key": True}, - ) - - -class PointBaseModel(VectorBaseModel): - geometry: str = Field(json_schema_extra={"geometry": "POINT"}) - - -class LineBaseModel(VectorBaseModel): - geometry: str = Field(json_schema_extra={"geometry": "LINESTRING"}) - - -class PolygonBaseModel(VectorBaseModel): - geometry: str = Field(json_schema_extra={"geometry": "POLYGON"}) - - -class MultiPointBaseModel(VectorBaseModel): - geometry: str = Field(json_schema_extra={"geometry": "MULTIPOINT"}) - - -class MultiLineBaseModel(VectorBaseModel): - geometry: str = Field(json_schema_extra={"geometry": "MULTILINESTRING"}) - - -class MultiPolygonBaseModel(VectorBaseModel): - geometry: str = Field(json_schema_extra={"geometry": "MULTIPOLYGON"}) - - -class GenericFeatureBaseModel(VectorBaseModel): - geometry: str = Field(json_schema_extra={"geometry": "GEOMETRY"}) - properties: Dict[str, Any] = {} diff --git a/descarteslabs/core/compute/__init__.py b/descarteslabs/core/compute/__init__.py deleted file mode 100644 index d6db5a0e..00000000 --- a/descarteslabs/core/compute/__init__.py +++ /dev/null @@ -1,30 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 .compute_client import ComputeClient -from .function import Function, FunctionStatus, Search -from .job import Job, JobSearch, JobStatus -from .result import ComputeResult, Serializable - -__all__ = [ - "ComputeClient", - "ComputeResult", - "Function", - "FunctionStatus", - "Job", - "JobSearch", - "JobStatus", - "Search", - "Serializable", -] diff --git a/descarteslabs/core/compute/compute_client.py b/descarteslabs/core/compute/compute_client.py deleted file mode 100644 index e3bd41d1..00000000 --- a/descarteslabs/core/compute/compute_client.py +++ /dev/null @@ -1,91 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 -from datetime import datetime, timezone -from typing import Iterator, Optional - -from descarteslabs.auth import Auth -from descarteslabs.config import get_settings - -from ..catalog import CatalogClient -from ..client.services.service import ApiService -from ..common.http.service import DefaultClientMixin - - -class ComputeClient(ApiService, DefaultClientMixin): - _namespace_cache = dict() - - def __init__(self, url=None, auth=None, catalog_client=None, retries=None): - if auth is None: - auth = Auth.get_default_auth() - - if catalog_client is None: - catalog_client = CatalogClient(auth=auth) - - if url is None: - url = get_settings().compute_url - - self.catalog_client = catalog_client - super().__init__(url, auth=auth, retries=retries) - - def iter_log_lines(self, url: str, timestamps: bool = True) -> Iterator[str]: - response = self.session.get(url, stream=True) - lines = response.iter_lines() - - for line in lines: - structured_log = json.loads(line) - timestamp, log = structured_log["date"], structured_log["log"] - log_date = ( - datetime.fromisoformat(timestamp[:-1] + "+00:00") - .replace(tzinfo=timezone.utc) - .astimezone() # Convert to users timezone - ) - - if timestamps: - log = f"{log_date} {log}" - - yield log - - def check_credentials(self): - """Determine if valid credentials are already set for the user.""" - _ = self.session.get("/credentials") - - def set_credentials(self): - if self.auth.client_id and self.auth.client_secret: - self.session.post( - "/credentials", - json={ - "client_id": self.auth.client_id, - "client_secret": self.auth.client_secret, - }, - ) - else: - # We only have a JWT and no client id/secret, so validate - # that there are already valid credentials set for the user. - # This situation will generally only arise when used from - # some backend process. - self.check_credentials() - - def get_namespace(self, function_id: str) -> Optional[str]: - if function_id in self._namespace_cache: - return self._namespace_cache[function_id] - - return None - - def set_namespace(self, function_id: str, namespace: str): - if not (function_id or namespace): - return - - self._namespace_cache[function_id] = namespace diff --git a/descarteslabs/core/compute/exceptions.py b/descarteslabs/core/compute/exceptions.py deleted file mode 100644 index 82f9a1aa..00000000 --- a/descarteslabs/core/compute/exceptions.py +++ /dev/null @@ -1,22 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 BoundGlobalError(NameError): - """ - Raised when a global is referenced in a function where it won't be available - when executed remotely. - """ - - pass diff --git a/descarteslabs/core/compute/function.py b/descarteslabs/core/compute/function.py deleted file mode 100644 index 586c7faa..00000000 --- a/descarteslabs/core/compute/function.py +++ /dev/null @@ -1,1500 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 builtins -import dis -import glob -import gzip -import importlib -import inspect -import io -import itertools -import os -import re -import sys -import time -import uuid -import warnings -import zipfile -from datetime import datetime -from tempfile import NamedTemporaryFile -from typing import ( - Any, - Callable, - Dict, - Iterable, - List, - Mapping, - Optional, - Set, - Type, - Union, -) - -from packaging.requirements import InvalidRequirement, Requirement -from strenum import StrEnum - -import descarteslabs.exceptions as exceptions - -from ..client.deprecation import deprecate -from ..client.services.service import ThirdPartyService -from ..common.client import ( - Attribute, - DatetimeAttribute, - Document, - DocumentState, - Search, -) -from .compute_client import ComputeClient -from .exceptions import BoundGlobalError -from .job import Job, JobSearch, JobStatus -from .result import Serializable - -MAX_FUNCTION_IDS_PER_REQUEST = 128 - - -def batched(iterable, n): - """Batch an iterable into lists of size n""" - if n < 1: - raise ValueError("n must be at least one") - it = iter(iterable) - while batch := list(itertools.islice(it, n)): - yield batch - - -class FunctionStatus(StrEnum): - "The status of the Function." - - AWAITING_BUNDLE = "awaiting_bundle" - BUILDING = "building" - BUILD_FAILED = "build_failed" - READY = "ready" - STOPPED = "stopped" - - -class Cpus(float): - """Validates CPUs for a Function""" - - NON_NUMERIC = r"[^0-9.]" - - def __new__(cls, value): - if isinstance(value, str): - value = re.sub(cls.NON_NUMERIC, "", value) - - return super().__new__(cls, value) - - -class Memory(int): - """Validates Memory for a Function""" - - MEMORY_MB = re.compile(r"[\s]*(?:mb|mi)$", flags=re.IGNORECASE) - MEMORY_GB = re.compile(r"[\s]*(?:gb|gi)$", flags=re.IGNORECASE) - - def __new__(cls, memory: Union[str, int, float]) -> None: - if isinstance(memory, str): - if memory.isnumeric(): - pass - elif re.search(cls.MEMORY_MB, memory): - memory = re.sub(cls.MEMORY_MB, "", memory) - elif re.search(cls.MEMORY_GB, memory): - memory = re.sub(cls.MEMORY_GB, "", memory) - memory = int(float(memory) * 1024) - else: - raise ValueError(f"Unable to convert memory to megabytes: {memory}") - - return super().__new__(cls, memory) - - -class JobBulkCreateError: - """An error that occurred while submitting a bulk job.""" - - def __init__( - self, - function: "Function", - args, - kwargs, - environments, - exception: Exception, - reference_id: str, - ): - self.function = function - self.args = args - self.kwargs = kwargs - self.environments = environments - self.exception = exception - self.reference_id = reference_id - - def __str__(self): - return f"{self.reference_id}: {self.exception}" - - def __repr__(self): - return ( - f"JobBulkCreateError(" - f"function={self.function}, " - f"reference_id={self.reference_id}, " - f"exception={repr(self.exception)})" - ) - - -class JobBulkCreateResult(List[Job]): - """The result of a bulk job submission.""" - - def __init__(self): - super().__init__() - self.errors: List[JobBulkCreateError] = [] - - def append_error(self, error: JobBulkCreateError): - """Append an error to the result.""" - self.errors.append(error) - - @property - def is_success(self) -> bool: - """Returns true if all jobs were successfully submitted.""" - return len(self.errors) == 0 - - -class Function(Document): - """The serverless cloud function that you can call directly or submit many jobs to.""" - - id: str = Attribute( - str, - filterable=True, - readonly=True, - sortable=True, - doc="The ID of the Function.", - ) - creation_date: datetime = DatetimeAttribute( - filterable=True, - readonly=True, - sortable=True, - doc="""The date the Function was created.""", - ) - name: str = Attribute( - str, - filterable=True, - sortable=True, - doc="The name of the Function.", - ) - image: str = Attribute( - str, - filterable=True, - mutable=False, - doc="The base image used to create the Function.", - ) - cpus: float = Attribute( - Cpus, - filterable=True, - sortable=True, - doc="The number of cpus to request when executing the Function.", - ) - memory: int = Attribute( - Memory, - filterable=True, - sortable=True, - doc="The amount of memory, in megabytes, to request when executing the Function.", - ) - maximum_concurrency: int = Attribute( - int, - filterable=True, - sortable=True, - doc="The maximum number of Jobs that execute at the same time for this Function.", - ) - namespace: str = Attribute( - str, - filterable=True, - readonly=True, - doc="The storage namespace for the Function.", - ) - owner: str = Attribute( - str, - filterable=True, - readonly=True, - doc="The owner of the Function.", - ) - status: FunctionStatus = Attribute( - FunctionStatus, - filterable=True, - readonly=True, - sortable=True, - doc="The status of the Function.", - ) - timeout: int = Attribute( - int, - filterable=True, - sortable=True, - doc="The number of seconds Jobs can run before timing out for this Function.", - ) - retry_count: int = Attribute( - int, - filterable=True, - sortable=True, - doc="The total number of retries requested for a Job before it failed.", - ) - enabled: bool = Attribute( - bool, - filterable=True, - sortable=True, - doc="Whether the Function accepts job submissions and reruns.", - ) - auto_start: bool = Attribute( - bool, - filterable=True, - sortable=True, - doc="Whether the Function will be placed into READY status once building is complete.", - ) - environment: Dict[str, str] = Attribute( - dict, - doc="The environment variables to set for Jobs run by the Function.", - ) - modified_date: datetime = DatetimeAttribute( - filterable=True, - readonly=True, - sortable=True, - doc="""The date the Function was last modified or processed a job submission.""", - ) - job_statistics: Dict = Attribute( - dict, - readonly=True, - doc=( - "Statistics about the Job statuses for this Function. This attribute will only be " - "available if includes='job.statistics' is specified in the request." - ), - ) - - _ENTRYPOINT_TEMPLATE = "{source}\n\n\nmain = {function_name}\n" - _IMPORT_TEMPLATE = "from {module} import {obj}" - _SYS_PACKAGES = ".*(?:site|dist)-packages" - - _ENTRYPOINT = "__dlentrypoint__.py" - _REQUIREMENTS = "requirements.txt" - - def __init__( - self, - function: Callable = None, - requirements: List[str] = None, - include_data: List[str] = None, - include_modules: List[str] = None, - name: str = None, - image: str = None, - cpus: Cpus = None, - memory: Memory = None, - maximum_concurrency: int = None, - timeout: int = None, - retry_count: int = None, - client: ComputeClient = None, - **extra, - ): # check to see if we need more validation here (type conversions) - """ - Parameters - ---------- - function : Callable - The function to be called in a Compute Job. - requirements : List[str], optional - A list of Python dependencies required by this function. - include_data : List[str], optional - Non-Python data files to include in the compute function. - name : str, optional - Name of the function, will take name of function if not provided. - image : str - The location of a docker image to be used for the environment where the function - will be executed. - cpus : Cpus - The number of CPUs requested for a single Job. CPUs can be specified as an integer, - float, or string. Supported CPU options include: ``0.25, 0.5, 1, 2, 4, 8, 16, - '0.25vCPU', '0.5vCPU', '1vCPU', '2vCPU', '4vCPU', '8vCPU', '16vCPU'``. - memory : Memory - The maximum memory requirement for a single Job. Memory can be specified as an - integer or string. If an integer is provided, it is assumed that the units are - megabytes. For instance, ``1024`` is equivalent to one ``1GB`` or ``1024MB`` of - memory. Alternatively, memory can be specified as a case-insensitive memory string, - such as ``'1GB'``, ``'1Gi'``, ``'1024MB'``, or ``'1024Mi'``, all of which are - equivalent. The allowable memory for a `Function` is determined by the number of - CPUs requested. Supported memory options per CPUs requested include: ``0.25vCPU: - 0.5GB, 1GB, or 2GB``, ``0.5vCPU: 1 - 4GB in 1GB increments``, ``1vCPU: 2 - 8GB in - 1GB increments``, ``2vCPU: 4 - 16GB in 1GB increments``, ``4vCPU: 8 - 30GB in 1GB - increments``, ``8vCPU: 16 - 60GB in 4GB increments``, ``16vCPU: 32 - 120GB in 8GB - increments``. - maximum_concurrency : int - The maximum number of jobs to run in parallel. - timeout : int, optional - Maximum runtime for a single job in seconds. Job will be killed if it exceeds - this limit. - retry_count : int, optional - Number of times to retry a job if it fails. - client : ComputeClient, optional - If set, operations on the function will be performed using the configured client. - Otherwise, the default client will be used. - - Examples - -------- - Retrieving an existing function and executing it. - - >>> fn = Function.get() - >>> fn() - Job : "pending" - - Creating a new function. - - >>> from descarteslabs.compute import Function - >>> def test_func(): - ... print("Hello :)") - >>> fn = Function( - ... test_func, - ... requirements=[], - ... name="my_func", - ... image="test_image", - ... cpus=1, - ... memory=16, - ... maximum_concurrency=5, - ... timeout=3600, - ... retry_count=1, - ... ) - >>> fn() - Job : "pending" - """ - - self._client = client or ComputeClient.get_default_client() - self._function = function - self._requirements = requirements - self._include_data = include_data - self._include_modules = include_modules - - # if name is not provided and function is a string, use the name of the function - # if name is not provided and function is a callable, set the name to __name__ - if not name and self._function: - if isinstance(self._function, str): - name = self._function.split(".")[-1] - else: - name = self._function.__name__ - - # When a Function is hydrated from the server, register the namespace - # with the client so that it can be used for subsequent calls. - if "id" in extra and "namespace" in extra: - self._client.set_namespace(extra["id"], extra["namespace"]) - - super().__init__( - name=name, - image=image, - cpus=cpus, - memory=memory, - maximum_concurrency=maximum_concurrency, - timeout=timeout, - retry_count=retry_count, - **extra, - ) - - def __call__( - self, - *args, - tags: List[str] = None, - environment: Dict[str, str] = None, - **kwargs, - ): - """Execute the function with the given arguments. - - This call will return a Job object that can be used to monitor the state - of the job. - - Returns - ------- - Job - The Job that was submitted. - - Parameters - ---------- - args : Any, optional - Positional arguments to pass to the function. - tags : List[str], optional - A list of tags to apply to the Job. - kwargs : Any, optional - Keyword arguments to pass to the function. - environment : Dict[str, str], optional - Environment variables to be set in the environment of the running Job. - Will be merged with environment variables set on the Function, with - the Job environment variables taking precedence. - """ - self.save() - job = Job( - function_id=self.id, - args=args, - kwargs=kwargs, - environment=environment, - tags=tags, - ) - job.save() - return job - - def _sys_paths(self): - """Get the system paths.""" - - if not hasattr(self, "_cached_sys_paths"): - # use longest matching path entries. - self._cached_sys_paths = sorted( - map(os.path.abspath, sys.path), key=len, reverse=True - ) - return self._cached_sys_paths - - def _get_globals(self, func) -> Set[str]: - """Get the globals for a function.""" - instructions: List[dis.Instruction] = dis.get_instructions(func) - globals = set() - - for i in instructions: - if inspect.iscode(i.argval): - # The function can have nested functions with their own globals - # so we need to recursively disassemble those as well - globals.update(self._get_globals(i.argval)) - elif i.opname == "LOAD_GLOBAL" and not hasattr(builtins, i.argval): - globals.add(i.argval) - - return globals - - def _find_object(self, name): - """Search for an object as specified by a fully qualified name. - The fully qualified name must refer to an object that can be resolved - through the module search path. - - Parameters - ---------- - name : str - Fully qualified name of the object to search for. - Must refer to an object that can be resolved through the module search path. - - Returns - ------- - object - The object specified by the fully qualified name. - module_path : list - The fully qualified module path. - object_path : list - The fully qualified object path. - """ - - module_path = [] - object_path = [] - - obj = None - parts = name.split(".") - - for part in parts: - error = None - - if hasattr(obj, part): - # Could be any object (module, class, etc.) - obj = getattr(obj, part) - - if inspect.ismodule(obj) and not object_path: - module_path.append(part) - else: - object_path.append(part) - else: - # If not found, assume it's a module that must be loaded - if object_path: - error = "'{}' has no attribute '{}'".format(type(obj), part) - raise NameError( - "Cannot resolve function name '{}': {}".format(name, error) - ) - else: - module_path.append(part) - - current_module_path = ".".join(module_path) - try: - obj = importlib.import_module(current_module_path) - except Exception as ex: - traceback = sys.exc_info()[2] - raise NameError( - "Cannot resolve function name '{}', error importing module {}: {}".format( - name, current_module_path, ex - ) - ).with_traceback(traceback) - - # When we're at the end, we should have found a valid object - return obj, module_path, object_path - - def _bundle(self): - """Bundle the function and its dependencies into a zip file.""" - - function = self._function - include_modules = self._include_modules - requirements = self._requirements - - if not function: - raise ValueError("Function not provided!") - - data_files = self._data_globs_to_paths() - - try: - with NamedTemporaryFile(delete=False, suffix=".zip", mode="wb") as f: - with zipfile.ZipFile( - f, mode="w", compression=zipfile.ZIP_DEFLATED - ) as bundle: - self._write_main_function(function, bundle) - self._write_data_files(data_files, bundle) - - if include_modules: - self._write_include_modules(self._include_modules, bundle) - - if requirements: - bundle.writestr( - self._REQUIREMENTS, self._bundle_requirements(requirements) - ) - return f.name - except Exception: - if os.path.exists(f.name): - os.remove(f.name) - raise - - def _write_main_function(self, f, archive): - """Write the main function to the archive.""" - - is_named_function = isinstance(f, str) - - if is_named_function: - f, module_path, function_path = self._find_object(f) - - if not callable(f): - raise ValueError( - "Compute main function must be a callable: `{}`".format(f) - ) - - # Simply import the module - source = self._IMPORT_TEMPLATE.format( - module=".".join(module_path), obj=function_path[0] - ) - function_name = ".".join(function_path) - else: - # make sure function_name is set - function_name = f.__name__ - - if not inspect.isfunction(f): - raise ValueError( - "Compute main function must be user-defined function: `{}`".format( - f - ) - ) - - # We can't get the code for a given lambda - if f.__name__ == "": - raise ValueError( - "Compute main function cannot be a lambda expression: `{}`".format( - f - ) - ) - - # Furthermore, the given function cannot refer to globals - bound_globals = self._get_globals(f) - - if bound_globals: - raise BoundGlobalError( - "Illegal reference to one or more global variables in your " - "function: {}".format(bound_globals) - ) - - try: - source = inspect.getsource(f).strip() - except Exception: - try: - import dill - - source = dill.source.getsource(f).strip() - except ImportError: - raise ValueError( - "Unable to retrieve the source of interactively defined functions." - " To support this install dill: pip install dill" - ) - - entrypoint_source = self._ENTRYPOINT_TEMPLATE.format( - source=source, function_name=function_name - ) - archive.writestr(self._ENTRYPOINT, entrypoint_source) - - def _write_data_files(self, data_files, archive): - """Write the data files to the archive.""" - - for path, archive_path in data_files: - archive.write(path, archive_path) - - def _find_module_file(self, mod_name): - """Search for module file in python path. Raise ImportError if not found.""" - - try: - mod = importlib.import_module(mod_name) - mod_file = mod.__file__.replace(".pyc", ".py", 1) - return mod_file - - except ImportError as ie: - # Search for possible pyx file - mod_basename = "{}.pyx".format(mod_name.replace(".", "/")) - for s in sys.path: - mod_file_option = os.path.join(s, mod_basename) - if os.path.isfile(mod_file_option): - # Check that found cython source not in CWD (causes build problems) - if os.getcwd() == os.path.dirname(os.path.abspath(mod_file_option)): - raise ValueError( - "Cannot include cython modules from working directory: `{}`.".format( - mod_file_option - ) - ) - else: - return mod_file_option - - # Raise caught ImportError if we still haven't found the module - raise ie - - def _write_include_modules(self, include_modules, archive): - """Write the included modules to the archive.""" - - for mod_name in include_modules: - mod_file = self._find_module_file(mod_name) - - # detect system packages from distribution or virtualenv locations. - if re.match(self._SYS_PACKAGES, mod_file) is not None: - raise ValueError( - "Cannot include system modules: `{}`.".format(mod_file) - ) - - if not os.path.exists(mod_file): - raise IOError( - "Source code for module is missing, only byte code exists: `{}`.".format( - mod_name - ) - ) - sys_path = self._sys_path_prefix(mod_file) - - self._include_init_files(os.path.dirname(mod_file), archive, sys_path) - archive_names = archive.namelist() - # this is a package, get all decendants if they exist. - if os.path.basename(mod_file) == "__init__.py": - for dirpath, dirnames, filenames in os.walk(os.path.dirname(mod_file)): - for file_ in [f for f in filenames if f.endswith((".py", ".pyx"))]: - path = os.path.join(dirpath, file_) - arcname = self._archive_path(path, None, sys_path) - if arcname not in archive_names: - archive.write(path, arcname=arcname) - else: - archive.write( - mod_file, arcname=self._archive_path(mod_file, None, sys_path) - ) - - def _include_init_files(self, dir_path, archive, sys_path): - """Include __init__.py files for all parent directories.""" - - relative_dir_path = os.path.relpath(dir_path, sys_path) - archive_names = archive.namelist() - # have we walked this path before? - if os.path.join(relative_dir_path, "__init__.py") not in archive_names: - partial_path = "" - for path_part in relative_dir_path.split(os.sep): - partial_path = os.path.join(partial_path, path_part) - rel_init_location = os.path.join(partial_path, "__init__.py") - abs_init_location = os.path.join(sys_path, rel_init_location) - if not os.path.exists(abs_init_location): - raise IOError( - "Source code for module is missing: `{}`.".format( - abs_init_location - ) - ) - if rel_init_location not in archive_names: - archive.write(abs_init_location, arcname=rel_init_location) - - def _bundle_requirements(self, requirements): - """Bundle the requirements into the archive.""" - - if isinstance(requirements, str): - return self._requirements_file(requirements) - else: - return self._requirements_list(requirements) - - def _requirements_file(self, requirements): - """Read the requirements file and validate it.""" - - if not os.path.isfile(requirements): - raise ValueError( - "Requirements file at {} not found. Did you mean to specify a single requirement? " - "Pass it wrapped in a list.".format(requirements) - ) - with open(requirements) as f: - return self._requirements_list([line.strip() for line in f.readlines()]) - - def _requirements_list(self, requirements): - """Validate the requirements list.""" - - bad_requirements = [] - for requirement in requirements: - try: - Requirement(requirement) - except InvalidRequirement: - # comment or pip-specific option not understood by packaging - if requirement.startswith("#") or requirement.startswith("-"): - continue - if requirement.startswith("https://"): - continue - # e.g. torch-2.0.1+cpu which packaging doesn't understand - if "+" in requirement: - try: - Requirement(requirement.rsplit("+")[0]) - continue - except InvalidRequirement: - pass - bad_requirements.append(requirement) - if bad_requirements: - raise ValueError( - "Invalid Python requirements: {}".format(",".join(bad_requirements)) - ) - - return "\n".join(requirements) - - def _sys_path_prefix(self, path): - """Get the system path prefix for a given path.""" - - absolute_path = os.path.abspath(path) - for sys_path in self._sys_paths(): - if absolute_path.startswith(sys_path): - return sys_path - else: - raise IOError("Location is not on system path: `{}`".format(path)) - - def _archive_path(self, path, archive_prefix, sys_path): - """Get the archive path for a given path.""" - - if archive_prefix: - return os.path.join(archive_prefix, os.path.relpath(path, sys_path)) - else: - return os.path.relpath(path, sys_path) - - def _data_globs_to_paths(self) -> List[str]: - """Convert data globs to absolute paths.""" - - data_files = [] - - # if there are no data files, return empty list - if not self._include_data: - return data_files - - for pattern in self._include_data: - is_glob = glob.has_magic(pattern) - matches = glob.glob(pattern) - - if not matches: - if is_glob: - warnings.warn(f"Include data pattern had no matches: {pattern}") - else: - raise ValueError(f"No data file found for path: {pattern}") - - for relative_path in matches: - path = os.path.abspath(relative_path) - - if os.path.exists(path): - if os.path.isdir(path): - relative_path = relative_path.rstrip("/") - - raise ValueError( - "Cannot accept directories as include data." - " Use globs instead: {} OR {}".format( - f"{relative_path}/*.*", f"{relative_path}/**/*.*" - ) - ) - else: - archive_path = self._archive_path( - path, None, sys_path=self._sys_path_prefix(path) - ) - if archive_path == "__main__.py": - raise ValueError( - f"{pattern} includes a file with the forbidden relative path `__main__.py`" - ) - data_files.append((path, archive_path)) - else: - raise ValueError(f"Data file does not exist: {path}") - - return data_files - - @classmethod - def get(cls, id: str, client: ComputeClient = None, **params): - """Get Function by id. - - Parameters - ---------- - id : str - Id of function to get. - client: ComputeClient, optional - If set, the result will be retrieved using the configured client. - Otherwise, the default client will be used. - include : List[str], optional - List of additional attributes to include in the response. - Allowed values are: - - - "job.statistics": Include statistics about the Job statuses for this Function. - - Example - ------- - >>> from descarteslabs.compute import Function - >>> fn = Function.get() - Search["Function"]: - """Lists all Functions for a user. - - If you would like to filter Functions, use :py:meth:`Function.search`. - - Parameters - ---------- - page_size : int, default=100 - Maximum number of results per page. - client: ComputeClient, optional - If set, the result will be retrieved using the configured client. - Otherwise, the default client will be used. - include : List[str], optional - List of additional attributes to include in the response. - Allowed values are: - - - "job.statistics": Include statistics about the Job statuses for this Function. - - Example - ------- - >>> from descarteslabs.compute import Function - >>> fn = Function.list() - """ - params = {"page_size": page_size, **params} - return cls.search(client=client).param(**params) - - @classmethod - def search(cls, client: ComputeClient = None) -> Search["Function"]: - """Creates a search for Functions. - - The search is lazy and will be executed when the search is iterated over or - :py:meth:`Search.collect` is called. - - Parameters - ---------- - client: ComputeClient, optional - If set, the result will be retrieved using the configured client. - Otherwise, the default client will be used. - - Example - ------- - >>> from descarteslabs.compute import Function, FunctionStatus - >>> fns: List[Function] = ( - ... Function.search() - ... .filter(Function.status.in_([ - ... FunctionStatus.BUILDING, FunctionStatus.AWAITING_BUNDLE - ... ]) - ... .collect() - ... ) - Collection([Function : building, Function : awaiting_bundle]) - """ - client = client or ComputeClient.get_default_client() - return Search(Function, client, url="/functions") - - @classmethod - def update_credentials(cls, client: ComputeClient = None): - """Updates the credentials for the Functions and Jobs run by this user. - - These credentials are used by other Descarteslabs services. - - If the user invalidates existing credentials and needs to update them, - you should call this method. - - Notes - ----- - Credentials are automatically updated when a new Function is created. - - Parameters - ---------- - client: ComputeClient, optional - If set, the operation will be performed using the configured client. - Otherwise, the default client will be used. - - """ - client = client or ComputeClient.get_default_client() - client.set_credentials() - - @property - def jobs(self) -> JobSearch: - """Returns a JobSearch for all the Jobs for the Function.""" - if self.state != DocumentState.SAVED: - raise ValueError( - "Cannot search for jobs for a Function that has not been saved" - ) - - return Job.search(client=self._client).filter(Job.function_id == self.id) - - def build_log(self): - """Retrieves the build log for the Function.""" - if self.state != DocumentState.SAVED: - raise ValueError( - "Cannot retrieve logs for a Function that has not been saved" - ) - - response = self._client.session.get(f"/functions/{self.id}/log") - - return gzip.decompress(response.content).decode() - - def delete(self, delete_results: bool = False): - """Deletes the Function and all associated Jobs. - - If any jobs are in a running state, the deletion will fail. - - Please see the `:meth:~descarteslabs.compute.Function.delete_jobs` method for more - information on deleting large numbers of jobs. - - Parameters - ---------- - delete_results : bool, default=False - If True, deletes the job result blobs as well. - """ - if self.state == DocumentState.NEW: - raise ValueError("Cannot delete a Function that has not been saved") - - self.delete_jobs(delete_results=delete_results) - - self._client.session.delete(f"/functions/{self.id}") - self._deleted = True - - def disable(self): - """Disables the Function so that new jobs cannot be submitted.""" - self.enabled = False - if self.state != DocumentState.NEW: - self.save() - - def enable(self): - """Enables the Function so that new jobs may be submitted.""" - self.enabled = True - if self.state != DocumentState.NEW: - self.save() - - def save(self): - """Creates the Function if it does not already exist. - - If the Function already exists, it will be updated on the server if the Function - instance was modified. - - Examples - -------- - Create a Function without creating jobs: - - >>> from descarteslabs.compute import Function - >>> def test_func(): - ... print("Hello :)") - >>> fn = Function( - ... test_func, - ... requirements=[], - ... name="my_func", - ... image="test_image", - ... cpus=1, - ... memory=16, - ... maximum_concurrency=5, - ... timeout=3600, - ... retry_count=1, - ... ) - >>> fn.save() - - Updating a Function: - - >>> from descarteslabs.compute import Function - >>> fn = Function.get() - >>> fn.memory = 4096 # 4 Gi - >>> fn.save() - """ - - if self.state == DocumentState.SAVED: - # Document already exists on the server without changes locally - return - - if self.state == DocumentState.NEW: - self.update_credentials() - - code_bundle_path = self._bundle() - response = self._client.session.post( - "/functions", json=self.to_dict(exclude_none=True) - ) - response_json = response.json() - self._load_from_remote(response_json["function"]) - - # Upload the bundle to s3 - s3_client = ThirdPartyService() - upload_url = response_json["bundle_upload_url"] - code_bundle = io.open(code_bundle_path, "rb") - headers = { - "content-type": "application/octet-stream", - } - s3_client.session.put(upload_url, data=code_bundle, headers=headers) - - # Complete the upload with compute - response = self._client.session.post(f"/functions/{self.id}/bundle") - self._load_from_remote(response.json()) - elif self.state == DocumentState.MODIFIED: - response = self._client.session.patch( - f"/functions/{self.id}", json=self.to_dict(only_modified=True) - ) - self._load_from_remote(response.json()) - else: - raise ValueError( - f"Unexpected Function state {self.state}." - f'Reload the function from the server: Function.get("{self.id}")' - ) - - def start(self): - """Starts Function so that pending jobs can be executed.""" - if self.state != DocumentState.SAVED: - raise ValueError("Cannot start a Function that has not been saved") - - response = self._client.session.patch( - f"/functions/{self.id}", json={"status": FunctionStatus.READY.value} - ) - self._load_from_remote(response.json()) - - def stop(self): - """Stops Function so that pending jobs cannot be executed.""" - if self.state != DocumentState.SAVED: - raise ValueError("Cannot start a Function that has not been saved") - - response = self._client.session.patch( - f"/functions/{self.id}", json={"status": FunctionStatus.STOPPED.value} - ) - self._load_from_remote(response.json()) - - @deprecate(renamed={"iterargs": "kwargs"}) - def map( - self, - args: Iterable[Iterable[Any]], - kwargs: Iterable[Mapping[str, Any]] = None, - tags: List[str] = None, - batch_size: int = 1000, - environments: Iterable[Mapping[str, str]] = None, - ) -> JobBulkCreateResult: - """Submits multiple jobs efficiently with positional args to each function call. - - Preferred over repeatedly calling the function, such as in a loop, when submitting - multiple jobs. - - If supplied, the length of ``kwargs`` must match the length of ``args``. All parameter - values must be JSON serializable. - - As an example, if the function takes two positional arguments and has a keyword - argument ``x``, you can submit multiple jobs like this: - - >>> async_func.map([['a', 'b'], ['c', 'd']], [{'x': 1}, {'x': 2}]) # doctest: +SKIP - - is equivalent to: - - >>> async_func('a', 'b', x=1) # doctest: +SKIP - >>> async_func('c', 'd', x=2) # doctest: +SKIP - - Notes - ----- - Map is idempotent for the initial call such that request errors that occur once started, - will not cause duplicate jobs to be submitted. However, if the method is called again - with the same arguments, it will submit duplicate jobs. - - You should always check the return value to ensure all jobs were submitted successfully - and handle any errors that may have occurred. - - Parameters - ---------- - args : Iterable[Iterable[Any]] - An iterable of iterables of arguments. For each outer element, a job will be submitted - with each of its elements as the positional arguments to the function. The length of - each element of the outer iterable must match the number of positional arguments to - the function. - kwargs : Iterable[Mapping[str, Any]], optional - An iterable of Mappings with keyword arguments. For each outer element, the Mapping will - be expanded into keyword arguments for the function. - environments : Iterable[Mapping[str, str]], optional - AN iterable of Mappings of Environment variables to be set in the environment of the - running Jobs. The values for each job will be merged with environment variables set - on the Function, with the Job environment variables taking precedence. - tags : List[str], optional - A list of tags to apply to all jobs submitted. - batch_size : int, default=1000 - The number of jobs to submit in each batch. The maximum batch size is 1000. - - Returns - ------_ - JobBulkCreateResult - An object containing the jobs that were submitted and any errors that occurred. - This object is compatible with a list of Job objects for backwards compatibility. - - If the value of `JobBulkCreateResult.is_success` is False, you should check - `JobBulkCreateResult.errors` and handle any errors that occurred. - - Raises - ------ - ClientError, ServerError - If the request to create the first batch of jobs, fails after all retries have - been exhausted. - - Otherwise, any errors will be available in the returned JobBulkCreateResult. - """ - if self.state != DocumentState.SAVED: - raise ValueError("Cannot execute a Function that has not been saved") - - if batch_size < 1 or batch_size > 1000: - raise ValueError("Batch size must between 1 and 1000") - - args = [list(iterable) for iterable in args] - if kwargs is not None: - kwargs = [dict(mapping) for mapping in kwargs] - if len(kwargs) != len(args): - raise ValueError( - "The number of kwargs must match the number of args. " - f"Got {len(args)} args and {len(kwargs)} kwargs." - ) - if environments is not None: - environments = [dict(mapping) for mapping in environments] - if len(environments) != len(args): - raise ValueError( - "The number of environments must match the number of args. " - f"Got {len(args)} args and {len(environments)} environments." - ) - - result = JobBulkCreateResult() - - # Send the jobs in batches of batch_size - for index, (positional, named, env) in enumerate( - itertools.zip_longest( - batched(args, batch_size), - batched(kwargs or [], batch_size), - batched(environments or [], batch_size), - ) - ): - payload = { - "function_id": self.id, - "bulk_args": positional, - "bulk_kwargs": named, - "bulk_environments": env, - "reference_id": str(uuid.uuid4()), - } - - if tags: - payload["tags"] = tags - - # This implementation uses a `reference_id` to ensure that the request is idempotent - # and duplicate jobs are not submitted in a retry scenario. - try: - response = self._client.session.post("/jobs/bulk", json=payload) - result.extend([Job(**data, saved=True) for data in response.json()]) - except exceptions.NotFoundError: - # If one of these errors occurs, we cannot continue submitting any jobs at all - raise - except Exception as exc: - if index == 0: - # The first batch failed, let the user deal with the exception as all - # the batches would likely fail. - raise - - result.append_error( - JobBulkCreateError( - function=self, - args=payload["bulk_args"], - kwargs=payload["bulk_kwargs"], - environments=payload["bulk_environments"], - reference_id=payload["reference_id"], - exception=exc, - ) - ) - - return result - - def cancel_jobs(self, query: Optional[JobSearch] = None, job_ids: List[str] = None): - """Cancels all jobs for the Function matching the given query. - - If both `query` and `job_ids` are None, all jobs for the Function will be canceled. - If both are provided, they will be combined with an AND operator. Any jobs matched - by `query` or `job_ids` which are not associated with this function will be ignored. - - Parameters - ---------- - query : JobSearch, optional - Query to filter jobs to cancel. - job_ids : List[str], optional - List of job ids to cancel. - """ - if self.state != DocumentState.SAVED: - raise ValueError( - "Cannot cancel jobs for a Function that has not been saved" - ) - - if query is None: - query = self.jobs - else: - query = query.filter(Job.function_id == self.id) - if job_ids: - query = query.filter(Job.id.in_(job_ids)) - - return query.cancel() - - def rerun(self, query: Optional[JobSearch] = None, job_ids: List[str] = None): - """Submits all the unsuccessful jobs matching the query to be rerun. - - If both `query` and `job_ids` are None, all rerunnable jobs for the Function - will be rerun. If both are provided, they will be combined with an AND operator. - Any jobs matched by `query` or `job_ids` which are not associated with - this function will be ignored. - - Parameters - ---------- - query : JobSearch, optional - Query to filter jobs to rerun. - job_ids : List[str], optional - List of job ids to rerun. - """ - if self.state != DocumentState.SAVED: - raise ValueError("Cannot execute a Function that has not been saved") - - if query is None: - query = self.jobs - else: - query = query.filter(Job.function_id == self.id) - if job_ids: - query = query.filter(Job.id.in_(job_ids)) - - return query.rerun() - - def delete_jobs( - self, - query: Optional[JobSearch] = None, - job_ids: List[str] = None, - delete_results: bool = False, - ) -> List[str]: - """Deletes all non-running jobs for the Function matching the given query. - - If both `query` and `job_ids` are None, all jobs for the Function will be deleted. - If both are provided, they will be combined with an AND operator. Any jobs matched - by `query` or `job_ids` which are not associated with this function will be ignored. - - Also deletes any job log blobs for the jobs. Use `delete_results=True` to delete the - job result blobs as well. - - There is a limit to how many jobs can be deleted in a single request before the request - times out. If you need to delete a large number of jobs and experience timeouts, consider - using a loop to delete batches, using the `query` parameter with a limit (e.g. - ``async_func.delete_jobs(async_func.jobs.limit(10000))``, or use the `job_ids` - parameter to limit the number of jobs to delete. - - Parameters - ---------- - query : JobSearch, optional - Query to filter jobs to delete. - job_ids : List[str], optional - List of job ids to delete. - delete_results : bool, default=False - If True, deletes the job result blobs as well. - - Returns - ------- - List[str] - List of job ids that were deleted. - """ - if self.state != DocumentState.SAVED: - raise ValueError( - "Cannot delete jobs for a Function that has not been saved" - ) - - if query is None: - query = self.jobs - else: - query = query.filter(Job.function_id == self.id) - if job_ids: - query = query.filter(Job.id.in_(job_ids)) - - return query.delete(delete_results=delete_results) - - def refresh(self, includes: Optional[str] = None): - """Updates the Function instance with data from the server. - - Parameters - ---------- - includes : Optional[str], optional - List of additional attributes to include in the response. - Allowed values are: - - - "job.statistics": Include statistics about the Job statuses for this Function. - """ - if self.state == DocumentState.NEW: - raise ValueError("Cannot refresh a Function that has not been saved") - - if includes: - params = {"include": includes} - elif includes is None and self.job_statistics: - params = {"include": ["job.statistics"]} - else: - params = {} - - response = self._client.session.get(f"/functions/{self.id}", params=params) - self._load_from_remote(response.json()) - - def iter_results(self, cast_type: Type[Serializable] = None): - """Iterates over all successful job results. - - Parameters - ---------- - cast_type : Type[Serializable], optional - If set, the results will be cast to the given type. - """ - if self.state != DocumentState.SAVED: - raise ValueError( - "Cannot retrieve results for a Function that has not been saved" - ) - - for job in self.jobs.filter(Job.status == JobStatus.SUCCESS): - yield job.result(cast_type=cast_type) - - def results(self, cast_type: Type[Serializable] = None): - """Retrieves all the job results for the Function as a list. - - Notes - ----- - This immediately downloads all results into a list and could run out of memory. - If the result set is large, strongly consider using :py:meth:`Function.iter_results` - instead. - - Parameters - ---------- - cast_type : Type[Serializable], optional - If set, the results will be cast to the given type. - """ - return list(self.iter_results(cast_type=cast_type)) - - def as_completed(self, jobs: Iterable[Job], timeout=None, interval=10): - """Yields jobs as they complete. - - Completion includes success, failure, timeout, and canceled. - - Can be used in any iterable context. - - Parameters - ---------- - jobs : Iterable[Job] - The jobs to wait for completion. Jobs must be associated with this Function. - timeout : int, default=None - Maximum time to wait before timing out. If not set, this will continue - polling until all jobs have completed. - interval : int, default=10 - Interval in seconds for how often to check if jobs have been completed. - """ - if self.state != DocumentState.SAVED: - raise ValueError( - "Cannot retrieve jobs for a Function that has not been saved" - ) - - job_ids = set() - for job in jobs: - if job.function_id != self.id: - raise ValueError( - f"Job {job.id} is not associated with Function {self.id}" - ) - job_ids.add(job.id) - - current_interval = interval - chunk_size = MAX_FUNCTION_IDS_PER_REQUEST - start_time = time.time() - while job_ids: - self.refresh() - if self.status == FunctionStatus.BUILD_FAILED: - raise RuntimeError( - f"Function {self.name} failed to build. Check the build log for more details." - ) - - hits = set() - query_ids = {job_id for job_id in job_ids} - # refresh the job state, but only a chunk at a time - while query_ids: - chunk_ids = set( - itertools.islice(query_ids, min(chunk_size, len(query_ids))) - ) - query_ids -= chunk_ids - - for job in Job.search(client=self._client).filter( - Job.id.in_(list(chunk_ids)) & Job.status.in_(JobStatus.terminal()) - ): - hits.add(job.id) - yield job - - job_ids -= hits - - if hits: - current_interval = interval - - if timeout: - t = timeout - (time.time() - start_time) - if t <= 0: - raise TimeoutError( - f"Function {self.name} did not complete before timeout!" - ) - - t = min(t, current_interval) - else: - t = current_interval - - # Don't sleep as long as we are picking up hits - if not hits: - time.sleep(t) - - current_interval = min(current_interval * 2, 60) - - def wait_for_completion(self, timeout=None, interval=10): - """Waits until all submitted jobs for a given Function are completed. - - Completion includes success, failure, timeout, and canceled. - - Parameters - ---------- - timeout : int, default=None - Maximum time to wait before timing out. If not set, this method will - block indefinitely. - interval : int, default=10 - Interval in seconds for how often to check if jobs have been completed. - """ - if self.state != DocumentState.SAVED: - raise ValueError("Cannot wait for a Function that has not been saved") - - start_time = time.time() - - while True: - self.refresh(includes=["job.statistics"]) - - if self.status == FunctionStatus.BUILD_FAILED: - raise RuntimeError( - f"Function {self.name} failed to build. Check the build log for more details." - ) - - if self.status in [FunctionStatus.READY, FunctionStatus.STOPPED]: - job_statistics = getattr(self, "job_statistics", {}) - if ( - job_statistics.get("pending", 0) == 0 - and job_statistics.get("running", 0) == 0 - and job_statistics.get("cancel", 0) == 0 - and job_statistics.get("canceling", 0) == 0 - ): - break - - if timeout: - t = timeout - (time.time() - start_time) - if t <= 0: - raise TimeoutError( - f"Function {self.name} did not complete before timeout!" - ) - - t = min(t, interval) - else: - t = interval - - time.sleep(t) diff --git a/descarteslabs/core/compute/job.py b/descarteslabs/core/compute/job.py deleted file mode 100644 index 668422ed..00000000 --- a/descarteslabs/core/compute/job.py +++ /dev/null @@ -1,593 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 time -import warnings -from datetime import datetime -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type - -from strenum import StrEnum - -from descarteslabs.exceptions import NotFoundError - -from ..catalog import Blob, CatalogClient, DeletedObjectError, StorageType -from ..common.client import ( - Attribute, - DatetimeAttribute, - Document, - DocumentState, - ListAttribute, - Search, -) -from .compute_client import ComputeClient -from .job_statistics import JobStatistics -from .result import Serializable - -if TYPE_CHECKING: - from .function import Function - - -class JobStatus(StrEnum): - """The status of the Job.""" - - PENDING = "pending" - RUNNING = "running" - CANCEL = "cancel" - CANCELING = "canceling" - SUCCESS = "success" - FAILURE = "failure" - TIMEOUT = "timeout" - CANCELED = "canceled" - - @classmethod - def terminal(cls): - return [ - cls.SUCCESS, - cls.FAILURE, - cls.TIMEOUT, - cls.CANCELED, - ] - - -class JobSearch(Search["Job"]): - def cancel(self): - response = self._client.session.post( - "/jobs/cancel", json=self._serialize(json_encode=False) - ) - return [Job(**job, client=self._client, saved=True) for job in response.json()] - - def rerun(self): - response = self._client.session.post( - "/jobs/rerun", json=self._serialize(json_encode=False) - ) - return [Job(**job, client=self._client, saved=True) for job in response.json()] - - def delete(self, delete_results: bool = False): - json = self._serialize(json_encode=False) - json["delete_results"] = delete_results - response = self._client.session.post("/jobs/delete", json=json) - return response.json() - - -class Job(Document): - """A single invocation of a Function.""" - - id: str = Attribute( - str, filterable=True, readonly=True, sortable=True, doc="The ID of the Job." - ) - function_id: str = Attribute( - str, - filterable=True, - mutable=False, - doc="The ID of the Function the Job belongs to.", - ) - creation_date: datetime = DatetimeAttribute( - filterable=True, - readonly=True, - sortable=True, - doc="The date the Job was created.", - ) - args: Optional[List] = Attribute(list, doc="The arguments provided to the Job.") - error_reason: Optional[str] = Attribute( - str, - readonly=True, - doc="The reason the Job failed.", - ) - execution_count: Optional[int] = Attribute( - int, - filterable=True, - readonly=True, - sortable=True, - doc="The number of attempts made to execute this job.", - ) - exit_code: Optional[int] = Attribute( - int, - filterable=True, - readonly=True, - sortable=True, - doc="The exit code of the Job.", - ) - kwargs: Optional[Dict[str, Any]] = Attribute( - dict, doc="The parameters provided to the Job." - ) - environment: Optional[Dict[str, str]] = Attribute( - dict, doc="The environment variables provided to the Job." - ) - last_completion_date: Optional[datetime] = DatetimeAttribute( - filterable=True, - readonly=True, - sortable=True, - doc="The date the Job was last completed or canceled.", - ) - last_execution_date: Optional[datetime] = DatetimeAttribute( - filterable=True, - readonly=True, - sortable=True, - doc="The date the Job was last executed.", - ) - runtime: Optional[int] = Attribute( - int, - filterable=True, - readonly=True, - sortable=True, - doc="The time it took the Job to complete.", - ) - status: JobStatus = Attribute( - JobStatus, - filterable=True, - readonly=True, - sortable=True, - doc="""The current status of the Job. - - The status may occasionally need to be refreshed by calling :py:meth:`Job.refresh` - """, - ) - statistics: Optional[JobStatistics] = Attribute( - JobStatistics, - readonly=True, - doc="""The runtime utilization statistics for the Job. - - The statistics include the cpu, memory, and network usage of the Job. - """, - ) - tags: List[str] = ListAttribute( - str, - filterable=True, - doc="A list of tags associated with the Job.", - ) - - # Lazy attributes - provisioning_time: Optional[int] = Attribute( - int, - readonly=True, - doc=( - "The time it took to provision the Job. This attribute will only be available " - "if include='timings' is specified in the request by setting params.", - ), - ) - pull_time: Optional[int] = Attribute( - int, - readonly=True, - doc=( - "The time it took to load the user code in the Job. This attribute will only" - " be available if include='timings' is specified in the request by setting params.", - ), - ) - - def __init__( - self, - function_id: str, - args: Optional[List] = None, - kwargs: Optional[Dict] = None, - client: ComputeClient = None, - environment: Optional[Dict[str, str]] = None, - **extra, - ): - """ - Parameters - ---------- - function_id : str - The id of the Function. A function must first be created to create a job. - args : List, optional - A list of positional arguments to pass to the function. - kwargs : Dict, optional - A dictionary of named arguments to pass to the function. - environment : Dict[str, str], optional - Environment variables to be set in the environment of the running Job. - Will be merged with environment variables set on the Function, with - the Job environment variables taking precedence. - client: ComputeClient, optional - The compute client to use for requests. - If not set, the default client will be used. - """ - self._client = client or ComputeClient.get_default_client() - super().__init__( - function_id=function_id, - args=args, - kwargs=kwargs, - environment=environment, - **extra, - ) - - # support use of jobs in sets - def __hash__(self): - return hash(self.id) - - # support use of jobs in sets - def __eq__(self, other): - if not isinstance(other, Job): - return False - - return self.id == other.id - - def _get_result_namespace(self) -> str: - """Returns the namespace for the Job result blob.""" - namespace = self._client.get_namespace(self.function_id) - - if not namespace: - # Fetching the function from the server will set the namespace - # during hydration in Function.__init__ - namespace = self.function.namespace - - return namespace - - @property - def function(self) -> "Function": - """Returns the Function the Job belongs to.""" - from .function import Function - - return Function.get(self.function_id) - - @classmethod - def get(cls, id, client: ComputeClient = None, **params) -> "Job": - """Retrieves the Job by id. - - Parameters - ---------- - id : str - The id of the Job to fetch. - client: ComputeClient, optional - If set, the result will be retrieved using the configured client. - Otherwise, the default client will be used. - include : List[str], optional - List of additional attributes to include in the response. - Allowed values are: - - - "timings": Include additional debugging timing information about the Job. - - Example - ------- - >>> from descarteslabs.compute import Job - >>> job = Job.get() - Job : pending - """ - client = client or ComputeClient.get_default_client() - response = client.session.get(f"/jobs/{id}", params=params) - return cls(**response.json(), client=client, saved=True) - - @classmethod - def list( - cls, page_size: int = 100, client: ComputeClient = None, **params - ) -> JobSearch: - """Retrieves an iterable of all jobs matching the given parameters. - - If you would like to filter Jobs, use :py:meth:`Job.search`. - - Parameters - ---------- - page_size : int, default=100 - Maximum number of results per page. - client: ComputeClient, optional - If set, the result will be retrieved using the configured client. - Otherwise, the default client will be used. - - Example - ------- - >>> from descarteslabs.compute import Job - >>> fn = Job.list() - [Job : pending, Job : pending, Job : pending] - """ - params = {"page_size": page_size, **params} - search = Job.search(client=client).param(**params) - - # Deprecation: remove this in a future release - if "function_id" in params or "status" in params: - examples = [] - - if "function_id" in params: - examples.append(f"Job.function_id == '{params['function_id']}'") - - if "status" in params: - if not isinstance(params["status"], list): - params["status"] = [params["status"]] - - examples.append(f"Job.status.in_({params['status']})") - - warnings.warn( - "The `function_id` parameter is deprecated. " - "Use `Job.search().filter({})` instead.".format(" & ".join(examples)) - ) - - return search - - def cancel(self): - """Cancels the Job. - - If the Job is already canceled or completed, this will do nothing. - - If the Job is still pending, it will be canceled immediately. - - If the job is running, it will be canceled as soon as possible. However, it may - complete before the cancel request is processed. - """ - if self.state != DocumentState.SAVED: - raise ValueError("Cannot cancel a Job that has not been saved") - - response = self._client.session.post(f"/jobs/{self.id}/cancel") - self._load_from_remote(response.json()) - - def delete(self, delete_result: bool = False): - """Deletes the Job. - - Also deletes any job log blob for the job. Use `delete_result=True` to delete the - job result blob as well. - - Parameters - ---------- - delete_result : bool, False - If set, the result of the job will also be deleted. - """ - if self.state == DocumentState.NEW: - raise ValueError("Cannot delete a Job that has not been saved") - - self._client.session.delete(f"/jobs/{self.id}?delete_results={delete_result}") - self._deleted = True - - def refresh(self, client: ComputeClient = None) -> None: - """Update the Job instance with the latest information from the server. - - Parameters - ---------- - client: ComputeClient, optional - If set, the result will be retrieved using the configured client. - Otherwise, the default client will be used. - """ - if self.pull_time or self.provisioning_time: - params = {"include": ["timings"]} - else: - params = {} - - response = self._client.session.get(f"/jobs/{self.id}", params=params) - self._load_from_remote(response.json()) - - def result( - self, - cast_type: Optional[Type[Serializable]] = None, - catalog_client: CatalogClient = None, - ): - """Retrieves the result of the Job. - - Parameters - ---------- - cast_type: Type[Serializable], None - If set, the result will be deserialized to the given type. - catalog_client: CatalogClient, None - If set, the result will be retrieved using the configured catalog client. - Otherwise, the default catalog client will be used. - - Raises - ------ - ValueError - When job has not completed successfully or - when `cast_type` does not implement Serializable. - """ - if self.status != JobStatus.SUCCESS: - # just check if maybe it is meanwhile done. - self.refresh() - if self.status != JobStatus.SUCCESS: - if self.status in JobStatus.terminal(): - raise ValueError( - f"Job {self.id} has not completed successfully, status is {self.status}" - ) - else: - raise ValueError( - f"Job {self.id} has not completed, status is {self.status}. " - "Please wait for the job to complete." - ) - - if not catalog_client: - catalog_client = self._client.catalog_client - - try: - namespace = self._get_result_namespace() - result = Blob.get_data( - id=f"{StorageType.COMPUTE}/{namespace}/{self.function_id}/{self.id}", - client=catalog_client, - ) - except NotFoundError: - return None - except ValueError: - raise - except DeletedObjectError: - raise - - if not result: - return None - - if cast_type: - deserialize = getattr(cast_type, "deserialize", None) - - if deserialize and callable(deserialize): - return deserialize(result) - else: - raise ValueError(f"Type {cast_type} must implement Serializable.") - - try: - return json.loads(result) - except Exception: - return result - - def result_blob( - self, - catalog_client: CatalogClient = None, - ): - """Retrieves the Catalog Blob holding the result of the Job. - - If there is no result Blob, `None` will be returned. - - Parameters - ---------- - catalog_client: CatalogClient, None - If set, the result will be retrieved using the configured client. - Otherwise, the default client will be used. - - Raises - ------ - ValueError - When job has not completed successfully or - when `cast_type` does not implement Serializable. - """ - if self.status != JobStatus.SUCCESS: - # just check if maybe it is meanwhile done. - self.refresh() - if self.status != JobStatus.SUCCESS: - if self.status in JobStatus.terminal(): - raise ValueError( - f"Job {self.id} has not completed successfully, status is {self.status}" - ) - else: - raise ValueError( - f"Job {self.id} has not completed, status is {self.status}. " - "Please wait for the job to complete." - ) - - if not catalog_client: - catalog_client = self._client.catalog_client - - return Blob.get( - name=f"{self.function_id}/{self.id}", - namespace=self._get_result_namespace(), - storage_type=StorageType.COMPUTE, - client=catalog_client, - ) - - @classmethod - def search(cls, client: ComputeClient = None) -> JobSearch: - """Creates a search for Jobs. - - The search is lazy and will be executed when the search is iterated over or - :py:meth:`Search.collect` is called. - - Parameters - ---------- - client: ComputeClient, optional - If set, the result will be retrieved using the configured client. - Otherwise, the default client will be used. - - Example - ------- - >>> from descarteslabs.compute import Job, JobStatus - >>> jobs: List[Job] = Job.search().filter(Job.status == JobStatus.SUCCESS).collect() - Collection([Job : success, : success]) - """ - client = client or ComputeClient.get_default_client() - return JobSearch(Job, client, url="/jobs") - - def wait_for_completion(self, timeout=None, interval=10): - """Waits until the Job is completed. - - Parameters - ---------- - timeout : int, default=None - Maximum time to wait before timing out. - If not set, the call will block until job completion. - interval : int, default=10 - Interval in seconds for how often to check if jobs have been completed. - """ - start_time = time.time() - - while True: - self.refresh() - - if self.status in JobStatus.terminal(): - break - - if timeout: - t = timeout - (time.time() - start_time) - if t <= 0: - raise TimeoutError( - f"Job {self.id} did not complete before timeout!" - ) - - t = min(t, interval) - else: - t = interval - - time.sleep(t) - - def save(self): - """Creates the Job if it does not already exist. - - If the job already exists, it will be updated on the server if modifications - were made to the Job instance. - """ - if self.state == DocumentState.SAVED: - return - - if self.state == DocumentState.MODIFIED: - response = self._client.session.patch( - f"/jobs/{self.id}", json=self.to_dict(only_modified=True) - ) - elif self.state == DocumentState.NEW: - response = self._client.session.post( - "/jobs", json=self.to_dict(exclude_none=True) - ) - else: - raise ValueError( - f"Unexpected Job state {self.state}." - f'Reload the job from the server: Job.get("{self.id}")' - ) - - self._load_from_remote(response.json()) - - def iter_log(self, timestamps: bool = True): - """Retrieves the log for the job, returning an iterator over the lines. - - Parameters - ---------- - timestamps : bool, True - If set, log timestamps will be included and converted to the users system - timezone from UTC. - - You may consider disabling this if you use a structured logger. - """ - return self._client.iter_log_lines( - f"/jobs/{self.id}/log", timestamps=timestamps - ) - - def log(self, timestamps: bool = True): - """Retrieves the log for the job, returning a string. - - As logs can potentially be unbounded, consider using :py:meth:`Job.iter_log`. - - Parameters - ---------- - timestamps : bool, True - If set, log timestamps will be included and converted to the users system - timezone from UTC. - - You may consider disabling this if you use a structured logger. - """ - return "\n".join(self.iter_log(timestamps=timestamps)) diff --git a/descarteslabs/core/compute/job_statistics.py b/descarteslabs/core/compute/job_statistics.py deleted file mode 100644 index 2169342e..00000000 --- a/descarteslabs/core/compute/job_statistics.py +++ /dev/null @@ -1,121 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from typing import List, Tuple - -from ..common.client import Attribute, Document, ListAttribute - - -class CpuStatistics(Document): - total: int = Attribute( - int, - readonly=True, - default=0, - doc="Total CPU usage as a percentage", - ) - time: int = Attribute( - int, - readonly=True, - default=0, - doc="Number of CPU nanoseconds used", - ) - timeseries: List[Tuple[int, int]] = ListAttribute( - tuple, - readonly=True, - doc="""Timeseries of CPU usage. - - Each list element holds the cpu percentage and nanoseconds used for that 5 minute interval. - """, - ) - - -class MemoryStatistics(Document): - total_bytes: int = Attribute( - int, - readonly=True, - default=0, - doc="Total memory usage in bytes", - ) - total_percentage: int = Attribute( - int, - readonly=True, - default=0, - doc="Total memory usage as a percentage", - ) - peak_bytes: int = Attribute( - int, - readonly=True, - default=0, - doc="Peak memory usage in bytes", - ) - peak_percentage: int = Attribute( - int, - readonly=True, - default=0, - doc="Peak memory usage as a percentage", - ) - timeseries: List[Tuple[int, int]] = ListAttribute( - tuple, - readonly=True, - doc="""Timeseries of the memory usage. - - Each list element holds the memory percentage and memory used in bytes for - that 5 minute interval. - """, - ) - - -class NetworkStatistics(Document): - rx_bytes: int = Attribute( - int, - readonly=True, - default=0, - doc="Total number of bytes received", - ) - tx_bytes: int = Attribute( - int, - readonly=True, - default=0, - doc="Total number of bytes transmitted", - ) - rx_dropped: int = Attribute( - int, - readonly=True, - default=0, - doc="Total number of packets dropped on receive", - ) - tx_dropped: int = Attribute( - int, - readonly=True, - default=0, - doc="Total number of packets dropped on transmit", - ) - rx_errors: int = Attribute( - int, - readonly=True, - default=0, - doc="Total number of receive errors", - ) - tx_errors: int = Attribute( - int, - readonly=True, - default=0, - doc="Total number of transmit errors", - ) - - -class JobStatistics(Document): - cpu: CpuStatistics = Attribute(CpuStatistics, readonly=True) - memory: MemoryStatistics = Attribute(MemoryStatistics, readonly=True) - network: NetworkStatistics = Attribute(NetworkStatistics, readonly=True) diff --git a/descarteslabs/core/compute/result.py b/descarteslabs/core/compute/result.py deleted file mode 100644 index a2658db4..00000000 --- a/descarteslabs/core/compute/result.py +++ /dev/null @@ -1,133 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 -from datetime import datetime -from typing import Any, Dict, List, Optional, Set, Union - -import shapely.geometry - -AnyDate = Union[datetime, str] -AnyExtraProperties = Dict[str, Union[str, float]] -AnyGeometry = Union[shapely.geometry.base.BaseGeometry, dict, str] -AnyResult = Union[bytes, "Serializable", Any] -AnyTags = Union[Set[str], List[str]] - - -class Serializable: - """Interface for serializing objects to bytes as - a result of a Function invocation.""" - - def serialize(self) -> bytes: - raise NotImplementedError() - - @classmethod - def deserialize(cls, data: bytes): - raise NotImplementedError() - - -class ComputeResult: - def __init__( - self, - value: Optional[AnyResult] = None, - description: Optional[str] = None, - expires: Optional[AnyDate] = None, - extra_properties: Optional[AnyExtraProperties] = None, - geometry: Optional[AnyGeometry] = None, - tags: Optional[AnyTags] = None, - ): - """Used to store the result of a compute function with additional attributes. - - When returned from a compute function, the result will be serialized and stored - in the Storage service with the given attributes. - - Notes - ----- - Results that are None and have no attributes will not be stored. If you want to - store a None result with attributes, you can do so by passing in a None value - as well as any attributes you wish to set. - - Examples - -------- - Result with raw binary data: - >>> from descarteslabs.compute import ComputeResult - >>> result = ComputeResult(value=b"result", description="result description") - - Null result with attributes: - >>> from descarteslabs.compute import ComputeResult - >>> result = ComputeResult(None, geometry=geometry, tags=["tag1", "tag2"]) - - Parameters - ---------- - value: bytes, Serializable, or Any - The resulting value of a compute function. - This can be any bytes, any JSON serializable type or any type implementing - the Serializable interface. - description: str or None - A description with further details on this result blob. The description can be - up to 80,000 characters and is used by :py:meth:`Search.find_text`. - expires: datetime, str, or None - The date the result should expire and be deleted from storage. - If a string is given, it must be in ISO 8601 format. - extra_properties: dict or None - A dictionary of up to 50 key/value pairs. - - The keys of this dictionary must be strings, and the values of this dictionary - can be strings or numbers. This allows for more structured custom metadata - to be associated with objects. - geometry: shapely.geometry.base.BaseGeometry, dict, str, or None - The geometry associated with the result if any. - tags: set, list, or None - The tags to set on the catalog object for the result. - """ - type_ = type(value) - - # The result is null and should not be stored if all attributes are null - # otherwise, we'll allow a user to store a null result with attributes. - self.isnull = ( - value is None - and description is None - and expires is None - and extra_properties is None - and geometry is None - and tags is None - ) - - # If the result is already bytes - if isinstance(value, bytes): - value = value - # If the user implements serialize - elif callable(getattr(value, "serialize", None)): - value = value.serialize() - - if not isinstance(value, bytes): - raise Exception( - f"Serializer on {type_} must return bytes got {type(value)}" - ) - # No specific serialize implementation try json - else: - try: - value = json.dumps(value).encode() - except Exception: - raise Exception( - "Unable to serialize result. Return value must be" - " JSON encodable or implement the Serializable interface" - ) from None - - self.value = value - self.description = description - self.expires = expires - self.extra_properties = extra_properties - self.geometry = geometry - self.tags = tags diff --git a/descarteslabs/core/compute/tests/__init__.py b/descarteslabs/core/compute/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/descarteslabs/core/compute/tests/base.py b/descarteslabs/core/compute/tests/base.py deleted file mode 100644 index ca641403..00000000 --- a/descarteslabs/core/compute/tests/base.py +++ /dev/null @@ -1,219 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 base64 -import json -import json as jsonlib -import os -import time -import urllib.parse -import uuid -from datetime import datetime, timezone -from unittest import TestCase - -import responses -from requests import PreparedRequest - -from descarteslabs.auth import Auth -from descarteslabs.compute import FunctionStatus, JobStatus - -from ..compute_client import ComputeClient - - -def make_uuid(): - return str(uuid.uuid4()) - - -class BaseTestCase(TestCase): - compute_url = "https://platform.dev.aws.descarteslabs.com/compute/v1" - - def setUp(self): - # make sure all of these are gone, so our Auth is only a JWT - for envvar in ( - "CLIENT_ID", - "DESCARTESLABS_CLIENT_ID", - "CLIENT_SECRET", - "DESCARTESLABS_CLIENT_SECRET", - "DESCARTESLABS_REFRESH_TOKEN", - "DESCARTESLABS_TOKEN", - ): - if envvar in os.environ: - del os.environ[envvar] - - responses.mock.assert_all_requests_are_fired = True - self.now = datetime.now(timezone.utc).replace(tzinfo=None) - - payload = ( - base64.b64encode( - json.dumps( - { - "aud": "client-id", - "exp": time.time() + 3600, - } - ).encode() - ) - .decode() - .strip("=") - ) - token = f"header.{payload}.signature" - auth = Auth(jwt_token=token, token_info_path=None) - ComputeClient.set_default_client(ComputeClient(auth=auth)) - - def tearDown(self): - responses.mock.assert_all_requests_are_fired = False - - def mock_credentials(self): - responses.add(responses.GET, f"{self.compute_url}/credentials") - - def mock_response(self, method, uri, json=None, status=200, **kwargs): - if json is not None: - kwargs["json"] = json - - responses.add( - method, - f"{self.compute_url}{uri}", - status=status, - **kwargs, - ) - - def mock_job_create(self, data: dict): - job = self.make_job(**data) - self.mock_response(responses.POST, "/jobs", json=job) - - def make_page( - self, - data: list, - page: int = 1, - page_size: int = 100, - page_cursor: str = None, - last_page: int = None, - ): - return { - "meta": { - "current_page": page, - "last_page": last_page or page, - "page_size": page_size, - "next_page": page + 1, - "page_cursor": page_cursor, - }, - "data": data, - } - - def make_job(self, **data): - job = { - "id": make_uuid(), - "function_id": make_uuid(), - "args": None, - "kwargs": None, - "creation_date": self.now.isoformat(), - "status": JobStatus.PENDING, - } - job.update(data) - - return job - - def make_function(self, **data): - if "cpus" in data: - data["cpus"] = float(data["cpus"]) - - if "memory" in data: - data["memory"] = int(data["memory"]) - - function = { - "id": make_uuid(), - "creation_date": self.now.isoformat(), - "status": FunctionStatus.AWAITING_BUNDLE, - } - function.update(data) - - return function - - def assert_url_called(self, uri, times=1, json=None, body=None, params=None): - if json and body: - raise ValueError("Using json and body together does not make sense") - - url = f"{self.compute_url}{uri}" - calls = [call for call in responses.calls if call.request.url.startswith(url)] - assert calls, f"No requests were made to uri: {uri}" - - data = json or body - matches = [] - calls_with_data = [] - calls_with_params = set() - - for call in calls: - request: PreparedRequest = call.request - - if json is not None: - request_data = jsonlib.loads(request.body) - else: - request_data = request.body - - if request_data: - calls_with_data.append(request.body.decode()) - - if params is not None: - request_params = {} - - for key, value in urllib.parse.parse_qsl( - urllib.parse.urlsplit(request.url).query - ): - try: - value = jsonlib.loads(value) - except jsonlib.JSONDecodeError: - value = value - - if key in request_params: - values = request_params[key] - - if not isinstance(values, list): - values = [values] - - values.append(value) - else: - values = value - - request_params[key] = values - - if request_params: - calls_with_params.add(jsonlib.dumps(request_params)) - else: - request_params = None - - if (data is None or request_data == data) and ( - params is None or request_params == params - ): - matches.append(call) - - count = len(matches) - msg = f"Expected {times} calls found {count} for {uri}" - - if data is not None: - msg += f" with data: {data}" - - if calls_with_data: - msg += "\n\nData:\n" + "\n".join(calls_with_data) - - if params is not None: - msg += f" with params: {params}" - - if calls_with_params: - msg += "\n\nParams:\n" + "\n".join(calls_with_params) - - assert count == times, msg - - # this was removed from python 3.12 unittest.TestCase - def assertDictContainsSubset(self, subset, dictionary): - for key, value in subset.items(): - assert key in dictionary and dictionary[key] == value diff --git a/descarteslabs/core/compute/tests/data/test_data1.csv b/descarteslabs/core/compute/tests/data/test_data1.csv deleted file mode 100644 index 59ade70d..00000000 --- a/descarteslabs/core/compute/tests/data/test_data1.csv +++ /dev/null @@ -1,3 +0,0 @@ -1,"foo" -2,"bar" -3,"baz" diff --git a/descarteslabs/core/compute/tests/data/test_data2.json b/descarteslabs/core/compute/tests/data/test_data2.json deleted file mode 100644 index 9732d385..00000000 --- a/descarteslabs/core/compute/tests/data/test_data2.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "1": "foo", - "2": "bar", - "3": "baz" -} diff --git a/descarteslabs/core/compute/tests/requirements.txt b/descarteslabs/core/compute/tests/requirements.txt deleted file mode 100644 index 43c1a3ff..00000000 --- a/descarteslabs/core/compute/tests/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -numpy==1.24.2 -pandas==2.0.0 -geopandas==0.12.2 -geojson==3.0.1 diff --git a/descarteslabs/core/compute/tests/test_function.py b/descarteslabs/core/compute/tests/test_function.py deleted file mode 100644 index 0c89aec5..00000000 --- a/descarteslabs/core/compute/tests/test_function.py +++ /dev/null @@ -1,1037 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 gzip -import itertools -import json -import os -import random -import string -import zipfile -from collections.abc import Iterable -from datetime import timezone - -import responses -from requests import PreparedRequest - -from descarteslabs import exceptions -from descarteslabs.compute import Function, FunctionStatus, Job, JobStatus - -from .base import BaseTestCase, make_uuid - -token = "header.e30.signature" - -s3_url = "https://bucket.region.amazonaws.com" - - -class TestCreateFunctionValidation(BaseTestCase): - def test_cpu_and_memory(self): - def test(): - pass - - tests = [ - ("1", "8Gb", 1.0, 8192), - ("8VCPUs", "8GB", 8.0, 8192), - ("0.25", "8Gi", 0.25, 8192), - (4, "512Mb", 4.0, 512), - (4, "512", 4.0, 512), - ] - - for cpus, memory, expected_cpus, expected_memory in tests: - fn = Function(test, cpus=cpus, memory=memory) - assert fn.cpus == expected_cpus - assert fn.memory == expected_memory - - with self.assertRaises(ValueError) as ctx: - Function(test, cpus=1, memory="100TB") - assert "Unable to convert memory to megabytes" in str(ctx.exception) - - -class FunctionTestCase(BaseTestCase): - def mock_function_create(self, data: dict): - function = self.make_function(**data) - self.mock_response( - responses.POST, - "/functions", - json={ - "function": function, - "bundle_upload_url": s3_url, - }, - ) - - response = {**function, "status": FunctionStatus.BUILDING} - self.mock_response( - responses.POST, - f"/functions/{function['id']}/bundle", - json=response, - ) - - def mock_s3_upload_url(self): - responses.add(responses.PUT, s3_url) - - def generate_function(self, **params): - function = { - "id": make_uuid(), - "creation_date": self.now.replace(tzinfo=timezone.utc).isoformat(), - "name": "".join( - random.choices(string.ascii_lowercase + string.digits, k=10) - ), - "image": "".join( - random.choices(string.ascii_lowercase + string.digits, k=10) - ), - "cpus": random.choice([1, 2]), - "memory": random.choice([512, 1024, 2048]), - "maximum_concurrency": random.randint(1, 10), - "timeout": random.randint(60, 900), - "retry_count": random.randint(1, 10), - "status": str(FunctionStatus.READY), - } - function.update(**params) - return function - - -class TestCreateFunction(FunctionTestCase): - def setUp(self): - super().setUp() - self.mock_credentials() - - @responses.activate - def test_requires_function(self): - with self.assertRaises(ValueError) as ctx: - fn = Function() - fn.save() - assert fn.state == "new" - assert "Function not provided" in str(ctx.exception) - - @responses.activate - def test_server_validation_errors(self): - self.mock_response( - responses.POST, - "/functions", - status=422, - json={ - "detail": "Validation error", - "errors": { - "image": ["Field is required", "Must be a valid docker image"] - }, - }, - ) - - def test(): - pass - - with self.assertRaises(exceptions.ValidationError) as ctx: - fn = Function(test) - fn.save() - assert fn.state == "new" - assert "image" in str(ctx.exception) - assert "Field is required" in str(ctx.exception) - assert "Must be a valid docker image" in str(ctx.exception) - - @responses.activate - def test_create_function(self): - params = { - "image": "python3.8", - "cpus": 1, - "memory": 8 * 1024, - "maximum_concurrency": 1, - "timeout": 60, - "retry_count": 1, - } - - self.mock_function_create(params) - self.mock_s3_upload_url() - - def test_compute_fn(a, b): - print(f"{a} to the power of {b}") - return a**b - - fn = Function(test_compute_fn, **params) - fn.save() - assert fn.state == "saved" - assert fn.name == "test_compute_fn" - assert fn.status == FunctionStatus.BUILDING - assert fn.creation_date == self.now.replace(tzinfo=timezone.utc) - assert fn.id - self.assertDictContainsSubset(params, fn.to_dict()) - - @responses.activate - def test_call_creates_function(self): - params = { - "image": "python3.8", - "cpus": 1, - "memory": 8 * 1024, - "maximum_concurrency": 1, - "timeout": 60, - "retry_count": 1, - } - - job_params = { - "args": [2, 8], - "kwargs": None, - } - - self.mock_job_create(job_params) - self.mock_function_create(params) - self.mock_s3_upload_url() - - def test_compute_fn(a, b): - print(f"{a} to the power of {b}") - return a**b - - fn = Function(test_compute_fn, **params) - job = fn(2, 8) - assert fn.state == "saved" - assert isinstance(job, Job) - assert job.id - assert job.status == JobStatus.PENDING - assert job.creation_date == self.now.replace(tzinfo=timezone.utc) - self.assertDictContainsSubset(job_params, job.to_dict()) - - @responses.activate - def test_bundle_lambda(self): - fn = Function(lambda: 1) - with self.assertRaises(ValueError) as ctx: - fn.save() - assert "Compute main function cannot be a lambda expression" in str( - ctx.exception - ) - - @responses.activate - def test_function_nested_globals(self): - params = { - "image": "python3.8", - "cpus": 1, - "memory": 8 * 1024, - "maximum_concurrency": 1, - "timeout": 60, - "retry_count": 1, - } - - def test_compute_fn(a, b): - def catch_this(): - print("failed to prevent nested global:", make_uuid()) - print(f"{a} to the power of {b}") - - return a**b - - fn = Function(test_compute_fn, **params) - with self.assertRaises(NameError) as ctx: - fn.save() - assert str(ctx.exception) == ( - "Illegal reference to one or more global variables in your function:" - " {'make_uuid'}" - ) - - -class TestFunctionBundle(FunctionTestCase): - def get_module_paths(self): - # Get the path to the module - - parts = ["descarteslabs"] + __file__.split("descarteslabs")[-1].strip( - "/" - ).split("/") - - # If the OS is Windows, the path will be different - if os.name == "nt": - parts = ["descarteslabs"] + __file__.split("descarteslabs")[-1].split("\\") - # remove empty elements - parts = [i for i in parts if i] - - # Construct the module path and module in dot notation - module_path = os.path.join(*parts[:-1]) - module_dot = ".".join(parts[:-1]) - - # Return the module path, module in dot notation and the parts of the path - return module_path, module_dot, parts - - def get_init_files(self, parts): - # Return list of paths to the __init__.py files - - init_files = [] - - # Don't need the last part of the path because it's the current file name - parts.remove(parts[-1]) - - # Construct list of paths to the __init__.py files for each sub-module - for i in range(len(parts)): - init_files.append("/".join(parts[: i + 1] + ["__init__.py"])) - - return init_files - - def test_function_bundling(self): - # Test with list of requirements, explicitly specified modules and explicitly - # specified data file - - module_path, module_dot, parts = self.get_module_paths() - - # Construct the module path with forward slashes, regardless of OS - # This is needed for the bundle check to work with ZipFile - module_path_forward_slash = "/".join(parts[:-1]) - - # Add the paths to the __init__.py files - # Must use forward slashes for the ZipFile check to work - files_to_be_bundled = [ - "__dlentrypoint__.py", - f"{module_path_forward_slash}/data/test_data1.csv", - f"{module_path_forward_slash}/test_function.py", - f"{module_path_forward_slash}/test_job.py", - "requirements.txt", - ] + self.get_init_files(parts) - - params = { - "image": "python3.8:latest", - "cpus": 1, - "memory": 8 * 1024, - "maximum_concurrency": 1, - "timeout": 60, - "retry_count": 1, - "requirements": [ - "descarteslabs[complete]>=2.0.3", - "geopandas==0.13.2", - ], - "include_modules": [ - f"{module_dot}.test_function", - f"{module_dot}.test_job", - ], - "include_data": [os.path.join(module_path, "data", "test_data1.csv")], - } - - def test_compute_fn(a, b): - print(f"{a} to the power of {b}") - return a**b - - fn = Function(test_compute_fn, **params) - bundle_path = fn._bundle() - - with zipfile.ZipFile(os.path.abspath(bundle_path)): - contents = zipfile.ZipFile(bundle_path).namelist() - - assert sorted(files_to_be_bundled) == sorted(set(contents)) - - # Check that the base.py file is not in the bundle since we didn't specify it - assert os.path.join(module_path, "base.py") not in contents - - def test_function_bundling_requirements_file(self): - # Test with requirements file, full module and all (*) contents of data folder - - module_path, module_dot, parts = self.get_module_paths() - - # Construct the module path with forward slashes, regardless of OS - # This is needed for the bundle check to work with ZipFile - module_path_forward_slash = "/".join(parts[:-1]) - - # Add the paths to the __init__.py files - # Must use forward slashes for the ZipFile check to work - files_to_be_bundled = [ - "__dlentrypoint__.py", - f"{module_path_forward_slash}/data/test_data1.csv", - f"{module_path_forward_slash}/data/test_data2.json", - f"{module_path_forward_slash}/base.py", - f"{module_path_forward_slash}/test_function.py", - f"{module_path_forward_slash}/test_job.py", - "requirements.txt", - ] + self.get_init_files(parts) - - params = { - "image": "python3.8:latest", - "cpus": 1, - "memory": 8 * 1024, - "maximum_concurrency": 1, - "timeout": 60, - "retry_count": 1, - "requirements": os.path.join(module_path, "requirements.txt"), - "include_modules": [module_dot], - "include_data": [os.path.join(module_path, "data", "*")], - } - - def test_compute_fn(a, b): - print(f"{a} to the power of {b}") - return a**b - - fn = Function(test_compute_fn, **params) - bundle_path = fn._bundle() - - with zipfile.ZipFile(os.path.abspath(bundle_path)): - contents = zipfile.ZipFile(bundle_path).namelist() - - contents = set(contents) - - # Remove the _main_tests.py file from the expected contents because it is - # dynamically generated - if os.path.join(module_path, "_main_tests.py") in contents: - contents.remove(os.path.join(module_path, "_main_tests.py")) - - assert sorted(files_to_be_bundled) == sorted(contents) - - -class TestListFunctions(FunctionTestCase): - @responses.activate - def test_list_function_empty(self): - self.mock_response(responses.GET, "/functions", json=self.make_page([])) - fn_iter = Function.list() - assert isinstance(fn_iter, Iterable) - assert list(fn_iter) == [] - - @responses.activate - def test_list_function(self): - self.mock_response( - responses.GET, - "/functions", - json=self.make_page( - [self.generate_function(), self.generate_function()], - page_cursor="page2", - ), - ) - self.mock_response( - responses.GET, - "/functions", - json=self.make_page([self.generate_function()]), - ) - functions = list(Function.list()) - - for function in functions: - assert isinstance(function, Function) - assert function.state == "saved" - - assert len(functions) == 3 - self.assert_url_called("/functions?page_size=100", 1) - self.assert_url_called("/functions?page_cursor=page2", 1) - - @responses.activate - def test_list_function_filters(self): - self.mock_response( - responses.GET, - "/functions", - json=self.make_page([self.generate_function()]), - ) - list(Function.list(status=FunctionStatus.BUILDING)) - self.assert_url_called("/functions?page_size=100&status=building", 1) - - list( - Function.list( - status=[FunctionStatus.BUILDING, FunctionStatus.AWAITING_BUNDLE] - ) - ) - self.assert_url_called( - "/functions?page_size=100&status=building&status=awaiting_bundle", 1 - ) - - -class TestGetFunction(FunctionTestCase): - @responses.activate - def test_get_missing(self): - self.mock_response(responses.GET, "/functions/missing-id", status=404) - - with self.assertRaises(exceptions.NotFoundError): - Function.get("missing-id") - - @responses.activate - def test_get_by_id(self): - expected = self.generate_function() - self.mock_response(responses.GET, "/functions/some-id", json=expected) - - fn = Function.get("some-id") - assert fn.state == "saved" - self.assertDictContainsSubset(expected, fn.to_dict()) - - -class TestFunction(FunctionTestCase): - @responses.activate - def test_build_log(self): - log_lines = ["test", "log"] - log = "\n".join(log_lines) - log_bytes = (log + "\n").encode() - buffer = gzip.compress(log_bytes) - self.mock_response(responses.GET, "/functions/some-id/log", body=buffer) - - fn = Function(id="some-id", saved=True) - fn.build_log() - - @responses.activate - def test_delete(self): - self.mock_response(responses.POST, "/jobs/delete", json=["1", "2", "3"]) - self.mock_response(responses.DELETE, "/functions/some-id", status=204) - - fn = Function(id="some-id", saved=True) - fn.delete() - self.assert_url_called("/functions/some-id") - assert fn._deleted is True - assert fn.state == "deleted" - - with self.assertRaises(AttributeError) as ctx: - fn.id - assert "Function has been deleted" in str(ctx.exception) - - @responses.activate - def test_delete_new(self): - fn = Function(id="some-id", saved=True) - fn._saved = False - - with self.assertRaises(ValueError) as ctx: - fn.delete() - assert "has not been saved" in str(ctx.exception) - assert fn._deleted is False - assert fn.state == "new" - - @responses.activate - def test_delete_no_jobs(self): - self.mock_response(responses.POST, "/jobs/delete", json=[]) - self.mock_response(responses.DELETE, "/functions/some-id", status=204) - - fn = Function(id="some-id", saved=True) - fn.delete() - self.assert_url_called("/functions/some-id") - assert fn._deleted is True - assert fn.state == "deleted" - - with self.assertRaises(AttributeError) as ctx: - fn.id - assert "Function has been deleted" in str(ctx.exception) - - @responses.activate - def test_delete_failed(self): - self.mock_response( - responses.DELETE, - "/functions/some-id", - status=400, - ) - self.mock_response(responses.POST, "/jobs/delete", json=[]) - - fn = Function(id="some-id", saved=True) - - with self.assertRaises(exceptions.BadRequestError): - fn.delete() - - assert fn._deleted is False - assert fn.state == "saved" - assert fn.id == "some-id" - - @responses.activate - def test_cancel_jobs(self): - self.mock_response( - responses.POST, - "/jobs/cancel", - json=[ - self.make_job( - function_id="some-id", args=[1, 2], status=JobStatus.CANCELED - ) - ], - ) - - fn = Function(id="some-id", saved=True) - jobs = fn.cancel_jobs() - assert isinstance(jobs, list) - job = jobs[0] - assert job.state == "saved" - assert job.id - assert job.args == [1, 2] - assert job.kwargs is None - assert job.creation_date == self.now.replace(tzinfo=timezone.utc) - assert job.function_id == "some-id" - assert job.status == JobStatus.CANCELED - self.assert_url_called( - "/jobs/cancel", - json={"filter": [{"op": "eq", "name": "function_id", "val": "some-id"}]}, - ) - - @responses.activate - def test_delete_jobs(self): - self.mock_response( - responses.POST, - "/jobs/delete", - json=["some-job-id"], - ) - - fn = Function(id="some-id", saved=True) - jobs = fn.delete_jobs() - assert isinstance(jobs, list) - assert jobs[0] == "some-job-id" - self.assert_url_called( - "/jobs/delete", - json={ - "filter": [{"op": "eq", "name": "function_id", "val": "some-id"}], - "delete_results": False, - }, - ) - - @responses.activate - def test_delete_jobs_delete_results(self): - self.mock_response( - responses.POST, - "/jobs/delete", - json=["some-job-id"], - ) - - fn = Function(id="some-id", saved=True) - jobs = fn.delete_jobs(delete_results=True) - assert isinstance(jobs, list) - assert jobs[0] == "some-job-id" - self.assert_url_called( - "/jobs/delete", - json={ - "filter": [{"op": "eq", "name": "function_id", "val": "some-id"}], - "delete_results": True, - }, - ) - - @responses.activate - def test_rerun(self): - self.mock_response( - responses.POST, - "/jobs/rerun", - json=[self.make_job(function_id="some-id", args=[1, 2])], - ) - - fn = Function(id="some-id", saved=True) - jobs = fn.rerun() - assert isinstance(jobs, list) - job = jobs[0] - assert job.state == "saved" - assert job.id - assert job.args == [1, 2] - assert job.kwargs is None - assert job.creation_date == self.now.replace(tzinfo=timezone.utc) - assert job.function_id == "some-id" - assert job.status == JobStatus.PENDING - self.assert_url_called( - "/jobs/rerun", - json={"filter": [{"op": "eq", "name": "function_id", "val": "some-id"}]}, - ) - - @responses.activate - def test_refresh(self): - params = { - "id": "some-id", - "name": "compute-test", - "image": "image:tag", - "cpus": 1, - "memory": 2048, - "maximum_concurrency": 1, - "retry_count": 1, - "status": FunctionStatus.READY, - "timeout": 60, - } - - self.mock_response( - responses.GET, - "/functions/some-id", - json=self.make_function(**params), - ) - - fn = Function(id="some-id", saved=True) - fn.refresh() - assert fn.state == "saved" - assert fn.creation_date == self.now.replace(tzinfo=timezone.utc) - self.assertDictContainsSubset(params, fn.to_dict()) - - @responses.activate - def test_map(self): - self.mock_response( - responses.POST, - "/jobs/bulk", - json=[self.make_job(args=[1, 2]), self.make_job(args=[3, 4])], - ) - - fn = Function(id="some-id", saved=True) - result = fn.map( - [[1, 2], [3, 4]], - kwargs=[{"first": 1, "second": 2}, {"first": 1.0, "second": 2.0}], - environments=[{"FOO": "BAR"}, {"FOO": "BAZ"}], - ) - assert result.is_success - assert len(result) == 2 - for job in result: - assert isinstance(job, Job) - - request = responses.calls[-1].request - request_json: dict = json.loads(request.body) - assert request_json.pop("reference_id") is not None - assert request_json == { - "bulk_args": [[1, 2], [3, 4]], - "bulk_kwargs": [{"first": 1, "second": 2}, {"first": 1.0, "second": 2.0}], - "bulk_environments": [{"FOO": "BAR"}, {"FOO": "BAZ"}], - "function_id": "some-id", - } - - @responses.activate - def test_map_batching(self): - def request_callback(request: PreparedRequest): - payload: dict = json.loads(request.body) - jobs = [] - - args = payload["bulk_args"] or [] - kwargs = payload["bulk_kwargs"] or [] - environments = payload["bulk_environments"] or [] - - for args, kwargs, envs in itertools.zip_longest(args, kwargs, environments): - jobs.append(self.make_job(args=args, kwargs=kwargs, environments=envs)) - - return (200, {}, json.dumps(jobs)) - - responses.add_callback( - responses.POST, - f"{self.compute_url}/jobs/bulk", - callback=request_callback, - ) - - fn = Function(id="some-id", saved=True) - result = fn.map([[n, n + 1] for n in range(3000)]) - assert result.is_success is True, result.errors - assert len(result) == 3000 - reference_ids = { - json.loads(call.request.body)["reference_id"] for call in responses.calls - } - assert len(reference_ids) == 3 - - @responses.activate - def test_map_errors(self): - global call_count - call_count = 0 - - def request_callback(request: PreparedRequest): - global call_count - call_count += 1 - - if call_count > 1: - return (400, {}, None) - - payload: dict = json.loads(request.body) - jobs = [] - - args = payload["bulk_args"] or [] - kwargs = payload["bulk_kwargs"] or [] - - for args, kwargs in itertools.zip_longest(args, kwargs): - jobs.append(self.make_job(args=args, kwargs=kwargs)) - - return (200, {}, json.dumps(jobs)) - - responses.add_callback( - responses.POST, - f"{self.compute_url}/jobs/bulk", - callback=request_callback, - ) - - fn = Function(id="some-id", saved=True) - result = fn.map( - [[1, 2], [3, 4]], - kwargs=[{"first": 1, "second": 2}, {"first": 1.0, "second": 2.0}], - batch_size=1, - ) - assert result.is_success is False - assert len(result) == 1 - assert len(result.errors) == 1 - assert result.errors[0].args == [[3, 4]] - assert result.errors[0].kwargs == [{"first": 1.0, "second": 2.0}] - assert len(responses.calls) == 2 - reference_ids = { - json.loads(call.request.body)["reference_id"] for call in responses.calls - } - assert len(reference_ids) == 2 - - @responses.activate - def test_map_deprecated(self): - self.mock_response( - responses.POST, - "/jobs/bulk", - json=[self.make_job(args=[1, 2]), self.make_job(args=[3, 4])], - ) - - fn = Function(id="some-id", saved=True) - fn.map( - [[1, 2], [3, 4]], - iterargs=[{"first": 1, "second": 2}, {"first": 1.0, "second": 2.0}], - ) - request = responses.calls[-1].request - request_json: dict = json.loads(request.body) - assert request_json.pop("reference_id") is not None - assert request_json == { - "bulk_args": [[1, 2], [3, 4]], - "bulk_kwargs": [{"first": 1, "second": 2}, {"first": 1.0, "second": 2.0}], - "bulk_environments": None, - "function_id": "some-id", - } - - @responses.activate - def test_map_with_generators(self): - self.mock_response( - responses.POST, - "/jobs/bulk", - json=[self.make_job(args=[1, 2]), self.make_job(args=[3, 4])], - ) - - fn = Function(id="some-id", saved=True) - - def generator(): - for i in range(2): - yield range(i * 2 + 1, i * 2 + 3) - - def kwgenerator(): - def inner(t): - yield ("first", t(1)) - yield ("second", t(2)) - - yield inner(int) - yield inner(float) - - def envgenerator(): - for i in range(2): - yield {"FOO": str(i)} - - fn.map( - generator(), - kwgenerator(), - environments=envgenerator(), - ) - request = responses.calls[-1].request - request_json: dict = json.loads(request.body) - assert request_json.pop("reference_id") is not None - assert request_json == { - "bulk_args": [[1, 2], [3, 4]], - "bulk_kwargs": [{"first": 1, "second": 2}, {"first": 1.0, "second": 2.0}], - "bulk_environments": [{"FOO": "0"}, {"FOO": "1"}], - "function_id": "some-id", - } - - @responses.activate - def test_map_with_tags(self): - self.mock_response( - responses.POST, - "/jobs/bulk", - json=[self.make_job(args=[1, 2]), self.make_job(args=[3, 4])], - ) - - fn = Function(id="some-id", saved=True) - fn.map( - [[1, 2], [3, 4]], - kwargs=[{"first": 1, "second": 2}, {"first": 1.0, "second": 2.0}], - tags=["tag1", "tag2"], - ) - request = responses.calls[-1].request - request_json: dict = json.loads(request.body) - assert request_json.pop("reference_id") is not None - assert request_json == { - "bulk_args": [[1, 2], [3, 4]], - "bulk_kwargs": [{"first": 1, "second": 2}, {"first": 1.0, "second": 2.0}], - "bulk_environments": None, - "function_id": "some-id", - "tags": ["tag1", "tag2"], - } - - @responses.activate - def test_as_completed(self): - self.mock_response( - responses.GET, - "/functions/some-id", - json=self.make_function( - id="some-id", - name="compute-test", - status=FunctionStatus.READY, - job_statistics={ - "pending": 1, - "running": 1, - }, - ), - ) - - self.mock_response( - responses.GET, - "/functions/some-id", - json=self.make_function( - id="some-id", - name="compute-test", - status=FunctionStatus.READY, - job_statistics={ - "pending": 0, - "running": 1, - }, - ), - ) - - self.mock_response( - responses.GET, - "/functions/some-id", - json=self.make_function( - id="some-id", - name="compute-test", - status=FunctionStatus.READY, - job_statistics={ - "pending": 0, - "running": 0, - }, - ), - ) - - job1 = Job( - **self.make_job( - id="job-1", - function_id="some-id", - args=[1, 2], - status=JobStatus.RUNNING, - saved=True, - ) - ) - job2 = Job( - **self.make_job( - id="job-2", - function_id="some-id", - args=[3, 4], - status=JobStatus.PENDING, - saved=True, - ) - ) - - self.mock_response( - responses.GET, - "/jobs", - json={ - "meta": {"page_cursor": None}, - "data": [ - self.make_job(id="job-1", args=[1, 2], status=JobStatus.SUCCESS) - ], - }, - ) - - self.mock_response( - responses.GET, - "/jobs", - json={ - "meta": {"page_cursor": None}, - "data": [], - }, - ) - - self.mock_response( - responses.GET, - "/jobs", - json={ - "meta": {"page_cursor": None}, - "data": [ - self.make_job(id="job-2", args=[3, 4], status=JobStatus.FAILURE) - ], - }, - ) - - fn = Function( - id="some-id", - name="compute-test", - status=FunctionStatus.READY, - saved=True, - ) - - completed = [ - job for job in fn.as_completed([job1, job2], timeout=10, interval=1) - ] - assert len(completed) == 2 - assert completed[0].id == "job-1" - assert completed[1].id == "job-2" - - @responses.activate - def test_wait_for_completion(self): - self.mock_response( - responses.GET, - "/functions/some-id", - json=self.make_function( - id="some-id", - name="compute-test", - status=FunctionStatus.READY, - job_statistics={ - "running": 1, - }, - ), - ) - self.mock_response( - responses.GET, - "/functions/some-id", - json=self.make_function( - id="some-id", - name="compute-test", - status=FunctionStatus.READY, - job_statistics={ - "running": 0, - }, - ), - ) - - fn = Function( - id="some-id", - name="compute-test", - status=FunctionStatus.BUILDING, - saved=True, - ) - fn.wait_for_completion(interval=0.1, timeout=5) - assert fn.state == "saved" - assert fn.status == FunctionStatus.READY - - @responses.activate - def test_wait_for_completion_timeout(self): - self.mock_response( - responses.GET, - "/functions/some-id", - json=self.make_function( - id="some-id", - name="compute-test", - status=FunctionStatus.READY, - job_statistics={ - "pending": 1, - "running": 1, - }, - ), - ) - - fn = Function( - id="some-id", - name="compute-test", - status=FunctionStatus.BUILDING, - saved=True, - ) - - with self.assertRaises(TimeoutError) as ctx: - fn.wait_for_completion(interval=0.1, timeout=5) - assert "did not complete before timeout" in str(ctx.exception) - - @responses.activate - def test_modified_patch(self): - self.mock_response( - responses.PATCH, - "/functions/some-id", - json=self.make_function(id="some-id", cpus=16, memory=16 * 1024), - ) - - fn = Function(id="some-id", saved=True) - fn.cpus = 16 - fn.memory = "16GB" - fn.save() - assert fn.state == "saved" - self.assert_url_called( - "/functions/some-id", json={"cpus": 16, "memory": 16 * 1024} - ) - - -class TestFunctionNoApi(BaseTestCase): - @responses.activate - def test_no_request_when_saved(self): - fn = Function(id="some-id", saved=True) - fn.save() - assert len(responses.calls) == 0 - - @responses.activate - def test_deleted(self): - fn = Function(id="some-id", saved=True) - fn._deleted = True - - with self.assertRaises(AttributeError) as ctx: - fn.save() - assert "Function has been deleted" in str(ctx.exception) diff --git a/descarteslabs/core/compute/tests/test_job.py b/descarteslabs/core/compute/tests/test_job.py deleted file mode 100644 index 0e8ac7c0..00000000 --- a/descarteslabs/core/compute/tests/test_job.py +++ /dev/null @@ -1,340 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 -from datetime import timezone -from unittest.mock import patch - -import responses - -from descarteslabs.catalog import Blob, StorageType -from descarteslabs.compute import Job, JobStatus - -from .base import BaseTestCase - - -class TestCreateJob(BaseTestCase): - @responses.activate - def test_create(self): - params = dict( - function_id="some-fn", - args=[1, 2], - kwargs={"key": "blah"}, - environment={"FOO": "BAR"}, - ) - self.mock_job_create(params) - - job = Job(**params) - assert job.state == "new" - job.save() - assert job.state == "saved" - assert job.id - assert job.creation_date == self.now.replace(tzinfo=timezone.utc) - self.assertDictContainsSubset(params, job.to_dict()) - - @responses.activate - def test_create_with_tags(self): - params = dict( - function_id="some-fn", - args=[1, 2], - kwargs={"key": "blah"}, - tags=["tag1", "tag2"], - ) - self.mock_job_create(params) - - job = Job(**params) - assert job.state == "new" - job.save() - assert job.state == "saved" - assert job.id - assert job.creation_date == self.now.replace(tzinfo=timezone.utc) - self.assertDictContainsSubset(params, job.to_dict()) - - -class TestListJobs(BaseTestCase): - @responses.activate - def test_list_jobs(self): - self.mock_response( - responses.GET, - "/jobs", - json=self.make_page( - [self.make_job(), self.make_job()], - page_cursor="page2", - ), - ) - self.mock_response( - responses.GET, - "/jobs", - json=self.make_page([self.make_job()]), - ) - jobs = list(Job.list()) - - for job in jobs: - assert isinstance(job, Job) - assert job.state == "saved" - assert job.to_dict() - - assert len(jobs) == 3 - self.assert_url_called("/jobs?page_size=100", 1) - self.assert_url_called("/jobs?page_cursor=page2", 1) - - @responses.activate - def test_list_jobs_compatible(self): - self.mock_response( - responses.GET, - "/jobs", - json=self.make_page([self.make_job()]), - ) - list(Job.list(status=JobStatus.PENDING, function_id="some-fn")) - self.assert_url_called( - "/jobs", - params={ - "page_size": 100, - "status": "pending", - "function_id": "some-fn", - }, - ) - - list(Job.list(status=[JobStatus.PENDING, JobStatus.RUNNING])) - self.assert_url_called( - "/jobs", - params={ - "page_size": 100, - "status": ["pending", "running"], - }, - ) - - -class TestJob(BaseTestCase): - @responses.activate - def test_get(self): - self.mock_response( - responses.GET, - "/jobs/some-id", - json=self.make_job( - id="some-id", - function_id="function-id", - args=[1, 2], - kwargs={"first": "blah", "second": "blah"}, - environment={"FOO": "BAR"}, - ), - ) - job = Job.get("some-id") - assert job.state == "saved" - assert job.to_dict() == { - "args": [1, 2], - "creation_date": self.now.replace(tzinfo=timezone.utc).isoformat(), - "error_reason": None, - "execution_count": None, - "exit_code": None, - "function_id": "function-id", - "id": "some-id", - "kwargs": {"first": "blah", "second": "blah"}, - "environment": {"FOO": "BAR"}, - "last_completion_date": None, - "last_execution_date": None, - "runtime": None, - "status": JobStatus.PENDING, - "statistics": None, - "tags": [], - "provisioning_time": None, - "pull_time": None, - } - - @responses.activate - def test_delete(self): - self.mock_response(responses.DELETE, "/jobs/some-id") - job = Job(id="some-id", function_id="some-fn", saved=True) - job.delete() - self.assert_url_called("/jobs/some-id") - assert job._deleted is True - assert job.state == "deleted" - - @responses.activate - def test_delete_new(self): - job = Job(id="some-id", function_id="some-fn", saved=True) - job._saved = False - - with self.assertRaises(ValueError) as ctx: - job.delete() - assert "has not been saved" in str(ctx.exception) - assert job._deleted is False - assert job.state == "new" - - @responses.activate - def test_delete_failed(self): - self.mock_response(responses.DELETE, "/jobs/some-id", status=400) - job = Job(id="some-id", function_id="some-fn", saved=True) - - with self.assertRaises(Exception): - job.delete() - - self.assert_url_called("/jobs/some-id") - assert job._deleted is False - assert job.state == "saved" - - @responses.activate - @patch.object(Job, "_get_result_namespace") - @patch.object(Blob, "get_data") - def test_result_json(self, mock_get_data, mock_get_result_namespace): - mock_get_data.return_value = json.dumps({"test": "blah"}).encode() - mock_get_result_namespace.return_value = "some-org:some-user" - job = Job( - id="some-id", function_id="some-fn", status=JobStatus.SUCCESS, saved=True - ) - assert job.result() == {"test": "blah"} - - @responses.activate - @patch.object(Job, "_get_result_namespace") - @patch.object(Blob, "get_data") - def test_result_float(self, mock_get_data, mock_get_result_namespace): - mock_get_data.return_value = json.dumps(15.68).encode() - mock_get_result_namespace.return_value = "some-org:some-user" - job = Job( - id="some-id", function_id="some-fn", status=JobStatus.SUCCESS, saved=True - ) - assert job.result() == 15.68 - - @responses.activate - @patch.object(Job, "_get_result_namespace") - @patch.object(Blob, "get_data") - def test_result_cast(self, mock_get_data, mock_get_result_namespace): - class CustomString: - @classmethod - def deserialize(cls, data: bytes): - return "custom" - - mock_get_data.return_value = "blah" - mock_get_result_namespace.return_value = "some-org:some-user" - job = Job( - id="some-id", function_id="some-fn", status=JobStatus.SUCCESS, saved=True - ) - assert job.result(CustomString) == "custom" - - with self.assertRaises(ValueError) as ctx: - job.result(bool) - assert "must implement Serializable" in str(ctx.exception) - - @patch.object(Job, "refresh") - def test_result_not_completed(self, mock_refresh): - job = Job( - id="some-id", function_id="some-fn", status=JobStatus.RUNNING, saved=True - ) - with self.assertRaises(ValueError) as ctx: - job.result() - assert "not completed" in str(ctx.exception) - assert mock_refresh.call_count == 1 - - @responses.activate - @patch.object(Job, "_get_result_namespace") - @patch.object(Blob, "get", Blob) - def test_result_blob(self, mock_get_result_namespace): - mock_get_result_namespace.return_value = "some-org:some-user" - job = Job( - id="some-id", function_id="some-fn", status=JobStatus.SUCCESS, saved=True - ) - blob = job.result_blob() - assert isinstance(blob, Blob) - assert blob.namespace == "some-org:some-user" - assert blob.name == "some-fn/some-id" - assert blob.storage_type == StorageType.COMPUTE - - @responses.activate - def test_log(self): - log_lines = ["test", "log"] - log = "\n".join( - [ - json.dumps({"date": self.now.isoformat() + "Z", "log": log}) - for log in log_lines - ] - ) - log_bytes = (log + "\n").encode() - self.mock_response(responses.GET, "/jobs/some-id/log", body=log_bytes) - - job = Job(id="some-id", function_id="some-fn", saved=True) - job.log() - - @responses.activate - def test_wait_for_complete(self): - self.mock_response( - responses.GET, - "/jobs/some-id", - json=self.make_job( - id="some-id", - function_id="function-id", - args=[1, 2], - kwargs={}, - ), - ) - self.mock_response( - responses.GET, - "/jobs/some-id", - json=self.make_job( - id="some-id", - function_id="function-id", - args=[1, 2], - kwargs={}, - status=JobStatus.SUCCESS, - ), - ) - job = Job(id="some-id", function_id="function-id", saved=True) - job.wait_for_completion(interval=0.1, timeout=5) - assert job.status == JobStatus.SUCCESS - - @responses.activate - def test_wait_for_complete_timeout(self): - self.mock_response( - responses.GET, - "/jobs/some-id", - json=self.make_job( - id="some-id", - function_id="function-id", - args=[1, 2], - kwargs={}, - ), - ) - job = Job(id="some-id", function_id="function-id", saved=True) - with self.assertRaises(TimeoutError): - job.wait_for_completion(interval=0.1, timeout=5) - - @responses.activate - def test_modified_patch(self): - self.mock_response( - responses.PATCH, - "/jobs/some-id", - json=self.make_job(id="some-id", function_id="some-fn", args=[1, 2]), - ) - - job = Job(id="some-id", function_id="some-fn", saved=True) - job.args = [1, 2] - job.save() - assert job.state == "saved" - self.assert_url_called("/jobs/some-id", json={"args": [1, 2]}) - - -class TestJobNoApi(BaseTestCase): - @responses.activate - def test_no_request_when_saved(self): - job = Job(id="some-id", function_id="some-fn", saved=True) - job.save() - assert len(responses.calls) == 0 - - @responses.activate - def test_deleted(self): - job = Job(id="some-id", function_id="some-fn", saved=True) - job._deleted = True - - with self.assertRaises(AttributeError) as ctx: - job.save() - assert "Job has been deleted" in str(ctx.exception) diff --git a/descarteslabs/core/geo/__init__.py b/descarteslabs/core/geo/__init__.py deleted file mode 100644 index 5cc20e17..00000000 --- a/descarteslabs/core/geo/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 ..common.geo import ( # noqa: F401 - __doc__, - AOI, - DLTile, - GeoContext, - XYZTile, -) diff --git a/descarteslabs/core/third_party/__init__.py b/descarteslabs/core/third_party/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/descarteslabs/core/third_party/boltons/__init__.py b/descarteslabs/core/third_party/boltons/__init__.py deleted file mode 100644 index cf5cad1a..00000000 --- a/descarteslabs/core/third_party/boltons/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 . import funcutils - -__all__ = ["funcutils"] diff --git a/descarteslabs/core/third_party/boltons/funcutils.py b/descarteslabs/core/third_party/boltons/funcutils.py deleted file mode 100644 index fa07406f..00000000 --- a/descarteslabs/core/third_party/boltons/funcutils.py +++ /dev/null @@ -1,936 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import print_function - -""" -Copyright (c) 2013, Mahmoud Hashemi - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - - * Redistributions in binary form must reproduce the above - copyright notice, this list of conditions and the following - disclaimer in the documentation and/or other materials provided - with the distribution. - - * The names of the contributors may not be used to endorse or - promote products derived from this software without specific - prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -""" - -""" -Forked from https://github.com/mahmoud/boltons/pull/217 -""" - -"""Python's built-in :mod:`functools` module builds several useful -utilities on top of Python's first-class function -support. ``funcutils`` generally stays in the same vein, adding to and -correcting Python's standard metaprogramming facilities. -""" -# flake8: noqa -import sys -import re -import inspect -import functools -import itertools -from types import MethodType, FunctionType - -try: - xrange - make_method = MethodType -except NameError: - # Python 3 - make_method = lambda desc, obj, obj_type: MethodType(desc, obj) # noqa - basestring = (str, bytes) # Python 3 compat - _IS_PY2 = False -else: - _IS_PY2 = True - - -try: - _inspect_iscoroutinefunction = inspect.iscoroutinefunction -except AttributeError: - # Python 3.4 - _inspect_iscoroutinefunction = lambda func: False # noqa - - -try: - from boltons.typeutils import make_sentinel - - NO_DEFAULT = make_sentinel(var_name="NO_DEFAULT") -except ImportError: - NO_DEFAULT = object() - - -def get_module_callables(mod, ignore=None): - """Returns two maps of (*types*, *funcs*) from *mod*, optionally - ignoring based on the :class:`bool` return value of the *ignore* - callable. *mod* can be a string name of a module in - :data:`sys.modules` or the module instance itself. - """ - if isinstance(mod, basestring): - mod = sys.modules[mod] - types, funcs = {}, {} - for attr_name in dir(mod): - if ignore and ignore(attr_name): - continue - try: - attr = getattr(mod, attr_name) - except Exception: - continue - try: - attr_mod_name = attr.__module__ - except AttributeError: - continue - if attr_mod_name != mod.__name__: - continue - if isinstance(attr, type): - types[attr_name] = attr - elif callable(attr): - funcs[attr_name] = attr - return types, funcs - - -def mro_items(type_obj): - """Takes a type and returns an iterator over all class variables - throughout the type hierarchy (respecting the MRO). - - >>> sorted(set([k for k, v in mro_items(int) if not k.startswith('__') and 'bytes' not in k and not callable(v)])) - ['denominator', 'imag', 'numerator', 'real'] - """ - # TODO: handle slots? - return itertools.chain.from_iterable(ct.__dict__.items() for ct in type_obj.__mro__) - - -def dir_dict(obj, raise_exc=False): - """Return a dictionary of attribute names to values for a given - object. Unlike ``obj.__dict__``, this function returns all - attributes on the object, including ones on parent classes. - """ - # TODO: separate function for handling descriptors on types? - ret = {} - for k in dir(obj): - try: - ret[k] = getattr(obj, k) - except Exception: - if raise_exc: - raise - return ret - - -def copy_function(orig, copy_dict=True): - """Returns a shallow copy of the function, including code object, - globals, closure, etc. - - >>> func = lambda: func - >>> func() is func - True - >>> func_copy = copy_function(func) - >>> func_copy() is func - True - >>> func_copy is not func - True - - Args: - orig (function): The function to be copied. Must be a - function, not just any method or callable. - copy_dict (bool): Also copy any attributes set on the function - instance. Defaults to ``True``. - """ - ret = FunctionType( - orig.__code__, - orig.__globals__, - name=orig.__name__, - argdefs=getattr(orig, "__defaults__", None), - closure=getattr(orig, "__closure__", None), - ) - if copy_dict: - ret.__dict__.update(orig.__dict__) - return ret - - -def partial_ordering(cls): - """Class decorator, similar to :func:`functools.total_ordering`, - except it is used to define `partial orderings`_ (i.e., it is - possible that *x* is neither greater than, equal to, or less than - *y*). It assumes the presence of the ``__le__()`` and ``__ge__()`` - method, but nothing else. It will not override any existing - additional comparison methods. - - .. _partial orderings: https://en.wikipedia.org/wiki/Partially_ordered_set - - >>> @partial_ordering - ... class MySet(set): - ... def __le__(self, other): - ... return self.issubset(other) - ... def __ge__(self, other): - ... return self.issuperset(other) - ... - >>> a = MySet([1,2,3]) - >>> b = MySet([1,2]) - >>> c = MySet([1,2,4]) - >>> b < a - True - >>> b > a - False - >>> b < c - True - >>> a < c - False - >>> c > a - False - """ - - def __lt__(self, other): - return self <= other and not self >= other - - def __gt__(self, other): - return self >= other and not self <= other - - def __eq__(self, other): - return self >= other and self <= other - - if not hasattr(cls, "__lt__"): - cls.__lt__ = __lt__ - if not hasattr(cls, "__gt__"): - cls.__gt__ = __gt__ - if not hasattr(cls, "__eq__"): - cls.__eq__ = __eq__ - - return cls - - -class InstancePartial(functools.partial): - """:class:`functools.partial` is a huge convenience for anyone - working with Python's great first-class functions. It allows - developers to curry arguments and incrementally create simpler - callables for a variety of use cases. - - Unfortunately there's one big gap in its usefulness: - methods. Partials just don't get bound as methods and - automatically handed a reference to ``self``. The - ``InstancePartial`` type remedies this by inheriting from - :class:`functools.partial` and implementing the necessary - descriptor protocol. There are no other differences in - implementation or usage. :class:`CachedInstancePartial`, below, - has the same ability, but is slightly more efficient. - - """ - - def __get__(self, obj, obj_type): - return make_method(self, obj, obj_type) - - -class CachedInstancePartial(functools.partial): - """The ``CachedInstancePartial`` is virtually the same as - :class:`InstancePartial`, adding support for method-usage to - :class:`functools.partial`, except that upon first access, it - caches the bound method on the associated object, speeding it up - for future accesses, and bringing the method call overhead to - about the same as non-``partial`` methods. - - See the :class:`InstancePartial` docstring for more details. - """ - - def __get__(self, obj, obj_type): - # These assignments could've been in __init__, but there was - # no simple way to do it without breaking one of PyPy or Py3. - self.__name__ = None - self.__doc__ = self.func.__doc__ - self.__module__ = self.func.__module__ - - name = self.__name__ - if name is None: - for k, v in mro_items(obj_type): - if v is self: - self.__name__ = name = k - if obj is None: - return make_method(self, obj, obj_type) - try: - # since this is a data descriptor, this block - # is probably only hit once (per object) - return obj.__dict__[name] - except KeyError: - obj.__dict__[name] = ret = make_method(self, obj, obj_type) - return ret - - -partial = CachedInstancePartial - - -# # # -# # # Function builder -# # # - - -def wraps(func, injected=None, expected=None, **kw): - """Modeled after the built-in :func:`functools.wraps`, this function is - used to make your decorator's wrapper functions reflect the - wrapped function's: - - * Name - * Documentation - * Module - * Signature - - The built-in :func:`functools.wraps` copies the first three, but - does not copy the signature. This version of ``wraps`` can copy - the inner function's signature exactly, allowing seamless usage - and :mod:`introspection `. Usage is identical to the - built-in version:: - - >>> from boltons.funcutils import wraps - >>> - >>> def print_return(func): - ... @wraps(func) - ... def wrapper(*args, **kwargs): - ... ret = func(*args, **kwargs) - ... print(ret) - ... return ret - ... return wrapper - ... - >>> @print_return - ... def example(): - ... '''docstring''' - ... return 'example return value' - >>> - >>> val = example() - example return value - >>> example.__name__ - 'example' - >>> example.__doc__ - 'docstring' - - In addition, the boltons version of wraps supports modifying the - outer signature based on the inner signature. By passing a list of - *injected* argument names, those arguments will be removed from - the outer wrapper's signature, allowing your decorator to provide - arguments that aren't passed in. - - Args: - - func (function): The callable whose attributes are to be copied. - injected (list): An optional list of argument names which - should not appear in the new wrapper's signature. - expected (list): An optional list of argument names (or (name, - default) pairs) representing new arguments introduced by - the wrapper (the opposite of *injected*). See - :meth:`FunctionBuilder.add_arg()` for more details. - update_dict (bool): Whether to copy other, non-standard - attributes of *func* over to the wrapper. Defaults to True. - inject_to_varkw (bool): Ignore missing arguments when a - ``**kwargs``-type catch-all is present. Defaults to True. - - For more in-depth wrapping of functions, see the - :class:`FunctionBuilder` type, on which wraps was built. - - """ - if injected is None: - injected = [] - elif isinstance(injected, basestring): - injected = [injected] - else: - injected = list(injected) - - expected_items = _parse_wraps_expected(expected) - - if isinstance(func, (classmethod, staticmethod)): - raise TypeError( - "wraps does not support wrapping classmethods and" - " staticmethods, change the order of wrapping to" - " wrap the underlying function: %r" % (getattr(func, "__func__", None),) - ) - - update_dict = kw.pop("update_dict", True) - inject_to_varkw = kw.pop("inject_to_varkw", True) - if kw: - raise TypeError("unexpected kwargs: %r" % kw.keys()) - - fb = FunctionBuilder.from_func(func) - for arg in injected: - try: - fb.remove_arg(arg) - except MissingArgument: - if inject_to_varkw and fb.varkw is not None: - continue # keyword arg will be caught by the varkw - raise - - for arg, default in expected_items: - fb.add_arg(arg, default) # may raise ExistingArgument - - if fb.is_async: - fb.body = "return await _call(%s)" % fb.get_invocation_str() - else: - fb.body = "return _call(%s)" % fb.get_invocation_str() - - def wrapper_wrapper(wrapper_func): - execdict = dict(_call=wrapper_func, _func=func) - fully_wrapped = fb.get_func(execdict, with_dict=update_dict) - fully_wrapped.__wrapped__ = func # ref to the original function (#115) - - return fully_wrapped - - return wrapper_wrapper - - -def _parse_wraps_expected(expected): - # expected takes a pretty powerful argument, it's processed - # here. admittedly this would be less trouble if I relied on - # OrderedDict (there's an impl of that in the commit history if - # you look - if expected is None: - expected = [] - elif isinstance(expected, basestring): - expected = [(expected, NO_DEFAULT)] - - expected_items = [] - try: - expected_iter = iter(expected) - except TypeError as e: - raise ValueError( - '"expected" takes string name, sequence of string names,' - " iterable of (name, default) pairs, or a mapping of " - " {name: default}, not %r (got: %r)" % (expected, e) - ) - for argname in expected_iter: - if isinstance(argname, basestring): - # dict keys and bare strings - try: - default = expected[argname] - except TypeError: - default = NO_DEFAULT - else: - # pairs - try: - argname, default = argname - except (TypeError, ValueError): - raise ValueError( - '"expected" takes string name, sequence of string names,' - " iterable of (name, default) pairs, or a mapping of " - " {name: default}, not %r" - ) - if not isinstance(argname, basestring): - raise ValueError( - 'all "expected" argnames must be strings, not %r' % (argname,) - ) - - expected_items.append((argname, default)) - - return expected_items - - -class FunctionBuilder(object): - """The FunctionBuilder type provides an interface for programmatically - creating new functions, either based on existing functions or from - scratch. - - Values are passed in at construction or set as attributes on the - instance. For creating a new function based of an existing one, - see the :meth:`~FunctionBuilder.from_func` classmethod. At any - point, :meth:`~FunctionBuilder.get_func` can be called to get a - newly compiled function, based on the values configured. - - >>> fb = FunctionBuilder('return_five', doc='returns the integer 5', - ... body='return 5') - >>> f = fb.get_func() - >>> f() - 5 - >>> fb.varkw = 'kw' - >>> f_kw = fb.get_func() - >>> f_kw(ignored_arg='ignored_val') - 5 - - Note that function signatures themselves changed quite a bit in - Python 3, so several arguments are only applicable to - FunctionBuilder in Python 3. Except for *name*, all arguments to - the constructor are keyword arguments. - - Args: - name (str): Name of the function. - doc (str): `Docstring`_ for the function, defaults to empty. - module (str): Name of the module from which this function was - imported. Defaults to None. - body (str): String version of the code representing the body - of the function. Defaults to ``'pass'``, which will result - in a function which does nothing and returns ``None``. - args (list): List of argument names, defaults to empty list, - denoting no arguments. - varargs (str): Name of the catch-all variable for positional - arguments. E.g., "args" if the resultant function is to have - ``*args`` in the signature. Defaults to None. - varkw (str): Name of the catch-all variable for keyword - arguments. E.g., "kwargs" if the resultant function is to have - ``**kwargs`` in the signature. Defaults to None. - defaults (dict): A mapping of argument names to default values. - kwonlyargs (list): Argument names which are only valid as - keyword arguments. **Python 3 only.** - kwonlydefaults (dict): A mapping, same as normal *defaults*, - but only for the *kwonlyargs*. **Python 3 only.** - annotations (dict): Mapping of type hints and so - forth. **Python 3 only.** - filename (str): The filename that will appear in - tracebacks. Defaults to "boltons.funcutils.FunctionBuilder". - indent (int): Number of spaces with which to indent the - function *body*. Values less than 1 will result in an error. - dict (dict): Any other attributes which should be added to the - functions compiled with this FunctionBuilder. - - All of these arguments are also made available as attributes which - can be mutated as necessary. - - .. _Docstring: https://en.wikipedia.org/wiki/Docstring#Python - - """ - - if _IS_PY2: - _argspec_defaults = { - "args": list, - "varargs": lambda: None, - "varkw": lambda: None, - "defaults": lambda: None, - } - - @classmethod - def _argspec_to_dict(cls, f): - args, varargs, varkw, defaults = inspect.getargspec(f) - return { - "args": args, - "varargs": varargs, - "varkw": varkw, - "defaults": defaults, - } - - else: - _argspec_defaults = { - "args": list, - "varargs": lambda: None, - "varkw": lambda: None, - "defaults": lambda: None, - "kwonlyargs": list, - "kwonlydefaults": dict, - "annotations": dict, - } - - @classmethod - def _argspec_to_dict(cls, f): - argspec = inspect.getfullargspec(f) - return dict( - (attr, getattr(argspec, attr)) for attr in cls._argspec_defaults - ) - - _defaults = { - "doc": str, - "dict": dict, - "is_async": lambda: False, - "module": lambda: None, - "body": lambda: "pass", - "indent": lambda: 4, - "annotations": dict, - "filename": lambda: "boltons.funcutils.FunctionBuilder", - } - - _defaults.update(_argspec_defaults) - - _compile_count = itertools.count() - - def __init__(self, name, **kw): - self.name = name - for a, default_factory in self._defaults.items(): - val = kw.pop(a, None) - if val is None: - val = default_factory() - setattr(self, a, val) - - if kw: - raise TypeError("unexpected kwargs: %r" % kw.keys()) - return - - # def get_argspec(self): # TODO - - if _IS_PY2: - - def get_sig_str(self, with_annotations=True): - """Return function signature as a string. - - with_annotations is ignored on Python 2. On Python 3 signature - will omit annotations if it is set to False. - """ - return inspect.formatargspec(self.args, self.varargs, self.varkw, []) - - def get_invocation_str(self): - return inspect.formatargspec(self.args, self.varargs, self.varkw, [])[1:-1] - - else: - - def get_sig_str(self, with_annotations=True): - """Return function signature as a string. - - with_annotations is ignored on Python 2. On Python 3 signature - will omit annotations if it is set to False. - """ - if with_annotations: - annotations = self.annotations - else: - annotations = {} - return formatargspec( - self.args, - self.varargs, - self.varkw, - [], - self.kwonlyargs, - {}, - annotations, - ) - - _KWONLY_MARKER = re.compile( - r""" - \* # a star - \s* # followed by any amount of whitespace - , # followed by a comma - \s* # followed by any amount of whitespace - """, - re.VERBOSE, - ) - - def get_invocation_str(self): - kwonly_pairs = None - formatters = {} - if self.kwonlyargs: - kwonly_pairs = dict((arg, arg) for arg in self.kwonlyargs) - formatters["formatvalue"] = lambda value: "=" + value - - sig = formatargspec( - self.args, - self.varargs, - self.varkw, - [], - kwonly_pairs, - kwonly_pairs, - {}, - **formatters - ) - sig = self._KWONLY_MARKER.sub("", sig) - return sig[1:-1] - - @classmethod - def from_func(cls, func): - """Create a new FunctionBuilder instance based on an existing - function. The original function will not be stored or - modified. - """ - # TODO: copy_body? gonna need a good signature regex. - # TODO: might worry about __closure__? - if not callable(func): - raise TypeError("expected callable object, not %r" % (func,)) - - kwargs = { - "name": func.__name__, - "doc": func.__doc__, - "module": func.__module__, - "annotations": getattr(func, "__annotations__", {}), - "dict": getattr(func, "__dict__", {}), - } - - kwargs.update(cls._argspec_to_dict(func)) - - if _inspect_iscoroutinefunction(func): - kwargs["is_async"] = True - - return cls(**kwargs) - - def get_func(self, execdict=None, add_source=True, with_dict=True): - """Compile and return a new function based on the current values of - the FunctionBuilder. - - Args: - execdict (dict): The dictionary representing the scope in - which the compilation should take place. Defaults to an empty - dict. - add_source (bool): Whether to add the source used to a - special ``__source__`` attribute on the resulting - function. Defaults to True. - with_dict (bool): Add any custom attributes, if - applicable. Defaults to True. - - To see an example of usage, see the implementation of - :func:`~boltons.funcutils.wraps`. - """ - execdict = execdict or {} - body = self.body or self._default_body - - tmpl = "def {name}{sig_str}:" - tmpl += "\n{body}" - - if self.is_async: - tmpl = "async " + tmpl - - body = _indent(self.body, " " * self.indent) - - name = self.name.replace("<", "_").replace(">", "_") # lambdas - src = tmpl.format( - name=name, - sig_str=self.get_sig_str(with_annotations=False), - doc=self.doc, - body=body, - ) - self._compile(src, execdict) - func = execdict[name] - - func.__name__ = self.name - func.__doc__ = self.doc - func.__defaults__ = self.defaults - if not _IS_PY2: - func.__kwdefaults__ = self.kwonlydefaults - func.__annotations__ = self.annotations - - if with_dict: - func.__dict__.update(self.dict) - func.__module__ = self.module - # TODO: caller module fallback? - - if add_source: - func.__source__ = src - - return func - - def get_defaults_dict(self): - """Get a dictionary of function arguments with defaults and the - respective values. - """ - ret = dict( - reversed(list(zip(reversed(self.args), reversed(self.defaults or [])))) - ) - kwonlydefaults = getattr(self, "kwonlydefaults", None) - if kwonlydefaults: - ret.update(kwonlydefaults) - return ret - - def get_arg_names(self, only_required=False): - arg_names = tuple(self.args) + tuple(getattr(self, "kwonlyargs", ())) - if only_required: - defaults_dict = self.get_defaults_dict() - arg_names = tuple([an for an in arg_names if an not in defaults_dict]) - return arg_names - - if _IS_PY2: - - def add_arg(self, arg_name, default=NO_DEFAULT): - "Add an argument with optional *default* (defaults to ``funcutils.NO_DEFAULT``)." - if arg_name in self.args: - raise ExistingArgument( - "arg %r already in func %s arg list" % (arg_name, self.name) - ) - self.args.append(arg_name) - if default is not NO_DEFAULT: - self.defaults = (self.defaults or ()) + (default,) - return - - else: - - def add_arg(self, arg_name, default=NO_DEFAULT, kwonly=False): - """Add an argument with optional *default* (defaults to - ``funcutils.NO_DEFAULT``). Pass *kwonly=True* to add a - keyword-only argument - """ - if arg_name in self.args: - raise ExistingArgument( - "arg %r already in func %s arg list" % (arg_name, self.name) - ) - if arg_name in self.kwonlyargs: - raise ExistingArgument( - "arg %r already in func %s kwonly arg list" % (arg_name, self.name) - ) - if not kwonly: - self.args.append(arg_name) - if default is not NO_DEFAULT: - self.defaults = (self.defaults or ()) + (default,) - else: - self.kwonlyargs.append(arg_name) - if default is not NO_DEFAULT: - self.kwonlydefaults[arg_name] = default - return - - def remove_arg(self, arg_name): - """Remove an argument from this FunctionBuilder's argument list. The - resulting function will have one less argument per call to - this function. - - Args: - arg_name (str): The name of the argument to remove. - - Raises a :exc:`ValueError` if the argument is not present. - - """ - args = self.args - d_dict = self.get_defaults_dict() - try: - args.remove(arg_name) - except ValueError: - try: - self.kwonlyargs.remove(arg_name) - except (AttributeError, ValueError): - # py2, or py3 and missing from both - exc = MissingArgument( - "arg %r not found in %s argument list:" - " %r" % (arg_name, self.name, args) - ) - exc.arg_name = arg_name - raise exc - else: - self.kwonlydefaults.pop(arg_name, None) - else: - d_dict.pop(arg_name, None) - self.defaults = tuple([d_dict[a] for a in args if a in d_dict]) - return - - def _compile(self, src, execdict): - filename = "<%s-%d>" % (self.filename, next(self._compile_count)) - try: - code = compile(src, filename, "single") - exec(code, execdict) - except Exception: - raise - return execdict - - -class MissingArgument(ValueError): - pass - - -class ExistingArgument(ValueError): - pass - - -def _indent(text, margin, newline="\n", key=bool): - "based on boltons.strutils.indent" - indented_lines = [ - (margin + line if key(line) else line) for line in text.splitlines() - ] - return newline.join(indented_lines) - - -try: - from functools import total_ordering # 2.7+ -except ImportError: - # python 2.6 - def total_ordering(cls): - """Class decorator that fills in missing comparators/ordering - methods. Backport of :func:`functools.total_ordering` to work - with Python 2.6. - - Code from http://code.activestate.com/recipes/576685/ - """ - convert = { - "__lt__": [ - ("__gt__", lambda self, other: not (self < other or self == other)), - ("__le__", lambda self, other: self < other or self == other), - ("__ge__", lambda self, other: not self < other), - ], - "__le__": [ - ("__ge__", lambda self, other: not self <= other or self == other), - ("__lt__", lambda self, other: self <= other and not self == other), - ("__gt__", lambda self, other: not self <= other), - ], - "__gt__": [ - ("__lt__", lambda self, other: not (self > other or self == other)), - ("__ge__", lambda self, other: self > other or self == other), - ("__le__", lambda self, other: not self > other), - ], - "__ge__": [ - ("__le__", lambda self, other: (not self >= other) or self == other), - ("__gt__", lambda self, other: self >= other and not self == other), - ("__lt__", lambda self, other: not self >= other), - ], - } - roots = set(dir(cls)) & set(convert) - if not roots: - raise ValueError( - "must define at least one ordering operation:" " < > <= >=" - ) - root = max(roots) # prefer __lt__ to __le__ to __gt__ to __ge__ - for opname, opfunc in convert[root]: - if opname not in roots: - opfunc.__name__ = opname - opfunc.__doc__ = getattr(int, opname).__doc__ - setattr(cls, opname, opfunc) - return cls - - -if not _IS_PY2: - # copied from python 3 implementation without deprecation warning - def formatargspec( - args, - varargs=None, - varkw=None, - defaults=None, - kwonlyargs=(), - kwonlydefaults={}, - annotations={}, - formatarg=str, - formatvarargs=lambda name: "*" + name, - formatvarkw=lambda name: "**" + name, - formatvalue=lambda value: "=" + repr(value), - formatreturns=lambda text: " -> " + text, - formatannotation=inspect.formatannotation, - ): - """Format an argument spec from the values returned by getfullargspec. - - The first seven arguments are (args, varargs, varkw, defaults, - kwonlyargs, kwonlydefaults, annotations). The other five arguments - are the corresponding optional formatting functions that are called to - turn names and values into strings. The last argument is an optional - function to format the sequence of arguments. - - Deprecated since Python 3.5: use the `signature` function and `Signature` - objects. - """ - - def formatargandannotation(arg): - result = formatarg(arg) - if arg in annotations: - result += ": " + formatannotation(annotations[arg]) - return result - - specs = [] - if defaults: - firstdefault = len(args) - len(defaults) - for i, arg in enumerate(args): - spec = formatargandannotation(arg) - if defaults and i >= firstdefault: - spec = spec + formatvalue(defaults[i - firstdefault]) - specs.append(spec) - if varargs is not None: - specs.append(formatvarargs(formatargandannotation(varargs))) - else: - if kwonlyargs: - specs.append("*") - if kwonlyargs: - for kwonlyarg in kwonlyargs: - spec = formatargandannotation(kwonlyarg) - if kwonlydefaults and kwonlyarg in kwonlydefaults: - spec += formatvalue(kwonlydefaults[kwonlyarg]) - specs.append(spec) - if varkw is not None: - specs.append(formatvarkw(formatargandannotation(varkw))) - result = "(" + ", ".join(specs) + ")" - if "return" in annotations: - result += formatreturns(formatannotation(annotations["return"])) - return result - - -# end funcutils.py diff --git a/descarteslabs/core/utils/__init__.py b/descarteslabs/core/utils/__init__.py deleted file mode 100644 index 46952e41..00000000 --- a/descarteslabs/core/utils/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 ..common.display import display, save_image # noqa: F401 -from ..common.dotdict import DotDict, DotList # noqa: F401 -from ..common.property_filtering import Properties # noqa: F401 diff --git a/descarteslabs/core/vector/README.md b/descarteslabs/core/vector/README.md deleted file mode 100644 index 45b58c8f..00000000 --- a/descarteslabs/core/vector/README.md +++ /dev/null @@ -1,138 +0,0 @@ -# Vector ⥂ - -> "Vector! That's me, 'cause I'm committing crimes with both direction and magnitude. Oh, yeah!" — "Vector", [Despicable Me](https://en.wikipedia.org/wiki/Despicable_Me) - -_Vector_ is a **catalog for vector data**. It enables users to store, query, and display vector data — which includes everything from fault lines to thermal anomalies to material spectra. - -Formal documentation for this library is available under the [Descartes Labs API Documentation](https://docs.descarteslabs.com/api.html). Below is a very simple example to get you started with uploading and querying data to and from Vector. - -To start using the Vector client, first import it: - -```python -from descarteslabs.vector import Table, TableOptions, models, properties as p -import json -import geopandas as gpd -import ipyleaflet -``` - -Next, we can create a Vector Table by executing the following Python code: - -```python -class CustomModel(models.PolygonBaseModel): - name: str - -table = Table.create(product_id="my-favourite-shapes", name="My favourite shapes", description="This is just one of my favorite shapes", model=CustomModel) -``` - -This product will contain only the most appealing shapes. Vector Tables consist of features, which themselves consist of a geometry and attributes. -Let's create a feature that contains a triangle geometry, and give it a name by adding a property: - -```python -geojson = { - "type": "FeatureCollection", - "features": [ - { - "type": "Feature", - "properties": { - "name": "The coldest lake" - }, - "geometry": { - "coordinates": [ - [ - [ - -110.40972704675957, - 54.464841702835145 - ], - [ - -109.69305293767921, - 54.41100418392534 - ], - [ - -110.00659786040178, - 54.75020711805794 - ], - [ - -110.40972704675957, - 54.464841702835145 - ] - ] - ], - "type": "Polygon" - } - } - ] -} -``` - -...and then add it to the Vector table we just created by executing the following Python code: - -```python -gdf = gpd.GeoDataFrame.from_features(geojson["features"], crs="EPSG:4326") -table.add(gdf) -``` - -We can retrieve this feature by querying the product in a few different ways. First, by its name: - -```python -table.options.property_filter = p.name == "The coldest lake" -gdf = table.collect() -``` - -...and second, by an AOI which intersects with the geometry of our feature. The AOI is defined as a GeoJSON geometry: - -```python -# Define the AOI... -aoi = { - "coordinates": [ - [ - [ - -110.7588318166646, - 54.941440389459956 - ], - [ - -110.7588318166646, - 54.14613049153121 - ], - [ - -109.34594259898329, - 54.14613049153121 - ], - [ - -109.34594259898329, - 54.941440389459956 - ], - [ - -110.7588318166646, - 54.941440389459956 - ] - ] - ], - "type": "Polygon" -} - -# ...and query the product -table.reset_options() -table.options.aoi = aoi -gdf = table.collect() -``` - -Since, in this case, our feature has a geometry, we can also visualise it on a map! Let's do this now, using `ipyleaflet`: - -```python -# Instantiate and configure the ipyleaflet Map -m = ipyleaflet.Map() -m.center = 54.549829, -110.060936 -m.zoom = 9 - -# Display the map -display(m) - -# Visualize the vector tile layer on the map -table.visualize("My favourite shapes", m) -``` - -Which should yield the feature we just created, outlining the coldest lake on Earth: - -![The coldest lake](https://raw.githubusercontent.com/descarteslabs/descarteslabs-python/master/vector/images/the-coldest-lake.png) - -The Vector service also provides for more advanced product and feature management and querying. You can read more about what can be done with Vector in the [Descartes Labs API Documentation](https://docs.descarteslabs.com/api.html). diff --git a/descarteslabs/core/vector/__init__.py b/descarteslabs/core/vector/__init__.py deleted file mode 100644 index 20580fbb..00000000 --- a/descarteslabs/core/vector/__init__.py +++ /dev/null @@ -1,30 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 ..common.property_filtering import Properties - -from ..common.vector import models -from .vector import Feature, Table, TableOptions -from .vector_client import VectorClient - -properties = Properties() - -__all__ = [ - "Feature", - "models", - "properties", - "Table", - "TableOptions", - "VectorClient", -] diff --git a/descarteslabs/core/vector/features.py b/descarteslabs/core/vector/features.py deleted file mode 100644 index 45af1e79..00000000 --- a/descarteslabs/core/vector/features.py +++ /dev/null @@ -1,493 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 __future__ import annotations - -from copy import deepcopy -from enum import Enum -from io import BytesIO -from typing import List, Optional, Tuple, Union - -import geopandas as gpd -import pandas as pd - -from ..client.deprecation import deprecate -from ..common.property_filtering import Properties - -from .util import response_to_dataframe -from .vector_client import VectorClient - - -TYPES = (gpd.GeoDataFrame, pd.DataFrame) - - -class Statistic(str, Enum): - """ - A class for aggregate statistics. - """ - - COUNT = "COUNT" - SUM = "SUM" - MIN = "MIN" - MAX = "MAX" - MEAN = "MEAN" - - -@deprecate(removed=["is_spatial"]) -def add( - product_id: str, - dataframe: Union[gpd.GeoDataFrame, pd.DataFrame], - is_spatial: Optional[bool] = None, - client: Optional[VectorClient] = None, -) -> Union[gpd.GeoDataFrame, pd.DataFrame]: - """ - Add features to a Vector Table. - - Parameters - ---------- - product_id : str - Product ID of the Vector Table. - dataframe : Union[gpd.GeoDataFrame, pd.DataFrame] - A GeoPandas GeoDataFrame or a Pandas DataFrame to add. - client : VectorClient, optional - Client to use for requests. If not provided, the default client will be used. - - Returns - ------- - Union[gpd.GeoDataFrame, pd.DataFrame] - """ - - buffer = BytesIO() - dataframe.to_parquet(buffer, index=False) - buffer.seek(0) - - files = {"file": ("vector.parquet", buffer, "application/octet-stream")} - - if client is None: - client = VectorClient.get_default_client() - - # The client session normally hardwires the content type. - # We need to remove it for this request so that the multipart - # support in urllib3 can set it appropriately. - session = client.session - content_type = session.headers.get("content-type") - if content_type is not None: - del session.headers["content-type"] - try: - response = session.post( - f"/products/{product_id}/featuresv2", - files=files, - timeout=(client.CONNECT_TIMEOUT, client.READ_TIMEOUT), - ) - finally: - if content_type is not None: - session.headers.update({"content-type": content_type}) - - return response_to_dataframe(response=response) - - -def query( - product_id: str, - property_filter: Properties = None, - aoi: dict = None, - columns: list = None, - client: Optional[VectorClient] = None, -) -> Union[gpd.GeoDataFrame, pd.DataFrame]: - """ - Query features in a Vector Table. - - Parameters - ---------- - product_id : str - Product ID of the Vector Table. - property_filter : Properties, optional - Property filters to filter the product with. - aoi : dict, optional - A GeoJSON Feature to filter the vector product with. - columns : list, optional - Optional list of column names. - client : VectorClient, optional - Client to use for requests. If not provided, the default client will be used. - - Returns - ------- - Union[gpd.GeoDataFrame, pd.DataFrame] - """ - if property_filter is not None: - property_filter = property_filter.serialize() - - if client is None: - client = VectorClient.get_default_client() - - response = client.session.post( - f"/products/{product_id}/features/query", - json={ - "format": "Parquet", - "filter": property_filter, - "aoi": aoi, - "columns": columns, - }, - timeout=(client.CONNECT_TIMEOUT, client.READ_TIMEOUT), - ) - - return response_to_dataframe(response=response) - - -def _join( - params: dict, - client: VectorClient, -) -> Union[gpd.GeoDataFrame, pd.DataFrame]: - """ - Internal join function. - - Parameters - ---------- - params : dict - Dictionary of parameters to pass to the join endpoint. - client : VectorClient - Client to use for requests. - - Returns - ------- - Union[gpd.GeoDataFrame, pd.DataFrame] - """ - params = deepcopy(params) - - input_property_filter = params.get("input_property_filter", None) - if input_property_filter is not None: - params["input_property_filter"] = input_property_filter.serialize() - - join_property_filter = params.get("join_property_filter", None) - if join_property_filter is not None: - params["join_property_filter"] = join_property_filter.serialize() - - response = client.session.post( - "/products/features/join", - json=params, - timeout=(client.CONNECT_TIMEOUT, client.READ_TIMEOUT), - ) - - return response_to_dataframe(response=response) - - -def join( - input_product_id: str, - join_product_id: str, - join_type: str, - join_columns: List[Tuple[str, str]], - include_columns: List[Tuple[str, ...]] = None, - input_property_filter: Properties = None, - join_property_filter: Properties = None, - input_aoi: dict = None, - join_aoi: dict = None, - client: Optional[VectorClient] = None, -) -> Union[gpd.GeoDataFrame, pd.DataFrame]: - """ - Execute relational join between two Vector Tables. - - Parameters - ---------- - input_product_id : str - Product ID of the input Vector Table. - join_product_id : str - Product ID of the join Vector Table. - join_type : str - String indicating the type of join to perform. - Must be one of INNER, LEFT, RIGHT, INTERSECTS, - CONTAINS, OVERLAPS, WITHIN. - join_columns : List[Tuple[str, str]] - List of columns to join the input and join Vector Table. - [(input_table.col1, join_table.col2), ...] - include_columns : List[Tuple[str, ...]] - List of columns to include from either side of - the join formatted as [(input_table.col1, input_table.col2), - (join_table.col3, join_table.col4)]. If None, all columns - from both Vector Tables are returned. - input_property_filter : Properties - Property filters to filter the input Vector Table. - join_property_filter : Properties - Property filters to filter the join Vector Table. - input_aoi : dict - A GeoJSON Feature to filter the input Vector Table. - join_aoi : dict - A GeoJSON Feature to filter the join Vector Table. - client : VectorClient, optional - Client to use for requests. If not provided, the default client will be used. - - Returns - ------- - Union[gpd.GeoDataFrame, pd.DataFrame] - """ - - params = { - "input_product_id": input_product_id, - "join_type": join_type, - "join_product_id": join_product_id, - "join_columns": join_columns, - "include_columns": include_columns, - "input_property_filter": input_property_filter, - "input_aoi": input_aoi, - "join_property_filter": join_property_filter, - "join_aoi": join_aoi, - "keep_all_input_rows": False, # not used with non-spatial join - } - - if client is None: - client = VectorClient.get_default_client() - - return _join(params, client) - - -def sjoin( - input_product_id: str, - join_product_id: str, - join_type: str, - include_columns: List[Tuple[str, ...]] = None, - input_property_filter: Properties = None, - join_property_filter: Properties = None, - input_aoi: dict = None, - join_aoi: dict = None, - keep_all_input_rows: bool = False, - client: Optional[VectorClient] = None, -) -> Union[gpd.GeoDataFrame, pd.DataFrame]: - """ - Execute spatial join between two Vector Tables. - - Parameters - ---------- - input_product_id : str - Product ID of the input Vector Table. - join_product_id : str - Product ID of the join Vector Table. - join_type : str - String indicating the type of join to perform. - Must be one of INNER, LEFT, RIGHT, INTERSECTS, - CONTAINS, OVERLAPS, WITHIN. - include_columns : List[Tuple[str, ...]] - List of columns to include from either side of - the join formatted as [(input_table.col1, input_table.col2), - (join_table.col3, join_table.col4)]. If None, all columns - from both Vector Tables are returned. - input_property_filter : Properties - Property filters to filter the input Vector Table. - join_property_filter : Properties - Property filters to filter the join Vector Table. - input_aoi : dict - A GeoJSON Feature to filter the input Vector Table. - join_aoi : dict - A GeoJSON Feature to filter the join Vector Table. - keep_all_input_rows : bool - Boolean indicating if the spatial join should keep all input rows - whether they satisfy the spatial query or not. - client : VectorClient, optional - Client to use for requests. If not provided, the default client will be used. - - Returns - ------- - Union[gpd.GeoDataFrame, pd.DataFrame] - """ - - params = { - "input_product_id": input_product_id, - "join_type": join_type, - "join_product_id": join_product_id, - "join_columns": None, # not used with spatial join - "include_columns": include_columns, - "input_property_filter": input_property_filter, - "input_aoi": input_aoi, - "join_property_filter": join_property_filter, - "join_aoi": join_aoi, - "keep_all_input_rows": keep_all_input_rows, - } - - if client is None: - client = VectorClient.get_default_client() - - return _join(params, client=client) - - -def get( - product_id: str, - feature_id: str, - client: Optional[VectorClient] = None, -) -> Union[gpd.GeoDataFrame, pd.DataFrame]: - """ - Get a feature from a Vector Table. - - Parameters - ---------- - product_id : str - Product ID of the Vector Table. - feature_id : str - ID of the feature. - client : VectorClient, optional - Client to use for requests. If not provided, the default client will be used. - - Returns - ------- - Union[gpd.GeoDataFrame, pd.DataFrame] - A Pandas or GeoPandas dataframe. - """ - if client is None: - client = VectorClient.get_default_client() - - response = client.session.get( - f"/products/{product_id}/features/{feature_id}", - params={"format": "Parquet"}, - ) - - return response_to_dataframe(response=response) - - -@deprecate(removed=["is_spatial"]) -def update( - product_id: str, - feature_id: str, - dataframe: Union[gpd.GeoDataFrame, pd.DataFrame], - is_spatial: Optional[bool] = None, - client: Optional[VectorClient] = None, -) -> None: - """ - Save/update a feature in a Vector Table. - - Parameters - ---------- - product_id : str - Product ID of the Vector Table. - feature_id : str - ID of the feature. - dataframe : Union[gpd.GeoDataFrame, pd.DataFrame] - A GeoPandas GeoDataFrame or a Pandas DataFrame to replace - the feature with. - client : VectorClient, optional - Client to use for requests. If not provided, the default client will be used. - - Returns - ------- - None - """ - if not isinstance(dataframe, TYPES): - raise TypeError(f"Unsupported data type {type(dataframe)}") - - if dataframe.shape[0] != 1: - raise ValueError("Only 1 row can be updated") - - buffer = BytesIO() - dataframe.to_parquet(buffer, index=False) - buffer.seek(0) - - files = {"file": ("vector.parquet", buffer, "application/octet-stream")} - - if client is None: - client = VectorClient.get_default_client() - - # The client session normally hardwires the content type. - # We need to remove it for this request so that the multipart - # support in urllib3 can set it appropriately. - session = client.session - content_type = session.headers.get("content-type") - if content_type is not None: - del session.headers["content-type"] - try: - session.put( - f"/products/{product_id}/featuresv2/{feature_id}", - files=files, - ) - finally: - if content_type is not None: - session.headers.update({"content-type": content_type}) - - -def aggregate( - product_id: str, - statistic: Statistic, - property_filter: Properties = None, - aoi: dict = None, - columns: list = None, - client: Optional[VectorClient] = None, -) -> Union[int, dict]: - """ - Calculate aggregate statistics for features in a Vector Table. - The statistic COUNT will always return an integer. All other - statistics will return a dictionary of results. Keys of the - dictionary will be the column names requested appended with - the statistic ('column_1.STATISTIC') and values are the result - of the aggregate statistic. - - Parameters - ---------- - product_id : str - Product ID of the Vector Table - statistic : Statistic - Statistic to calculate. - property_filter : Properties, optional - Property filters to filter the product with. - aoi : dict, optional - A GeoJSON Feature to filter the vector product with. - columns : list, optional - Optional list of column names. - client : VectorClient, optional - Client to use for requests. If not provided, the default client will be used. - - Returns - ------- - Union[int, dict] - """ - if not isinstance(statistic, Statistic): - raise TypeError("'statistic' must be of type .") - - if property_filter is not None: - property_filter = property_filter.serialize() - - if client is None: - client = VectorClient.get_default_client() - - response = client.session.post( - f"/products/{product_id}/features/aggregate", - json={ - "statistic": statistic.value, - "filter": property_filter, - "aoi": aoi, - "columns": columns, - }, - timeout=(client.CONNECT_TIMEOUT, client.READ_TIMEOUT), - ) - - return response.json() - - -def delete( - product_id: str, - feature_id: str, - client: Optional[VectorClient] = None, -): - """ - Delete a feature in a Vector Table. - - Parameters - ---------- - product_id : str - Product ID of the Vector Table. - feature_id : str - ID of the feature. - client : VectorClient, optional - Client to use for requests. If not provided, the default client will be used. - """ - - if client is None: - client = VectorClient.get_default_client() - - client.session.delete( - f"/products/{product_id}/features/{feature_id}", - ) diff --git a/descarteslabs/core/vector/images/the-coldest-lake.png b/descarteslabs/core/vector/images/the-coldest-lake.png deleted file mode 100644 index adf72529f4518dc385ebae64a778f270a89ac183..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 738665 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πj*+}vOvVycKz;1L_ht$> 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

-1!Mf$X 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

)c{G;iLUQ4KDzYT9~JW$BZ78PMs}_d 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

)=$R@ljUgZyIga0z-Dcc0U%RX+ih|zd3;!N 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 +;kaA9J?uvK=6S%ZRG{YM zsZrit(SoU7iRQ%El-AZ!!?ScYX?NATw~vkN2>@*$uy_^YhhL95GgnXbcGtjw_J_e0Obgj&W{v z!_2&KAP~sDmDngT6k%m->RFjUD(jD%r~qb1c+AcVWz4pb@YEEPbDkBHjk3unWdhcB z$~I5I$?;%N{qW2%O5I3{A+qZ2~U(mEc^=O=V&+&B08$(^6$rv$(gls)z=Lp)`LxZIE@`C4qy z>;QJ?oqQi)b{%l=#oCw5B`qRt?L?S5^+$EZ(D`|vM2Y$Eluq52Eub`dQ&4FCZFTs= zchz~jSFQcSzg1@&&ptU|cF5Vs+IEa&k?t7`OfudTaAB3ezd$ ze4ov#1CDq(MxQ$koz9t%`4HZSp&ZZVuy!tRD{jmQ=qVY}6`jH3RjOU)i;TcE7wC5~ zH<9nX(!6}Qrao8za%)?FMq3j<=wBB$l6zI)9uEgCvoA_6bAjyg0NTUpIs>ieUYV*4;xlzY8L16X-J=!CNUT?S&VL*9r$b$%N z{M^{bH6607cw+7|OJMf8-dKLOq`;B_OA0J0u%y7%6!?E`$8la= 0: - raise ValueError('tags cannot contain ","') - - -def _strip_null_values(d: dict) -> dict: - """Strip null (ie. None) values from a dictionary. - - This is used to strip null values from request query strings. - - Parameters - ---------- - d : dict - The input dictionary. - - Returns - ------- - dict - """ - return {k: v for k, v in d.items() if v is not None} - - -def create( - product_id: str, - name: str, - description: Optional[str] = None, - tags: Optional[List[str]] = None, - readers: Optional[List[str]] = None, - writers: Optional[List[str]] = None, - owners: Optional[List[str]] = None, - model: Optional[VectorBaseModel] = GenericFeatureBaseModel, - client: Optional[VectorClient] = None, -) -> dict: - """ - Create a Vector Table. - - Parameters - ---------- - product_id : str - Product ID of a Vector Table. - name : str - Name of the Vector Table. - description : str, optional - Description of the Vector Table. - tags : list of str, optional - A list of tags to associate with the Vector Table. - readers : list of str, optional - A list of Vector Table readers. Can take the form "user:{namespace}", "group:{group}", "org:{org}", or - "email:{email}". - writers : list of str, optional - A list of Vector Table writers. Can take the form "user:{namespace}", "group:{group}", "org:{org}", or - "email:{email}". - owners : list of str, optional - A list of Vector Table owners. Can take the form "user:{namespace}", "group:{group}", "org:{org}", or - "email:{email}". - model : VectorBaseModel, optional - A json schema describing the table - client : VectorClient, optional - Client to use for requests. If not provided, the default client will be used. - - Returns - ------- - dict - """ - _check_tags(tags) - - if "geometry" in model.model_json_schema()["properties"].keys(): - is_spatial = True - else: - is_spatial = False - - request_json = _strip_null_values( - { - "id": product_id, - "name": name, - "is_spatial": is_spatial, - "description": description, - "tags": tags, - "readers": readers, - "writers": writers, - "owners": owners, - "model": model.model_json_schema(), - } - ) - - if client is None: - client = VectorClient.get_default_client() - - response = client.session.post("/products/", json=request_json) - - return response.json() - - -def list( - tags: Union[List[str], None] = None, - client: Optional[VectorClient] = None, -) -> List[dict]: - """ - List Vector Tables. - - Parameters - ---------- - tags: List[str] - Optional list of tags a Vector Table must have to be included in the returned list. - client : VectorClient, optional - Client to use for requests. If not provided, the default client will be used. - - Returns - ------- - List[dict] - """ - _check_tags(tags) - - params = None - if tags: - params = {"tags": ",".join(tags)} - - if client is None: - client = VectorClient.get_default_client() - - response = client.session.get("/products/", params=params) - - return response.json() - - -def get( - product_id: str, - client: Optional[VectorClient] = None, -) -> dict: - """ - Get a Vector Table. - - Parameters - ---------- - product_id : str - Product ID of a Vector Table. - client : VectorClient, optional - Client to use for requests. If not provided, the default client will be used. - - Returns - ------- - dict - """ - - if client is None: - client = VectorClient.get_default_client() - - response = client.session.get(f"/products/{product_id}") - - return response.json() - - -def update( - product_id: str, - name: Optional[str] = None, - description: Optional[str] = None, - tags: Optional[List[str]] = None, - readers: Optional[List[str]] = None, - writers: Optional[List[str]] = None, - owners: Optional[List[str]] = None, - client: Optional[VectorClient] = None, -) -> dict: - """ - Save/update a Vector Table. - - Parameters - ---------- - product_id : str - Product ID of a Vector Table. - name : str - Name of the Vector Table. - description : str, optional - Description of the Vector Table. - tags : list of str, optional - A list of tags to associate with the Vector Table. - readers : list of str, optional - A list of Vector Table readers. Can take the form "user:{namespace}", "group:{group}", "org:{org}", or - "email:{email}". - writers : list of str, optional - A list of Vector Table writers. Can take the form "user:{namespace}", "group:{group}", "org:{org}", or - "email:{email}". - owners : list of str, optional - A list of Vector Table owners. Can take the form "user:{namespace}", "group:{group}", "org:{org}", or - "email:{email}". - client : VectorClient, optional - Client to use for requests. If not provided, the default client will be used. - - Returns - ------- - dict - """ - _check_tags(tags) - - if client is None: - client = VectorClient.get_default_client() - - response = client.session.patch( - f"/products/{product_id}", - json=_strip_null_values( - { - "name": name, - "description": description, - "tags": tags, - "readers": readers, - "writers": writers, - "owners": owners, - }, - ), - ) - - return response.json() - - -def delete( - product_id: str, - client: Optional[VectorClient] = None, -) -> None: - """ - Delete a Vector Table. - - Parameters - ---------- - product_id : str - Product ID of a Vector Table. - client : VectorClient, optional - Client to use for requests. If not provided, the default client will be used. - - Returns - ------- - None - """ - if client is None: - client = VectorClient.get_default_client() - - client.session.delete(f"/products/{product_id}") diff --git a/descarteslabs/core/vector/tests/__init__.py b/descarteslabs/core/vector/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/descarteslabs/core/vector/tests/base.py b/descarteslabs/core/vector/tests/base.py deleted file mode 100644 index ac9aafca..00000000 --- a/descarteslabs/core/vector/tests/base.py +++ /dev/null @@ -1,199 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 base64 -import json -import json as jsonlib -import time -import urllib.parse -import uuid -from datetime import datetime, timezone -from unittest import TestCase - -import geopandas as gpd -import pandas as pd - -import responses -from requests import PreparedRequest - -from descarteslabs.auth import Auth -from descarteslabs.config import get_settings - -from ..vector_client import VectorClient - - -def make_uuid(): - return str(uuid.uuid4()) - - -class BaseTestCase(TestCase): - vector_url = get_settings().vector_url - - spatial_test_dataframe = gpd.GeoDataFrame.from_features( - [ - { - "geometry": {"coordinates": [4.473067, 52.119339], "type": "Point"}, - "properties": {"color": "red", "num": 1}, - "type": "Feature", - }, - { - "geometry": {"coordinates": [4.686934, 52.116113], "type": "Point"}, - "properties": {"color": "blue", "num": 4}, - "type": "Feature", - }, - ], - crs="EPSG:4326", - ) - - nonspatial_test_dataframe = pd.DataFrame( - [ - {"color": "red", "num": 1}, - {"color": "blue", "num": 4}, - ], - ) - - spatial_product_id = "some-org:snappy-spatial-vector-product" - - nonspatial_product_id = "some-org:snappy-nonspatial-vector-product" - - spatial_feature_id = "snappy-spatial-vector-feature" - - nonspatial_feature_id = "snappy-nonspatial-vector-feature" - - def setUp(self): - responses.mock.assert_all_requests_are_fired = True - self.now = datetime.now(timezone.utc).replace(tzinfo=None) - - payload = ( - base64.b64encode( - json.dumps( - { - "aud": "client-id", - "exp": time.time() + 3600, - } - ).encode() - ) - .decode() - .strip("=") - ) - token = f"header.{payload}.signature" - auth = Auth(jwt_token=token, token_info_path=None) - VectorClient.set_default_client(VectorClient(auth=auth)) - - def tearDown(self): - responses.mock.assert_all_requests_are_fired = False - - def mock_response(self, method, uri, status=200, **kwargs): - responses.add( - method, - f"{self.vector_url}{uri}", - status=status, - **kwargs, - ) - - def assert_url_called( - self, method, uri, times=1, json=None, body=None, params=None, headers=None - ): - if json and body: - raise ValueError("Using json and body together does not make sense") - - url = f"{self.vector_url}{uri}" - calls = [call for call in responses.calls if call.request.url.startswith(url)] - assert calls, f"No requests were made to uri: {uri}" - - data = json or body - matches = [] - calls_with_data = [] - calls_with_params = set() - calls_with_headers = set() - - for call in calls: - request: PreparedRequest = call.request - - if json is not None: - request_data = jsonlib.loads(request.body) - else: - request_data = request.body - - if request_data: - calls_with_data.append(repr(request.body)) - - if params is not None: - request_params = {} - - for key, value in urllib.parse.parse_qsl( - urllib.parse.urlsplit(request.url).query - ): - try: - value = jsonlib.loads(value) - except jsonlib.JSONDecodeError: - value = value - - if key in request_params: - values = request_params[key] - - if not isinstance(values, list): - values = [values] - - values.append(value) - else: - values = value - - request_params[key] = values - - if request_params: - calls_with_params.add(jsonlib.dumps(request_params)) - else: - request_params = None - - if headers is not None: - request_headers = {} - for key, value in request.headers.items(): - if key in headers: - request_headers[key] = value - if request_headers: - calls_with_headers.add(jsonlib.dumps(request_headers)) - else: - request_headers = None - - if ( - (method == request.method) - and (data is None or request_data == data) - and (params is None or request_params == params) - and (headers is None or request_headers == headers) - ): - matches.append(call) - - count = len(matches) - msg = f"Expected {times} calls found {count} for {method} {uri}" - - if data is not None: - msg += f" with data: {data}" - - if calls_with_data: - msg += "\n\nData:\n" + "\n".join(calls_with_data) - - if params is not None: - msg += f" with params: {params}" - - if calls_with_params: - msg += "\n\nParams:\n" + "\n".join(calls_with_params) - - if headers is not None: - msg += f" with headers: {headers}" - - if headers: - msg += "\n\nHeaders:\n" + "\n".join(calls_with_headers) - - assert count == times, msg diff --git a/descarteslabs/core/vector/tests/test_features.py b/descarteslabs/core/vector/tests/test_features.py deleted file mode 100644 index 24da5f3b..00000000 --- a/descarteslabs/core/vector/tests/test_features.py +++ /dev/null @@ -1,252 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 io import BytesIO - -# import pytest -import responses - -import geopandas as gpd -import pandas as pd - -from .. import features - -from .base import BaseTestCase - - -# TODO: These tests are all a bit incorrect, because we are not working -# with the service and there is no product with a model to define the -# dataframe schema. Oh well... -class FeaturesTestCase(BaseTestCase): - @responses.activate - def test_add_spatial(self): - buffer = BytesIO() - self.spatial_test_dataframe.to_parquet(buffer, index=False) - buffer.seek(0) - - expected = buffer.read() - - self.mock_response( - "POST", - f"/products/{self.spatial_product_id}/featuresv2", - body=expected, - headers={"is_spatial": "True"}, - ) - - result = features.add(self.spatial_product_id, self.spatial_test_dataframe) - - assert isinstance(result, gpd.GeoDataFrame) - assert result.equals(self.spatial_test_dataframe) - # not practical to verify data due to the use of file upload - self.assert_url_called( - "POST", - f"/products/{self.spatial_product_id}/featuresv2", - ) - - @responses.activate - def test_add_nonspatial(self): - buffer = BytesIO() - self.nonspatial_test_dataframe.to_parquet(buffer, index=False) - buffer.seek(0) - - expected = buffer.read() - - self.mock_response( - "POST", - f"/products/{self.nonspatial_product_id}/featuresv2", - body=expected, - headers={"is_spatial": "False"}, - ) - - result = features.add( - self.nonspatial_product_id, self.nonspatial_test_dataframe - ) - - assert isinstance(result, pd.DataFrame) and not isinstance( - result, gpd.GeoDataFrame - ) - assert result.equals(self.nonspatial_test_dataframe) - # not practical to verify data due to the use of file upload - self.assert_url_called( - "POST", - f"/products/{self.nonspatial_product_id}/featuresv2", - ) - - @responses.activate - def test_query_spatial(self): - buffer = BytesIO() - self.spatial_test_dataframe.to_parquet(buffer, index=False) - buffer.seek(0) - - expected = buffer.read() - - self.mock_response( - "POST", - f"/products/{self.spatial_product_id}/features/query", - body=expected, - headers={"is_spatial": "True"}, - ) - - result = features.query(self.spatial_product_id) - - assert isinstance(result, gpd.GeoDataFrame) - assert result.equals(self.spatial_test_dataframe) - self.assert_url_called( - "POST", - f"/products/{self.spatial_product_id}/features/query", - ) - - @responses.activate - def test_query_nonspatial(self): - buffer = BytesIO() - self.nonspatial_test_dataframe.to_parquet(buffer, index=False) - buffer.seek(0) - - expected = buffer.read() - - self.mock_response( - "POST", - f"/products/{self.nonspatial_product_id}/features/query", - body=expected, - headers={"is_spatial": "False"}, - ) - - result = features.query(self.nonspatial_product_id) - - assert isinstance(result, pd.DataFrame) and not isinstance( - result, gpd.GeoDataFrame - ) - assert result.equals(self.nonspatial_test_dataframe) - self.assert_url_called( - "POST", - f"/products/{self.nonspatial_product_id}/features/query", - ) - - @responses.activate - def test_get_spatial(self): - buffer = BytesIO() - self.spatial_test_dataframe.iloc[:1, :].to_parquet(buffer, index=False) - buffer.seek(0) - - expected = buffer.read() - - self.mock_response( - "GET", - f"/products/{self.spatial_product_id}/features/{self.spatial_feature_id}", - body=expected, - headers={"is_spatial": "True"}, - ) - - result = features.get(self.spatial_product_id, self.spatial_feature_id) - - assert isinstance(result, gpd.GeoDataFrame) - assert result.equals(self.spatial_test_dataframe.iloc[:1, :]) - self.assert_url_called( - "GET", - f"/products/{self.spatial_product_id}/features/{self.spatial_feature_id}", - ) - - @responses.activate - def test_get_nonspatial(self): - buffer = BytesIO() - self.nonspatial_test_dataframe.iloc[:1, :].to_parquet(buffer, index=False) - buffer.seek(0) - - expected = buffer.read() - - self.mock_response( - "GET", - f"/products/{self.nonspatial_product_id}/features/{self.nonspatial_feature_id}", - body=expected, - headers={"is_spatial": "False"}, - ) - - result = features.get(self.nonspatial_product_id, self.nonspatial_feature_id) - - assert isinstance(result, pd.DataFrame) and not isinstance( - result, gpd.GeoDataFrame - ) - assert result.equals(self.nonspatial_test_dataframe.iloc[:1, :]) - self.assert_url_called( - "GET", - f"/products/{self.nonspatial_product_id}/features/{self.nonspatial_feature_id}", - ) - - @responses.activate - def test_update_spatial(self): - self.mock_response( - "PUT", - f"/products/{self.spatial_product_id}/featuresv2/{self.spatial_feature_id}", - ) - - features.update( - self.spatial_product_id, - self.spatial_feature_id, - self.spatial_test_dataframe.iloc[:1, :], - ) - - self.assert_url_called( - "PUT", - f"/products/{self.spatial_product_id}/featuresv2/{self.spatial_feature_id}", - ) - - @responses.activate - def test_update_nonspatial(self): - self.mock_response( - "PUT", - f"/products/{self.nonspatial_product_id}/featuresv2/{self.nonspatial_feature_id}", - ) - - features.update( - self.nonspatial_product_id, - self.nonspatial_feature_id, - self.nonspatial_test_dataframe.iloc[:1, :], - ) - - self.assert_url_called( - "PUT", - f"/products/{self.nonspatial_product_id}/featuresv2/{self.nonspatial_feature_id}", - ) - - @responses.activate - def test_aggregate(self): - self.mock_response( - "POST", - f"/products/{self.spatial_product_id}/features/aggregate", - json={"num.MAX": 4}, - ) - - result = features.aggregate(self.spatial_product_id, features.Statistic.MAX) - - assert result == {"num.MAX": 4} - - self.assert_url_called( - "POST", - f"/products/{self.spatial_product_id}/features/aggregate", - ) - - @responses.activate - def test_delete(self): - self.mock_response( - "DELETE", - f"/products/{self.spatial_product_id}/features/{self.spatial_feature_id}", - status=204, - ) - - features.delete(self.spatial_product_id, self.spatial_feature_id) - - self.assert_url_called( - "DELETE", - f"/products/{self.spatial_product_id}/features/{self.spatial_feature_id}", - ) diff --git a/descarteslabs/core/vector/tests/test_products.py b/descarteslabs/core/vector/tests/test_products.py deleted file mode 100644 index 3faa067c..00000000 --- a/descarteslabs/core/vector/tests/test_products.py +++ /dev/null @@ -1,158 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 pytest -import responses - -from descarteslabs.exceptions import BadRequestError, ConflictError, NotFoundError - -from .. import products -from ...common.vector.models import GenericFeatureBaseModel, VectorBaseModel - -from .base import BaseTestCase - - -class ProductsTestCase(BaseTestCase): - @responses.activate - def test_create_spatial(self): - expected = { - "id": self.spatial_product_id, - "name": self.spatial_product_id, - "is_spatial": True, - "model": GenericFeatureBaseModel.model_json_schema(), - } - - self.mock_response("POST", "/products/", json=expected) - - result = products.create(self.spatial_product_id, self.spatial_product_id) - assert result == expected - self.assert_url_called("POST", "/products/", json=expected) - - @responses.activate - def test_create_nonspatial(self): - expected = { - "id": self.nonspatial_product_id, - "name": self.nonspatial_product_id, - "is_spatial": False, - "model": VectorBaseModel.model_json_schema(), - } - - self.mock_response("POST", "/products/", json=expected) - - result = products.create( - self.nonspatial_product_id, - self.nonspatial_product_id, - model=VectorBaseModel, - ) - assert result == expected - self.assert_url_called("POST", "/products/", json=expected) - - @responses.activate - def test_create_conflict(self): - expected = { - "id": self.spatial_product_id, - "name": self.spatial_product_id, - "is_spatial": True, - "model": GenericFeatureBaseModel.model_json_schema(), - } - - self.mock_response("POST", "/products/", status=409) - - with pytest.raises(ConflictError): - products.create(self.spatial_product_id, self.spatial_product_id) - - self.assert_url_called("POST", "/products/", json=expected) - - @responses.activate - def test_create_bad_request(self): - expected = { - "id": self.spatial_product_id, - "name": self.spatial_product_id, - "is_spatial": True, - "model": GenericFeatureBaseModel.model_json_schema(), - } - - self.mock_response("POST", "/products/", status=400) - - with pytest.raises(BadRequestError): - products.create(self.spatial_product_id, self.spatial_product_id) - - self.assert_url_called("POST", "/products/", json=expected) - - @responses.activate - def test_list(self): - expected = [ - { - "id": self.spatial_product_id, - "name": self.spatial_product_id, - }, - { - "id": self.nonspatial_product_id, - "name": self.nonspatial_product_id, - }, - ] - - self.mock_response("GET", "/products/", json=expected) - - result = products.list() - assert result == expected - - @responses.activate - def test_get(self): - expected = { - "id": self.spatial_product_id, - "name": self.spatial_product_id, - } - - self.mock_response("GET", f"/products/{self.spatial_product_id}", json=expected) - - result = products.get(self.spatial_product_id) - assert result == expected - - @responses.activate - def test_get_not_found(self): - self.mock_response("GET", f"/products/{self.spatial_product_id}", status=404) - - with pytest.raises(NotFoundError): - products.get(self.spatial_product_id) - - @responses.activate - def test_update(self): - expected = { - "description": "This is a test", - } - - self.mock_response( - "PATCH", f"/products/{self.spatial_product_id}", json=expected - ) - - result = products.update(self.spatial_product_id, description="This is a test") - assert result == expected - self.assert_url_called( - "PATCH", f"/products/{self.spatial_product_id}", json=expected - ) - - @responses.activate - def test_delete(self): - self.mock_response("DELETE", f"/products/{self.spatial_product_id}") - - products.delete(self.spatial_product_id) - self.assert_url_called("DELETE", f"/products/{self.spatial_product_id}") - - @responses.activate - def test_delete_not_found(self): - self.mock_response("DELETE", f"/products/{self.spatial_product_id}", status=404) - - with pytest.raises(NotFoundError): - products.delete(self.spatial_product_id) diff --git a/descarteslabs/core/vector/tests/test_tiles.py b/descarteslabs/core/vector/tests/test_tiles.py deleted file mode 100644 index 8bf9b2f9..00000000 --- a/descarteslabs/core/vector/tests/test_tiles.py +++ /dev/null @@ -1,45 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 pytest -import responses - -try: - import ipyleaflet -except ImportError: - ipyleaflet = None - -from ...common.vector import models as vector_models -from .. import tiles -from .base import BaseTestCase - - -class TilesTestCase(BaseTestCase): - @pytest.mark.skipif(ipyleaflet is None, reason="ipyleaflet not installed") - @responses.activate - def test_tiles(self): - # mock table - table_response = { - "id": self.spatial_product_id, - "name": self.spatial_product_id, - "is_spatial": True, - "model": vector_models.GenericFeatureBaseModel.model_json_schema(), - "created": "2024-01-01T00:00:00.000000", - } - - self.mock_response( - "GET", f"/products/{self.spatial_product_id}", json=table_response - ) - - tiles.create_layer(self.spatial_product_id, self.spatial_product_id) diff --git a/descarteslabs/core/vector/tests/test_vector.py b/descarteslabs/core/vector/tests/test_vector.py deleted file mode 100644 index fe95d984..00000000 --- a/descarteslabs/core/vector/tests/test_vector.py +++ /dev/null @@ -1,461 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 datetime import datetime -from io import BytesIO -from uuid import uuid4 - -import geojson -import geopandas as gpd -import pandas as pd -import pytest -import responses - -from ...common.vector import models as vector_models -from ...common.geo import AOI -from ...common.property_filtering import Properties - -from ..vector import Table, Feature - -from .base import BaseTestCase - - -p = Properties() - - -class SpatialModel(vector_models.PointBaseModel): - color: str - size: str - name: str - type: str - - -class NonSpatialModel(vector_models.VectorBaseModel): - color: str - size: str - name: str - type: str - - -# Table operations -class TableTestCase(BaseTestCase): - product_id = "test_product" - table_name = "test_product" - table_description = "test spatial vector product" - - @responses.activate - def test_spatial_table_create(self): - table_request = { - "id": self.product_id, - "name": self.table_name, - "description": self.table_description, - "is_spatial": True, - "model": SpatialModel.model_json_schema(), - } - - table_response = dict(**table_request, created="2024-01-01T00:00:00.000000") - - self.mock_response("POST", "/products/", json=table_response) - - table = Table.create( - self.product_id, self.table_name, self.table_description, model=SpatialModel - ) - - self.assert_url_called("POST", "/products/", json=table_request) - - assert table.id == self.product_id - assert table.name == self.table_name - assert table.description == self.table_description - assert table.model == SpatialModel.model_json_schema() - assert table.is_spatial is True - assert table.created == datetime.fromisoformat("2024-01-01T00:00:00.000000") - - # Test that str and repr don't error out for Table on creation - assert str(table) - assert repr(table) - - @responses.activate - def test_nonspatial_table_create(self): - table_request = { - "id": self.product_id, - "name": self.table_name, - "description": self.table_description, - "is_spatial": False, - "model": NonSpatialModel.model_json_schema(), - } - - table_response = dict(**table_request, created="2024-01-01T00:00:00.000000") - - self.mock_response("POST", "/products/", json=table_response) - - table = Table.create( - self.product_id, - self.table_name, - self.table_description, - model=NonSpatialModel, - ) - - self.assert_url_called("POST", "/products/", json=table_request) - - assert table.id == self.product_id - assert table.name == self.table_name - assert table.description == self.table_description - assert table.model == NonSpatialModel.model_json_schema() - assert table.is_spatial is False - assert table.created == datetime.fromisoformat("2024-01-01T00:00:00.000000") - - # Test that str and repr don't error out for Table on creation - assert str(table) - assert repr(table) - - @responses.activate - def test_table_get(self): - table_response = { - "id": self.product_id, - "name": self.table_name, - "description": self.table_description, - "is_spatial": True, - "model": SpatialModel.model_json_schema(), - "created": "2024-01-01T00:00:00.000000", - } - - self.mock_response("GET", f"/products/{self.product_id}", json=table_response) - - table = Table.get(self.product_id) - - self.assert_url_called("GET", f"/products/{self.product_id}") - - assert table.id == self.product_id - assert table.name == self.table_name - assert table.description == self.table_description - assert table.model == SpatialModel.model_json_schema() - assert table.is_spatial is True - assert table.created == datetime.fromisoformat("2024-01-01T00:00:00.000000") - - # Test that str and repr don't error out for Table on get - assert str(table) - assert repr(table) - - @responses.activate - def test_table_delete(self): - self.mock_response("DELETE", f"/products/{self.product_id}", status=204) - - table = Table({"id": self.product_id}) - - table.delete() - - self.assert_url_called("DELETE", f"/products/{self.product_id}") - - @responses.activate - def test_spatial_add_features(self): - # Get the table - table_response = { - "id": self.product_id, - "name": self.table_name, - "description": self.table_description, - "is_spatial": True, - "model": SpatialModel.model_json_schema(), - "created": "2024-01-01T00:00:00.000000", - } - - self.mock_response("GET", f"/products/{self.product_id}", json=table_response) - - table = Table.get(self.product_id) - - # Add features - fc = geojson.FeatureCollection( - [ - { - "geometry": {"coordinates": [4.5, 52.1], "type": "Point"}, - "properties": { - "color": "red", - "size": "small", - "name": "Frank", - "type": "Person", - }, - "type": "Feature", - }, - { - "geometry": {"coordinates": [4.6, 52.1], "type": "Point"}, - "properties": { - "color": "blue", - "size": "small", - "name": "Earth", - "type": "planet", - }, - "type": "Feature", - }, - { - "geometry": {"coordinates": [4.6, 52.2], "type": "Point"}, - "properties": { - "color": "red", - "size": "big", - "name": "Clifford", - "type": "dog", - }, - "type": "Feature", - }, - { - "geometry": {"coordinates": [4.5, 52.2], "type": "Point"}, - "properties": { - "color": "blue", - "size": "big", - "name": "Pacific", - "type": "ocean", - }, - "type": "Feature", - }, - ] - ) - - df = gpd.GeoDataFrame.from_features(fc.features, crs="EPSG:4326") - - # restructure for GenericFeatureBaseModel response - fc2 = geojson.FeatureCollection( - [ - { - "geometry": f["geometry"], - "properties": {**f["properties"], "uuid": str(uuid4())}, - "type": "Feature", - } - for f in fc["features"] - ] - ) - df2 = gpd.GeoDataFrame.from_features(fc2.features, crs="EPSG:4326") - - buffer = BytesIO() - df2.to_parquet(buffer, index=False) - buffer.seek(0) - - self.mock_response( - "POST", - f"/products/{self.product_id}/featuresv2", - body=buffer.read(), - headers={"is_spatial": "True"}, - ) - - result = table.add(df) - - assert result.equals(df2) - - @responses.activate - def test_nonspatial_add_features(self): - # Get the table - table_response = { - "id": self.product_id, - "name": self.table_name, - "description": self.table_description, - "is_spatial": False, - "model": NonSpatialModel.model_json_schema(), - "created": "2024-01-01T00:00:00.000000", - } - - self.mock_response("GET", f"/products/{self.product_id}", json=table_response) - - table = Table.get(self.product_id) - - # Add features - df = pd.DataFrame( - { - "color": ["red", "blue", "red", "blue"], - "size": ["small", "small", "big", "big"], - "name": ["Frank", "Earth", "Clifford", "Pacific"], - "type": ["Person", "planet", "dog", "ocean"], - } - ) - - # restructure for NonSpatialModel response - df2 = df.copy(True) - df2["uuid"] = [str(uuid4()) for _ in range(len(df))] - - buffer = BytesIO() - df2.to_parquet(buffer, index=False) - buffer.seek(0) - - self.mock_response( - "POST", - f"/products/{self.product_id}/featuresv2", - body=buffer.read(), - headers={"is_spatial": "False"}, - ) - - result = table.add(df) - - assert result.equals(df2) - - @responses.activate - def test_spatial_filter_features(self): - # Get the table - table_response = { - "id": self.product_id, - "name": self.table_name, - "description": self.table_description, - "is_spatial": True, - "model": SpatialModel.model_json_schema(), - "created": "2024-01-01T00:00:00.000000", - } - - self.mock_response("GET", f"/products/{self.product_id}", json=table_response) - - aoi = AOI(bounds=(4.55, 52.0, 4.65, 53.0)) - filter = p.color == "red" - - table = Table.get(self.product_id, aoi=aoi, property_filter=filter) - - assert table.options.aoi is not None - assert table.options.property_filter is filter - - def test_spatial_filter_bad_aoi(self): - aoi = "abc" - - with pytest.raises(TypeError, match=f"'{aoi}' not recognized as an aoi"): - Table.get(self.product_id, aoi=aoi, property_filter=filter) - - @responses.activate - def test_spatial_get_feature(self): - # Get the table - table_response = { - "id": self.product_id, - "name": self.table_name, - "description": self.table_description, - "is_spatial": True, - "model": SpatialModel.model_json_schema(), - "created": "2024-01-01T00:00:00.000000", - } - - self.mock_response("GET", f"/products/{self.product_id}", json=table_response) - - table = Table.get(self.product_id) - - feature_id = "1234" - - fc = geojson.FeatureCollection( - [ - { - "geometry": {"coordinates": [4.5, 52.1], "type": "Point"}, - "properties": { - "color": "red", - "size": "small", - "name": "Frank", - "type": "Person", - "uuid": feature_id, - }, - "type": "Feature", - }, - ] - ) - - df = gpd.GeoDataFrame.from_features(fc.features, crs="EPSG:4326") - - buffer = BytesIO() - df.to_parquet(buffer, index=False) - buffer.seek(0) - - self.mock_response( - "GET", - f"/products/{self.product_id}/features/{feature_id}", - body=buffer.read(), - headers={"is_spatial": "True"}, - ) - - feature = table.get_feature(feature_id) - - assert isinstance(feature, Feature) - assert feature.id == f"{self.product_id}:{feature_id}" - assert feature.product_id == self.product_id - assert feature.name == feature_id - assert feature.is_spatial is True - assert feature.values == { - "geometry": df.iloc[0]["geometry"], - "color": "red", - "size": "small", - "name": "Frank", - "type": "Person", - "uuid": feature_id, - } - - @responses.activate - def test_nonspatial_get_feature(self): - # Get the table - table_response = { - "id": self.product_id, - "name": self.table_name, - "description": self.table_description, - "is_spatial": False, - "model": NonSpatialModel.model_json_schema(), - "created": "2024-01-01T00:00:00.000000", - } - - self.mock_response("GET", f"/products/{self.product_id}", json=table_response) - - table = Table.get(self.product_id) - - feature_id = "1234" - - df = pd.DataFrame( - { - "color": ["red"], - "size": ["small"], - "name": ["Frank"], - "type": ["Person"], - "uuid": [feature_id], - } - ) - - buffer = BytesIO() - df.to_parquet(buffer, index=False) - buffer.seek(0) - - self.mock_response( - "GET", - f"/products/{self.product_id}/features/{feature_id}", - body=buffer.read(), - headers={"is_spatial": "False"}, - ) - - feature = table.get_feature(feature_id) - - assert isinstance(feature, Feature) - assert feature.id == f"{self.product_id}:{feature_id}" - assert feature.product_id == self.product_id - assert feature.name == feature_id - assert feature.is_spatial is False - assert feature.values == { - "color": "red", - "size": "small", - "name": "Frank", - "type": "Person", - "uuid": feature_id, - } - - @responses.activate - def test_delete_table(self): - # Get the table - table_response = { - "id": self.product_id, - "name": self.table_name, - "description": self.table_description, - "is_spatial": True, - "model": SpatialModel.model_json_schema(), - "created": "2024-01-01T00:00:00.000000", - } - - self.mock_response("GET", f"/products/{self.product_id}", json=table_response) - - table = Table.get(self.product_id) - - # Delete the table: - self.mock_response("DELETE", f"/products/{self.product_id}") - - table.delete() diff --git a/descarteslabs/core/vector/tiles.py b/descarteslabs/core/vector/tiles.py deleted file mode 100644 index 641ec37b..00000000 --- a/descarteslabs/core/vector/tiles.py +++ /dev/null @@ -1,91 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 urllib.parse -from typing import List, Optional - -from ..common.property_filtering import Properties - -from .layers import DLVectorTileLayer -from .vector import Table -from .vector_client import VectorClient - - -def create_layer( - product_id: str, - name: str, - property_filter: Optional[Properties] = None, - columns: Optional[List[str]] = None, - vector_tile_layer_styles: Optional[dict] = None, - client: Optional[VectorClient] = None, -) -> DLVectorTileLayer: - """ - Create vector tile layer from a Vector Table. - - Parameters - ---------- - product_id : str - Product ID of the Vector Table. - name : str - Name to give to the ipyleaflet vector tile layer. - property_filter : Properties, optional - Property filter to apply to the vector tiles. - columns : list of str, optional - Optional list of column names to include. These can be used for styling. - vector_tile_layer_styles : dict, optional - Vector tile styles to apply. See https://ipyleaflet.readthedocs.io/en/latest/layers/vector_tile.html for more - details. - client : VectorClient, optional - Client to use for requests. If not provided, the default client will be used. - - Returns - ------- - DLVectorTileLayer - """ - if client is None: - client = VectorClient.get_default_client() - - # Error if the table is not found or spatial - table = Table.get(product_id, client=client) - - if not table.is_spatial: - raise ValueError(f"'{product_id}' is not a spatially enabled Vector Table") - - # Initialize vector tile layer styles if no styles are provided - if vector_tile_layer_styles is None: - vector_tile_layer_styles = {} - - # Initialize the property filter if none is provided - if property_filter is not None: - property_filter = property_filter.serialize() - - # Construct the query parameters - property_filter = json.dumps(property_filter) - columns = json.dumps(columns) - query_params = urllib.parse.urlencode( - { - "property_filter": property_filter, - "columns": columns, - }, - doseq=True, - ) - - # Create an ipyleaflet vector tile layer and return it - lyr = DLVectorTileLayer( - url=f"{client.base_url}/products/{product_id}/tiles/{{z}}/{{x}}/{{y}}?{query_params}", - name=name, - vector_tile_layer_styles=vector_tile_layer_styles, - ) - return lyr diff --git a/descarteslabs/core/vector/util.py b/descarteslabs/core/vector/util.py deleted file mode 100644 index 17c523c4..00000000 --- a/descarteslabs/core/vector/util.py +++ /dev/null @@ -1,51 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 io -from typing import Union - -import geopandas as gpd -import pandas as pd -import requests - -from descarteslabs.exceptions import ServerError - - -def response_to_dataframe( - response: requests.Response, -) -> Union[pd.DataFrame, gpd.GeoDataFrame]: - """ - Function to convert the content of a response to - Pandas DataFrame or GeoPandas GeoDataFrame. - - Parameters - ---------- - response: requests.Response - Response object from requests call. - - Returns - ------- - Union[pd.DataFrame, gpd.GeoDataFrame] - """ - buffer = io.BytesIO(response.content) - - is_spatial = response.headers.get("is_spatial", "false").lower() == "true" - - try: - if is_spatial: - return gpd.read_parquet(buffer) - else: - return pd.read_parquet(buffer) - except Exception as e: - raise ServerError(f"Unable to convert response to DataFrame: {e}") diff --git a/descarteslabs/core/vector/vector.py b/descarteslabs/core/vector/vector.py deleted file mode 100644 index b39c07e5..00000000 --- a/descarteslabs/core/vector/vector.py +++ /dev/null @@ -1,1575 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 __future__ import annotations - -from datetime import datetime -from typing import List, Literal, Optional, Tuple, Union - -import geopandas as gpd -import pandas as pd -import shapely - -from descarteslabs.exceptions import NotFoundError - -from ..common.geo import GeoContext -from ..common.property_filtering import Properties -from ..common.vector.models import GenericFeatureBaseModel, VectorBaseModel - -# To avoid confusion we import these as _ -from .features import Statistic -from .features import add as features_add -from .features import aggregate as features_aggregate -from .features import delete as features_delete -from .features import get as features_get -from .features import join as features_join -from .features import query as features_query -from .features import sjoin as features_sjoin -from .features import update as features_update -from .products import create as products_create -from .products import delete as products_delete -from .products import get as products_get -from .products import list as products_list -from .products import update as products_update -from .vector_client import VectorClient - -ACCEPTED_GEOM_TYPES = [ - "Point", - "MultiPoint", - "Line", - "LineString", - "MultiLine", - "MultiLineString", - "Polygon", - "MultiPolygon", - "GeometryCollection", -] - - -# Supporting functions for geometry filtering. - - -def _geojson_to_shape(gj: dict) -> shapely.geometry.base.BaseGeometry: - """ - Convert a GeoJSON dict into a shapely shape. - - Parameters - ---------- - gj: dict - GeoJSON object - - Returns - ------- - shp: shapely.geometry.base.BaseGeometry - Shapely shape for the geojson. - """ - return shapely.geometry.shape(gj) - - -def _dl_aoi_to_shape(aoi: GeoContext) -> shapely.geometry.base.BaseGeometry: - """ - Convert an AOI object into a shapely shape. - - Parameters - ---------- - aoi: descarteslabs.geo.GeoContext - AOI for which we want a shapely shape. - - Returns - ------- - shp: shapely.geometry.base.BaseGeometry - Shapely shape for this AOI. - """ - # This only works if we have a geometry or the aoi.bounds_crs is 4326! - # We have no way in the client to do the right thing otherwise. - if aoi.geometry is not None: - return aoi.geometry - if aoi.bounds_crs == "EPSG:4326": - return shapely.geometry.box(*list(aoi.bounds)) - raise ValueError( - "Can't convert aoi to shape. Please provide aoi.geometry or aoi.bounds_crs='EPSG:4326'" - ) - - -def _to_shape( - aoi: Optional[Union[GeoContext, dict, shapely.geometry.base.BaseGeometry]] = None -) -> Union[shapely.geometry.base.BaseGeometry, None]: - """ - Attempt to convert input to a shapely object. - - Raise an exception for non-None values that can't be converted. - - Parameters - ---------- - aoi: Optional[Union[descarteslabs.geo.GeoContext, dict, shapely.geometry.base.BaseGeometry]] - Optional AOI to convert to a shapely object. - - Returns - ------- - shp: Union[shapely.geometry.base.BaseGeometry, None] - None if aoi is None, or a shapely representation of the aoi. - """ - - if not aoi: - return None - - # Convert the AOI object to a shapely object so we can - # perform intersections. - if isinstance(aoi, dict): - aoi = _geojson_to_shape(aoi) - elif issubclass(type(aoi), GeoContext): - aoi = _dl_aoi_to_shape(aoi) - elif issubclass(type(aoi), shapely.geometry.base.BaseGeometry): - return aoi - else: - raise TypeError(f"'{aoi}' not recognized as an aoi") - - return aoi - - -def _shape_to_geojson(shp: shapely.geometry.base.BaseGeometry) -> dict: - """ - Convert a shapely object into a GeoJSON. - - Parameters - ---------- - shp: shapely.geometry.base.BaseGeometry - - Returns - ------- - gj: dict - GeoJSON dict for this shape - """ - if shp: - return shapely.geometry.mapping(shp) - return None - - -class TableOptions: - """ - A class for controlling Table options and parameters. - """ - - def __init__( - self, - product_id: str, - aoi: Optional[ - Union[GeoContext, dict, shapely.geometry.base.BaseGeometry] - ] = None, - property_filter: Optional[Properties] = None, - columns: Optional[List[str]] = None, - ): - """ - Initialize a TableOptions instance. - - Parameters - ---------- - product_id: str - Product ID of a Vector Table. - aoi: Optional[Union[descarteslabs.geo.GeoContext, dict, shapely.geometry.base.BaseGeometry]] - AOI to associate with this TableOptions. - property_filter: Optional[Properties] - Property filter to associate with this TableOptions. - columns: Optional[List[str]] - List of columns to include with this TableOptions. - """ - self._product_id = product_id - self._aoi = _to_shape(aoi) - self._property_filter = property_filter - self._columns = columns - - @property - def product_id(self) -> str: - """ - Return the product ID of this TableOptions. - - Parameters - ---------- - None - - Returns - ------- - str - """ - return self._product_id - - @product_id.setter - def product_id(self, product_id: str) -> None: - """ - Set the product ID of this TableOptions. - - Parameters - ---------- - product_id: str - Product ID of a Vector Table. - - Returns - ------- - None - """ - if not isinstance(product_id, str): - raise TypeError("'product_id' must be of type ") - self._product_id = product_id - - @property - def aoi(self) -> shapely.geometry.shape: - """ - Return the AOI option of this TableOptions. - - Parameters - ---------- - None - - Returns - ------- - shapely.geometry.shape - """ - return self._aoi - - @aoi.setter - def aoi( - self, - aoi: Optional[ - Union[GeoContext, dict, shapely.geometry.base.BaseGeometry] - ] = None, - ) -> None: - """ - Set the AOI option of this TableOptions. - - Parameters - ---------- - aoi: Union[descarteslabs.geo.GeoContext, dict, shapely.geometry.base.BaseGeometry] - AOI of this TableOptions. - - Returns - ------- - None - """ - self._aoi = _to_shape(aoi) - - @property - def property_filter(self) -> Properties: - """ - Return the property_filter option of this TableOptions. - - Parameters - ---------- - None - - Returns - ------- - Properties - """ - return self._property_filter - - @property_filter.setter - def property_filter(self, property_filter: Optional[Properties] = None) -> None: - """ - Set the property_filter option of this TableOptions. - - Parameters - ---------- - property_filter: Properties - property_filter option of this TableOptions. - - Returns - ------- - None - """ - if hasattr(property_filter, "jsonapi_serialize"): - self._property_filter = property_filter - elif not property_filter: - self._property_filter = None - else: - raise TypeError("'property_filter' must be of type or ") - - @property - def columns(self) -> List[str]: - """ - Return the columns option of this TableOptions. - - Parameters - ---------- - None - - Returns - ------- - list - """ - return self._columns - - @columns.setter - def columns(self, columns: Optional[List[str]] = None) -> None: - """ - Set the columns option of this TableOptions. - - Parameters - ---------- - columns: List[str] - List of columns to include. - - Returns - ------- - None - """ - if isinstance(columns, list): - self._columns = columns - elif not columns: - self._columns = None - else: - raise TypeError("'columns' must be of type or ") - self._columns = columns - - -class Table: - """ - A class for creating and interacting with Vector Tables. - """ - - def __init__( - self, - table_parameters: Union[dict, str], - options: TableOptions = None, - client: Optional[VectorClient] = None, - ): - """ - Initialize a Vector Table instance. - - Users should create a Table instance via `Table.get` or `Table.create`. - - Parameters - ---------- - product_parameters: Union[dict, str] - Dictionary of product parameters or the product ID of a Vector Table. - client : VectorClient, optional - Client to use for requests. If not provided, the default client will be used. - """ - - if client is None: - client = VectorClient.get_default_client() - - if isinstance(table_parameters, str): - table_parameters = products_get(table_parameters, client=client) - - for k, v in table_parameters.items(): - setattr(self, f"_{k}", v) - - if not options: - options = TableOptions(self.id) - - if not isinstance(options, TableOptions): - raise TypeError(("'options' must be of type ")) - - self.options = options - self._client = client - - @staticmethod - def get( - product_id: str, - aoi: Optional[ - Union[GeoContext, dict, shapely.geometry.base.BaseGeometry] - ] = None, - property_filter: Optional[Properties] = None, - columns: Optional[List[str]] = [], # noqa: M511 - client: Optional[VectorClient] = None, - ) -> Table: - """ - Get a Vector Table instance from a Vector Table product ID. Raise an exception if - this `product_id` doesn't exit. - - Parameters - ---------- - product_id: str - Product ID of the Vector Table. - aoi: Optional[Union[descarteslabs.geo.GeoContext, dict, shapely.geometry.base.BaseGeometry]] - AOI to associate with this Vector Table. - property_filter: Optional[Properties] - Property filter to associate with this Vector Table. - columns: Optional[List[str]] - List of columns to include. - client : VectorClient, optional - Client to use for requests. If not provided, the default client will be used. - - Returns - ------- - Table - """ - options = TableOptions( - product_id=product_id, - aoi=aoi, - property_filter=property_filter, - columns=columns, - ) - - if client is None: - client = VectorClient.get_default_client() - - return Table( - table_parameters=products_get(product_id, client=client), - options=options, - client=client, - ) - - @staticmethod - def create( - product_id: str, - name: str, - description: Optional[str] = None, - tags: Optional[List[str]] = None, - readers: Optional[List[str]] = None, - writers: Optional[List[str]] = None, - owners: Optional[List[str]] = None, - model: Optional[VectorBaseModel] = GenericFeatureBaseModel, - client: Optional[VectorClient] = None, - ) -> Table: - """ - Create a Vector Table. - - Parameters - ---------- - product_id : str - Product ID of the Vector Table. - name : str - Name of the Vector Table. - description : str, optional - Description of the Vector Table. - tags : list of str, optional - A list of tags to associate with the Vector Table. - readers : list of str, optional - A list of Vector Table readers. Can take the form "user:{namespace}", "group:{group}", "org:{org}", or - "email:{email}". - writers : list of str, optional - A list of Vector Table writers. Can take the form "user:{namespace}", "group:{group}", "org:{org}", or - "email:{email}". - owners : list of str, optional - A list of Vector Table owners. Can take the form "user:{namespace}", "group:{group}", "org:{org}", or - "email:{email}". - model : VectorBaseModel, optional - A model that provides a user provided schema for the Vector Table. - client : VectorClient, optional - Client to use for requests. If not provided, the default client will be used. - - Returns - ------- - Table - """ - - if client is None: - client = VectorClient.get_default_client() - - return Table( - table_parameters=products_create( - product_id=product_id, - name=name, - description=description, - tags=tags, - readers=readers, - writers=writers, - owners=owners, - model=model, - client=client, - ), - client=client, - ) - - @staticmethod - def list( - tags: Optional[List[str]] = None, - client: Optional[VectorClient] = None, - ) -> List[Table]: - """ - List available Vector Tables. - - Parameters - ---------- - tags: list of str - Optional list of tags a Vector Table must have to be returned. - client : VectorClient, optional - Client to use for requests. If not provided, the default client will be used. - - Returns - ------- - List[Table] - """ - - if client is None: - client = VectorClient.get_default_client() - - return [ - Table(table_parameters=d, client=client) - for d in products_list(tags=tags, client=client) - ] - - @property - def id(self) -> str: - """ - Return the product ID of this Vector Table. - - Parameters - ---------- - None - - Returns - ------- - str - """ - return self._id - - @property - def created(self) -> datetime: - """ - Return the datetime this Vector Table was created. - - Parameters - ---------- - None - - Returns - ------- - datetime - """ - try: - return datetime.fromisoformat(self._created) - except ValueError: - return datetime.strptime(self._created, "%Y-%m-%dT%H:%M:%S.%f") - - @property - def is_spatial(self) -> bool: - """ - Return a boolean indicating whether or not this Vector Table is spatial. - - Parameters - ---------- - None - - Returns - ------- - bool - """ - return self._is_spatial - - @property - def name(self) -> str: - """ - Return the name of this Vector Table. - - Parameters - ---------- - None - - Returns - ------- - str - """ - return self._name - - @name.setter - def name(self, value: str) -> None: - """ - Set the name of this Vector Table. - - Parameters - ---------- - value: str - Name of the Vector Table. - - Returns - ------- - None - """ - if isinstance(value, str): - self._name = value - else: - raise TypeError("Table 'name' must be of type ") - - @property - def description(self) -> str: - """ - Return the description of this Vector Table. - - Parameters - ---------- - None - - Returns - ------- - str - """ - return self._description - - @description.setter - def description(self, value: str) -> None: - """ - Set the description of this Vector Table. - - Parameters - ---------- - value: str - Description of the Vector Table. - - Returns - ------- - None - """ - if isinstance(value, str): - self._description = value - else: - raise TypeError("Table 'description' must be of type ") - - @property - def tags(self) -> List[str]: - """ - Return the tags of this Vector Table. - - Parameters - ---------- - None - - Returns - ------- - List[str] - """ - return self._tags - - @tags.setter - def tags(self, value: List[str]) -> None: - """ - Set the tags for this Vector Table. - - Parameters - ---------- - value: List[str] - A list of tags to associate with the Vector Table. - Returns - ------- - None - """ - if isinstance(value, list): - self._tags = value - else: - raise TypeError("Table 'tags' must be of type ") - - @property - def readers(self) -> List[str]: - """ - Return the readers of this Vector Table. - - Parameters - ---------- - None - - Returns - ------- - List[str] - """ - return self._readers - - @readers.setter - def readers(self, value: List[str]) -> None: - """ - Set the readers for this Vector Table. - - Parameters - ---------- - value: List[str] - Readers for this Vector Table. - - Returns - ------- - None - """ - if isinstance(value, list): - self._readers = value - else: - raise TypeError("Table 'readers' must be of type ") - - @property - def writers(self) -> List[str]: - """ - Return the writers of this Vector Table. - - Parameters - ---------- - None - - Returns - ------- - List[str] - """ - return self._writers - - @writers.setter - def writers(self, value: List[str]) -> None: - """ - Set the writers for the Vector Table. - - Parameters - ---------- - value: List[str] - Writers for the Vector Table - - Returns - ------- - None - """ - if isinstance(value, list): - self._writers = value - else: - raise TypeError("Table 'writers' must be of type ") - - @property - def owners(self) -> List[str]: - """ - Return the owners of this Vector Table. - - Parameters - ---------- - None - - Returns - ------- - List[str] - """ - return self._owners - - @owners.setter - def owners(self, value: List[str]) -> None: - """ - Set the owners for this Vector Table. - - Parameters - ---------- - value: List[str] - Owners of this Vector Table. - - Returns - ------- - None - """ - if isinstance(value, list): - self._owners = value - else: - raise TypeError("Table 'owners' must be of type ") - - @property - def model(self) -> dict: - """ - Return the model of this Vector Table. - - Parameters - ---------- - None - - Returns - ------- - dict - """ - return self._model - - @property - def columns(self) -> List[str]: - """ - Return the column names of this Vector Table. - - Parameters - ---------- - None - - Returns - ------- - List[str] - """ - return list(self._model["properties"].keys()) - - @property - def parameters(self) -> dict: - """ - Return the Vector Table parameters as dictionary. - - Parameters - ---------- - None - - Returns - ------- - dict - """ - - keys = [ - "_id", - "_name", - "_description", - "_tags", - "_readers", - "_writers", - "_owners", - "_model", - ] - - params = {} - - for k in keys: - params[k.lstrip("_")] = self.__dict__[k] - - return params - - def __repr__(self) -> str: - """ - Generate a string representation of this Vector Table. - - Parameters - ---------- - None - - Return - ------ - str - """ - return f"Table: {self.name}\n id: {self.id}\n created: {self.created.strftime('%a %b %d %H:%M:%S %Y')}" - - def __str__(self) -> str: - """ - Generate a string representation of this Vector Table. - - Parameters - ---------- - None - - Return - ------ - str - """ - return self.__repr__() - - def save(self) -> None: - """ - Save/update this Vector Table. - - Parameters - ---------- - None - - Return - ------ - None - """ - products_update( - product_id=self.id, - name=self.name, - description=self.description, - tags=self.tags, - readers=self.readers, - writers=self.writers, - owners=self.owners, - client=self._client, - ) - - def add( - self, - dataframe: Union[pd.DataFrame, gpd.GeoDataFrame], - ) -> Union[pd.DataFrame, gpd.GeoDataFrame]: - """ - Add a dataframe to this table. If the Vector Table has a `geometry` column - the dataframe must be a GeoPandas GeoDataFrame, otherwise a Pandas DataFrame - must be provided. Note that the returned dataframe will have UUID attribution - for each row. - - Parameters - ---------- - dataframe:gpd.GeoDataFrame|pd.DataFrame - GeoPandas dataframe to add to this table. - - Returns - ------- - Union[pd.DataFrame, gpd.GeoDataFrame] - """ - - return features_add( - product_id=self.id, - dataframe=dataframe, - client=self._client, - ) - - def get_feature( - self, - feature_id: str, - ) -> Feature: - """ - Get a Vector Feature from this Vector Table instance. - - Parameters - ---------- - feature_id: str - Vector Feature ID for the feature to get. - - Returns - ------- - Feature - """ - return Feature.get(id=f"{self.id}:{feature_id}", client=self._client) - - def try_get_feature(self, feature_id: str) -> Feature: - """ - Get a Vector Feature from this Vector Table instance. - - Parameters - ---------- - feature_id: str - Vector Feature ID for the feature to get. - - Returns - ------- - Feature - """ - try: - return Feature.get(id=f"{self.id}:{feature_id}", client=self._client) - except NotFoundError: - return None - - def visualize( - self, - name: str, - map, # No type hint because we don't want to require ipyleaflet - vector_tile_layer_styles: Optional[dict] = None, - override_options: TableOptions = None, - ) -> None: - """ - Visualize this Vector Table as an `ipyleaflet` VectorTileLayer. - The property_filter and the columns specified with the Table - options will be honored but the AOI option will be ignored. - - To use this method, you must have the `ipyleaflet` package installed, - it is not installed by default when installing the Descartes Labs python - client. - - Parameters - ---------- - name : str - Name to give to the ipyleaflet VectorTileLayer. - map: ipyleaflet.leaflet.Map - Map to which to add the layer - vector_tile_layer_styles : dict, optional - Vector tile styles to apply. See https://ipyleaflet.readthedocs.io/en/latest/layers/vector_tile.html for - more details. - override_options: TableOptions - Override options for this query. AOI option is ignored - when invoking this method. - - Returns - ------- - DLVectorTileLayer - Vector tile layer that can be added to an ipyleaflet map. - """ - # Avoid circular import - from .tiles import create_layer - - options = override_options if override_options else self.options - - if not isinstance(options, TableOptions): - raise TypeError("'options' must be of type ") - - lyr = create_layer( - product_id=self.id, - name=name, - property_filter=options.property_filter, - columns=options.columns, - vector_tile_layer_styles={self.id: vector_tile_layer_styles}, - ) - for layer in map.layers: - if layer.name == name: - map.remove_layer(layer) - break - map.add_layer(lyr) - - def collect( - self, override_options: TableOptions = None - ) -> Union[pd.DataFrame, gpd.GeoDataFrame]: - """ - Method to execute a query/collect on this Vector Table, returning a - dataframe. Table options will be honored when executing the query. - If the Vector Table has a `geometry` column and the `geometry` column - is included in the Table options, a GeoPandas GeoDataFrame will be - returned, otherwise a Pandas DataFrame will be returned. - - Parameters - ---------- - override_options: TableOptions - Override options for this query. - - Returns - ------- - Union[pd.DataFrame, gpd.GeoDataFrame] - """ - - options = override_options if override_options else self.options - - if not isinstance(options, TableOptions): - raise TypeError("'options' must be of type ") - - return features_query( - options.product_id, - property_filter=options.property_filter, - aoi=_shape_to_geojson(options.aoi), - columns=options.columns, - client=self._client, - ) - - def join( - self, - join_table: [Union[Table, TableOptions]], - join_type: Literal["INNER", "LEFT", "RIGHT"], - join_columns: List[Tuple[str, str]], - override_options: Optional[TableOptions] = None, - ) -> Union[pd.DataFrame, gpd.GeoDataFrame]: - """ - Method to execute a relational join between two Vector Tables, - returning a dataframe. Table options will be honored when executing - the query. If either Vector Table has a `geometry` column and either - Vector Table included the 'geometry' column in the Table options, a - GeoPandas GeoDataFrame will be returned, otherwise a Pandas DataFrame - will be returned. - - Parameters - ---------- - join_table: [Union[Table, TableOptions]] - The Vector Table or TableOptions to join. - join_type: Literal["INNER", "LEFT", "RIGHT"] - The type of join to perform. Must be one of INNER, - LEFT, or RIGHT. - join_columns: List[Tuple[str, str]] - List of column names to join on. Must be formatted - as [(table1_col1, table2_col2), ...]. - override_options: TableOptions - Override options for this query. - - Returns - ------- - Union[pd.DataFrame, gpd.GeoDataFrame] - """ - options = override_options if override_options else self.options - - if not isinstance(options, TableOptions): - raise TypeError("'override_options' must be of type ") - - if isinstance(join_table, TableOptions): - pass - elif isinstance(join_table, Table): - join_table = join_table.options - else: - raise TypeError("'join_table' must be of type ") - - include_columns = [tuple(options.columns), tuple(join_table.columns)] - - return features_join( - input_product_id=options.product_id, - join_product_id=join_table.product_id, - join_type=join_type, - join_columns=join_columns, - include_columns=include_columns, - input_property_filter=options.property_filter, - input_aoi=_shape_to_geojson(options.aoi), - join_property_filter=join_table.property_filter, - join_aoi=_shape_to_geojson(join_table.aoi), - client=self._client, - ) - - def sjoin( - self, - join_table: [Union[Table, TableOptions]], - join_type: Literal["INTERSECTS", "CONTAINS", "OVERLAPS", "WITHIN"], - override_options: Optional[TableOptions] = None, - keep_all_input_rows: Optional[bool] = False, - ) -> Union[pd.DataFrame, gpd.GeoDataFrame]: - """ - Method to execute a spatial join between two Vector Tables, - returning a dataframe. Table options will be honored when executing - the query. Both Vector Tables must have a `geometry` column. If either - Vector Table included the 'geometry' column in the Table options, a - GeoPandas GeoDataFrame will be returned, otherwise a Pandas DataFrame - will be returned. - - Parameters - ---------- - join_table: [Union[Table, TableOptions]] - The Vector Table or TableOptions to join. - join_type: Literal["INTERSECTS", "CONTAINS", "OVERLAPS", "WITHIN"] - The type of join to perform. Must be one of INTERSECTS, - CONTAINS, OVERLAPS, WITHIN. - override_options: TableOptions - Override options for this query. - - Returns - ------- - Union[pd.DataFrame, gpd.GeoDataFrame] - """ - options = override_options if override_options else self.options - - if not isinstance(options, TableOptions): - raise TypeError("'override_options' must be of type ") - - if isinstance(join_table, TableOptions): - join_is_spatial = Table.get(product_id=join_table.product_id).is_spatial - elif isinstance(join_table, Table): - join_is_spatial = join_table.is_spatial - join_table = join_table.options - else: - raise TypeError("'join_table' must be of type ") - - if not self.is_spatial and not join_is_spatial: - raise TypeError("Both Tables must have a geometry column for spatial joins") - - include_columns = [tuple(options.columns), tuple(join_table.columns)] - - return features_sjoin( - input_product_id=options.product_id, - join_product_id=join_table.product_id, - join_type=join_type, - include_columns=include_columns, - input_property_filter=options.property_filter, - input_aoi=_shape_to_geojson(options.aoi), - join_property_filter=join_table.property_filter, - join_aoi=_shape_to_geojson(join_table.aoi), - keep_all_input_rows=keep_all_input_rows, - client=self._client, - ) - - def _aggregate( - self, statistic: Statistic, override_options: TableOptions - ) -> Union[int, dict]: - """ - Private method for handling aggregate functions. The statistic - COUNT will always return an integer. All other statistics will - return a dictionary of results. Keys of the dictionary will be - the column names requested appended with the statistic - ('column_1.STATISTIC') and values are the result of the aggregate - statistic. - - Parameters - ---------- - statistic: Statistic - Statistic to calculate. - override_options: TableOptions - Override options for this query. - - Returns - ------- - Union[int, dict] - """ - options = override_options if override_options else self.options - - if not isinstance(statistic, Statistic): - raise TypeError("'statistic' must be of type ") - - if not isinstance(options, TableOptions): - raise TypeError("'options' must be of type ") - - return features_aggregate( - product_id=options.product_id, - statistic=statistic, - columns=options.columns, - property_filter=options.property_filter, - aoi=_shape_to_geojson(options.aoi), - client=self._client, - ) - - def count( - self, - override_options: Optional[TableOptions] = None, - ) -> int: - """ - Method to return the row count of a Vector Table. Table options - will be honored when counting rows. - - Parameters - ---------- - override_options: TableOptions - Override options for this query. - - Returns - ------- - int - """ - - return self._aggregate( - statistic=Statistic.COUNT, override_options=override_options - ) - - def sum( - self, - override_options: Optional[TableOptions] = None, - ) -> dict: - """ - Method to calculate the column sum for this Vector Table. - Table options will be honored when calculating the sum. The keys - of the returned dictionary correspond to the columns requested, - appended with the statistic ('column_1.SUM') and the values - are the result of the aggregate statistic. - - Parameters - ---------- - override_options: TableOptions - Override options for this query. - - Returns - ------- - dict - """ - - return self._aggregate( - statistic=Statistic.SUM, override_options=override_options - ) - - def min( - self, - override_options: Optional[TableOptions] = None, - ) -> dict: - """ - Method to calculate the column minumum for this Vector Table. - Table options will be honored when calculating the min. The keys - of the returned dictionary correspond to the columns requested, - appended with the statistic ('column_1.MIN') and the values - are the result of the aggregate statistic. - - Parameters - ---------- - override_options: TableOptions - Override options for this query. - - Returns - ------- - dict - """ - - return self._aggregate( - statistic=Statistic.MIN, override_options=override_options - ) - - def max( - self, - override_options: Optional[TableOptions] = None, - ) -> dict: - """ - Method to calculate the column maximum for this Vector Table. - Table options will be honored when calculating the max. The keys - of the returned dictionary correspond to the columns requested, - appended with the statistic ('column_1.MAX') and the values - are the result of the aggregate statistic. - - Parameters - ---------- - override_options: TableOptions - Override options for this query. - - Returns - ------- - dict - """ - - return self._aggregate( - statistic=Statistic.MAX, override_options=override_options - ) - - def mean( - self, - override_options: Optional[TableOptions] = None, - ) -> dict: - """ - Method to calculate the column mean/average for this Vector Table. - Table options will be honored when calculating the mean. The keys - of the returned dictionary correspond to the columns requested, - appended with the statistic ('column_1.MEAN') and the values - are the result of the aggregate statistic. - - Parameters - ---------- - override_options: TableOptions - Override options for this query. - - Returns - ------- - dict - """ - - return self._aggregate( - statistic=Statistic.MEAN, override_options=override_options - ) - - def reset_options(self) -> None: - """ - Method to reset/clear current TableOptions. - - Parameters - ---------- - None - - Returns - ------- - None - """ - self.options.property_filter = None - self.options.columns = None - self.options.aoi = None - - def delete(self) -> None: - """ - Delete this Vector Table. This method will disable all subsequent non-static method calls. - - Parameters - ---------- - None - - Returns - ------- - None - """ - products_delete(product_id=self.id, client=self._client) - - -class Feature: - """ - A class for interacting with a Vector Feature. - """ - - def __init__( - self, - id: str, - dataframe: Union[pd.DataFrame, gpd.GeoDataFrame], - client: Optional[VectorClient] = None, - ): - """ - Initialize a Vector Feature instance. - - Users should create a Vector Feature instance via `Table.get_feature` - or `Feature.get`. - - Parameters - ---------- - id: str - ID of the Vector Feature. - dataframe: Union[pd.DataFrame, gpd.GeoDataFrame] - Pandas DataFrame or a GeoPandas GeoDataFrame. - client : VectorClient, optional - Client to use for requests. If not provided, the default client will be used. - """ - - try: - pid, fid = id.rsplit(":", 1) - except ValueError: - raise ValueError("Invalid Feature ID") from None - - if isinstance(dataframe, gpd.GeoDataFrame): - self._is_spatial = True - elif isinstance(dataframe, pd.DataFrame): - self._is_spatial = False - else: - raise TypeError( - "'dataframe' must be of type or " - ) - self._id = id - self._values = {} - for k, v in dataframe.to_dict().items(): - self._values[k] = v[0] - - # I don't think we need to initialize this if it is None - self._client = client - - def __repr__(self) -> str: - """ - Generate a string representation of this Vector Feature. - - Parameters - ---------- - None - - Return - ------ - str - """ - return f"Feature: {self.name}\n id: {self.id}\n table: {self.product_id}" - - def __str__(self) -> str: - """ - Generate a string representation of this Vector Feature. - - Parameters - ---------- - None - - Return - ------ - str - """ - return self.__repr__() - - @property - def is_spatial(self) -> bool: - """ - Return a boolean indicating whether or not this Vector Feature is spatial. - - Parameters - ---------- - None - - Returns - ------- - bool - """ - return self._is_spatial - - @property - def values(self) -> dict: - """ - Return a dictionary of colum/value pairs for this Vector Feature. - - Returns - ------- - dict - """ - return self._values - - @values.setter - def values(self, key, value) -> None: - """ - Set a colum/value pair for this Vector Feature. - - Returns - ------- - None - """ - self._values[key] = value - - @property - def id(self) -> str: - """ - Return the ID of this Vector Feature. - - Returns - ------- - str - """ - return self._id - - @property - def product_id(self) -> str: - """ - Return the Vector Table product ID of this Vector Feature. - - Returns - ------- - str - """ - return self._id.rsplit(":", 1)[0] - - @property - def name(self) -> str: - """ - Return the name/uuid of ths Vector Feature. - - Returns - ------- - str - """ - return self._id.rsplit(":", 1)[1] - - @property - def table(self) -> Table: - """ - Return the Vector Table of this Vector Feature. - - Returns - ------- - Table - """ - return Table.get(product_id=self.product_id) - - @staticmethod - def get( - id: str, - client: Optional[VectorClient] = None, - ) -> Feature: - """ - Get a Vector Feature instance associated with an ID. - - Parameters - ---------- - id: str - ID of the Vector Feature. - client : VectorClient, optional - Client to use for requests. If not provided, the default client will be used. - - Returns - ------- - Feature - """ - try: - pid, fid = id.rsplit(":", 1) - except ValueError: - raise ValueError("Invalid Feature ID") from None - - if client is None: - client = VectorClient.get_default_client() - - dataframe = features_get(product_id=pid, feature_id=fid, client=client) - return Feature(id=id, dataframe=dataframe, client=client) - - def save(self) -> None: - """ - Save/update this Vector Feature. - - Parameters - ---------- - None - - Returns - ------- - None - """ - - if self.is_spatial: - dataframe = gpd.GeoDataFrame.from_features([self], crs="EPSG:4326") - else: - dataframe = pd.DataFrame([self.values]) - features_update( - product_id=self.product_id, - feature_id=self.name, - dataframe=dataframe, - client=self._client, - ) - - def delete(self) -> None: - """ - Delete this Vector Feature. - - Parameters - ---------- - None - - Returns - ------- - None - """ - features_delete( - product_id=self.product_id, feature_id=self.name, client=self._client - ) - - @property - def __geo_interface__(self) -> Union[dict, None]: - if self.is_spatial: - return { - "geometry": self.values["geometry"].__geo_interface__, - "properties": { - c: self.values[c] for c in self.table.columns if c != "geometry" - }, - } - return None diff --git a/descarteslabs/core/vector/vector_client.py b/descarteslabs/core/vector/vector_client.py deleted file mode 100644 index 1abf268d..00000000 --- a/descarteslabs/core/vector/vector_client.py +++ /dev/null @@ -1,38 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 descarteslabs.auth import Auth -from descarteslabs.config import get_settings - -from ..client.services.service import ApiService -from ..common.http.service import DefaultClientMixin - - -class VectorClient(ApiService, DefaultClientMixin): - """Client for the Vector service.""" - - # We need a long timeout until we rewrite uploading and downloading - # features to work in chunks. Note that this is not applied by default - # to the session (which has a 30 second read timeout), but we use it - # where we need it. - READ_TIMEOUT = 300 - - def __init__(self, url=None, auth=None, retries=None): - if auth is None: - auth = Auth.get_default_auth() - - if url is None: - url = get_settings().vector_url - - super().__init__(url, auth=auth, retries=retries) diff --git a/descarteslabs/exceptions.py b/descarteslabs/exceptions.py deleted file mode 100644 index 8d2880e9..00000000 --- a/descarteslabs/exceptions.py +++ /dev/null @@ -1,153 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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. - - -"""Exceptions raised by HTTP clients.""" - - -class ClientError(Exception): - """Base class for all client exceptions.""" - - pass - - -class AuthError(ClientError): - """Authentication error, improperly supplied credentials.""" - - pass - - -class OauthError(AuthError): - """Authentication error, failure from OAuth authentication service.""" - - pass - - -class ConfigError(Exception): - """Configuration error during initial configuration of the library.""" - - pass - - -class ServerError(Exception): - """Server or service failure.""" - - status = 500 - - -class BadRequestError(ClientError): - """Client request with incorrect parameters.""" - - status = 400 - - -class UnauthorizedError(ClientError): - """Client request lacking authentication.""" - - status = 401 - - -class ForbiddenError(ClientError): - """Client request lacks necessary permissions.""" - - status = 403 - - -class NotFoundError(ClientError): - """Resource not found.""" - - status = 404 - - -class MethodNotAllowedError(ClientError): - """Requested nethod not supported by the resource.""" - - status = 405 - - -class ProxyAuthenticationRequiredError(ClientError): - """Client request needs proxy authentication. - - Attributes - ========== - status : int - The status code of the error response. - proxy_authenticate : Optional[str] - A `ProxyAuthenticate `_ - header if found in the response. - """ - - status = 407 - - def __init__(self, message, proxy_authenticate=None) -> None: - super(ProxyAuthenticationRequiredError, self).__init__(message) - - self.proxy_authenticate = proxy_authenticate - - -class ConflictError(ClientError): - """Client request conflicts with existing state.""" - - status = 409 - - -class GoneError(ClientError): - """Client request to a URL which has been permanently removed.""" - - status = 410 - - -class ValidationError(BadRequestError): - """Client request with invalid parameters.""" - - status = 422 - - -class RateLimitError(ClientError): - """ - Client request exceeds rate limits. - - The retry_after member will contain any time limit returned - in the response. - """ - - status = 429 - - def __init__(self, message, retry_after=None): - """ - Construct a new instance. - - :param str message: The error message. - :type retry_after: str or None - :param retry_after: An indication of a - ``retry-after`` timeout specified by the error response. - """ - super(RateLimitError, self).__init__(message) - self.retry_after = retry_after - - -class RetryWithError(ClientError): - """Vector service query request timed out.""" - - status = 449 - - -class GatewayTimeoutError(ServerError): - """Timeout from the gateway after failing to route request to destination service.""" - - status = 504 - - -class RequestCancellationError(ClientError): - """Client cancelled the request and no status or response was received.""" diff --git a/descarteslabs/geo/__init__.py b/descarteslabs/geo/__init__.py deleted file mode 100644 index 563e7fa0..00000000 --- a/descarteslabs/geo/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 descarteslabs.core.geo import * # noqa F401 F403 diff --git a/descarteslabs/utils/__init__.py b/descarteslabs/utils/__init__.py deleted file mode 100644 index 585951a0..00000000 --- a/descarteslabs/utils/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 descarteslabs.core.utils import * # noqa F401 F403 diff --git a/descarteslabs/vector/__init__.py b/descarteslabs/vector/__init__.py deleted file mode 100644 index 3cac23ab..00000000 --- a/descarteslabs/vector/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. -# -# 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 descarteslabs.core.vector import * # noqa F401 F403 diff --git a/docs/examples/plot_create_product.py b/docs/examples/plot_create_product.py deleted file mode 100644 index b08f09b9..00000000 --- a/docs/examples/plot_create_product.py +++ /dev/null @@ -1,116 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. - -""" -============================= -Upload ndarray to new product -============================= - -This example demonstrates how to create a product -in our Catalog and upload an example image. -""" - -from descarteslabs.catalog import Product, SpectralBand, Image, properties as p -import uuid - -################################################ -# Create a unique product id (to avoid collisions). -product_id = uuid.uuid4().hex - -################################################ -# Create a product entry in our Catalog. -product = Product( - id=product_id, - name="Simple Image Upload", - description="An example of creating a product, adding the visible band range, and ingesting a single scene.", -) -product.save() - -################################################ -# Add band information to the product. -# This is a necessary step, and requires the user -# to know a bit about the data to be ingested. -bands = ["red", "green", "blue"] - -for band_index, band in enumerate(bands): - SpectralBand( - product=product, # product this band will belong to - name=band, # name of the band - band_index=band_index, # 0 based index for storage and retrieval - data_type="Float64", # data type for storage - nodata=0, # pixel value indicating no data available - data_range=(0.0, 1.0), # list of the min and max data values - display_range=(0.0, 0.4), # a good default scale for display - ).save() - -################################################ -# As an aside, we can add a writer to this product. -# The product that we just created doesn't have any writers. -print("Product writers: {}".format(product.writers)) - -################################################ -# However, we can add a writer to this product. -product.writers = ["email:someuser@gmail.com"] -product.save() - -################################################ -# Now, ``'email:someuser@gmail.com'`` is a writer for this product. -# This user can now change the product metadata, -# add bands, and add imagery to this product. -print("Changed product writers: {}".format(product.writers)) - -################################################ -# Search for Sentinel-2 imagery over an AOI. -# Define a bounding box around Paris, France. -paris = { - "type": "Polygon", - "coordinates": [ - [ - [2.165946315534452, 48.713171120067045], - [2.5359015712706023, 48.713171120067045], - [2.5359015712706023, 48.957687975409726], - [2.165946315534452, 48.957687975409726], - [2.165946315534452, 48.713171120067045], - ] - ], -} - -search = ( - Product.get("esa:sentinel-2:l2a:v1") - .images() - .intersects(paris) - .filter("2020-06-24" < p.acquired < "2020-06-30") - .filter(p.cloud_fraction < 0.1) - .limit(2) -) -images = search.collect() - -################################################ -# Mosaic the image collection to a single RGB image. -ndarray_mosaic, raster_info = images.mosaic("red green blue", raster_info=True) - -################################################ -# Upload the ndarray as a single scene in our new product. -# Note: It can take several minutes for the image to -# appear in various interfaces. -image = Image( - name="Paris", product=product, acquired="2020-06-24", acquired_end="2020-06-30" -) - -upload = image.upload_ndarray(ndarray_mosaic, raster_meta=raster_info) -upload.wait_for_completion() -print(upload.status) - -################################################ -# Now the image exists and can be found by search. -print(product.images().collect()) - -################################################ -# Delete our product; we don't need it anymore. -task = product.delete_related_objects() -while task is not None: - task.wait_for_completion() - if task.status == "success": - break - task = product.delete_related_objects() -product.delete() -print("Product removed.") diff --git a/docs/examples/plot_images_mosaic.py b/docs/examples/plot_images_mosaic.py deleted file mode 100644 index 79e8cb0c..00000000 --- a/docs/examples/plot_images_mosaic.py +++ /dev/null @@ -1,85 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. - -""" -================================ -Compositing Imagery with Catalog -================================ - -Most often, our area of interest (AOI) does not conform to the arbitrary boundaries -of an image as collected by the satellite. The Catalog API enables us to -retrieve imagery mosaicked across our AOI. This example illustrates how Catalog -mosaics imagery, and how we can format our call to Catalog to group images -by acquisition date (and/or any other metadata property). - - -""" - -from descarteslabs.catalog import Product, properties as p -from descarteslabs.geo import DLTile -from descarteslabs.utils import display - -# Define my area of interest -tile = DLTile.from_latlon( - lat=38.8664364, lon=-107.238606300, resolution=20.0, tilesize=1024, pad=0 -) - -# Search for Sentinel-2 imagery collected between -# August 13 - August 21, 2017 over the AOI -search = ( - Product.get("esa:sentinel-2:l2a:v1") - .images() - .intersects(tile) - .filter("2020-08-13" <= p.acquired < "2020-08-22") - .sort("acquired") -) -images = search.collect() - -################################################ -# Let's first visualize each of these image acquisitions separately. - -# Retrieve each image separately -rasters = images.stack("nir red green") - -# Plot -dates = [image.acquired.date().isoformat() for image in images] -display(*rasters, title=dates, size=2) - -################################################ -# We can see that our area of interest straddles multiple -# Sentinel-2 granules, which is why we see only partial coverage of our AOI -# in each image. From the acquisition dates, we can see that -# these fours images were actually acquired on only one of two dates, August 13 -# and August 20, 2017. Instead of obtaining each image individually, we -# may instead want to group these by their acquisition date, and mosaic -# the images acquired on the same date. - -flatten = ["acquired.year", "acquired.month", "acquired.day"] - -rasters = images.stack( - "nir red green", - flatten=flatten, -) - -# plot the mosaics -dates = [ic[0].acquired.date().isoformat() for _, ic in images.groupby(*flatten)] -display(*rasters, title=dates, size=2) - -################################################ -# ImageCollection will mosaic the imagery in the order in which -# they appear in the list. By default this will be ordered -# by acquisition date, and the mosaic will return -# the latest image on top. -arr = images.mosaic("nir red green") - -# plot the mosaic -display(arr, title="latest", size=2) - -################################################ -# Now, let's reverse the order of the collection, -# and mosaic will return the earliest image on top. -arr = images.sorted("acquired", reverse=True).mosaic( - "nir red green", -) - -# plot the mosaic -display(arr, title="earliest", size=2) diff --git a/docs/examples/plot_multi_product.py b/docs/examples/plot_multi_product.py deleted file mode 100644 index cf075ab4..00000000 --- a/docs/examples/plot_multi_product.py +++ /dev/null @@ -1,64 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. - -""" -=============================== -Composite Multi-Product Imagery -=============================== - -Composite imagery from two data sources and -display as a single image. - -""" - -from descarteslabs.catalog import Image, properties as p -from descarteslabs.utils import display -import numpy as np - -# Define a bounding box around Taos in a GeoJSON - -taos = { - "type": "Polygon", - "coordinates": [ - [ - [-105.71868896484375, 36.33725319397006], - [-105.2105712890625, 36.33725319397006], - [-105.2105712890625, 36.73668306473141], - [-105.71868896484375, 36.73668306473141], - [-105.71868896484375, 36.33725319397006], - ] - ], -} - -# Create an ImageCollection -search = ( - Image.search() - .intersects(taos) - .filter( - p.product_id.any_of(["usgs:landsat:oli-tirs:c2:l2:v0", "esa:sentinel-2:l2a:v1"]) - ) - .filter("2018-05-01" <= p.acquired < "2018-06-01") - .filter(p.cloud_fraction < 0.2) - .sort("acquired") - .limit(15) -) -images = search.collect() - -##################################################### -# See which images we have, and how many per product: - -print(images) - -######################################### -# And if you're curious, which image IDs: - -print(images.each.id) - -####################################### -# Make a median composite of the images. - -# Request a stack of all the images using the same GeoContext with lower resolution -arr_stack = images.stack("red green blue", resolution=60, data_type="Float64") - -# Composite the images based on the median pixel value -composite = np.ma.median(arr_stack, axis=0) -display(composite, title="Taos Composite", size=2) diff --git a/docs/examples/plot_save_geotiff.py b/docs/examples/plot_save_geotiff.py deleted file mode 100644 index 7be14348..00000000 --- a/docs/examples/plot_save_geotiff.py +++ /dev/null @@ -1,50 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. - -""" -===================== -Save image to GeoTIFF -===================== - -This example demonstrates how to save an image -to your local machine in GeoTiff format. -""" - -import os -from descarteslabs.catalog import Product, properties as p - -################################################# -# Create an aoi feature to clip imagery. -box = { - "type": "Polygon", - "coordinates": [ - [ - [-108.64292971398066, 33.58051349561343], - [-108.27082685426221, 33.58051349561343], - [-108.27082685426221, 33.83925599538719], - [-108.64292971398066, 33.83925599538719], - [-108.64292971398066, 33.58051349561343], - ] - ], -} - -################################################# -# Find the images. -search = ( - Product.get("usgs:landsat:oli-tirs:c2:l2:v0") - .images() - .intersects(box) - .filter("2018-06-02" <= p.acquired < "2018-06-03") - .sort("acquired") -) -images = search.collect() - -################################################# -# Mosaic and download. -files = images.download_mosaic( - bands=["red", "green", "blue", "alpha"], - resolution=60, - dest="save-local.tif", - data_type="Float64", -) - -print(files) diff --git a/docs/examples/plot_simple_viz.py b/docs/examples/plot_simple_viz.py deleted file mode 100644 index 90b1fbcf..00000000 --- a/docs/examples/plot_simple_viz.py +++ /dev/null @@ -1,42 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. - -""" -========================== -Simple Image Visualization -========================== - -Visualize a true color Landsat 8 image. - -""" - -from descarteslabs.catalog import Product, properties as p -from descarteslabs.geo import DLTile -from descarteslabs.utils import display - -################################################# -# Create a tile around Pisa, Italy. -tile = DLTile.from_latlon(43.7230, 10.3966, resolution=20.0, tilesize=1024, pad=0) - -################################################# -# Use the Catalog API to search for imagery -# available over the area of interest. -search = ( - Product.get("usgs:landsat:oli-tirs:c2:l2:v0") - .images() - .intersects(tile) - .filter("2022-04-01" <= p.acquired < "2022-05-01") - .filter(p.cloud_fraction < 0.001) - .sort("acquired") - .limit(1) -) -images = search.collect() - -################################################# -# Pick just one image to raster and display. -image = images[0] - -# Load the data as an ndarray -arr = image.ndarray("red green blue", geocontext=images.geocontext) - -# Display the image -display(arr, size=5, title=image.id) diff --git a/docs/examples/plot_timestacks.py b/docs/examples/plot_timestacks.py deleted file mode 100644 index 1a6f4b39..00000000 --- a/docs/examples/plot_timestacks.py +++ /dev/null @@ -1,70 +0,0 @@ -# © 2025 EarthDaily Analytics Corp. - -""" -============================ -Create time stacks of images -============================ - -This example demonstrates how to aggregate the images returned from a Catalog image search by date. -""" - -from descarteslabs.catalog import Product, properties as p -from descarteslabs.utils import display - -# Define a bounding box around Taos in a GeoJSON -taos = { - "type": "Polygon", - "coordinates": [ - [ - [-105.71868896484375, 36.33725319397006], - [-105.2105712890625, 36.33725319397006], - [-105.2105712890625, 36.73668306473141], - [-105.71868896484375, 36.73668306473141], - [-105.71868896484375, 36.33725319397006], - ] - ], -} - -################################################ -# Create an ImageCollection. -search = ( - Product.get("usgs:landsat:oli-tirs:c2:l2:v0") - .images() - .intersects(taos) - .filter("2018-01-01" <= p.acquired < "2018-12-31") - .filter(p.cloud_fraction < 0.7) - .sort("acquired") - .limit(500) -) -images = search.collect() -print("There are {} images in the collection.".format(len(images))) - -################################################ -# To create subcollections using the ImageCollection API, we have -# the built in methods -# :meth:`ImageCollection.groupby ` -# and :meth:`ImageCollection.filter `. -# -# If we want to create multiple subsets based on those properties, we can use the -# :meth:`ImageCollection.groupby ` method. -for (year, month), month_images in images.groupby("acquired.year", "acquired.month"): - print("{}: {} images".format(month, len(month_images))) - -################################################ -# You can further group the subsets using the built in -# :meth:`ImageCollection.filter ` method. -spring_images = images.filter(lambda i: i.acquired.month > 2 and i.acquired.month < 6) -fall_images = images.filter(lambda i: i.acquired.month > 8 and i.acquired.month < 12) - -print( - "There are {} Spring images & {} Fall images.".format( - len(spring_images), len(fall_images) - ) -) - -################################################ -# Mosaic and display these two image collections. -spring_arr = spring_images.mosaic("red green blue", resolution=120) - -fall_arr = fall_images.mosaic("red green blue", resolution=120) -display(spring_arr, fall_arr, size=4, title=["Spring", "Fall"]) diff --git a/setup.py b/setup.py index 16894eb7..67d63b2f 100644 --- a/setup.py +++ b/setup.py @@ -14,103 +14,20 @@ # See the License for the specific language governing permissions and # limitations under the License. -import ast -import re - -from setuptools import find_packages, setup - -# Parse the docstring out of descarteslabs/__init__.py -_docstring_re = re.compile(r'"""((.|\n)*)\n"""', re.MULTILINE) -with open("descarteslabs/__init__.py", "rb") as f: - __doc__ = _docstring_re.search(f.read().decode("utf-8")).group(1) - -DOCLINES = __doc__.split("\n") - -# Parse version out of descarteslabs/core/client/version.py -_version_re = re.compile(r"__version__\s+=\s+(.*)") -with open("descarteslabs/core/client/version.py", "rb") as f: - version = str( - ast.literal_eval(_version_re.search(f.read().decode("utf-8")).group(1)) - ) +from setuptools import setup +version = "4.0.0post1" def do_setup(): - viz_requires = [ - "matplotlib>=3.1.2", - "ipyleaflet>=0.17.2", - ] - tests_requires = [ - "pytest==6.0.0", - "responses==0.12.1", - "freezegun==0.3.12", - ] setup( name="descarteslabs", - description=DOCLINES[0], - long_description="\n".join(DOCLINES[2:]), - author="Descartes Labs", - author_email="hello@descarteslabs.com", - url="https://github.com/descarteslabs/descarteslabs-python", - classifiers=[ - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - ], - license="Apache 2.0", - download_url=( - "https://github.com/descarteslabs/descarteslabs-python/archive/v{}.tar.gz".format( - version - ) - ), + description="Discontinued. Please use earthone-earthdaily instead.", + long_description="Discontinued. Please use earthone-earthdaily instead.", + author="EarthDaily Analytics", + author_email="support@earthdaily.com", + url="https://github.com/earthdaily/earthone-python", version=version, - packages=find_packages(), - package_data={ - "descarteslabs": [ - "config/settings.toml", - ] - }, - include_package_data=True, - entry_points={ - "console_scripts": [ - "descarteslabs = descarteslabs.core.client.scripts.__main__:main" - ] - }, - python_requires="~=3.10", - install_requires=[ - "affine>=2.2.2", - "blosc>=1.11.2", - "cachetools>=3.1.1", - "click>=8.2.0", - "dill>=0.3.6", - "dynaconf>=3.2.1", - "geojson>=2.5.0", - "geopandas>=0.13.2", - "imagecodecs>=2023.3.16", - "lazy_object_proxy>=1.7.1", - "mercantile>=1.1.3", - "numpy>=1.22.0;python_version>='3.10' and python_version<'3.11'", - "numpy>=1.23.2;python_version>='3.11'", - "packaging>=25.0", - "Pillow>=9.2.0", - "pyarrow>=14.0.1", - "pydantic>=2.4.0", - "requests>=2.32.3,<3", - "shapely>=2.0.0", - "strenum>=0.4.8", - "tifffile>=2023.9.26", - "tqdm>=4.66.3", - "urllib3>=1.26.19, !=2.0.0, !=2.0.1, !=2.0.2, !=2.0.3, !=2.0.4", - ], - extras_require={ - "visualization": viz_requires, - "complete": viz_requires, - "tests": tests_requires, - }, - data_files=[("docs/descarteslabs", ["README.md"])], ) - if __name__ == "__main__": do_setup() From 5becf0760500d98410e24db2184a261d5dd52cd4 Mon Sep 17 00:00:00 2001 From: "Stephen C. Pope" Date: Wed, 3 Dec 2025 19:49:06 +0000 Subject: [PATCH 2/2] oops --- README.md | 2 +- setup.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 78c427eb..e074160a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ The Descartes Labs Platform API and client has been discontinued and is no longer functional. -Please see [The EarthDaily EarthOne Platform Client](https:github.com/earthdaily/earthone-python) for the currently supported API and Client. +Please see [The EarthDaily EarthOne Platform Client](https:github.com/earthdaily/earthone-python) for the currently supported API and Client. This is available on PyPI at [earthdaily-earthone](https://pypi.org/project/earthdaily-earthone/). diff --git a/setup.py b/setup.py index 67d63b2f..fbea2d6f 100644 --- a/setup.py +++ b/setup.py @@ -16,13 +16,13 @@ from setuptools import setup -version = "4.0.0post1" +version = "4.0.0post2" def do_setup(): setup( name="descarteslabs", - description="Discontinued. Please use earthone-earthdaily instead.", - long_description="Discontinued. Please use earthone-earthdaily instead.", + description="Discontinued. Please use earthdaily-earthone instead.", + long_description="Discontinued. Please use earthdaily-earthone instead.", author="EarthDaily Analytics", author_email="support@earthdaily.com", url="https://github.com/earthdaily/earthone-python",

^wA~NTq1iqH`;YrI`t(23{|A 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 F^4U*92zKnF*u3 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