From 83667f812b7ed8112e8cda97af9ec5b2bd3697bc Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Fri, 5 Jun 2026 12:25:38 -0600 Subject: [PATCH 01/71] Add v2 generated client library (openapi-python-client) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated from the live v2 API spec (2.0.0-beta, OpenAPI 3.1) with openapi-python-client 0.29.0 — attrs models + httpx, relative imports, sync and async variants of every endpoint. Generator selection rationale is recorded in fastfuels_sdk/v2/COMPARISON.md (following commit). generate_clients.sh is the reproducible regeneration recipe. It patches the spec before generation: the spec contains two schemas titled 'Feature' (the FastFuels feature resource and geojson-pydantic's GeoJSON Feature), and openapi-python-client names classes from titles and refuses duplicates — without the patch it silently drops every domain endpoint. The proper fix is re-titling the model in FastFuels-API-v2. The generated tree is excluded from pre-commit hooks like v1's, and from distributions via the fastfuels_sdk/v2 build exclude (PR #181) until the preview ships with its runtime deps (#176). Part of #176 --- .pre-commit-config.yaml | 2 +- fastfuels_sdk/v2/__init__.py | 0 fastfuels_sdk/v2/client_library/__init__.py | 8 + .../v2/client_library/api/__init__.py | 1 + .../api/applications/__init__.py | 1 + .../api/applications/create_application.py | 174 +++++ .../api/applications/delete_application.py | 167 +++++ .../api/applications/get_application.py | 169 +++++ .../api/applications/list_applications.py | 189 ++++++ .../api/applications/update_application.py | 190 ++++++ .../v2/client_library/api/domains/__init__.py | 1 + .../api/domains/create_domain.py | 638 ++++++++++++++++++ .../api/domains/delete_domain.py | 308 +++++++++ .../client_library/api/domains/get_domain.py | 273 ++++++++ .../api/domains/get_domain_lattice.py | 317 +++++++++ .../api/domains/list_domains.py | 595 ++++++++++++++++ .../api/domains/preview_domain.py | 290 ++++++++ .../api/domains/reproject_domain.py | 301 +++++++++ .../api/domains/update_domain.py | 402 +++++++++++ .../v2/client_library/api/exports/__init__.py | 1 + .../api/exports/delete_export.py | 227 +++++++ .../client_library/api/exports/get_export.py | 229 +++++++ .../api/exports/list_exports.py | 363 ++++++++++ .../api/exports/update_export.py | 266 ++++++++ .../client_library/api/features/__init__.py | 1 + .../api/features/create_layerset.py | 334 +++++++++ .../api/features/create_osm_road_feature.py | 310 +++++++++ .../api/features/create_osm_water_feature.py | 310 +++++++++ .../api/features/delete_feature.py | 189 ++++++ .../api/features/get_feature.py | 243 +++++++ .../api/features/get_feature_data_metadata.py | 371 ++++++++++ .../features/get_feature_data_partition.py | 331 +++++++++ .../api/features/list_features.py | 406 +++++++++++ .../features/list_features_cross_domain.py | 374 ++++++++++ .../api/features/update_feature.py | 320 +++++++++ .../v2/client_library/api/grids/__init__.py | 1 + .../api/grids/check_3dep_coverage.py | 251 +++++++ .../api/grids/create_3dep_topography.py | 344 ++++++++++ .../api/grids/create_fbfm40_lookup.py | 410 +++++++++++ .../api/grids/create_geotiff_upload.py | 310 +++++++++ .../api/grids/create_grid_export.py | 275 ++++++++ .../api/grids/create_landfire_canopy.py | 330 +++++++++ .../api/grids/create_landfire_fbfm40.py | 282 ++++++++ .../api/grids/create_landfire_fccs.py | 290 ++++++++ .../api/grids/create_landfire_topography.py | 298 ++++++++ .../api/grids/create_layerset_rasterize.py | 314 +++++++++ .../api/grids/create_meta_chm.py | 274 ++++++++ .../api/grids/create_naip_chm.py | 254 +++++++ .../api/grids/create_netcdf_upload.py | 402 +++++++++++ .../api/grids/create_quicfire_export.py | 274 ++++++++ .../api/grids/create_resample.py | 314 +++++++++ .../api/grids/create_tree_inventory_grid.py | 382 +++++++++++ .../api/grids/create_treemap.py | 294 ++++++++ .../api/grids/create_uniform_grid.py | 330 +++++++++ .../client_library/api/grids/delete_grid.py | 245 +++++++ .../api/grids/get_chunk_metadata.py | 313 +++++++++ .../v2/client_library/api/grids/get_grid.py | 243 +++++++ .../api/grids/get_grid_data_binary.py | 515 ++++++++++++++ .../api/grids/get_grid_data_json.py | 457 +++++++++++++ .../v2/client_library/api/grids/list_grids.py | 403 +++++++++++ .../api/grids/list_grids_cross_domain.py | 371 ++++++++++ .../client_library/api/grids/update_grid.py | 328 +++++++++ .../v2/client_library/api/index/__init__.py | 1 + .../v2/client_library/api/index/index.py | 91 +++ .../api/inventories/__init__.py | 1 + .../api/inventories/create_chm_inventory.py | 290 ++++++++ .../inventories/create_inventory_export.py | 267 ++++++++ .../inventories/create_inventory_upload.py | 278 ++++++++ .../api/inventories/create_pim_inventory.py | 366 ++++++++++ .../api/inventories/delete_inventory.py | 245 +++++++ .../api/inventories/duplicate_inventory.py | 339 ++++++++++ .../api/inventories/get_inventory.py | 243 +++++++ .../api/inventories/get_inventory_data.py | 387 +++++++++++ .../get_inventory_data_metadata.py | 275 ++++++++ .../api/inventories/list_inventories.py | 402 +++++++++++ .../list_inventories_cross_domain.py | 370 ++++++++++ .../api/inventories/update_inventory.py | 328 +++++++++ .../v2/client_library/api/keys/__init__.py | 1 + .../v2/client_library/api/keys/create_key.py | 186 +++++ .../v2/client_library/api/keys/delete_key.py | 167 +++++ .../client_library/api/keys/get_key_by_id.py | 169 +++++ .../v2/client_library/api/keys/list_keys.py | 189 ++++++ fastfuels_sdk/v2/client_library/client.py | 282 ++++++++ fastfuels_sdk/v2/client_library/errors.py | 16 + .../v2/client_library/models/__init__.py | 413 ++++++++++++ .../v2/client_library/models/access.py | 9 + .../models/allometry_biomass_source.py | 153 +++++ ...lometry_biomass_source_component_states.py | 64 ++ .../allometry_max_crown_radius_source.py | 50 ++ .../v2/client_library/models/application.py | 133 ++++ .../v2/client_library/models/band.py | 165 +++++ .../v2/client_library/models/band_type.py | 9 + .../v2/client_library/models/base_model.py | 47 ++ .../models/biomass_component.py | 10 + .../models/biomass_component_state.py | 52 ++ .../models/biomass_equations.py | 9 + .../v2/client_library/models/biomass_unit.py | 8 + .../v2/client_library/models/chunks.py | 146 ++++ .../models/chunks_count_by_axis_type_0.py | 47 ++ .../v2/client_library/models/column.py | 93 +++ .../v2/client_library/models/column_type.py | 9 + .../models/create_application_request.py | 84 +++ .../models/create_chm_inventory_request.py | 236 +++++++ .../models/create_domain_request_body.py | 262 +++++++ .../models/create_fbfm_40_lookup_request.py | 149 ++++ .../models/create_geo_tiff_upload_request.py | 117 ++++ .../models/create_inventory_upload_request.py | 120 ++++ .../models/create_key_request.py | 152 +++++ .../models/create_key_response.py | 217 ++++++ .../models/create_landfire_canopy_request.py | 245 +++++++ .../models/create_landfire_fbfm_40_request.py | 264 ++++++++ .../models/create_landfire_fccs_request.py | 146 ++++ .../create_landfire_topography_request.py | 238 +++++++ .../create_layerset_rasterize_request.py | 230 +++++++ .../models/create_layerset_request_body.py | 213 ++++++ .../models/create_meta_chm_request.py | 216 ++++++ .../models/create_naip_chm_request.py | 199 ++++++ .../models/create_netcdf_upload_request.py | 92 +++ .../models/create_osm_road_feature_request.py | 111 +++ .../create_osm_water_feature_request.py | 111 +++ .../models/create_pim_inventory_request.py | 222 ++++++ .../models/create_resample_request.py | 219 ++++++ ...reate_resample_request_method_overrides.py | 63 ++ .../create_three_dep_topography_request.py | 241 +++++++ .../models/create_tree_inventory_request.py | 292 ++++++++ .../models/create_tree_map_request.py | 239 +++++++ .../models/create_uniform_request.py | 150 ++++ .../models/crown_profile_model.py | 9 + .../client_library/models/dense_grid_data.py | 89 +++ .../v2/client_library/models/distribution.py | 10 + .../v2/client_library/models/domain.py | 333 +++++++++ .../client_library/models/domain_lattice.py | 125 ++++ .../models/domain_sort_field.py | 10 + .../models/domain_sort_order.py | 9 + .../v2/client_library/models/domain_style.py | 157 +++++ .../models/duplicate_inventory_request.py | 128 ++++ .../v2/client_library/models/export.py | 309 +++++++++ .../models/export_grid_request.py | 127 ++++ .../models/export_inventory_request.py | 127 ++++ .../models/export_sort_field.py | 10 + .../v2/client_library/models/export_source.py | 50 ++ .../models/fbfm_40_lookup_band.py | 18 + .../v2/client_library/models/feature.py | 298 ++++++++ .../models/feature_data_metadata.py | 94 +++ .../models/feature_georeference.py | 83 +++ .../models/feature_partition_info.py | 70 ++ .../models/feature_sort_field.py | 10 + .../client_library/models/feature_source.py | 47 ++ .../v2/client_library/models/feature_type.py | 10 + .../v2/client_library/models/field_source.py | 76 +++ .../models/fine_biomass_config.py | 60 ++ .../v2/client_library/models/geo_json_crs.py | 86 +++ .../models/geo_json_crs_properties.py | 61 ++ .../client_library/models/geo_json_feature.py | 327 +++++++++ .../geo_json_feature_properties_type_0.py | 47 ++ .../models/geometry_collection.py | 247 +++++++ .../v2/client_library/models/georeference.py | 111 +++ .../client_library/models/georeference_3d.py | 120 ++++ .../v2/client_library/models/grid.py | 407 +++++++++++ .../models/grid_alignment_domain_target.py | 124 ++++ .../models/grid_alignment_grid_target.py | 135 ++++ .../models/grid_alignment_native_target.py | 125 ++++ .../models/grid_data_array_format.py | 9 + .../models/grid_data_chunk_metadata.py | 184 +++++ .../client_library/models/grid_data_order.py | 9 + .../models/grid_data_response.py | 118 ++++ .../models/grid_data_response_order.py | 9 + .../models/grid_export_format.py | 10 + .../models/grid_feature_spatial_condition.py | 142 ++++ .../models/grid_geometry_spatial_condition.py | 195 ++++++ ...d_geometry_spatial_condition_crs_type_0.py | 47 ++ ...rid_geometry_spatial_condition_geometry.py | 50 ++ .../models/grid_modification.py | 154 +++++ .../models/grid_modification_action.py | 86 +++ .../models/grid_modification_condition.py | 111 +++ .../client_library/models/grid_sort_field.py | 10 + .../v2/client_library/models/grid_source.py | 47 ++ .../models/grid_spatial_target.py | 9 + .../models/grid_upload_created_response.py | 85 +++ .../client_library/models/grid_upload_spec.py | 104 +++ .../models/http_validation_error.py | 79 +++ .../v2/client_library/models/inventory.py | 424 ++++++++++++ .../models/inventory_attribute.py | 11 + .../models/inventory_basal_area_treatment.py | 196 ++++++ .../models/inventory_biomass_column.py | 62 ++ .../models/inventory_column_mapping.py | 179 +++++ ...nventory_column_max_crown_radius_source.py | 81 +++ .../inventory_columns_biomass_source.py | 155 +++++ ...nventory_columns_biomass_source_columns.py | 64 ++ ...columns_biomass_source_component_states.py | 64 ++ .../models/inventory_data_format.py | 9 + .../models/inventory_data_metadata.py | 107 +++ .../models/inventory_data_response.py | 136 ++++ ...nventory_data_response_data_type_1_item.py | 47 ++ .../models/inventory_diameter_treatment.py | 196 ++++++ .../inventory_diameter_treatment_method.py | 9 + .../models/inventory_export_format.py | 11 + .../models/inventory_expression_condition.py | 67 ++ .../inventory_feature_spatial_condition.py | 125 ++++ .../inventory_geometry_spatial_condition.py | 178 +++++ ...y_geometry_spatial_condition_crs_type_0.py | 47 ++ ...ory_geometry_spatial_condition_geometry.py | 50 ++ .../models/inventory_georeference.py | 83 +++ .../models/inventory_json_orientation.py | 9 + .../models/inventory_modification.py | 208 ++++++ .../models/inventory_modification_action.py | 109 +++ .../inventory_modification_condition.py | 135 ++++ .../models/inventory_partition_info.py | 69 ++ .../models/inventory_sort_field.py | 10 + .../client_library/models/inventory_source.py | 47 ++ .../models/inventory_treatment_method.py | 10 + .../client_library/models/inventory_type.py | 8 + .../inventory_upload_created_response.py | 81 +++ .../models/inventory_upload_format.py | 10 + .../models/inventory_upload_spec.py | 104 +++ .../v2/client_library/models/job_error.py | 124 ++++ .../v2/client_library/models/job_progress.py | 91 +++ .../v2/client_library/models/job_status.py | 11 + fastfuels_sdk/v2/client_library/models/key.py | 209 ++++++ .../models/landfire_canopy_fuel_band.py | 11 + .../models/landfire_canopy_version.py | 8 + .../models/landfire_fbfm_40_version.py | 12 + .../models/landfire_fccs_version.py | 8 + .../models/landfire_topography_version.py | 8 + .../v2/client_library/models/layerset_crs.py | 89 +++ .../models/layerset_crs_properties.py | 47 ++ .../client_library/models/layerset_feature.py | 203 ++++++ .../models/layerset_properties.py | 229 +++++++ .../v2/client_library/models/line_string.py | 163 +++++ .../models/list_applications_response.py | 100 +++ .../models/list_domains_response.py | 100 +++ .../models/list_exports_response.py | 100 +++ .../models/list_features_response.py | 100 +++ .../models/list_grids_response.py | 100 +++ .../models/list_inventories_response.py | 100 +++ .../models/list_keys_response.py | 100 +++ .../models/max_crown_radius_unit.py | 8 + .../client_library/models/meta_chm_version.py | 9 + .../v2/client_library/models/modifier.py | 12 + .../client_library/models/moisture_model.py | 103 +++ .../models/multi_line_string.py | 180 +++++ .../v2/client_library/models/multi_point.py | 163 +++++ .../v2/client_library/models/multi_polygon.py | 191 ++++++ .../models/non_burnable_fuel_model.py | 12 + .../v2/client_library/models/operator.py | 13 + .../client_library/models/overlap_method.py | 10 + .../v2/client_library/models/point.py | 151 +++++ .../v2/client_library/models/point_process.py | 8 + .../v2/client_library/models/polygon.py | 180 +++++ ...uic_fire_export_alignment_domain_target.py | 104 +++ .../quic_fire_export_alignment_grid_target.py | 83 +++ .../models/quicfire_export_request.py | 387 +++++++++++ .../quicfire_export_request_moist_merge.py | 9 + .../v2/client_library/models/remove_action.py | 69 ++ .../models/resampling_method.py | 21 + .../v2/client_library/models/resolution_3d.py | 54 ++ .../v2/client_library/models/scope.py | 9 + .../v2/client_library/models/sort_order.py | 9 + .../client_library/models/sparse_grid_data.py | 111 +++ .../client_library/models/spatial_operator.py | 10 + .../models/stem_isolation_lmf.py | 87 +++ .../models/stem_isolation_vwf.py | 120 ++++ .../models/three_dep_coverage_response.py | 120 ++++ .../models/three_dep_resolution.py | 10 + .../client_library/models/topography_band.py | 10 + .../v2/client_library/models/tree_band.py | 19 + .../v2/client_library/models/tree_map_band.py | 9 + .../client_library/models/tree_map_version.py | 11 + .../v2/client_library/models/uniform_band.py | 19 + .../models/uniform_band_input.py | 79 +++ .../models/uniform_moisture_value.py | 78 +++ .../models/update_application_request.py | 94 +++ .../models/update_domain_request_body.py | 167 +++++ .../models/update_export_request_body.py | 125 ++++ .../models/update_feature_request_body.py | 125 ++++ .../models/update_grid_request_body.py | 125 ++++ .../models/update_inventory_request_body.py | 125 ++++ .../models/upload_band_definition.py | 94 +++ .../client_library/models/validation_error.py | 90 +++ fastfuels_sdk/v2/client_library/types.py | 54 ++ fastfuels_sdk/v2/generate_clients.sh | 34 + 281 files changed, 41964 insertions(+), 1 deletion(-) create mode 100644 fastfuels_sdk/v2/__init__.py create mode 100644 fastfuels_sdk/v2/client_library/__init__.py create mode 100644 fastfuels_sdk/v2/client_library/api/__init__.py create mode 100644 fastfuels_sdk/v2/client_library/api/applications/__init__.py create mode 100644 fastfuels_sdk/v2/client_library/api/applications/create_application.py create mode 100644 fastfuels_sdk/v2/client_library/api/applications/delete_application.py create mode 100644 fastfuels_sdk/v2/client_library/api/applications/get_application.py create mode 100644 fastfuels_sdk/v2/client_library/api/applications/list_applications.py create mode 100644 fastfuels_sdk/v2/client_library/api/applications/update_application.py create mode 100644 fastfuels_sdk/v2/client_library/api/domains/__init__.py create mode 100644 fastfuels_sdk/v2/client_library/api/domains/create_domain.py create mode 100644 fastfuels_sdk/v2/client_library/api/domains/delete_domain.py create mode 100644 fastfuels_sdk/v2/client_library/api/domains/get_domain.py create mode 100644 fastfuels_sdk/v2/client_library/api/domains/get_domain_lattice.py create mode 100644 fastfuels_sdk/v2/client_library/api/domains/list_domains.py create mode 100644 fastfuels_sdk/v2/client_library/api/domains/preview_domain.py create mode 100644 fastfuels_sdk/v2/client_library/api/domains/reproject_domain.py create mode 100644 fastfuels_sdk/v2/client_library/api/domains/update_domain.py create mode 100644 fastfuels_sdk/v2/client_library/api/exports/__init__.py create mode 100644 fastfuels_sdk/v2/client_library/api/exports/delete_export.py create mode 100644 fastfuels_sdk/v2/client_library/api/exports/get_export.py create mode 100644 fastfuels_sdk/v2/client_library/api/exports/list_exports.py create mode 100644 fastfuels_sdk/v2/client_library/api/exports/update_export.py create mode 100644 fastfuels_sdk/v2/client_library/api/features/__init__.py create mode 100644 fastfuels_sdk/v2/client_library/api/features/create_layerset.py create mode 100644 fastfuels_sdk/v2/client_library/api/features/create_osm_road_feature.py create mode 100644 fastfuels_sdk/v2/client_library/api/features/create_osm_water_feature.py create mode 100644 fastfuels_sdk/v2/client_library/api/features/delete_feature.py create mode 100644 fastfuels_sdk/v2/client_library/api/features/get_feature.py create mode 100644 fastfuels_sdk/v2/client_library/api/features/get_feature_data_metadata.py create mode 100644 fastfuels_sdk/v2/client_library/api/features/get_feature_data_partition.py create mode 100644 fastfuels_sdk/v2/client_library/api/features/list_features.py create mode 100644 fastfuels_sdk/v2/client_library/api/features/list_features_cross_domain.py create mode 100644 fastfuels_sdk/v2/client_library/api/features/update_feature.py create mode 100644 fastfuels_sdk/v2/client_library/api/grids/__init__.py create mode 100644 fastfuels_sdk/v2/client_library/api/grids/check_3dep_coverage.py create mode 100644 fastfuels_sdk/v2/client_library/api/grids/create_3dep_topography.py create mode 100644 fastfuels_sdk/v2/client_library/api/grids/create_fbfm40_lookup.py create mode 100644 fastfuels_sdk/v2/client_library/api/grids/create_geotiff_upload.py create mode 100644 fastfuels_sdk/v2/client_library/api/grids/create_grid_export.py create mode 100644 fastfuels_sdk/v2/client_library/api/grids/create_landfire_canopy.py create mode 100644 fastfuels_sdk/v2/client_library/api/grids/create_landfire_fbfm40.py create mode 100644 fastfuels_sdk/v2/client_library/api/grids/create_landfire_fccs.py create mode 100644 fastfuels_sdk/v2/client_library/api/grids/create_landfire_topography.py create mode 100644 fastfuels_sdk/v2/client_library/api/grids/create_layerset_rasterize.py create mode 100644 fastfuels_sdk/v2/client_library/api/grids/create_meta_chm.py create mode 100644 fastfuels_sdk/v2/client_library/api/grids/create_naip_chm.py create mode 100644 fastfuels_sdk/v2/client_library/api/grids/create_netcdf_upload.py create mode 100644 fastfuels_sdk/v2/client_library/api/grids/create_quicfire_export.py create mode 100644 fastfuels_sdk/v2/client_library/api/grids/create_resample.py create mode 100644 fastfuels_sdk/v2/client_library/api/grids/create_tree_inventory_grid.py create mode 100644 fastfuels_sdk/v2/client_library/api/grids/create_treemap.py create mode 100644 fastfuels_sdk/v2/client_library/api/grids/create_uniform_grid.py create mode 100644 fastfuels_sdk/v2/client_library/api/grids/delete_grid.py create mode 100644 fastfuels_sdk/v2/client_library/api/grids/get_chunk_metadata.py create mode 100644 fastfuels_sdk/v2/client_library/api/grids/get_grid.py create mode 100644 fastfuels_sdk/v2/client_library/api/grids/get_grid_data_binary.py create mode 100644 fastfuels_sdk/v2/client_library/api/grids/get_grid_data_json.py create mode 100644 fastfuels_sdk/v2/client_library/api/grids/list_grids.py create mode 100644 fastfuels_sdk/v2/client_library/api/grids/list_grids_cross_domain.py create mode 100644 fastfuels_sdk/v2/client_library/api/grids/update_grid.py create mode 100644 fastfuels_sdk/v2/client_library/api/index/__init__.py create mode 100644 fastfuels_sdk/v2/client_library/api/index/index.py create mode 100644 fastfuels_sdk/v2/client_library/api/inventories/__init__.py create mode 100644 fastfuels_sdk/v2/client_library/api/inventories/create_chm_inventory.py create mode 100644 fastfuels_sdk/v2/client_library/api/inventories/create_inventory_export.py create mode 100644 fastfuels_sdk/v2/client_library/api/inventories/create_inventory_upload.py create mode 100644 fastfuels_sdk/v2/client_library/api/inventories/create_pim_inventory.py create mode 100644 fastfuels_sdk/v2/client_library/api/inventories/delete_inventory.py create mode 100644 fastfuels_sdk/v2/client_library/api/inventories/duplicate_inventory.py create mode 100644 fastfuels_sdk/v2/client_library/api/inventories/get_inventory.py create mode 100644 fastfuels_sdk/v2/client_library/api/inventories/get_inventory_data.py create mode 100644 fastfuels_sdk/v2/client_library/api/inventories/get_inventory_data_metadata.py create mode 100644 fastfuels_sdk/v2/client_library/api/inventories/list_inventories.py create mode 100644 fastfuels_sdk/v2/client_library/api/inventories/list_inventories_cross_domain.py create mode 100644 fastfuels_sdk/v2/client_library/api/inventories/update_inventory.py create mode 100644 fastfuels_sdk/v2/client_library/api/keys/__init__.py create mode 100644 fastfuels_sdk/v2/client_library/api/keys/create_key.py create mode 100644 fastfuels_sdk/v2/client_library/api/keys/delete_key.py create mode 100644 fastfuels_sdk/v2/client_library/api/keys/get_key_by_id.py create mode 100644 fastfuels_sdk/v2/client_library/api/keys/list_keys.py create mode 100644 fastfuels_sdk/v2/client_library/client.py create mode 100644 fastfuels_sdk/v2/client_library/errors.py create mode 100644 fastfuels_sdk/v2/client_library/models/__init__.py create mode 100644 fastfuels_sdk/v2/client_library/models/access.py create mode 100644 fastfuels_sdk/v2/client_library/models/allometry_biomass_source.py create mode 100644 fastfuels_sdk/v2/client_library/models/allometry_biomass_source_component_states.py create mode 100644 fastfuels_sdk/v2/client_library/models/allometry_max_crown_radius_source.py create mode 100644 fastfuels_sdk/v2/client_library/models/application.py create mode 100644 fastfuels_sdk/v2/client_library/models/band.py create mode 100644 fastfuels_sdk/v2/client_library/models/band_type.py create mode 100644 fastfuels_sdk/v2/client_library/models/base_model.py create mode 100644 fastfuels_sdk/v2/client_library/models/biomass_component.py create mode 100644 fastfuels_sdk/v2/client_library/models/biomass_component_state.py create mode 100644 fastfuels_sdk/v2/client_library/models/biomass_equations.py create mode 100644 fastfuels_sdk/v2/client_library/models/biomass_unit.py create mode 100644 fastfuels_sdk/v2/client_library/models/chunks.py create mode 100644 fastfuels_sdk/v2/client_library/models/chunks_count_by_axis_type_0.py create mode 100644 fastfuels_sdk/v2/client_library/models/column.py create mode 100644 fastfuels_sdk/v2/client_library/models/column_type.py create mode 100644 fastfuels_sdk/v2/client_library/models/create_application_request.py create mode 100644 fastfuels_sdk/v2/client_library/models/create_chm_inventory_request.py create mode 100644 fastfuels_sdk/v2/client_library/models/create_domain_request_body.py create mode 100644 fastfuels_sdk/v2/client_library/models/create_fbfm_40_lookup_request.py create mode 100644 fastfuels_sdk/v2/client_library/models/create_geo_tiff_upload_request.py create mode 100644 fastfuels_sdk/v2/client_library/models/create_inventory_upload_request.py create mode 100644 fastfuels_sdk/v2/client_library/models/create_key_request.py create mode 100644 fastfuels_sdk/v2/client_library/models/create_key_response.py create mode 100644 fastfuels_sdk/v2/client_library/models/create_landfire_canopy_request.py create mode 100644 fastfuels_sdk/v2/client_library/models/create_landfire_fbfm_40_request.py create mode 100644 fastfuels_sdk/v2/client_library/models/create_landfire_fccs_request.py create mode 100644 fastfuels_sdk/v2/client_library/models/create_landfire_topography_request.py create mode 100644 fastfuels_sdk/v2/client_library/models/create_layerset_rasterize_request.py create mode 100644 fastfuels_sdk/v2/client_library/models/create_layerset_request_body.py create mode 100644 fastfuels_sdk/v2/client_library/models/create_meta_chm_request.py create mode 100644 fastfuels_sdk/v2/client_library/models/create_naip_chm_request.py create mode 100644 fastfuels_sdk/v2/client_library/models/create_netcdf_upload_request.py create mode 100644 fastfuels_sdk/v2/client_library/models/create_osm_road_feature_request.py create mode 100644 fastfuels_sdk/v2/client_library/models/create_osm_water_feature_request.py create mode 100644 fastfuels_sdk/v2/client_library/models/create_pim_inventory_request.py create mode 100644 fastfuels_sdk/v2/client_library/models/create_resample_request.py create mode 100644 fastfuels_sdk/v2/client_library/models/create_resample_request_method_overrides.py create mode 100644 fastfuels_sdk/v2/client_library/models/create_three_dep_topography_request.py create mode 100644 fastfuels_sdk/v2/client_library/models/create_tree_inventory_request.py create mode 100644 fastfuels_sdk/v2/client_library/models/create_tree_map_request.py create mode 100644 fastfuels_sdk/v2/client_library/models/create_uniform_request.py create mode 100644 fastfuels_sdk/v2/client_library/models/crown_profile_model.py create mode 100644 fastfuels_sdk/v2/client_library/models/dense_grid_data.py create mode 100644 fastfuels_sdk/v2/client_library/models/distribution.py create mode 100644 fastfuels_sdk/v2/client_library/models/domain.py create mode 100644 fastfuels_sdk/v2/client_library/models/domain_lattice.py create mode 100644 fastfuels_sdk/v2/client_library/models/domain_sort_field.py create mode 100644 fastfuels_sdk/v2/client_library/models/domain_sort_order.py create mode 100644 fastfuels_sdk/v2/client_library/models/domain_style.py create mode 100644 fastfuels_sdk/v2/client_library/models/duplicate_inventory_request.py create mode 100644 fastfuels_sdk/v2/client_library/models/export.py create mode 100644 fastfuels_sdk/v2/client_library/models/export_grid_request.py create mode 100644 fastfuels_sdk/v2/client_library/models/export_inventory_request.py create mode 100644 fastfuels_sdk/v2/client_library/models/export_sort_field.py create mode 100644 fastfuels_sdk/v2/client_library/models/export_source.py create mode 100644 fastfuels_sdk/v2/client_library/models/fbfm_40_lookup_band.py create mode 100644 fastfuels_sdk/v2/client_library/models/feature.py create mode 100644 fastfuels_sdk/v2/client_library/models/feature_data_metadata.py create mode 100644 fastfuels_sdk/v2/client_library/models/feature_georeference.py create mode 100644 fastfuels_sdk/v2/client_library/models/feature_partition_info.py create mode 100644 fastfuels_sdk/v2/client_library/models/feature_sort_field.py create mode 100644 fastfuels_sdk/v2/client_library/models/feature_source.py create mode 100644 fastfuels_sdk/v2/client_library/models/feature_type.py create mode 100644 fastfuels_sdk/v2/client_library/models/field_source.py create mode 100644 fastfuels_sdk/v2/client_library/models/fine_biomass_config.py create mode 100644 fastfuels_sdk/v2/client_library/models/geo_json_crs.py create mode 100644 fastfuels_sdk/v2/client_library/models/geo_json_crs_properties.py create mode 100644 fastfuels_sdk/v2/client_library/models/geo_json_feature.py create mode 100644 fastfuels_sdk/v2/client_library/models/geo_json_feature_properties_type_0.py create mode 100644 fastfuels_sdk/v2/client_library/models/geometry_collection.py create mode 100644 fastfuels_sdk/v2/client_library/models/georeference.py create mode 100644 fastfuels_sdk/v2/client_library/models/georeference_3d.py create mode 100644 fastfuels_sdk/v2/client_library/models/grid.py create mode 100644 fastfuels_sdk/v2/client_library/models/grid_alignment_domain_target.py create mode 100644 fastfuels_sdk/v2/client_library/models/grid_alignment_grid_target.py create mode 100644 fastfuels_sdk/v2/client_library/models/grid_alignment_native_target.py create mode 100644 fastfuels_sdk/v2/client_library/models/grid_data_array_format.py create mode 100644 fastfuels_sdk/v2/client_library/models/grid_data_chunk_metadata.py create mode 100644 fastfuels_sdk/v2/client_library/models/grid_data_order.py create mode 100644 fastfuels_sdk/v2/client_library/models/grid_data_response.py create mode 100644 fastfuels_sdk/v2/client_library/models/grid_data_response_order.py create mode 100644 fastfuels_sdk/v2/client_library/models/grid_export_format.py create mode 100644 fastfuels_sdk/v2/client_library/models/grid_feature_spatial_condition.py create mode 100644 fastfuels_sdk/v2/client_library/models/grid_geometry_spatial_condition.py create mode 100644 fastfuels_sdk/v2/client_library/models/grid_geometry_spatial_condition_crs_type_0.py create mode 100644 fastfuels_sdk/v2/client_library/models/grid_geometry_spatial_condition_geometry.py create mode 100644 fastfuels_sdk/v2/client_library/models/grid_modification.py create mode 100644 fastfuels_sdk/v2/client_library/models/grid_modification_action.py create mode 100644 fastfuels_sdk/v2/client_library/models/grid_modification_condition.py create mode 100644 fastfuels_sdk/v2/client_library/models/grid_sort_field.py create mode 100644 fastfuels_sdk/v2/client_library/models/grid_source.py create mode 100644 fastfuels_sdk/v2/client_library/models/grid_spatial_target.py create mode 100644 fastfuels_sdk/v2/client_library/models/grid_upload_created_response.py create mode 100644 fastfuels_sdk/v2/client_library/models/grid_upload_spec.py create mode 100644 fastfuels_sdk/v2/client_library/models/http_validation_error.py create mode 100644 fastfuels_sdk/v2/client_library/models/inventory.py create mode 100644 fastfuels_sdk/v2/client_library/models/inventory_attribute.py create mode 100644 fastfuels_sdk/v2/client_library/models/inventory_basal_area_treatment.py create mode 100644 fastfuels_sdk/v2/client_library/models/inventory_biomass_column.py create mode 100644 fastfuels_sdk/v2/client_library/models/inventory_column_mapping.py create mode 100644 fastfuels_sdk/v2/client_library/models/inventory_column_max_crown_radius_source.py create mode 100644 fastfuels_sdk/v2/client_library/models/inventory_columns_biomass_source.py create mode 100644 fastfuels_sdk/v2/client_library/models/inventory_columns_biomass_source_columns.py create mode 100644 fastfuels_sdk/v2/client_library/models/inventory_columns_biomass_source_component_states.py create mode 100644 fastfuels_sdk/v2/client_library/models/inventory_data_format.py create mode 100644 fastfuels_sdk/v2/client_library/models/inventory_data_metadata.py create mode 100644 fastfuels_sdk/v2/client_library/models/inventory_data_response.py create mode 100644 fastfuels_sdk/v2/client_library/models/inventory_data_response_data_type_1_item.py create mode 100644 fastfuels_sdk/v2/client_library/models/inventory_diameter_treatment.py create mode 100644 fastfuels_sdk/v2/client_library/models/inventory_diameter_treatment_method.py create mode 100644 fastfuels_sdk/v2/client_library/models/inventory_export_format.py create mode 100644 fastfuels_sdk/v2/client_library/models/inventory_expression_condition.py create mode 100644 fastfuels_sdk/v2/client_library/models/inventory_feature_spatial_condition.py create mode 100644 fastfuels_sdk/v2/client_library/models/inventory_geometry_spatial_condition.py create mode 100644 fastfuels_sdk/v2/client_library/models/inventory_geometry_spatial_condition_crs_type_0.py create mode 100644 fastfuels_sdk/v2/client_library/models/inventory_geometry_spatial_condition_geometry.py create mode 100644 fastfuels_sdk/v2/client_library/models/inventory_georeference.py create mode 100644 fastfuels_sdk/v2/client_library/models/inventory_json_orientation.py create mode 100644 fastfuels_sdk/v2/client_library/models/inventory_modification.py create mode 100644 fastfuels_sdk/v2/client_library/models/inventory_modification_action.py create mode 100644 fastfuels_sdk/v2/client_library/models/inventory_modification_condition.py create mode 100644 fastfuels_sdk/v2/client_library/models/inventory_partition_info.py create mode 100644 fastfuels_sdk/v2/client_library/models/inventory_sort_field.py create mode 100644 fastfuels_sdk/v2/client_library/models/inventory_source.py create mode 100644 fastfuels_sdk/v2/client_library/models/inventory_treatment_method.py create mode 100644 fastfuels_sdk/v2/client_library/models/inventory_type.py create mode 100644 fastfuels_sdk/v2/client_library/models/inventory_upload_created_response.py create mode 100644 fastfuels_sdk/v2/client_library/models/inventory_upload_format.py create mode 100644 fastfuels_sdk/v2/client_library/models/inventory_upload_spec.py create mode 100644 fastfuels_sdk/v2/client_library/models/job_error.py create mode 100644 fastfuels_sdk/v2/client_library/models/job_progress.py create mode 100644 fastfuels_sdk/v2/client_library/models/job_status.py create mode 100644 fastfuels_sdk/v2/client_library/models/key.py create mode 100644 fastfuels_sdk/v2/client_library/models/landfire_canopy_fuel_band.py create mode 100644 fastfuels_sdk/v2/client_library/models/landfire_canopy_version.py create mode 100644 fastfuels_sdk/v2/client_library/models/landfire_fbfm_40_version.py create mode 100644 fastfuels_sdk/v2/client_library/models/landfire_fccs_version.py create mode 100644 fastfuels_sdk/v2/client_library/models/landfire_topography_version.py create mode 100644 fastfuels_sdk/v2/client_library/models/layerset_crs.py create mode 100644 fastfuels_sdk/v2/client_library/models/layerset_crs_properties.py create mode 100644 fastfuels_sdk/v2/client_library/models/layerset_feature.py create mode 100644 fastfuels_sdk/v2/client_library/models/layerset_properties.py create mode 100644 fastfuels_sdk/v2/client_library/models/line_string.py create mode 100644 fastfuels_sdk/v2/client_library/models/list_applications_response.py create mode 100644 fastfuels_sdk/v2/client_library/models/list_domains_response.py create mode 100644 fastfuels_sdk/v2/client_library/models/list_exports_response.py create mode 100644 fastfuels_sdk/v2/client_library/models/list_features_response.py create mode 100644 fastfuels_sdk/v2/client_library/models/list_grids_response.py create mode 100644 fastfuels_sdk/v2/client_library/models/list_inventories_response.py create mode 100644 fastfuels_sdk/v2/client_library/models/list_keys_response.py create mode 100644 fastfuels_sdk/v2/client_library/models/max_crown_radius_unit.py create mode 100644 fastfuels_sdk/v2/client_library/models/meta_chm_version.py create mode 100644 fastfuels_sdk/v2/client_library/models/modifier.py create mode 100644 fastfuels_sdk/v2/client_library/models/moisture_model.py create mode 100644 fastfuels_sdk/v2/client_library/models/multi_line_string.py create mode 100644 fastfuels_sdk/v2/client_library/models/multi_point.py create mode 100644 fastfuels_sdk/v2/client_library/models/multi_polygon.py create mode 100644 fastfuels_sdk/v2/client_library/models/non_burnable_fuel_model.py create mode 100644 fastfuels_sdk/v2/client_library/models/operator.py create mode 100644 fastfuels_sdk/v2/client_library/models/overlap_method.py create mode 100644 fastfuels_sdk/v2/client_library/models/point.py create mode 100644 fastfuels_sdk/v2/client_library/models/point_process.py create mode 100644 fastfuels_sdk/v2/client_library/models/polygon.py create mode 100644 fastfuels_sdk/v2/client_library/models/quic_fire_export_alignment_domain_target.py create mode 100644 fastfuels_sdk/v2/client_library/models/quic_fire_export_alignment_grid_target.py create mode 100644 fastfuels_sdk/v2/client_library/models/quicfire_export_request.py create mode 100644 fastfuels_sdk/v2/client_library/models/quicfire_export_request_moist_merge.py create mode 100644 fastfuels_sdk/v2/client_library/models/remove_action.py create mode 100644 fastfuels_sdk/v2/client_library/models/resampling_method.py create mode 100644 fastfuels_sdk/v2/client_library/models/resolution_3d.py create mode 100644 fastfuels_sdk/v2/client_library/models/scope.py create mode 100644 fastfuels_sdk/v2/client_library/models/sort_order.py create mode 100644 fastfuels_sdk/v2/client_library/models/sparse_grid_data.py create mode 100644 fastfuels_sdk/v2/client_library/models/spatial_operator.py create mode 100644 fastfuels_sdk/v2/client_library/models/stem_isolation_lmf.py create mode 100644 fastfuels_sdk/v2/client_library/models/stem_isolation_vwf.py create mode 100644 fastfuels_sdk/v2/client_library/models/three_dep_coverage_response.py create mode 100644 fastfuels_sdk/v2/client_library/models/three_dep_resolution.py create mode 100644 fastfuels_sdk/v2/client_library/models/topography_band.py create mode 100644 fastfuels_sdk/v2/client_library/models/tree_band.py create mode 100644 fastfuels_sdk/v2/client_library/models/tree_map_band.py create mode 100644 fastfuels_sdk/v2/client_library/models/tree_map_version.py create mode 100644 fastfuels_sdk/v2/client_library/models/uniform_band.py create mode 100644 fastfuels_sdk/v2/client_library/models/uniform_band_input.py create mode 100644 fastfuels_sdk/v2/client_library/models/uniform_moisture_value.py create mode 100644 fastfuels_sdk/v2/client_library/models/update_application_request.py create mode 100644 fastfuels_sdk/v2/client_library/models/update_domain_request_body.py create mode 100644 fastfuels_sdk/v2/client_library/models/update_export_request_body.py create mode 100644 fastfuels_sdk/v2/client_library/models/update_feature_request_body.py create mode 100644 fastfuels_sdk/v2/client_library/models/update_grid_request_body.py create mode 100644 fastfuels_sdk/v2/client_library/models/update_inventory_request_body.py create mode 100644 fastfuels_sdk/v2/client_library/models/upload_band_definition.py create mode 100644 fastfuels_sdk/v2/client_library/models/validation_error.py create mode 100644 fastfuels_sdk/v2/client_library/types.py create mode 100644 fastfuels_sdk/v2/generate_clients.sh diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f87da45..9a7a9d7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,4 +1,4 @@ -exclude: ^fastfuels_sdk/v1/client_library/ +exclude: ^fastfuels_sdk/(v1|v2)/client_library/ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.6.0 diff --git a/fastfuels_sdk/v2/__init__.py b/fastfuels_sdk/v2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fastfuels_sdk/v2/client_library/__init__.py b/fastfuels_sdk/v2/client_library/__init__.py new file mode 100644 index 0000000..174eaa3 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/__init__.py @@ -0,0 +1,8 @@ +"""A client library for accessing FastFuels API""" + +from .client import AuthenticatedClient, Client + +__all__ = ( + "AuthenticatedClient", + "Client", +) diff --git a/fastfuels_sdk/v2/client_library/api/__init__.py b/fastfuels_sdk/v2/client_library/api/__init__.py new file mode 100644 index 0000000..81f9fa2 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/__init__.py @@ -0,0 +1 @@ +"""Contains methods for accessing the API""" diff --git a/fastfuels_sdk/v2/client_library/api/applications/__init__.py b/fastfuels_sdk/v2/client_library/api/applications/__init__.py new file mode 100644 index 0000000..2d7c0b2 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/applications/__init__.py @@ -0,0 +1 @@ +"""Contains endpoint functions for accessing the API""" diff --git a/fastfuels_sdk/v2/client_library/api/applications/create_application.py b/fastfuels_sdk/v2/client_library/api/applications/create_application.py new file mode 100644 index 0000000..adf9d1f --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/applications/create_application.py @@ -0,0 +1,174 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.application import Application +from ...models.create_application_request import CreateApplicationRequest +from ...models.http_validation_error import HTTPValidationError +from ...types import Response + + +def _get_kwargs( + *, + body: CreateApplicationRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/applications", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Application | HTTPValidationError | None: + if response.status_code == 201: + response_201 = Application.from_dict(response.json()) + + return response_201 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Application | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + body: CreateApplicationRequest, +) -> Response[Application | HTTPValidationError]: + """Create an application + + Create a new application. Only personal-access users can create applications. + + Args: + body (CreateApplicationRequest): Request body for creating an application. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Application | HTTPValidationError] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + body: CreateApplicationRequest, +) -> Application | HTTPValidationError | None: + """Create an application + + Create a new application. Only personal-access users can create applications. + + Args: + body (CreateApplicationRequest): Request body for creating an application. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Application | HTTPValidationError + """ + + return sync_detailed( + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + body: CreateApplicationRequest, +) -> Response[Application | HTTPValidationError]: + """Create an application + + Create a new application. Only personal-access users can create applications. + + Args: + body (CreateApplicationRequest): Request body for creating an application. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Application | HTTPValidationError] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + body: CreateApplicationRequest, +) -> Application | HTTPValidationError | None: + """Create an application + + Create a new application. Only personal-access users can create applications. + + Args: + body (CreateApplicationRequest): Request body for creating an application. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Application | HTTPValidationError + """ + + return ( + await asyncio_detailed( + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/applications/delete_application.py b/fastfuels_sdk/v2/client_library/api/applications/delete_application.py new file mode 100644 index 0000000..a75f856 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/applications/delete_application.py @@ -0,0 +1,167 @@ +from http import HTTPStatus +from typing import Any, cast +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.http_validation_error import HTTPValidationError +from ...types import Response + + +def _get_kwargs( + application_id: str, +) -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "delete", + "url": "/applications/{application_id}".format( + application_id=quote(str(application_id), safe=""), + ), + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Any | HTTPValidationError | None: + if response.status_code == 204: + response_204 = cast(Any, None) + return response_204 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Any | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + application_id: str, + *, + client: AuthenticatedClient, +) -> Response[Any | HTTPValidationError]: + """Delete an application + + Delete an application and all its API keys. Validates ownership first. + + Args: + application_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any | HTTPValidationError] + """ + + kwargs = _get_kwargs( + application_id=application_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + application_id: str, + *, + client: AuthenticatedClient, +) -> Any | HTTPValidationError | None: + """Delete an application + + Delete an application and all its API keys. Validates ownership first. + + Args: + application_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Any | HTTPValidationError + """ + + return sync_detailed( + application_id=application_id, + client=client, + ).parsed + + +async def asyncio_detailed( + application_id: str, + *, + client: AuthenticatedClient, +) -> Response[Any | HTTPValidationError]: + """Delete an application + + Delete an application and all its API keys. Validates ownership first. + + Args: + application_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any | HTTPValidationError] + """ + + kwargs = _get_kwargs( + application_id=application_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + application_id: str, + *, + client: AuthenticatedClient, +) -> Any | HTTPValidationError | None: + """Delete an application + + Delete an application and all its API keys. Validates ownership first. + + Args: + application_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Any | HTTPValidationError + """ + + return ( + await asyncio_detailed( + application_id=application_id, + client=client, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/applications/get_application.py b/fastfuels_sdk/v2/client_library/api/applications/get_application.py new file mode 100644 index 0000000..c848e28 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/applications/get_application.py @@ -0,0 +1,169 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.application import Application +from ...models.http_validation_error import HTTPValidationError +from ...types import Response + + +def _get_kwargs( + application_id: str, +) -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/applications/{application_id}".format( + application_id=quote(str(application_id), safe=""), + ), + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Application | HTTPValidationError | None: + if response.status_code == 200: + response_200 = Application.from_dict(response.json()) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Application | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + application_id: str, + *, + client: AuthenticatedClient, +) -> Response[Application | HTTPValidationError]: + """Get an application by ID + + Get an application by ID with ownership check. + + Args: + application_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Application | HTTPValidationError] + """ + + kwargs = _get_kwargs( + application_id=application_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + application_id: str, + *, + client: AuthenticatedClient, +) -> Application | HTTPValidationError | None: + """Get an application by ID + + Get an application by ID with ownership check. + + Args: + application_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Application | HTTPValidationError + """ + + return sync_detailed( + application_id=application_id, + client=client, + ).parsed + + +async def asyncio_detailed( + application_id: str, + *, + client: AuthenticatedClient, +) -> Response[Application | HTTPValidationError]: + """Get an application by ID + + Get an application by ID with ownership check. + + Args: + application_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Application | HTTPValidationError] + """ + + kwargs = _get_kwargs( + application_id=application_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + application_id: str, + *, + client: AuthenticatedClient, +) -> Application | HTTPValidationError | None: + """Get an application by ID + + Get an application by ID with ownership check. + + Args: + application_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Application | HTTPValidationError + """ + + return ( + await asyncio_detailed( + application_id=application_id, + client=client, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/applications/list_applications.py b/fastfuels_sdk/v2/client_library/api/applications/list_applications.py new file mode 100644 index 0000000..d7effbd --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/applications/list_applications.py @@ -0,0 +1,189 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.http_validation_error import HTTPValidationError +from ...models.list_applications_response import ListApplicationsResponse +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + *, + page: int | Unset = 0, + size: int | Unset = 100, +) -> dict[str, Any]: + + params: dict[str, Any] = {} + + params["page"] = page + + params["size"] = size + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/applications", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> HTTPValidationError | ListApplicationsResponse | None: + if response.status_code == 200: + response_200 = ListApplicationsResponse.from_dict(response.json()) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[HTTPValidationError | ListApplicationsResponse]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, +) -> Response[HTTPValidationError | ListApplicationsResponse]: + """List applications + + List applications owned by the authenticated user. + + Args: + page (int | Unset): Default: 0. + size (int | Unset): Default: 100. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | ListApplicationsResponse] + """ + + kwargs = _get_kwargs( + page=page, + size=size, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, +) -> HTTPValidationError | ListApplicationsResponse | None: + """List applications + + List applications owned by the authenticated user. + + Args: + page (int | Unset): Default: 0. + size (int | Unset): Default: 100. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | ListApplicationsResponse + """ + + return sync_detailed( + client=client, + page=page, + size=size, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, +) -> Response[HTTPValidationError | ListApplicationsResponse]: + """List applications + + List applications owned by the authenticated user. + + Args: + page (int | Unset): Default: 0. + size (int | Unset): Default: 100. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | ListApplicationsResponse] + """ + + kwargs = _get_kwargs( + page=page, + size=size, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, +) -> HTTPValidationError | ListApplicationsResponse | None: + """List applications + + List applications owned by the authenticated user. + + Args: + page (int | Unset): Default: 0. + size (int | Unset): Default: 100. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | ListApplicationsResponse + """ + + return ( + await asyncio_detailed( + client=client, + page=page, + size=size, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/applications/update_application.py b/fastfuels_sdk/v2/client_library/api/applications/update_application.py new file mode 100644 index 0000000..b2fb453 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/applications/update_application.py @@ -0,0 +1,190 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.application import Application +from ...models.http_validation_error import HTTPValidationError +from ...models.update_application_request import UpdateApplicationRequest +from ...types import Response + + +def _get_kwargs( + application_id: str, + *, + body: UpdateApplicationRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "patch", + "url": "/applications/{application_id}".format( + application_id=quote(str(application_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Application | HTTPValidationError | None: + if response.status_code == 200: + response_200 = Application.from_dict(response.json()) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Application | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + application_id: str, + *, + client: AuthenticatedClient, + body: UpdateApplicationRequest, +) -> Response[Application | HTTPValidationError]: + """Update an application + + Update an application's name or description. + + Args: + application_id (str): + body (UpdateApplicationRequest): Request body for updating an application. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Application | HTTPValidationError] + """ + + kwargs = _get_kwargs( + application_id=application_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + application_id: str, + *, + client: AuthenticatedClient, + body: UpdateApplicationRequest, +) -> Application | HTTPValidationError | None: + """Update an application + + Update an application's name or description. + + Args: + application_id (str): + body (UpdateApplicationRequest): Request body for updating an application. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Application | HTTPValidationError + """ + + return sync_detailed( + application_id=application_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + application_id: str, + *, + client: AuthenticatedClient, + body: UpdateApplicationRequest, +) -> Response[Application | HTTPValidationError]: + """Update an application + + Update an application's name or description. + + Args: + application_id (str): + body (UpdateApplicationRequest): Request body for updating an application. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Application | HTTPValidationError] + """ + + kwargs = _get_kwargs( + application_id=application_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + application_id: str, + *, + client: AuthenticatedClient, + body: UpdateApplicationRequest, +) -> Application | HTTPValidationError | None: + """Update an application + + Update an application's name or description. + + Args: + application_id (str): + body (UpdateApplicationRequest): Request body for updating an application. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Application | HTTPValidationError + """ + + return ( + await asyncio_detailed( + application_id=application_id, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/domains/__init__.py b/fastfuels_sdk/v2/client_library/api/domains/__init__.py new file mode 100644 index 0000000..2d7c0b2 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/domains/__init__.py @@ -0,0 +1 @@ +"""Contains endpoint functions for accessing the API""" diff --git a/fastfuels_sdk/v2/client_library/api/domains/create_domain.py b/fastfuels_sdk/v2/client_library/api/domains/create_domain.py new file mode 100644 index 0000000..eee3033 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/domains/create_domain.py @@ -0,0 +1,638 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.create_domain_request_body import CreateDomainRequestBody +from ...models.domain import Domain +from ...models.http_validation_error import HTTPValidationError +from ...types import Response + + +def _get_kwargs( + *, + body: CreateDomainRequestBody, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/domains", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Domain | HTTPValidationError | None: + if response.status_code == 201: + response_201 = Domain.from_dict(response.json()) + + return response_201 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Domain | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + body: CreateDomainRequestBody, +) -> Response[Domain | HTTPValidationError]: + r"""Create a new domain + + # Create Domain Endpoint + + This endpoint creates a new domain resource based on a spatial extent and + additional details provided by the user. The domain resource acts as the + spatial container for all other resources that create data within the system. + + ## What is a Domain Resource? + + A domain resource is a spatial container that represents a specific geographical + area. It includes metadata such as the name, description, creation date, and the + spatial extent defined by geographic coordinates. Domains are used to organize + and manage spatial data and operations within a defined area. + + ## Request Body + + The request body must be a GeoJSON FeatureCollection as defined by the + [GeoJSON specification (RFC 7946)](https://datatracker.ietf.org/doc/html/rfc7946). + + ### Required Fields + + - **type**: (string) Must be \"FeatureCollection\". + - **features**: (array) An array of Feature objects. Each Feature must have: + - **type**: (string) Must be \"Feature\". + - **geometry**: (GeoJSON Geometry) A geometry object (typically Polygon). + - **type**: (string) Must be a valid GeoJSON type, e.g., \"Polygon\". + - **coordinates**: (array) An array of coordinates defining the geometry. + + ### Optional Fields + + - **name**: (string) The name of the domain. Default: empty string. + - **description**: (string) A brief description of the domain. Default: empty string. + - **tags**: (array of strings) Tags for organizing and filtering domains. + - **crs**: (object) The coordinate reference system. Default: EPSG:4326 (WGS84). + - **type**: (string) Must be \"name\". + - **properties**: (object) Contains the CRS details. + - **name**: (string) The CRS identifier, e.g., \"EPSG:4326\", \"EPSG:5070\", + or URN format \"urn:ogc:def:crs:EPSG::32611\". + - **pad_to_resolution**: (number) Optional resolution in meters to snap the + domain bounding box to. When set, the bounding box (the \"domain\" feature) + is snapped outward to the nearest multiple of this value. Grids whose + resolutions divide evenly into this value will produce identical, aligned + footprints on this domain. Useful for compositional workflows where + multiple grids at different resolutions need to share an extent. + - **style**: (object) Optional visual style for rendering the domain on a + map. Sub-fields: `stroke_color`, `stroke_opacity` (0-1), `stroke_width` + (>= 0), `fill_color`, `fill_opacity` (0-1). Color strings accept any + format the renderer understands (hex, named, `rgb()`, ...) and are + capped at 64 characters. + + ## Response + + On successful creation, returns the domain resource with: + + - **id**: (string) A unique 32-character hex identifier for the domain. + - **type**: (string) Always \"FeatureCollection\". + - **name**: (string) The name of the domain. + - **description**: (string) The description of the domain. + - **created_on**: (datetime) When the domain was created. + - **modified_on**: (datetime) When the domain was last modified. + - **tags**: (array) The tags associated with the domain. + - **crs**: (object) The coordinate reference system (always projected). + - **features**: (array) Two named features: + - **`name: \"domain\"`** — A polygon covering the working extent (bounding + box of the input, possibly padded). This is what griddle, standgen, + and exporter use as the authoritative spatial extent. + - **`name: \"input\"`** — The user's original projected geometry, preserved + for visualization and reference. + - **bbox**: (array) Standard GeoJSON bbox `[minx, miny, maxx, maxy]` in the + domain's projected CRS. Equals the bounds of the \"domain\" feature. + - **pad_to_resolution**: (number, optional) The padding value, if set. + + ## CRS Handling + + The API handles coordinate reference systems as follows: + + 1. **Geographic CRS (e.g., EPSG:4326)**: Automatically projected to the + appropriate UTM zone based on the geometry's centroid. The response CRS + will be the UTM zone (e.g., EPSG:32611 for UTM Zone 11N). + + 2. **Projected CRS (e.g., EPSG:5070, EPSG:32611)**: Used as-is without + reprojection. The response CRS will match the input CRS. + + ## Validation + + The following validations are performed: + + 1. **CRS Validation**: Must be a valid EPSG code or URN format. + 2. **Area Validation**: Geometry must have non-zero area (no points or lines). + 3. **Location**: Geometry must be entirely within CONUS (Continental US). + Validated against the original input polygon (not the padded bbox). + 4. **Size Limit**: The working extent (possibly padded bbox) must be less + than 16 square kilometers. + + ## Important Notes + + 1. **FeatureCollection Only**: Unlike v1, this endpoint only accepts + FeatureCollection input, not individual Feature objects. Wrap single + features in a FeatureCollection. + + 2. **Two-Feature Output**: The created domain stores two named features: + a \"domain\" feature (bounding box, the working extent used by all + downstream services) and an \"input\" feature (the user's original + polygon, preserved for visualization). + + 3. **Projection**: Geographic coordinates are always projected to a suitable + UTM zone for accurate area calculations and grid operations. + + 4. **Maximum Area**: The 16 sq km limit ensures reasonable processing times. + Contact support if you need larger domains. + + ## Error Responses + + - **422 Unprocessable Entity**: + - \"Invalid CRS '{crs}'. Must be a valid authority string (e.g., 'EPSG:4326').\" + - \"Invalid geometry. The feature must have an area greater than zero.\" + - \"Invalid spatial extent. Area must be less than 16 square kilometers.\" + - \"Invalid spatial extent. The domain must be entirely within CONUS.\" + + Args: + body (CreateDomainRequestBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Domain | HTTPValidationError] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + body: CreateDomainRequestBody, +) -> Domain | HTTPValidationError | None: + r"""Create a new domain + + # Create Domain Endpoint + + This endpoint creates a new domain resource based on a spatial extent and + additional details provided by the user. The domain resource acts as the + spatial container for all other resources that create data within the system. + + ## What is a Domain Resource? + + A domain resource is a spatial container that represents a specific geographical + area. It includes metadata such as the name, description, creation date, and the + spatial extent defined by geographic coordinates. Domains are used to organize + and manage spatial data and operations within a defined area. + + ## Request Body + + The request body must be a GeoJSON FeatureCollection as defined by the + [GeoJSON specification (RFC 7946)](https://datatracker.ietf.org/doc/html/rfc7946). + + ### Required Fields + + - **type**: (string) Must be \"FeatureCollection\". + - **features**: (array) An array of Feature objects. Each Feature must have: + - **type**: (string) Must be \"Feature\". + - **geometry**: (GeoJSON Geometry) A geometry object (typically Polygon). + - **type**: (string) Must be a valid GeoJSON type, e.g., \"Polygon\". + - **coordinates**: (array) An array of coordinates defining the geometry. + + ### Optional Fields + + - **name**: (string) The name of the domain. Default: empty string. + - **description**: (string) A brief description of the domain. Default: empty string. + - **tags**: (array of strings) Tags for organizing and filtering domains. + - **crs**: (object) The coordinate reference system. Default: EPSG:4326 (WGS84). + - **type**: (string) Must be \"name\". + - **properties**: (object) Contains the CRS details. + - **name**: (string) The CRS identifier, e.g., \"EPSG:4326\", \"EPSG:5070\", + or URN format \"urn:ogc:def:crs:EPSG::32611\". + - **pad_to_resolution**: (number) Optional resolution in meters to snap the + domain bounding box to. When set, the bounding box (the \"domain\" feature) + is snapped outward to the nearest multiple of this value. Grids whose + resolutions divide evenly into this value will produce identical, aligned + footprints on this domain. Useful for compositional workflows where + multiple grids at different resolutions need to share an extent. + - **style**: (object) Optional visual style for rendering the domain on a + map. Sub-fields: `stroke_color`, `stroke_opacity` (0-1), `stroke_width` + (>= 0), `fill_color`, `fill_opacity` (0-1). Color strings accept any + format the renderer understands (hex, named, `rgb()`, ...) and are + capped at 64 characters. + + ## Response + + On successful creation, returns the domain resource with: + + - **id**: (string) A unique 32-character hex identifier for the domain. + - **type**: (string) Always \"FeatureCollection\". + - **name**: (string) The name of the domain. + - **description**: (string) The description of the domain. + - **created_on**: (datetime) When the domain was created. + - **modified_on**: (datetime) When the domain was last modified. + - **tags**: (array) The tags associated with the domain. + - **crs**: (object) The coordinate reference system (always projected). + - **features**: (array) Two named features: + - **`name: \"domain\"`** — A polygon covering the working extent (bounding + box of the input, possibly padded). This is what griddle, standgen, + and exporter use as the authoritative spatial extent. + - **`name: \"input\"`** — The user's original projected geometry, preserved + for visualization and reference. + - **bbox**: (array) Standard GeoJSON bbox `[minx, miny, maxx, maxy]` in the + domain's projected CRS. Equals the bounds of the \"domain\" feature. + - **pad_to_resolution**: (number, optional) The padding value, if set. + + ## CRS Handling + + The API handles coordinate reference systems as follows: + + 1. **Geographic CRS (e.g., EPSG:4326)**: Automatically projected to the + appropriate UTM zone based on the geometry's centroid. The response CRS + will be the UTM zone (e.g., EPSG:32611 for UTM Zone 11N). + + 2. **Projected CRS (e.g., EPSG:5070, EPSG:32611)**: Used as-is without + reprojection. The response CRS will match the input CRS. + + ## Validation + + The following validations are performed: + + 1. **CRS Validation**: Must be a valid EPSG code or URN format. + 2. **Area Validation**: Geometry must have non-zero area (no points or lines). + 3. **Location**: Geometry must be entirely within CONUS (Continental US). + Validated against the original input polygon (not the padded bbox). + 4. **Size Limit**: The working extent (possibly padded bbox) must be less + than 16 square kilometers. + + ## Important Notes + + 1. **FeatureCollection Only**: Unlike v1, this endpoint only accepts + FeatureCollection input, not individual Feature objects. Wrap single + features in a FeatureCollection. + + 2. **Two-Feature Output**: The created domain stores two named features: + a \"domain\" feature (bounding box, the working extent used by all + downstream services) and an \"input\" feature (the user's original + polygon, preserved for visualization). + + 3. **Projection**: Geographic coordinates are always projected to a suitable + UTM zone for accurate area calculations and grid operations. + + 4. **Maximum Area**: The 16 sq km limit ensures reasonable processing times. + Contact support if you need larger domains. + + ## Error Responses + + - **422 Unprocessable Entity**: + - \"Invalid CRS '{crs}'. Must be a valid authority string (e.g., 'EPSG:4326').\" + - \"Invalid geometry. The feature must have an area greater than zero.\" + - \"Invalid spatial extent. Area must be less than 16 square kilometers.\" + - \"Invalid spatial extent. The domain must be entirely within CONUS.\" + + Args: + body (CreateDomainRequestBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Domain | HTTPValidationError + """ + + return sync_detailed( + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + body: CreateDomainRequestBody, +) -> Response[Domain | HTTPValidationError]: + r"""Create a new domain + + # Create Domain Endpoint + + This endpoint creates a new domain resource based on a spatial extent and + additional details provided by the user. The domain resource acts as the + spatial container for all other resources that create data within the system. + + ## What is a Domain Resource? + + A domain resource is a spatial container that represents a specific geographical + area. It includes metadata such as the name, description, creation date, and the + spatial extent defined by geographic coordinates. Domains are used to organize + and manage spatial data and operations within a defined area. + + ## Request Body + + The request body must be a GeoJSON FeatureCollection as defined by the + [GeoJSON specification (RFC 7946)](https://datatracker.ietf.org/doc/html/rfc7946). + + ### Required Fields + + - **type**: (string) Must be \"FeatureCollection\". + - **features**: (array) An array of Feature objects. Each Feature must have: + - **type**: (string) Must be \"Feature\". + - **geometry**: (GeoJSON Geometry) A geometry object (typically Polygon). + - **type**: (string) Must be a valid GeoJSON type, e.g., \"Polygon\". + - **coordinates**: (array) An array of coordinates defining the geometry. + + ### Optional Fields + + - **name**: (string) The name of the domain. Default: empty string. + - **description**: (string) A brief description of the domain. Default: empty string. + - **tags**: (array of strings) Tags for organizing and filtering domains. + - **crs**: (object) The coordinate reference system. Default: EPSG:4326 (WGS84). + - **type**: (string) Must be \"name\". + - **properties**: (object) Contains the CRS details. + - **name**: (string) The CRS identifier, e.g., \"EPSG:4326\", \"EPSG:5070\", + or URN format \"urn:ogc:def:crs:EPSG::32611\". + - **pad_to_resolution**: (number) Optional resolution in meters to snap the + domain bounding box to. When set, the bounding box (the \"domain\" feature) + is snapped outward to the nearest multiple of this value. Grids whose + resolutions divide evenly into this value will produce identical, aligned + footprints on this domain. Useful for compositional workflows where + multiple grids at different resolutions need to share an extent. + - **style**: (object) Optional visual style for rendering the domain on a + map. Sub-fields: `stroke_color`, `stroke_opacity` (0-1), `stroke_width` + (>= 0), `fill_color`, `fill_opacity` (0-1). Color strings accept any + format the renderer understands (hex, named, `rgb()`, ...) and are + capped at 64 characters. + + ## Response + + On successful creation, returns the domain resource with: + + - **id**: (string) A unique 32-character hex identifier for the domain. + - **type**: (string) Always \"FeatureCollection\". + - **name**: (string) The name of the domain. + - **description**: (string) The description of the domain. + - **created_on**: (datetime) When the domain was created. + - **modified_on**: (datetime) When the domain was last modified. + - **tags**: (array) The tags associated with the domain. + - **crs**: (object) The coordinate reference system (always projected). + - **features**: (array) Two named features: + - **`name: \"domain\"`** — A polygon covering the working extent (bounding + box of the input, possibly padded). This is what griddle, standgen, + and exporter use as the authoritative spatial extent. + - **`name: \"input\"`** — The user's original projected geometry, preserved + for visualization and reference. + - **bbox**: (array) Standard GeoJSON bbox `[minx, miny, maxx, maxy]` in the + domain's projected CRS. Equals the bounds of the \"domain\" feature. + - **pad_to_resolution**: (number, optional) The padding value, if set. + + ## CRS Handling + + The API handles coordinate reference systems as follows: + + 1. **Geographic CRS (e.g., EPSG:4326)**: Automatically projected to the + appropriate UTM zone based on the geometry's centroid. The response CRS + will be the UTM zone (e.g., EPSG:32611 for UTM Zone 11N). + + 2. **Projected CRS (e.g., EPSG:5070, EPSG:32611)**: Used as-is without + reprojection. The response CRS will match the input CRS. + + ## Validation + + The following validations are performed: + + 1. **CRS Validation**: Must be a valid EPSG code or URN format. + 2. **Area Validation**: Geometry must have non-zero area (no points or lines). + 3. **Location**: Geometry must be entirely within CONUS (Continental US). + Validated against the original input polygon (not the padded bbox). + 4. **Size Limit**: The working extent (possibly padded bbox) must be less + than 16 square kilometers. + + ## Important Notes + + 1. **FeatureCollection Only**: Unlike v1, this endpoint only accepts + FeatureCollection input, not individual Feature objects. Wrap single + features in a FeatureCollection. + + 2. **Two-Feature Output**: The created domain stores two named features: + a \"domain\" feature (bounding box, the working extent used by all + downstream services) and an \"input\" feature (the user's original + polygon, preserved for visualization). + + 3. **Projection**: Geographic coordinates are always projected to a suitable + UTM zone for accurate area calculations and grid operations. + + 4. **Maximum Area**: The 16 sq km limit ensures reasonable processing times. + Contact support if you need larger domains. + + ## Error Responses + + - **422 Unprocessable Entity**: + - \"Invalid CRS '{crs}'. Must be a valid authority string (e.g., 'EPSG:4326').\" + - \"Invalid geometry. The feature must have an area greater than zero.\" + - \"Invalid spatial extent. Area must be less than 16 square kilometers.\" + - \"Invalid spatial extent. The domain must be entirely within CONUS.\" + + Args: + body (CreateDomainRequestBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Domain | HTTPValidationError] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + body: CreateDomainRequestBody, +) -> Domain | HTTPValidationError | None: + r"""Create a new domain + + # Create Domain Endpoint + + This endpoint creates a new domain resource based on a spatial extent and + additional details provided by the user. The domain resource acts as the + spatial container for all other resources that create data within the system. + + ## What is a Domain Resource? + + A domain resource is a spatial container that represents a specific geographical + area. It includes metadata such as the name, description, creation date, and the + spatial extent defined by geographic coordinates. Domains are used to organize + and manage spatial data and operations within a defined area. + + ## Request Body + + The request body must be a GeoJSON FeatureCollection as defined by the + [GeoJSON specification (RFC 7946)](https://datatracker.ietf.org/doc/html/rfc7946). + + ### Required Fields + + - **type**: (string) Must be \"FeatureCollection\". + - **features**: (array) An array of Feature objects. Each Feature must have: + - **type**: (string) Must be \"Feature\". + - **geometry**: (GeoJSON Geometry) A geometry object (typically Polygon). + - **type**: (string) Must be a valid GeoJSON type, e.g., \"Polygon\". + - **coordinates**: (array) An array of coordinates defining the geometry. + + ### Optional Fields + + - **name**: (string) The name of the domain. Default: empty string. + - **description**: (string) A brief description of the domain. Default: empty string. + - **tags**: (array of strings) Tags for organizing and filtering domains. + - **crs**: (object) The coordinate reference system. Default: EPSG:4326 (WGS84). + - **type**: (string) Must be \"name\". + - **properties**: (object) Contains the CRS details. + - **name**: (string) The CRS identifier, e.g., \"EPSG:4326\", \"EPSG:5070\", + or URN format \"urn:ogc:def:crs:EPSG::32611\". + - **pad_to_resolution**: (number) Optional resolution in meters to snap the + domain bounding box to. When set, the bounding box (the \"domain\" feature) + is snapped outward to the nearest multiple of this value. Grids whose + resolutions divide evenly into this value will produce identical, aligned + footprints on this domain. Useful for compositional workflows where + multiple grids at different resolutions need to share an extent. + - **style**: (object) Optional visual style for rendering the domain on a + map. Sub-fields: `stroke_color`, `stroke_opacity` (0-1), `stroke_width` + (>= 0), `fill_color`, `fill_opacity` (0-1). Color strings accept any + format the renderer understands (hex, named, `rgb()`, ...) and are + capped at 64 characters. + + ## Response + + On successful creation, returns the domain resource with: + + - **id**: (string) A unique 32-character hex identifier for the domain. + - **type**: (string) Always \"FeatureCollection\". + - **name**: (string) The name of the domain. + - **description**: (string) The description of the domain. + - **created_on**: (datetime) When the domain was created. + - **modified_on**: (datetime) When the domain was last modified. + - **tags**: (array) The tags associated with the domain. + - **crs**: (object) The coordinate reference system (always projected). + - **features**: (array) Two named features: + - **`name: \"domain\"`** — A polygon covering the working extent (bounding + box of the input, possibly padded). This is what griddle, standgen, + and exporter use as the authoritative spatial extent. + - **`name: \"input\"`** — The user's original projected geometry, preserved + for visualization and reference. + - **bbox**: (array) Standard GeoJSON bbox `[minx, miny, maxx, maxy]` in the + domain's projected CRS. Equals the bounds of the \"domain\" feature. + - **pad_to_resolution**: (number, optional) The padding value, if set. + + ## CRS Handling + + The API handles coordinate reference systems as follows: + + 1. **Geographic CRS (e.g., EPSG:4326)**: Automatically projected to the + appropriate UTM zone based on the geometry's centroid. The response CRS + will be the UTM zone (e.g., EPSG:32611 for UTM Zone 11N). + + 2. **Projected CRS (e.g., EPSG:5070, EPSG:32611)**: Used as-is without + reprojection. The response CRS will match the input CRS. + + ## Validation + + The following validations are performed: + + 1. **CRS Validation**: Must be a valid EPSG code or URN format. + 2. **Area Validation**: Geometry must have non-zero area (no points or lines). + 3. **Location**: Geometry must be entirely within CONUS (Continental US). + Validated against the original input polygon (not the padded bbox). + 4. **Size Limit**: The working extent (possibly padded bbox) must be less + than 16 square kilometers. + + ## Important Notes + + 1. **FeatureCollection Only**: Unlike v1, this endpoint only accepts + FeatureCollection input, not individual Feature objects. Wrap single + features in a FeatureCollection. + + 2. **Two-Feature Output**: The created domain stores two named features: + a \"domain\" feature (bounding box, the working extent used by all + downstream services) and an \"input\" feature (the user's original + polygon, preserved for visualization). + + 3. **Projection**: Geographic coordinates are always projected to a suitable + UTM zone for accurate area calculations and grid operations. + + 4. **Maximum Area**: The 16 sq km limit ensures reasonable processing times. + Contact support if you need larger domains. + + ## Error Responses + + - **422 Unprocessable Entity**: + - \"Invalid CRS '{crs}'. Must be a valid authority string (e.g., 'EPSG:4326').\" + - \"Invalid geometry. The feature must have an area greater than zero.\" + - \"Invalid spatial extent. Area must be less than 16 square kilometers.\" + - \"Invalid spatial extent. The domain must be entirely within CONUS.\" + + Args: + body (CreateDomainRequestBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Domain | HTTPValidationError + """ + + return ( + await asyncio_detailed( + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/domains/delete_domain.py b/fastfuels_sdk/v2/client_library/api/domains/delete_domain.py new file mode 100644 index 0000000..0334849 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/domains/delete_domain.py @@ -0,0 +1,308 @@ +from http import HTTPStatus +from typing import Any, cast +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.http_validation_error import HTTPValidationError +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + domain_id: str, + *, + force: bool | Unset = False, +) -> dict[str, Any]: + + params: dict[str, Any] = {} + + params["force"] = force + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "delete", + "url": "/domains/{domain_id}".format( + domain_id=quote(str(domain_id), safe=""), + ), + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Any | HTTPValidationError | None: + if response.status_code == 204: + response_204 = cast(Any, None) + return response_204 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Any | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + force: bool | Unset = False, +) -> Response[Any | HTTPValidationError]: + """Delete a domain + + # Delete Domain Endpoint + + This endpoint permanently deletes a domain resource by its unique identifier. + This action cannot be undone. + + ## Path Parameters + + - **domain_id**: (string) The unique 32-character hex identifier of the domain. + + ## Query Parameters + + - **force**: (boolean, optional) If true, cascade-deletes all child resources + (grids, etc.) before deleting the domain. Default: false. + + ## Response + + On success, returns HTTP 204 No Content with an empty response body. + + ## Cascade Behavior (AIP-135) + + - **Without `force`**: If the domain has child grids, returns 412 Precondition + Failed. Delete child resources first, or use `force=true`. + - **With `force=true`**: Deletes the domain and all child grids in a single + operation. + + ## Error Responses + + - **404 Not Found**: The domain does not exist or the user does not have access. + - **412 Precondition Failed**: The domain has child resources and `force` was + not set to true. + + Args: + domain_id (str): + force (bool | Unset): Force cascade delete of all child resources (grids, etc.). Without + this, returns 412 if child resources exist. Default: False. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + force=force, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + *, + client: AuthenticatedClient, + force: bool | Unset = False, +) -> Any | HTTPValidationError | None: + """Delete a domain + + # Delete Domain Endpoint + + This endpoint permanently deletes a domain resource by its unique identifier. + This action cannot be undone. + + ## Path Parameters + + - **domain_id**: (string) The unique 32-character hex identifier of the domain. + + ## Query Parameters + + - **force**: (boolean, optional) If true, cascade-deletes all child resources + (grids, etc.) before deleting the domain. Default: false. + + ## Response + + On success, returns HTTP 204 No Content with an empty response body. + + ## Cascade Behavior (AIP-135) + + - **Without `force`**: If the domain has child grids, returns 412 Precondition + Failed. Delete child resources first, or use `force=true`. + - **With `force=true`**: Deletes the domain and all child grids in a single + operation. + + ## Error Responses + + - **404 Not Found**: The domain does not exist or the user does not have access. + - **412 Precondition Failed**: The domain has child resources and `force` was + not set to true. + + Args: + domain_id (str): + force (bool | Unset): Force cascade delete of all child resources (grids, etc.). Without + this, returns 412 if child resources exist. Default: False. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Any | HTTPValidationError + """ + + return sync_detailed( + domain_id=domain_id, + client=client, + force=force, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + force: bool | Unset = False, +) -> Response[Any | HTTPValidationError]: + """Delete a domain + + # Delete Domain Endpoint + + This endpoint permanently deletes a domain resource by its unique identifier. + This action cannot be undone. + + ## Path Parameters + + - **domain_id**: (string) The unique 32-character hex identifier of the domain. + + ## Query Parameters + + - **force**: (boolean, optional) If true, cascade-deletes all child resources + (grids, etc.) before deleting the domain. Default: false. + + ## Response + + On success, returns HTTP 204 No Content with an empty response body. + + ## Cascade Behavior (AIP-135) + + - **Without `force`**: If the domain has child grids, returns 412 Precondition + Failed. Delete child resources first, or use `force=true`. + - **With `force=true`**: Deletes the domain and all child grids in a single + operation. + + ## Error Responses + + - **404 Not Found**: The domain does not exist or the user does not have access. + - **412 Precondition Failed**: The domain has child resources and `force` was + not set to true. + + Args: + domain_id (str): + force (bool | Unset): Force cascade delete of all child resources (grids, etc.). Without + this, returns 412 if child resources exist. Default: False. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + force=force, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + *, + client: AuthenticatedClient, + force: bool | Unset = False, +) -> Any | HTTPValidationError | None: + """Delete a domain + + # Delete Domain Endpoint + + This endpoint permanently deletes a domain resource by its unique identifier. + This action cannot be undone. + + ## Path Parameters + + - **domain_id**: (string) The unique 32-character hex identifier of the domain. + + ## Query Parameters + + - **force**: (boolean, optional) If true, cascade-deletes all child resources + (grids, etc.) before deleting the domain. Default: false. + + ## Response + + On success, returns HTTP 204 No Content with an empty response body. + + ## Cascade Behavior (AIP-135) + + - **Without `force`**: If the domain has child grids, returns 412 Precondition + Failed. Delete child resources first, or use `force=true`. + - **With `force=true`**: Deletes the domain and all child grids in a single + operation. + + ## Error Responses + + - **404 Not Found**: The domain does not exist or the user does not have access. + - **412 Precondition Failed**: The domain has child resources and `force` was + not set to true. + + Args: + domain_id (str): + force (bool | Unset): Force cascade delete of all child resources (grids, etc.). Without + this, returns 412 if child resources exist. Default: False. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Any | HTTPValidationError + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + client=client, + force=force, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/domains/get_domain.py b/fastfuels_sdk/v2/client_library/api/domains/get_domain.py new file mode 100644 index 0000000..0bdc681 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/domains/get_domain.py @@ -0,0 +1,273 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.domain import Domain +from ...models.http_validation_error import HTTPValidationError +from ...types import Response + + +def _get_kwargs( + domain_id: str, +) -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/domains/{domain_id}".format( + domain_id=quote(str(domain_id), safe=""), + ), + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Domain | HTTPValidationError | None: + if response.status_code == 200: + response_200 = Domain.from_dict(response.json()) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Domain | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + *, + client: AuthenticatedClient, +) -> Response[Domain | HTTPValidationError]: + r"""Get a domain by ID + + # Get Domain Endpoint + + This endpoint retrieves a specific domain resource by its unique identifier. + + ## Path Parameters + + - **domain_id**: (string) The unique 32-character hex identifier of the domain. + + ## Response + + On success, returns the domain resource with: + + - **id**: (string) The unique identifier for the domain. + - **type**: (string) Always \"FeatureCollection\". + - **name**: (string) The name of the domain. + - **description**: (string) The description of the domain. + - **created_on**: (datetime) When the domain was created. + - **modified_on**: (datetime) When the domain was last modified. + - **tags**: (array) The tags associated with the domain. + - **crs**: (object) The coordinate reference system (always projected). + - **features**: (array) The domain geometry features. + + ## Error Responses + + - **404 Not Found**: The domain does not exist or the user does not have access. + - Returns 404 for both missing documents and ownership mismatches to avoid + leaking information about document existence. + + Args: + domain_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Domain | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + *, + client: AuthenticatedClient, +) -> Domain | HTTPValidationError | None: + r"""Get a domain by ID + + # Get Domain Endpoint + + This endpoint retrieves a specific domain resource by its unique identifier. + + ## Path Parameters + + - **domain_id**: (string) The unique 32-character hex identifier of the domain. + + ## Response + + On success, returns the domain resource with: + + - **id**: (string) The unique identifier for the domain. + - **type**: (string) Always \"FeatureCollection\". + - **name**: (string) The name of the domain. + - **description**: (string) The description of the domain. + - **created_on**: (datetime) When the domain was created. + - **modified_on**: (datetime) When the domain was last modified. + - **tags**: (array) The tags associated with the domain. + - **crs**: (object) The coordinate reference system (always projected). + - **features**: (array) The domain geometry features. + + ## Error Responses + + - **404 Not Found**: The domain does not exist or the user does not have access. + - Returns 404 for both missing documents and ownership mismatches to avoid + leaking information about document existence. + + Args: + domain_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Domain | HTTPValidationError + """ + + return sync_detailed( + domain_id=domain_id, + client=client, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + *, + client: AuthenticatedClient, +) -> Response[Domain | HTTPValidationError]: + r"""Get a domain by ID + + # Get Domain Endpoint + + This endpoint retrieves a specific domain resource by its unique identifier. + + ## Path Parameters + + - **domain_id**: (string) The unique 32-character hex identifier of the domain. + + ## Response + + On success, returns the domain resource with: + + - **id**: (string) The unique identifier for the domain. + - **type**: (string) Always \"FeatureCollection\". + - **name**: (string) The name of the domain. + - **description**: (string) The description of the domain. + - **created_on**: (datetime) When the domain was created. + - **modified_on**: (datetime) When the domain was last modified. + - **tags**: (array) The tags associated with the domain. + - **crs**: (object) The coordinate reference system (always projected). + - **features**: (array) The domain geometry features. + + ## Error Responses + + - **404 Not Found**: The domain does not exist or the user does not have access. + - Returns 404 for both missing documents and ownership mismatches to avoid + leaking information about document existence. + + Args: + domain_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Domain | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + *, + client: AuthenticatedClient, +) -> Domain | HTTPValidationError | None: + r"""Get a domain by ID + + # Get Domain Endpoint + + This endpoint retrieves a specific domain resource by its unique identifier. + + ## Path Parameters + + - **domain_id**: (string) The unique 32-character hex identifier of the domain. + + ## Response + + On success, returns the domain resource with: + + - **id**: (string) The unique identifier for the domain. + - **type**: (string) Always \"FeatureCollection\". + - **name**: (string) The name of the domain. + - **description**: (string) The description of the domain. + - **created_on**: (datetime) When the domain was created. + - **modified_on**: (datetime) When the domain was last modified. + - **tags**: (array) The tags associated with the domain. + - **crs**: (object) The coordinate reference system (always projected). + - **features**: (array) The domain geometry features. + + ## Error Responses + + - **404 Not Found**: The domain does not exist or the user does not have access. + - Returns 404 for both missing documents and ownership mismatches to avoid + leaking information about document existence. + + Args: + domain_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Domain | HTTPValidationError + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + client=client, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/domains/get_domain_lattice.py b/fastfuels_sdk/v2/client_library/api/domains/get_domain_lattice.py new file mode 100644 index 0000000..d0a87e5 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/domains/get_domain_lattice.py @@ -0,0 +1,317 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.domain_lattice import DomainLattice +from ...models.http_validation_error import HTTPValidationError +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + domain_id: str, + *, + resolution: float, + num_buffer_cells: int | Unset = 0, +) -> dict[str, Any]: + + params: dict[str, Any] = {} + + params["resolution"] = resolution + + params["num_buffer_cells"] = num_buffer_cells + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/domains/{domain_id}/lattice".format( + domain_id=quote(str(domain_id), safe=""), + ), + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> DomainLattice | HTTPValidationError | None: + if response.status_code == 200: + response_200 = DomainLattice.from_dict(response.json()) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[DomainLattice | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + resolution: float, + num_buffer_cells: int | Unset = 0, +) -> Response[DomainLattice | HTTPValidationError]: + """Get the pixel lattice for a domain at a given resolution + + # Get Domain Lattice Endpoint + + Returns the pixel lattice (transform + shape) for the domain at the + requested resolution. Use this to align a GeoTIFF before uploading it + via `POST /domains/{domain_id}/grids/upload`. + + ## Query Parameters + + - **resolution** (required): Pixel size in meters. + - **num_buffer_cells** (optional, default 0): Expand the lattice by + `N * resolution` meters on each side. + + ## Response + + - **crs**: The domain CRS (always projected). + - **resolution**: Echoes the input. + - **num_buffer_cells**: Echoes the input. + - **transform**: Affine coefficients `[a, b, c, d, e, f]` (rasterio + convention). + - **shape**: `[height, width]` in pixels. + + ## Error Responses + + - **404 Not Found**: The domain does not exist or the user does not + have access. + - **422 Unprocessable Entity**: `resolution` is missing or + non-positive, or `num_buffer_cells` is negative. + + Args: + domain_id (str): + resolution (float): Pixel size in meters (domain CRS units, always projected). + num_buffer_cells (int | Unset): Expand the lattice by N cells on each side. Mirrors the + buffer semantics of POST /domains/{domain_id}/grids/upload and the LANDFIRE/3DEP grid + creation endpoints. Default: 0. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[DomainLattice | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + resolution=resolution, + num_buffer_cells=num_buffer_cells, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + *, + client: AuthenticatedClient, + resolution: float, + num_buffer_cells: int | Unset = 0, +) -> DomainLattice | HTTPValidationError | None: + """Get the pixel lattice for a domain at a given resolution + + # Get Domain Lattice Endpoint + + Returns the pixel lattice (transform + shape) for the domain at the + requested resolution. Use this to align a GeoTIFF before uploading it + via `POST /domains/{domain_id}/grids/upload`. + + ## Query Parameters + + - **resolution** (required): Pixel size in meters. + - **num_buffer_cells** (optional, default 0): Expand the lattice by + `N * resolution` meters on each side. + + ## Response + + - **crs**: The domain CRS (always projected). + - **resolution**: Echoes the input. + - **num_buffer_cells**: Echoes the input. + - **transform**: Affine coefficients `[a, b, c, d, e, f]` (rasterio + convention). + - **shape**: `[height, width]` in pixels. + + ## Error Responses + + - **404 Not Found**: The domain does not exist or the user does not + have access. + - **422 Unprocessable Entity**: `resolution` is missing or + non-positive, or `num_buffer_cells` is negative. + + Args: + domain_id (str): + resolution (float): Pixel size in meters (domain CRS units, always projected). + num_buffer_cells (int | Unset): Expand the lattice by N cells on each side. Mirrors the + buffer semantics of POST /domains/{domain_id}/grids/upload and the LANDFIRE/3DEP grid + creation endpoints. Default: 0. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + DomainLattice | HTTPValidationError + """ + + return sync_detailed( + domain_id=domain_id, + client=client, + resolution=resolution, + num_buffer_cells=num_buffer_cells, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + resolution: float, + num_buffer_cells: int | Unset = 0, +) -> Response[DomainLattice | HTTPValidationError]: + """Get the pixel lattice for a domain at a given resolution + + # Get Domain Lattice Endpoint + + Returns the pixel lattice (transform + shape) for the domain at the + requested resolution. Use this to align a GeoTIFF before uploading it + via `POST /domains/{domain_id}/grids/upload`. + + ## Query Parameters + + - **resolution** (required): Pixel size in meters. + - **num_buffer_cells** (optional, default 0): Expand the lattice by + `N * resolution` meters on each side. + + ## Response + + - **crs**: The domain CRS (always projected). + - **resolution**: Echoes the input. + - **num_buffer_cells**: Echoes the input. + - **transform**: Affine coefficients `[a, b, c, d, e, f]` (rasterio + convention). + - **shape**: `[height, width]` in pixels. + + ## Error Responses + + - **404 Not Found**: The domain does not exist or the user does not + have access. + - **422 Unprocessable Entity**: `resolution` is missing or + non-positive, or `num_buffer_cells` is negative. + + Args: + domain_id (str): + resolution (float): Pixel size in meters (domain CRS units, always projected). + num_buffer_cells (int | Unset): Expand the lattice by N cells on each side. Mirrors the + buffer semantics of POST /domains/{domain_id}/grids/upload and the LANDFIRE/3DEP grid + creation endpoints. Default: 0. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[DomainLattice | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + resolution=resolution, + num_buffer_cells=num_buffer_cells, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + *, + client: AuthenticatedClient, + resolution: float, + num_buffer_cells: int | Unset = 0, +) -> DomainLattice | HTTPValidationError | None: + """Get the pixel lattice for a domain at a given resolution + + # Get Domain Lattice Endpoint + + Returns the pixel lattice (transform + shape) for the domain at the + requested resolution. Use this to align a GeoTIFF before uploading it + via `POST /domains/{domain_id}/grids/upload`. + + ## Query Parameters + + - **resolution** (required): Pixel size in meters. + - **num_buffer_cells** (optional, default 0): Expand the lattice by + `N * resolution` meters on each side. + + ## Response + + - **crs**: The domain CRS (always projected). + - **resolution**: Echoes the input. + - **num_buffer_cells**: Echoes the input. + - **transform**: Affine coefficients `[a, b, c, d, e, f]` (rasterio + convention). + - **shape**: `[height, width]` in pixels. + + ## Error Responses + + - **404 Not Found**: The domain does not exist or the user does not + have access. + - **422 Unprocessable Entity**: `resolution` is missing or + non-positive, or `num_buffer_cells` is negative. + + Args: + domain_id (str): + resolution (float): Pixel size in meters (domain CRS units, always projected). + num_buffer_cells (int | Unset): Expand the lattice by N cells on each side. Mirrors the + buffer semantics of POST /domains/{domain_id}/grids/upload and the LANDFIRE/3DEP grid + creation endpoints. Default: 0. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + DomainLattice | HTTPValidationError + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + client=client, + resolution=resolution, + num_buffer_cells=num_buffer_cells, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/domains/list_domains.py b/fastfuels_sdk/v2/client_library/api/domains/list_domains.py new file mode 100644 index 0000000..4e02b13 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/domains/list_domains.py @@ -0,0 +1,595 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.domain_sort_field import DomainSortField +from ...models.domain_sort_order import DomainSortOrder +from ...models.http_validation_error import HTTPValidationError +from ...models.list_domains_response import ListDomainsResponse +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + *, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: DomainSortField | None | Unset = UNSET, + sort_order: DomainSortOrder | None | Unset = UNSET, +) -> dict[str, Any]: + + params: dict[str, Any] = {} + + params["page"] = page + + params["size"] = size + + json_sort_by: None | str | Unset + if isinstance(sort_by, Unset): + json_sort_by = UNSET + elif isinstance(sort_by, DomainSortField): + json_sort_by = sort_by.value + else: + json_sort_by = sort_by + params["sort_by"] = json_sort_by + + json_sort_order: None | str | Unset + if isinstance(sort_order, Unset): + json_sort_order = UNSET + elif isinstance(sort_order, DomainSortOrder): + json_sort_order = sort_order.value + else: + json_sort_order = sort_order + params["sort_order"] = json_sort_order + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/domains", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> HTTPValidationError | ListDomainsResponse | None: + if response.status_code == 200: + response_200 = ListDomainsResponse.from_dict(response.json()) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[HTTPValidationError | ListDomainsResponse]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: DomainSortField | None | Unset = UNSET, + sort_order: DomainSortOrder | None | Unset = UNSET, +) -> Response[HTTPValidationError | ListDomainsResponse]: + r"""List all domains + + # List Domains Endpoint + + This endpoint retrieves a paginated list of all domains belonging to the + authenticated user. + + ## Query Parameters + + - **page**: (integer, optional) The page number to retrieve. Zero-indexed, + meaning the first page is `0`. Default: 0. + - **size**: (integer, optional) The number of domains to retrieve per page. + Must be between 1 and 1000. Default: 100. + - **sort_by**: (string, optional) The field to sort results by. Valid values: + - `created_on`: Sort by creation date. + - `modified_on`: Sort by last modification date. + - `name`: Sort alphabetically by name. + - **sort_order**: (string, optional) The order to sort results. Valid values: + - `ascending`: Sort in ascending order (A-Z, oldest first). + - `descending`: Sort in descending order (Z-A, newest first). + Default: descending when sort_by is specified. + + ## Response + + Returns a paginated list of domains with metadata: + + - **domains**: (array) List of domain resources for the current page. + Each domain includes: + - **id**: (string) The unique identifier for the domain. + - **type**: (string) Always \"FeatureCollection\". + - **name**: (string) The name of the domain. + - **description**: (string) The description of the domain. + - **created_on**: (datetime) When the domain was created. + - **modified_on**: (datetime) When the domain was last modified. + - **tags**: (array) The tags associated with the domain. + - **crs**: (object) The coordinate reference system. + - **features**: (array) The domain geometry features. + - **current_page**: (integer) The current page number (zero-indexed). + - **page_size**: (integer) The number of domains per page. + - **total_items**: (integer) The total number of domains owned by the user. + + ## Pagination + + Use `page` and `size` parameters to navigate through large result sets: + + - First page: `?page=0&size=10` + - Second page: `?page=1&size=10` + - Calculate total pages: `ceil(total_items / page_size)` + + ## Sorting + + Combine `sort_by` and `sort_order` for custom ordering: + + - Newest first: `?sort_by=created_on&sort_order=descending` + - Alphabetical: `?sort_by=name&sort_order=ascending` + - Recently modified: `?sort_by=modified_on&sort_order=descending` + + ## Example Request + + ```http + GET /v2/domains?page=0&size=10&sort_by=created_on&sort_order=descending + ``` + + ## Example Response + + ```json + { + \"domains\": [ + { + \"id\": \"abc123...\", + \"type\": \"FeatureCollection\", + \"name\": \"My Domain\", + \"description\": \"A test domain\", + \"created_on\": \"2024-01-15T10:30:00\", + \"modified_on\": \"2024-01-15T10:30:00\", + \"tags\": [\"test\"], + \"crs\": {\"type\": \"name\", \"properties\": {\"name\": \"EPSG:32611\"}}, + \"features\": [...] + } + ], + \"current_page\": 0, + \"page_size\": 10, + \"total_items\": 42 + } + ``` + + ## Error Responses + + - **422 Unprocessable Entity**: Invalid query parameters. + - Page must be a non-negative integer. + - Size must be between 1 and 1000. + - Invalid sort_by or sort_order values. + + Args: + page (int | Unset): The page number to retrieve (zero-indexed). Default: 0. + size (int | Unset): The number of domains to retrieve per page. Default: 100. + sort_by (DomainSortField | None | Unset): The field to sort results by. + sort_order (DomainSortOrder | None | Unset): The order to sort results (ascending or + descending). + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | ListDomainsResponse] + """ + + kwargs = _get_kwargs( + page=page, + size=size, + sort_by=sort_by, + sort_order=sort_order, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: DomainSortField | None | Unset = UNSET, + sort_order: DomainSortOrder | None | Unset = UNSET, +) -> HTTPValidationError | ListDomainsResponse | None: + r"""List all domains + + # List Domains Endpoint + + This endpoint retrieves a paginated list of all domains belonging to the + authenticated user. + + ## Query Parameters + + - **page**: (integer, optional) The page number to retrieve. Zero-indexed, + meaning the first page is `0`. Default: 0. + - **size**: (integer, optional) The number of domains to retrieve per page. + Must be between 1 and 1000. Default: 100. + - **sort_by**: (string, optional) The field to sort results by. Valid values: + - `created_on`: Sort by creation date. + - `modified_on`: Sort by last modification date. + - `name`: Sort alphabetically by name. + - **sort_order**: (string, optional) The order to sort results. Valid values: + - `ascending`: Sort in ascending order (A-Z, oldest first). + - `descending`: Sort in descending order (Z-A, newest first). + Default: descending when sort_by is specified. + + ## Response + + Returns a paginated list of domains with metadata: + + - **domains**: (array) List of domain resources for the current page. + Each domain includes: + - **id**: (string) The unique identifier for the domain. + - **type**: (string) Always \"FeatureCollection\". + - **name**: (string) The name of the domain. + - **description**: (string) The description of the domain. + - **created_on**: (datetime) When the domain was created. + - **modified_on**: (datetime) When the domain was last modified. + - **tags**: (array) The tags associated with the domain. + - **crs**: (object) The coordinate reference system. + - **features**: (array) The domain geometry features. + - **current_page**: (integer) The current page number (zero-indexed). + - **page_size**: (integer) The number of domains per page. + - **total_items**: (integer) The total number of domains owned by the user. + + ## Pagination + + Use `page` and `size` parameters to navigate through large result sets: + + - First page: `?page=0&size=10` + - Second page: `?page=1&size=10` + - Calculate total pages: `ceil(total_items / page_size)` + + ## Sorting + + Combine `sort_by` and `sort_order` for custom ordering: + + - Newest first: `?sort_by=created_on&sort_order=descending` + - Alphabetical: `?sort_by=name&sort_order=ascending` + - Recently modified: `?sort_by=modified_on&sort_order=descending` + + ## Example Request + + ```http + GET /v2/domains?page=0&size=10&sort_by=created_on&sort_order=descending + ``` + + ## Example Response + + ```json + { + \"domains\": [ + { + \"id\": \"abc123...\", + \"type\": \"FeatureCollection\", + \"name\": \"My Domain\", + \"description\": \"A test domain\", + \"created_on\": \"2024-01-15T10:30:00\", + \"modified_on\": \"2024-01-15T10:30:00\", + \"tags\": [\"test\"], + \"crs\": {\"type\": \"name\", \"properties\": {\"name\": \"EPSG:32611\"}}, + \"features\": [...] + } + ], + \"current_page\": 0, + \"page_size\": 10, + \"total_items\": 42 + } + ``` + + ## Error Responses + + - **422 Unprocessable Entity**: Invalid query parameters. + - Page must be a non-negative integer. + - Size must be between 1 and 1000. + - Invalid sort_by or sort_order values. + + Args: + page (int | Unset): The page number to retrieve (zero-indexed). Default: 0. + size (int | Unset): The number of domains to retrieve per page. Default: 100. + sort_by (DomainSortField | None | Unset): The field to sort results by. + sort_order (DomainSortOrder | None | Unset): The order to sort results (ascending or + descending). + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | ListDomainsResponse + """ + + return sync_detailed( + client=client, + page=page, + size=size, + sort_by=sort_by, + sort_order=sort_order, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: DomainSortField | None | Unset = UNSET, + sort_order: DomainSortOrder | None | Unset = UNSET, +) -> Response[HTTPValidationError | ListDomainsResponse]: + r"""List all domains + + # List Domains Endpoint + + This endpoint retrieves a paginated list of all domains belonging to the + authenticated user. + + ## Query Parameters + + - **page**: (integer, optional) The page number to retrieve. Zero-indexed, + meaning the first page is `0`. Default: 0. + - **size**: (integer, optional) The number of domains to retrieve per page. + Must be between 1 and 1000. Default: 100. + - **sort_by**: (string, optional) The field to sort results by. Valid values: + - `created_on`: Sort by creation date. + - `modified_on`: Sort by last modification date. + - `name`: Sort alphabetically by name. + - **sort_order**: (string, optional) The order to sort results. Valid values: + - `ascending`: Sort in ascending order (A-Z, oldest first). + - `descending`: Sort in descending order (Z-A, newest first). + Default: descending when sort_by is specified. + + ## Response + + Returns a paginated list of domains with metadata: + + - **domains**: (array) List of domain resources for the current page. + Each domain includes: + - **id**: (string) The unique identifier for the domain. + - **type**: (string) Always \"FeatureCollection\". + - **name**: (string) The name of the domain. + - **description**: (string) The description of the domain. + - **created_on**: (datetime) When the domain was created. + - **modified_on**: (datetime) When the domain was last modified. + - **tags**: (array) The tags associated with the domain. + - **crs**: (object) The coordinate reference system. + - **features**: (array) The domain geometry features. + - **current_page**: (integer) The current page number (zero-indexed). + - **page_size**: (integer) The number of domains per page. + - **total_items**: (integer) The total number of domains owned by the user. + + ## Pagination + + Use `page` and `size` parameters to navigate through large result sets: + + - First page: `?page=0&size=10` + - Second page: `?page=1&size=10` + - Calculate total pages: `ceil(total_items / page_size)` + + ## Sorting + + Combine `sort_by` and `sort_order` for custom ordering: + + - Newest first: `?sort_by=created_on&sort_order=descending` + - Alphabetical: `?sort_by=name&sort_order=ascending` + - Recently modified: `?sort_by=modified_on&sort_order=descending` + + ## Example Request + + ```http + GET /v2/domains?page=0&size=10&sort_by=created_on&sort_order=descending + ``` + + ## Example Response + + ```json + { + \"domains\": [ + { + \"id\": \"abc123...\", + \"type\": \"FeatureCollection\", + \"name\": \"My Domain\", + \"description\": \"A test domain\", + \"created_on\": \"2024-01-15T10:30:00\", + \"modified_on\": \"2024-01-15T10:30:00\", + \"tags\": [\"test\"], + \"crs\": {\"type\": \"name\", \"properties\": {\"name\": \"EPSG:32611\"}}, + \"features\": [...] + } + ], + \"current_page\": 0, + \"page_size\": 10, + \"total_items\": 42 + } + ``` + + ## Error Responses + + - **422 Unprocessable Entity**: Invalid query parameters. + - Page must be a non-negative integer. + - Size must be between 1 and 1000. + - Invalid sort_by or sort_order values. + + Args: + page (int | Unset): The page number to retrieve (zero-indexed). Default: 0. + size (int | Unset): The number of domains to retrieve per page. Default: 100. + sort_by (DomainSortField | None | Unset): The field to sort results by. + sort_order (DomainSortOrder | None | Unset): The order to sort results (ascending or + descending). + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | ListDomainsResponse] + """ + + kwargs = _get_kwargs( + page=page, + size=size, + sort_by=sort_by, + sort_order=sort_order, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: DomainSortField | None | Unset = UNSET, + sort_order: DomainSortOrder | None | Unset = UNSET, +) -> HTTPValidationError | ListDomainsResponse | None: + r"""List all domains + + # List Domains Endpoint + + This endpoint retrieves a paginated list of all domains belonging to the + authenticated user. + + ## Query Parameters + + - **page**: (integer, optional) The page number to retrieve. Zero-indexed, + meaning the first page is `0`. Default: 0. + - **size**: (integer, optional) The number of domains to retrieve per page. + Must be between 1 and 1000. Default: 100. + - **sort_by**: (string, optional) The field to sort results by. Valid values: + - `created_on`: Sort by creation date. + - `modified_on`: Sort by last modification date. + - `name`: Sort alphabetically by name. + - **sort_order**: (string, optional) The order to sort results. Valid values: + - `ascending`: Sort in ascending order (A-Z, oldest first). + - `descending`: Sort in descending order (Z-A, newest first). + Default: descending when sort_by is specified. + + ## Response + + Returns a paginated list of domains with metadata: + + - **domains**: (array) List of domain resources for the current page. + Each domain includes: + - **id**: (string) The unique identifier for the domain. + - **type**: (string) Always \"FeatureCollection\". + - **name**: (string) The name of the domain. + - **description**: (string) The description of the domain. + - **created_on**: (datetime) When the domain was created. + - **modified_on**: (datetime) When the domain was last modified. + - **tags**: (array) The tags associated with the domain. + - **crs**: (object) The coordinate reference system. + - **features**: (array) The domain geometry features. + - **current_page**: (integer) The current page number (zero-indexed). + - **page_size**: (integer) The number of domains per page. + - **total_items**: (integer) The total number of domains owned by the user. + + ## Pagination + + Use `page` and `size` parameters to navigate through large result sets: + + - First page: `?page=0&size=10` + - Second page: `?page=1&size=10` + - Calculate total pages: `ceil(total_items / page_size)` + + ## Sorting + + Combine `sort_by` and `sort_order` for custom ordering: + + - Newest first: `?sort_by=created_on&sort_order=descending` + - Alphabetical: `?sort_by=name&sort_order=ascending` + - Recently modified: `?sort_by=modified_on&sort_order=descending` + + ## Example Request + + ```http + GET /v2/domains?page=0&size=10&sort_by=created_on&sort_order=descending + ``` + + ## Example Response + + ```json + { + \"domains\": [ + { + \"id\": \"abc123...\", + \"type\": \"FeatureCollection\", + \"name\": \"My Domain\", + \"description\": \"A test domain\", + \"created_on\": \"2024-01-15T10:30:00\", + \"modified_on\": \"2024-01-15T10:30:00\", + \"tags\": [\"test\"], + \"crs\": {\"type\": \"name\", \"properties\": {\"name\": \"EPSG:32611\"}}, + \"features\": [...] + } + ], + \"current_page\": 0, + \"page_size\": 10, + \"total_items\": 42 + } + ``` + + ## Error Responses + + - **422 Unprocessable Entity**: Invalid query parameters. + - Page must be a non-negative integer. + - Size must be between 1 and 1000. + - Invalid sort_by or sort_order values. + + Args: + page (int | Unset): The page number to retrieve (zero-indexed). Default: 0. + size (int | Unset): The number of domains to retrieve per page. Default: 100. + sort_by (DomainSortField | None | Unset): The field to sort results by. + sort_order (DomainSortOrder | None | Unset): The order to sort results (ascending or + descending). + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | ListDomainsResponse + """ + + return ( + await asyncio_detailed( + client=client, + page=page, + size=size, + sort_by=sort_by, + sort_order=sort_order, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/domains/preview_domain.py b/fastfuels_sdk/v2/client_library/api/domains/preview_domain.py new file mode 100644 index 0000000..5f3b2aa --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/domains/preview_domain.py @@ -0,0 +1,290 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.create_domain_request_body import CreateDomainRequestBody +from ...models.domain import Domain +from ...models.http_validation_error import HTTPValidationError +from ...types import Response + + +def _get_kwargs( + *, + body: CreateDomainRequestBody, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/domains/preview", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Domain | HTTPValidationError | None: + if response.status_code == 200: + response_200 = Domain.from_dict(response.json()) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Domain | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + body: CreateDomainRequestBody, +) -> Response[Domain | HTTPValidationError]: + r"""Preview a domain without persisting it + + # Preview Domain Endpoint + + Runs the same validation and projection pipeline as `POST /v2/domains` but + returns the resulting `Domain` resource without writing to Firestore. Use + this to let users inspect the projected, padded bounding box before committing + to a create. + + ## Request Body + + Identical to `POST /v2/domains`. See that endpoint for full documentation. + + ## Response + + Returns the same `Domain` response model as create, with: + + - **id**: Always `\"preview\"` — not a real domain identifier. + - **created_on** / **modified_on**: Set to the current request time (not persisted). + - **features**: Two named features — `\"domain\"` (working extent) and `\"input\"` + (original polygon), identical to what create would return. + - **bbox**: Bounding box of the `\"domain\"` feature. + - **crs**: Projected CRS, identical to what create would return. + + ## Error Responses + + Same 422 error responses as `POST /v2/domains`: + + - \"Invalid CRS '{crs}'. Must be a valid authority string (e.g., 'EPSG:4326').\" + - \"Invalid geometry. The feature must have an area greater than zero.\" + - \"Invalid spatial extent. Area must be less than 16 square kilometers.\" + - \"Invalid spatial extent. The domain must be entirely within CONUS.\" + + Args: + body (CreateDomainRequestBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Domain | HTTPValidationError] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + body: CreateDomainRequestBody, +) -> Domain | HTTPValidationError | None: + r"""Preview a domain without persisting it + + # Preview Domain Endpoint + + Runs the same validation and projection pipeline as `POST /v2/domains` but + returns the resulting `Domain` resource without writing to Firestore. Use + this to let users inspect the projected, padded bounding box before committing + to a create. + + ## Request Body + + Identical to `POST /v2/domains`. See that endpoint for full documentation. + + ## Response + + Returns the same `Domain` response model as create, with: + + - **id**: Always `\"preview\"` — not a real domain identifier. + - **created_on** / **modified_on**: Set to the current request time (not persisted). + - **features**: Two named features — `\"domain\"` (working extent) and `\"input\"` + (original polygon), identical to what create would return. + - **bbox**: Bounding box of the `\"domain\"` feature. + - **crs**: Projected CRS, identical to what create would return. + + ## Error Responses + + Same 422 error responses as `POST /v2/domains`: + + - \"Invalid CRS '{crs}'. Must be a valid authority string (e.g., 'EPSG:4326').\" + - \"Invalid geometry. The feature must have an area greater than zero.\" + - \"Invalid spatial extent. Area must be less than 16 square kilometers.\" + - \"Invalid spatial extent. The domain must be entirely within CONUS.\" + + Args: + body (CreateDomainRequestBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Domain | HTTPValidationError + """ + + return sync_detailed( + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + body: CreateDomainRequestBody, +) -> Response[Domain | HTTPValidationError]: + r"""Preview a domain without persisting it + + # Preview Domain Endpoint + + Runs the same validation and projection pipeline as `POST /v2/domains` but + returns the resulting `Domain` resource without writing to Firestore. Use + this to let users inspect the projected, padded bounding box before committing + to a create. + + ## Request Body + + Identical to `POST /v2/domains`. See that endpoint for full documentation. + + ## Response + + Returns the same `Domain` response model as create, with: + + - **id**: Always `\"preview\"` — not a real domain identifier. + - **created_on** / **modified_on**: Set to the current request time (not persisted). + - **features**: Two named features — `\"domain\"` (working extent) and `\"input\"` + (original polygon), identical to what create would return. + - **bbox**: Bounding box of the `\"domain\"` feature. + - **crs**: Projected CRS, identical to what create would return. + + ## Error Responses + + Same 422 error responses as `POST /v2/domains`: + + - \"Invalid CRS '{crs}'. Must be a valid authority string (e.g., 'EPSG:4326').\" + - \"Invalid geometry. The feature must have an area greater than zero.\" + - \"Invalid spatial extent. Area must be less than 16 square kilometers.\" + - \"Invalid spatial extent. The domain must be entirely within CONUS.\" + + Args: + body (CreateDomainRequestBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Domain | HTTPValidationError] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + body: CreateDomainRequestBody, +) -> Domain | HTTPValidationError | None: + r"""Preview a domain without persisting it + + # Preview Domain Endpoint + + Runs the same validation and projection pipeline as `POST /v2/domains` but + returns the resulting `Domain` resource without writing to Firestore. Use + this to let users inspect the projected, padded bounding box before committing + to a create. + + ## Request Body + + Identical to `POST /v2/domains`. See that endpoint for full documentation. + + ## Response + + Returns the same `Domain` response model as create, with: + + - **id**: Always `\"preview\"` — not a real domain identifier. + - **created_on** / **modified_on**: Set to the current request time (not persisted). + - **features**: Two named features — `\"domain\"` (working extent) and `\"input\"` + (original polygon), identical to what create would return. + - **bbox**: Bounding box of the `\"domain\"` feature. + - **crs**: Projected CRS, identical to what create would return. + + ## Error Responses + + Same 422 error responses as `POST /v2/domains`: + + - \"Invalid CRS '{crs}'. Must be a valid authority string (e.g., 'EPSG:4326').\" + - \"Invalid geometry. The feature must have an area greater than zero.\" + - \"Invalid spatial extent. Area must be less than 16 square kilometers.\" + - \"Invalid spatial extent. The domain must be entirely within CONUS.\" + + Args: + body (CreateDomainRequestBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Domain | HTTPValidationError + """ + + return ( + await asyncio_detailed( + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/domains/reproject_domain.py b/fastfuels_sdk/v2/client_library/api/domains/reproject_domain.py new file mode 100644 index 0000000..b86f842 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/domains/reproject_domain.py @@ -0,0 +1,301 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.create_domain_request_body import CreateDomainRequestBody +from ...models.http_validation_error import HTTPValidationError +from ...types import UNSET, Response + + +def _get_kwargs( + *, + body: CreateDomainRequestBody, + target_epsg: int, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + params: dict[str, Any] = {} + + params["target_epsg"] = target_epsg + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/domains/reproject", + "params": params, + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> CreateDomainRequestBody | HTTPValidationError | None: + if response.status_code == 200: + response_200 = CreateDomainRequestBody.from_dict(response.json()) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[CreateDomainRequestBody | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + body: CreateDomainRequestBody, + target_epsg: int, +) -> Response[CreateDomainRequestBody | HTTPValidationError]: + """Reproject a FeatureCollection to a target CRS + + # Reproject Domain Endpoint + + Stateless utility that reprojects a GeoJSON `FeatureCollection` from one + coordinate reference system to another. No resource is created; the + reprojected `FeatureCollection` is returned immediately. + + ## Query Parameters + + - **target_epsg**: (integer, required) EPSG code of the target CRS + (e.g., `4326` for WGS84, `32611` for UTM zone 11N). + + ## Request Body + + A GeoJSON `FeatureCollection`. The source CRS is read from the + `crs.properties.name` field if present; otherwise EPSG:4326 is assumed. + + ## Response + + Returns the reprojected `FeatureCollection` with: + + - **features**: All input features reprojected to the target CRS, with + original feature properties preserved. + - **crs**: Set to the target EPSG code. + + ## Error Responses + + - **422**: Invalid source CRS, invalid target EPSG, or geometry that + cannot be reprojected. + + Args: + target_epsg (int): EPSG code of the target CRS. + body (CreateDomainRequestBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[CreateDomainRequestBody | HTTPValidationError] + """ + + kwargs = _get_kwargs( + body=body, + target_epsg=target_epsg, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + body: CreateDomainRequestBody, + target_epsg: int, +) -> CreateDomainRequestBody | HTTPValidationError | None: + """Reproject a FeatureCollection to a target CRS + + # Reproject Domain Endpoint + + Stateless utility that reprojects a GeoJSON `FeatureCollection` from one + coordinate reference system to another. No resource is created; the + reprojected `FeatureCollection` is returned immediately. + + ## Query Parameters + + - **target_epsg**: (integer, required) EPSG code of the target CRS + (e.g., `4326` for WGS84, `32611` for UTM zone 11N). + + ## Request Body + + A GeoJSON `FeatureCollection`. The source CRS is read from the + `crs.properties.name` field if present; otherwise EPSG:4326 is assumed. + + ## Response + + Returns the reprojected `FeatureCollection` with: + + - **features**: All input features reprojected to the target CRS, with + original feature properties preserved. + - **crs**: Set to the target EPSG code. + + ## Error Responses + + - **422**: Invalid source CRS, invalid target EPSG, or geometry that + cannot be reprojected. + + Args: + target_epsg (int): EPSG code of the target CRS. + body (CreateDomainRequestBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + CreateDomainRequestBody | HTTPValidationError + """ + + return sync_detailed( + client=client, + body=body, + target_epsg=target_epsg, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + body: CreateDomainRequestBody, + target_epsg: int, +) -> Response[CreateDomainRequestBody | HTTPValidationError]: + """Reproject a FeatureCollection to a target CRS + + # Reproject Domain Endpoint + + Stateless utility that reprojects a GeoJSON `FeatureCollection` from one + coordinate reference system to another. No resource is created; the + reprojected `FeatureCollection` is returned immediately. + + ## Query Parameters + + - **target_epsg**: (integer, required) EPSG code of the target CRS + (e.g., `4326` for WGS84, `32611` for UTM zone 11N). + + ## Request Body + + A GeoJSON `FeatureCollection`. The source CRS is read from the + `crs.properties.name` field if present; otherwise EPSG:4326 is assumed. + + ## Response + + Returns the reprojected `FeatureCollection` with: + + - **features**: All input features reprojected to the target CRS, with + original feature properties preserved. + - **crs**: Set to the target EPSG code. + + ## Error Responses + + - **422**: Invalid source CRS, invalid target EPSG, or geometry that + cannot be reprojected. + + Args: + target_epsg (int): EPSG code of the target CRS. + body (CreateDomainRequestBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[CreateDomainRequestBody | HTTPValidationError] + """ + + kwargs = _get_kwargs( + body=body, + target_epsg=target_epsg, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + body: CreateDomainRequestBody, + target_epsg: int, +) -> CreateDomainRequestBody | HTTPValidationError | None: + """Reproject a FeatureCollection to a target CRS + + # Reproject Domain Endpoint + + Stateless utility that reprojects a GeoJSON `FeatureCollection` from one + coordinate reference system to another. No resource is created; the + reprojected `FeatureCollection` is returned immediately. + + ## Query Parameters + + - **target_epsg**: (integer, required) EPSG code of the target CRS + (e.g., `4326` for WGS84, `32611` for UTM zone 11N). + + ## Request Body + + A GeoJSON `FeatureCollection`. The source CRS is read from the + `crs.properties.name` field if present; otherwise EPSG:4326 is assumed. + + ## Response + + Returns the reprojected `FeatureCollection` with: + + - **features**: All input features reprojected to the target CRS, with + original feature properties preserved. + - **crs**: Set to the target EPSG code. + + ## Error Responses + + - **422**: Invalid source CRS, invalid target EPSG, or geometry that + cannot be reprojected. + + Args: + target_epsg (int): EPSG code of the target CRS. + body (CreateDomainRequestBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + CreateDomainRequestBody | HTTPValidationError + """ + + return ( + await asyncio_detailed( + client=client, + body=body, + target_epsg=target_epsg, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/domains/update_domain.py b/fastfuels_sdk/v2/client_library/api/domains/update_domain.py new file mode 100644 index 0000000..294c2e4 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/domains/update_domain.py @@ -0,0 +1,402 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.domain import Domain +from ...models.http_validation_error import HTTPValidationError +from ...models.update_domain_request_body import UpdateDomainRequestBody +from ...types import Response + + +def _get_kwargs( + domain_id: str, + *, + body: UpdateDomainRequestBody, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "patch", + "url": "/domains/{domain_id}".format( + domain_id=quote(str(domain_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Domain | HTTPValidationError | None: + if response.status_code == 200: + response_200 = Domain.from_dict(response.json()) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Domain | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: UpdateDomainRequestBody, +) -> Response[Domain | HTTPValidationError]: + r"""Update a domain + + # Update Domain Endpoint + + This endpoint updates the metadata of an existing domain resource. Only the + fields provided in the request body will be modified; other fields remain + unchanged. + + ## Path Parameters + + - **domain_id**: (string) The unique 32-character hex identifier of the domain. + + ## Request Body + + All fields are optional. Only provided fields will be updated. + + - **name**: (string, optional) The new name for the domain. + - **description**: (string, optional) The new description for the domain. + - **tags**: (array of strings, optional) The new tags for the domain. + This replaces the existing tags array entirely. + + ## What Cannot Be Updated + + The following fields are immutable after domain creation: + + - **id**: The domain identifier is permanent. + - **features**: Geometry cannot be modified. Create a new domain instead. + - **crs**: Coordinate reference system is tied to the geometry. + - **created_on**: Creation timestamp is permanent. + + The **modified_on** field is automatically updated to the current time. + + ## Response + + On success, returns the updated domain resource with all fields, + including the new `modified_on` timestamp. + + ## Example Request + + ```http + PATCH /v2/domains/abc123def456... + Content-Type: application/json + + { + \"name\": \"Updated Domain Name\", + \"tags\": [\"production\", \"verified\"] + } + ``` + + ## Error Responses + + - **404 Not Found**: The domain does not exist or the user does not have access. + - **422 Unprocessable Entity**: Invalid request body. + + Args: + domain_id (str): + body (UpdateDomainRequestBody): Request body for updating a domain's metadata. + + All fields are optional. Only provided fields will be updated. + Geometry (features) and CRS cannot be modified after creation. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Domain | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + *, + client: AuthenticatedClient, + body: UpdateDomainRequestBody, +) -> Domain | HTTPValidationError | None: + r"""Update a domain + + # Update Domain Endpoint + + This endpoint updates the metadata of an existing domain resource. Only the + fields provided in the request body will be modified; other fields remain + unchanged. + + ## Path Parameters + + - **domain_id**: (string) The unique 32-character hex identifier of the domain. + + ## Request Body + + All fields are optional. Only provided fields will be updated. + + - **name**: (string, optional) The new name for the domain. + - **description**: (string, optional) The new description for the domain. + - **tags**: (array of strings, optional) The new tags for the domain. + This replaces the existing tags array entirely. + + ## What Cannot Be Updated + + The following fields are immutable after domain creation: + + - **id**: The domain identifier is permanent. + - **features**: Geometry cannot be modified. Create a new domain instead. + - **crs**: Coordinate reference system is tied to the geometry. + - **created_on**: Creation timestamp is permanent. + + The **modified_on** field is automatically updated to the current time. + + ## Response + + On success, returns the updated domain resource with all fields, + including the new `modified_on` timestamp. + + ## Example Request + + ```http + PATCH /v2/domains/abc123def456... + Content-Type: application/json + + { + \"name\": \"Updated Domain Name\", + \"tags\": [\"production\", \"verified\"] + } + ``` + + ## Error Responses + + - **404 Not Found**: The domain does not exist or the user does not have access. + - **422 Unprocessable Entity**: Invalid request body. + + Args: + domain_id (str): + body (UpdateDomainRequestBody): Request body for updating a domain's metadata. + + All fields are optional. Only provided fields will be updated. + Geometry (features) and CRS cannot be modified after creation. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Domain | HTTPValidationError + """ + + return sync_detailed( + domain_id=domain_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: UpdateDomainRequestBody, +) -> Response[Domain | HTTPValidationError]: + r"""Update a domain + + # Update Domain Endpoint + + This endpoint updates the metadata of an existing domain resource. Only the + fields provided in the request body will be modified; other fields remain + unchanged. + + ## Path Parameters + + - **domain_id**: (string) The unique 32-character hex identifier of the domain. + + ## Request Body + + All fields are optional. Only provided fields will be updated. + + - **name**: (string, optional) The new name for the domain. + - **description**: (string, optional) The new description for the domain. + - **tags**: (array of strings, optional) The new tags for the domain. + This replaces the existing tags array entirely. + + ## What Cannot Be Updated + + The following fields are immutable after domain creation: + + - **id**: The domain identifier is permanent. + - **features**: Geometry cannot be modified. Create a new domain instead. + - **crs**: Coordinate reference system is tied to the geometry. + - **created_on**: Creation timestamp is permanent. + + The **modified_on** field is automatically updated to the current time. + + ## Response + + On success, returns the updated domain resource with all fields, + including the new `modified_on` timestamp. + + ## Example Request + + ```http + PATCH /v2/domains/abc123def456... + Content-Type: application/json + + { + \"name\": \"Updated Domain Name\", + \"tags\": [\"production\", \"verified\"] + } + ``` + + ## Error Responses + + - **404 Not Found**: The domain does not exist or the user does not have access. + - **422 Unprocessable Entity**: Invalid request body. + + Args: + domain_id (str): + body (UpdateDomainRequestBody): Request body for updating a domain's metadata. + + All fields are optional. Only provided fields will be updated. + Geometry (features) and CRS cannot be modified after creation. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Domain | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + *, + client: AuthenticatedClient, + body: UpdateDomainRequestBody, +) -> Domain | HTTPValidationError | None: + r"""Update a domain + + # Update Domain Endpoint + + This endpoint updates the metadata of an existing domain resource. Only the + fields provided in the request body will be modified; other fields remain + unchanged. + + ## Path Parameters + + - **domain_id**: (string) The unique 32-character hex identifier of the domain. + + ## Request Body + + All fields are optional. Only provided fields will be updated. + + - **name**: (string, optional) The new name for the domain. + - **description**: (string, optional) The new description for the domain. + - **tags**: (array of strings, optional) The new tags for the domain. + This replaces the existing tags array entirely. + + ## What Cannot Be Updated + + The following fields are immutable after domain creation: + + - **id**: The domain identifier is permanent. + - **features**: Geometry cannot be modified. Create a new domain instead. + - **crs**: Coordinate reference system is tied to the geometry. + - **created_on**: Creation timestamp is permanent. + + The **modified_on** field is automatically updated to the current time. + + ## Response + + On success, returns the updated domain resource with all fields, + including the new `modified_on` timestamp. + + ## Example Request + + ```http + PATCH /v2/domains/abc123def456... + Content-Type: application/json + + { + \"name\": \"Updated Domain Name\", + \"tags\": [\"production\", \"verified\"] + } + ``` + + ## Error Responses + + - **404 Not Found**: The domain does not exist or the user does not have access. + - **422 Unprocessable Entity**: Invalid request body. + + Args: + domain_id (str): + body (UpdateDomainRequestBody): Request body for updating a domain's metadata. + + All fields are optional. Only provided fields will be updated. + Geometry (features) and CRS cannot be modified after creation. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Domain | HTTPValidationError + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/exports/__init__.py b/fastfuels_sdk/v2/client_library/api/exports/__init__.py new file mode 100644 index 0000000..2d7c0b2 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/exports/__init__.py @@ -0,0 +1 @@ +"""Contains endpoint functions for accessing the API""" diff --git a/fastfuels_sdk/v2/client_library/api/exports/delete_export.py b/fastfuels_sdk/v2/client_library/api/exports/delete_export.py new file mode 100644 index 0000000..e44d99d --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/exports/delete_export.py @@ -0,0 +1,227 @@ +from http import HTTPStatus +from typing import Any, cast +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.http_validation_error import HTTPValidationError +from ...types import Response + + +def _get_kwargs( + export_id: str, +) -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "delete", + "url": "/exports/{export_id}".format( + export_id=quote(str(export_id), safe=""), + ), + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Any | HTTPValidationError | None: + if response.status_code == 204: + response_204 = cast(Any, None) + return response_204 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Any | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + export_id: str, + *, + client: AuthenticatedClient, +) -> Response[Any | HTTPValidationError]: + """Delete an export + + # Delete Export Endpoint + + Permanently deletes an export resource by its unique identifier. + This action cannot be undone. + + ## Path Parameters + + - **export_id**: (string) The unique identifier of the export. + + ## Response + + Returns HTTP 204 No Content with an empty response body. + + ## Error Responses + + - **404 Not Found**: The export does not exist or the user does not have access. + + Args: + export_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any | HTTPValidationError] + """ + + kwargs = _get_kwargs( + export_id=export_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + export_id: str, + *, + client: AuthenticatedClient, +) -> Any | HTTPValidationError | None: + """Delete an export + + # Delete Export Endpoint + + Permanently deletes an export resource by its unique identifier. + This action cannot be undone. + + ## Path Parameters + + - **export_id**: (string) The unique identifier of the export. + + ## Response + + Returns HTTP 204 No Content with an empty response body. + + ## Error Responses + + - **404 Not Found**: The export does not exist or the user does not have access. + + Args: + export_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Any | HTTPValidationError + """ + + return sync_detailed( + export_id=export_id, + client=client, + ).parsed + + +async def asyncio_detailed( + export_id: str, + *, + client: AuthenticatedClient, +) -> Response[Any | HTTPValidationError]: + """Delete an export + + # Delete Export Endpoint + + Permanently deletes an export resource by its unique identifier. + This action cannot be undone. + + ## Path Parameters + + - **export_id**: (string) The unique identifier of the export. + + ## Response + + Returns HTTP 204 No Content with an empty response body. + + ## Error Responses + + - **404 Not Found**: The export does not exist or the user does not have access. + + Args: + export_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any | HTTPValidationError] + """ + + kwargs = _get_kwargs( + export_id=export_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + export_id: str, + *, + client: AuthenticatedClient, +) -> Any | HTTPValidationError | None: + """Delete an export + + # Delete Export Endpoint + + Permanently deletes an export resource by its unique identifier. + This action cannot be undone. + + ## Path Parameters + + - **export_id**: (string) The unique identifier of the export. + + ## Response + + Returns HTTP 204 No Content with an empty response body. + + ## Error Responses + + - **404 Not Found**: The export does not exist or the user does not have access. + + Args: + export_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Any | HTTPValidationError + """ + + return ( + await asyncio_detailed( + export_id=export_id, + client=client, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/exports/get_export.py b/fastfuels_sdk/v2/client_library/api/exports/get_export.py new file mode 100644 index 0000000..c670379 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/exports/get_export.py @@ -0,0 +1,229 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.export import Export +from ...models.http_validation_error import HTTPValidationError +from ...types import Response + + +def _get_kwargs( + export_id: str, +) -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/exports/{export_id}".format( + export_id=quote(str(export_id), safe=""), + ), + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Export | HTTPValidationError | None: + if response.status_code == 200: + response_200 = Export.from_dict(response.json()) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Export | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + export_id: str, + *, + client: AuthenticatedClient, +) -> Response[Export | HTTPValidationError]: + """Get an export by ID + + # Get Export Endpoint + + Retrieves a specific export resource by its unique identifier. + When the export is completed, the response includes a signed_url. + + ## Path Parameters + + - **export_id**: (string) The unique identifier of the export. + + ## Response + + Returns the export resource. + + ## Error Responses + + - **404 Not Found**: The export does not exist or the user does not have access. + + Args: + export_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Export | HTTPValidationError] + """ + + kwargs = _get_kwargs( + export_id=export_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + export_id: str, + *, + client: AuthenticatedClient, +) -> Export | HTTPValidationError | None: + """Get an export by ID + + # Get Export Endpoint + + Retrieves a specific export resource by its unique identifier. + When the export is completed, the response includes a signed_url. + + ## Path Parameters + + - **export_id**: (string) The unique identifier of the export. + + ## Response + + Returns the export resource. + + ## Error Responses + + - **404 Not Found**: The export does not exist or the user does not have access. + + Args: + export_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Export | HTTPValidationError + """ + + return sync_detailed( + export_id=export_id, + client=client, + ).parsed + + +async def asyncio_detailed( + export_id: str, + *, + client: AuthenticatedClient, +) -> Response[Export | HTTPValidationError]: + """Get an export by ID + + # Get Export Endpoint + + Retrieves a specific export resource by its unique identifier. + When the export is completed, the response includes a signed_url. + + ## Path Parameters + + - **export_id**: (string) The unique identifier of the export. + + ## Response + + Returns the export resource. + + ## Error Responses + + - **404 Not Found**: The export does not exist or the user does not have access. + + Args: + export_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Export | HTTPValidationError] + """ + + kwargs = _get_kwargs( + export_id=export_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + export_id: str, + *, + client: AuthenticatedClient, +) -> Export | HTTPValidationError | None: + """Get an export by ID + + # Get Export Endpoint + + Retrieves a specific export resource by its unique identifier. + When the export is completed, the response includes a signed_url. + + ## Path Parameters + + - **export_id**: (string) The unique identifier of the export. + + ## Response + + Returns the export resource. + + ## Error Responses + + - **404 Not Found**: The export does not exist or the user does not have access. + + Args: + export_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Export | HTTPValidationError + """ + + return ( + await asyncio_detailed( + export_id=export_id, + client=client, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/exports/list_exports.py b/fastfuels_sdk/v2/client_library/api/exports/list_exports.py new file mode 100644 index 0000000..b8c63ce --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/exports/list_exports.py @@ -0,0 +1,363 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.export_sort_field import ExportSortField +from ...models.http_validation_error import HTTPValidationError +from ...models.list_exports_response import ListExportsResponse +from ...models.sort_order import SortOrder +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + *, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: ExportSortField | None | Unset = UNSET, + sort_order: None | SortOrder | Unset = UNSET, + domain_id: None | str | Unset = UNSET, + source_name: None | str | Unset = UNSET, + tag: None | str | Unset = UNSET, +) -> dict[str, Any]: + + params: dict[str, Any] = {} + + params["page"] = page + + params["size"] = size + + json_sort_by: None | str | Unset + if isinstance(sort_by, Unset): + json_sort_by = UNSET + elif isinstance(sort_by, ExportSortField): + json_sort_by = sort_by.value + else: + json_sort_by = sort_by + params["sort_by"] = json_sort_by + + json_sort_order: None | str | Unset + if isinstance(sort_order, Unset): + json_sort_order = UNSET + elif isinstance(sort_order, SortOrder): + json_sort_order = sort_order.value + else: + json_sort_order = sort_order + params["sort_order"] = json_sort_order + + json_domain_id: None | str | Unset + if isinstance(domain_id, Unset): + json_domain_id = UNSET + else: + json_domain_id = domain_id + params["domain_id"] = json_domain_id + + json_source_name: None | str | Unset + if isinstance(source_name, Unset): + json_source_name = UNSET + else: + json_source_name = source_name + params["source_name"] = json_source_name + + json_tag: None | str | Unset + if isinstance(tag, Unset): + json_tag = UNSET + else: + json_tag = tag + params["tag"] = json_tag + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/exports", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> HTTPValidationError | ListExportsResponse | None: + if response.status_code == 200: + response_200 = ListExportsResponse.from_dict(response.json()) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[HTTPValidationError | ListExportsResponse]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: ExportSortField | None | Unset = UNSET, + sort_order: None | SortOrder | Unset = UNSET, + domain_id: None | str | Unset = UNSET, + source_name: None | str | Unset = UNSET, + tag: None | str | Unset = UNSET, +) -> Response[HTTPValidationError | ListExportsResponse]: + """List all exports + + # List Exports Endpoint + + Retrieves a paginated list of all exports belonging to the authenticated user. + + ## Query Parameters + + - **page**: (integer, optional) Page number (zero-indexed). Default: 0. + - **size**: (integer, optional) Items per page (1-1000). Default: 100. + - **sort_by**: (string, optional) Field to sort by: `created_on`, `modified_on`, `name`. + - **sort_order**: (string, optional) Sort direction: `ascending`, `descending`. + - **domain_id**: (string, optional) Filter by source domain. + - **source_name**: (string, optional) Filter by export format (e.g., `geotiff`). + - **tag**: (string, optional) Filter exports that contain this tag. + + ## Response + + Returns a paginated list of exports with metadata. + + Args: + page (int | Unset): The page number to retrieve (zero-indexed). Default: 0. + size (int | Unset): The number of exports to retrieve per page. Default: 100. + sort_by (ExportSortField | None | Unset): The field to sort results by. + sort_order (None | SortOrder | Unset): The order to sort results (ascending or + descending). + domain_id (None | str | Unset): Filter exports by domain ID. + source_name (None | str | Unset): Filter exports by source format (e.g., 'geotiff'). + tag (None | str | Unset): Filter exports that contain this tag. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | ListExportsResponse] + """ + + kwargs = _get_kwargs( + page=page, + size=size, + sort_by=sort_by, + sort_order=sort_order, + domain_id=domain_id, + source_name=source_name, + tag=tag, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: ExportSortField | None | Unset = UNSET, + sort_order: None | SortOrder | Unset = UNSET, + domain_id: None | str | Unset = UNSET, + source_name: None | str | Unset = UNSET, + tag: None | str | Unset = UNSET, +) -> HTTPValidationError | ListExportsResponse | None: + """List all exports + + # List Exports Endpoint + + Retrieves a paginated list of all exports belonging to the authenticated user. + + ## Query Parameters + + - **page**: (integer, optional) Page number (zero-indexed). Default: 0. + - **size**: (integer, optional) Items per page (1-1000). Default: 100. + - **sort_by**: (string, optional) Field to sort by: `created_on`, `modified_on`, `name`. + - **sort_order**: (string, optional) Sort direction: `ascending`, `descending`. + - **domain_id**: (string, optional) Filter by source domain. + - **source_name**: (string, optional) Filter by export format (e.g., `geotiff`). + - **tag**: (string, optional) Filter exports that contain this tag. + + ## Response + + Returns a paginated list of exports with metadata. + + Args: + page (int | Unset): The page number to retrieve (zero-indexed). Default: 0. + size (int | Unset): The number of exports to retrieve per page. Default: 100. + sort_by (ExportSortField | None | Unset): The field to sort results by. + sort_order (None | SortOrder | Unset): The order to sort results (ascending or + descending). + domain_id (None | str | Unset): Filter exports by domain ID. + source_name (None | str | Unset): Filter exports by source format (e.g., 'geotiff'). + tag (None | str | Unset): Filter exports that contain this tag. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | ListExportsResponse + """ + + return sync_detailed( + client=client, + page=page, + size=size, + sort_by=sort_by, + sort_order=sort_order, + domain_id=domain_id, + source_name=source_name, + tag=tag, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: ExportSortField | None | Unset = UNSET, + sort_order: None | SortOrder | Unset = UNSET, + domain_id: None | str | Unset = UNSET, + source_name: None | str | Unset = UNSET, + tag: None | str | Unset = UNSET, +) -> Response[HTTPValidationError | ListExportsResponse]: + """List all exports + + # List Exports Endpoint + + Retrieves a paginated list of all exports belonging to the authenticated user. + + ## Query Parameters + + - **page**: (integer, optional) Page number (zero-indexed). Default: 0. + - **size**: (integer, optional) Items per page (1-1000). Default: 100. + - **sort_by**: (string, optional) Field to sort by: `created_on`, `modified_on`, `name`. + - **sort_order**: (string, optional) Sort direction: `ascending`, `descending`. + - **domain_id**: (string, optional) Filter by source domain. + - **source_name**: (string, optional) Filter by export format (e.g., `geotiff`). + - **tag**: (string, optional) Filter exports that contain this tag. + + ## Response + + Returns a paginated list of exports with metadata. + + Args: + page (int | Unset): The page number to retrieve (zero-indexed). Default: 0. + size (int | Unset): The number of exports to retrieve per page. Default: 100. + sort_by (ExportSortField | None | Unset): The field to sort results by. + sort_order (None | SortOrder | Unset): The order to sort results (ascending or + descending). + domain_id (None | str | Unset): Filter exports by domain ID. + source_name (None | str | Unset): Filter exports by source format (e.g., 'geotiff'). + tag (None | str | Unset): Filter exports that contain this tag. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | ListExportsResponse] + """ + + kwargs = _get_kwargs( + page=page, + size=size, + sort_by=sort_by, + sort_order=sort_order, + domain_id=domain_id, + source_name=source_name, + tag=tag, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: ExportSortField | None | Unset = UNSET, + sort_order: None | SortOrder | Unset = UNSET, + domain_id: None | str | Unset = UNSET, + source_name: None | str | Unset = UNSET, + tag: None | str | Unset = UNSET, +) -> HTTPValidationError | ListExportsResponse | None: + """List all exports + + # List Exports Endpoint + + Retrieves a paginated list of all exports belonging to the authenticated user. + + ## Query Parameters + + - **page**: (integer, optional) Page number (zero-indexed). Default: 0. + - **size**: (integer, optional) Items per page (1-1000). Default: 100. + - **sort_by**: (string, optional) Field to sort by: `created_on`, `modified_on`, `name`. + - **sort_order**: (string, optional) Sort direction: `ascending`, `descending`. + - **domain_id**: (string, optional) Filter by source domain. + - **source_name**: (string, optional) Filter by export format (e.g., `geotiff`). + - **tag**: (string, optional) Filter exports that contain this tag. + + ## Response + + Returns a paginated list of exports with metadata. + + Args: + page (int | Unset): The page number to retrieve (zero-indexed). Default: 0. + size (int | Unset): The number of exports to retrieve per page. Default: 100. + sort_by (ExportSortField | None | Unset): The field to sort results by. + sort_order (None | SortOrder | Unset): The order to sort results (ascending or + descending). + domain_id (None | str | Unset): Filter exports by domain ID. + source_name (None | str | Unset): Filter exports by source format (e.g., 'geotiff'). + tag (None | str | Unset): Filter exports that contain this tag. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | ListExportsResponse + """ + + return ( + await asyncio_detailed( + client=client, + page=page, + size=size, + sort_by=sort_by, + sort_order=sort_order, + domain_id=domain_id, + source_name=source_name, + tag=tag, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/exports/update_export.py b/fastfuels_sdk/v2/client_library/api/exports/update_export.py new file mode 100644 index 0000000..8a9869d --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/exports/update_export.py @@ -0,0 +1,266 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.export import Export +from ...models.http_validation_error import HTTPValidationError +from ...models.update_export_request_body import UpdateExportRequestBody +from ...types import Response + + +def _get_kwargs( + export_id: str, + *, + body: UpdateExportRequestBody, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "patch", + "url": "/exports/{export_id}".format( + export_id=quote(str(export_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Export | HTTPValidationError | None: + if response.status_code == 200: + response_200 = Export.from_dict(response.json()) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Export | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + export_id: str, + *, + client: AuthenticatedClient, + body: UpdateExportRequestBody, +) -> Response[Export | HTTPValidationError]: + """Update an export + + # Update Export Endpoint + + Updates the metadata of an existing export resource. Only the fields provided + in the request body will be modified. + + ## Path Parameters + + - **export_id**: (string) The unique identifier of the export. + + ## Request Body + + All fields are optional: + + - **name**: (string) New name for the export. + - **description**: (string) New description. + - **tags**: (array of strings) New tags (replaces existing). + + ## Response + + Returns the updated export resource. + + Args: + export_id (str): + body (UpdateExportRequestBody): Request body for updating export metadata. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Export | HTTPValidationError] + """ + + kwargs = _get_kwargs( + export_id=export_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + export_id: str, + *, + client: AuthenticatedClient, + body: UpdateExportRequestBody, +) -> Export | HTTPValidationError | None: + """Update an export + + # Update Export Endpoint + + Updates the metadata of an existing export resource. Only the fields provided + in the request body will be modified. + + ## Path Parameters + + - **export_id**: (string) The unique identifier of the export. + + ## Request Body + + All fields are optional: + + - **name**: (string) New name for the export. + - **description**: (string) New description. + - **tags**: (array of strings) New tags (replaces existing). + + ## Response + + Returns the updated export resource. + + Args: + export_id (str): + body (UpdateExportRequestBody): Request body for updating export metadata. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Export | HTTPValidationError + """ + + return sync_detailed( + export_id=export_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + export_id: str, + *, + client: AuthenticatedClient, + body: UpdateExportRequestBody, +) -> Response[Export | HTTPValidationError]: + """Update an export + + # Update Export Endpoint + + Updates the metadata of an existing export resource. Only the fields provided + in the request body will be modified. + + ## Path Parameters + + - **export_id**: (string) The unique identifier of the export. + + ## Request Body + + All fields are optional: + + - **name**: (string) New name for the export. + - **description**: (string) New description. + - **tags**: (array of strings) New tags (replaces existing). + + ## Response + + Returns the updated export resource. + + Args: + export_id (str): + body (UpdateExportRequestBody): Request body for updating export metadata. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Export | HTTPValidationError] + """ + + kwargs = _get_kwargs( + export_id=export_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + export_id: str, + *, + client: AuthenticatedClient, + body: UpdateExportRequestBody, +) -> Export | HTTPValidationError | None: + """Update an export + + # Update Export Endpoint + + Updates the metadata of an existing export resource. Only the fields provided + in the request body will be modified. + + ## Path Parameters + + - **export_id**: (string) The unique identifier of the export. + + ## Request Body + + All fields are optional: + + - **name**: (string) New name for the export. + - **description**: (string) New description. + - **tags**: (array of strings) New tags (replaces existing). + + ## Response + + Returns the updated export resource. + + Args: + export_id (str): + body (UpdateExportRequestBody): Request body for updating export metadata. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Export | HTTPValidationError + """ + + return ( + await asyncio_detailed( + export_id=export_id, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/features/__init__.py b/fastfuels_sdk/v2/client_library/api/features/__init__.py new file mode 100644 index 0000000..2d7c0b2 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/features/__init__.py @@ -0,0 +1 @@ +"""Contains endpoint functions for accessing the API""" diff --git a/fastfuels_sdk/v2/client_library/api/features/create_layerset.py b/fastfuels_sdk/v2/client_library/api/features/create_layerset.py new file mode 100644 index 0000000..0ad3c53 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/features/create_layerset.py @@ -0,0 +1,334 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.create_layerset_request_body import CreateLayersetRequestBody +from ...models.feature import Feature +from ...models.http_validation_error import HTTPValidationError +from ...types import Response + + +def _get_kwargs( + domain_id: str, + *, + body: CreateLayersetRequestBody, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/domains/{domain_id}/features/layerset/geojson".format( + domain_id=quote(str(domain_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Feature | HTTPValidationError | None: + if response.status_code == 201: + response_201 = Feature.from_dict(response.json()) + + return response_201 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Feature | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateLayersetRequestBody, +) -> Response[Feature | HTTPValidationError]: + r"""Upload a custom Layerset + + # Create Layerset Endpoint + + Uploads a flat GeoJSON FeatureCollection of fuelbed polygons as a new + feature resource. Each Feature's ``properties`` block carries one + fuelbed's input columns for ``fastfuels_core.rasterize_layerset``. The + payload is validated and saved directly to Cloud Storage. + + ## Path Parameters + - **domain_id**: (string) The domain this layerset belongs to. + + ## Request Body + + The body **is** the GeoJSON FeatureCollection (mirroring `POST /domains`), + with optional resource metadata as top-level fields: + + - **type**: (string) Must be `\"FeatureCollection\"`. + - **features**: (array) At least one Feature, each carrying one fuelbed's + `properties` and a `Polygon`/`MultiPolygon` `geometry`. + - **crs**: (object) The GeoJSON `crs` block declaring a **projected** CRS + (e.g. `EPSG:32612`). Geographic CRSes are rejected — rasterization + requires cell sizes in meters. + - **name**: (string, optional) Name of the layerset. + - **description**: (string, optional) Description of the data. + - **tags**: (array of strings, optional) Searchable tags. + + ## Response + Returns the created Feature resource with a status of `completed`. + + Args: + domain_id (str): + body (CreateLayersetRequestBody): Request body for uploading a flat GeoJSON layerset. + + The body **is** the GeoJSON FeatureCollection (matching ``POST /domains``, + whose body is a ``FeatureCollection`` directly), extended with the + resource-metadata fields. No ``type`` discriminator: the URL + ``/features/layerset/geojson`` already discriminates layersets from + road/water uploads. + + ``name`` overrides the optional GeoJSON ``name`` member inherited from + ``LayersetFeatureCollection`` — the FeatureCollection's name doubles as the + resource name, exactly as ``CreateDomainRequestBody`` treats it. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Feature | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateLayersetRequestBody, +) -> Feature | HTTPValidationError | None: + r"""Upload a custom Layerset + + # Create Layerset Endpoint + + Uploads a flat GeoJSON FeatureCollection of fuelbed polygons as a new + feature resource. Each Feature's ``properties`` block carries one + fuelbed's input columns for ``fastfuels_core.rasterize_layerset``. The + payload is validated and saved directly to Cloud Storage. + + ## Path Parameters + - **domain_id**: (string) The domain this layerset belongs to. + + ## Request Body + + The body **is** the GeoJSON FeatureCollection (mirroring `POST /domains`), + with optional resource metadata as top-level fields: + + - **type**: (string) Must be `\"FeatureCollection\"`. + - **features**: (array) At least one Feature, each carrying one fuelbed's + `properties` and a `Polygon`/`MultiPolygon` `geometry`. + - **crs**: (object) The GeoJSON `crs` block declaring a **projected** CRS + (e.g. `EPSG:32612`). Geographic CRSes are rejected — rasterization + requires cell sizes in meters. + - **name**: (string, optional) Name of the layerset. + - **description**: (string, optional) Description of the data. + - **tags**: (array of strings, optional) Searchable tags. + + ## Response + Returns the created Feature resource with a status of `completed`. + + Args: + domain_id (str): + body (CreateLayersetRequestBody): Request body for uploading a flat GeoJSON layerset. + + The body **is** the GeoJSON FeatureCollection (matching ``POST /domains``, + whose body is a ``FeatureCollection`` directly), extended with the + resource-metadata fields. No ``type`` discriminator: the URL + ``/features/layerset/geojson`` already discriminates layersets from + road/water uploads. + + ``name`` overrides the optional GeoJSON ``name`` member inherited from + ``LayersetFeatureCollection`` — the FeatureCollection's name doubles as the + resource name, exactly as ``CreateDomainRequestBody`` treats it. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Feature | HTTPValidationError + """ + + return sync_detailed( + domain_id=domain_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateLayersetRequestBody, +) -> Response[Feature | HTTPValidationError]: + r"""Upload a custom Layerset + + # Create Layerset Endpoint + + Uploads a flat GeoJSON FeatureCollection of fuelbed polygons as a new + feature resource. Each Feature's ``properties`` block carries one + fuelbed's input columns for ``fastfuels_core.rasterize_layerset``. The + payload is validated and saved directly to Cloud Storage. + + ## Path Parameters + - **domain_id**: (string) The domain this layerset belongs to. + + ## Request Body + + The body **is** the GeoJSON FeatureCollection (mirroring `POST /domains`), + with optional resource metadata as top-level fields: + + - **type**: (string) Must be `\"FeatureCollection\"`. + - **features**: (array) At least one Feature, each carrying one fuelbed's + `properties` and a `Polygon`/`MultiPolygon` `geometry`. + - **crs**: (object) The GeoJSON `crs` block declaring a **projected** CRS + (e.g. `EPSG:32612`). Geographic CRSes are rejected — rasterization + requires cell sizes in meters. + - **name**: (string, optional) Name of the layerset. + - **description**: (string, optional) Description of the data. + - **tags**: (array of strings, optional) Searchable tags. + + ## Response + Returns the created Feature resource with a status of `completed`. + + Args: + domain_id (str): + body (CreateLayersetRequestBody): Request body for uploading a flat GeoJSON layerset. + + The body **is** the GeoJSON FeatureCollection (matching ``POST /domains``, + whose body is a ``FeatureCollection`` directly), extended with the + resource-metadata fields. No ``type`` discriminator: the URL + ``/features/layerset/geojson`` already discriminates layersets from + road/water uploads. + + ``name`` overrides the optional GeoJSON ``name`` member inherited from + ``LayersetFeatureCollection`` — the FeatureCollection's name doubles as the + resource name, exactly as ``CreateDomainRequestBody`` treats it. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Feature | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateLayersetRequestBody, +) -> Feature | HTTPValidationError | None: + r"""Upload a custom Layerset + + # Create Layerset Endpoint + + Uploads a flat GeoJSON FeatureCollection of fuelbed polygons as a new + feature resource. Each Feature's ``properties`` block carries one + fuelbed's input columns for ``fastfuels_core.rasterize_layerset``. The + payload is validated and saved directly to Cloud Storage. + + ## Path Parameters + - **domain_id**: (string) The domain this layerset belongs to. + + ## Request Body + + The body **is** the GeoJSON FeatureCollection (mirroring `POST /domains`), + with optional resource metadata as top-level fields: + + - **type**: (string) Must be `\"FeatureCollection\"`. + - **features**: (array) At least one Feature, each carrying one fuelbed's + `properties` and a `Polygon`/`MultiPolygon` `geometry`. + - **crs**: (object) The GeoJSON `crs` block declaring a **projected** CRS + (e.g. `EPSG:32612`). Geographic CRSes are rejected — rasterization + requires cell sizes in meters. + - **name**: (string, optional) Name of the layerset. + - **description**: (string, optional) Description of the data. + - **tags**: (array of strings, optional) Searchable tags. + + ## Response + Returns the created Feature resource with a status of `completed`. + + Args: + domain_id (str): + body (CreateLayersetRequestBody): Request body for uploading a flat GeoJSON layerset. + + The body **is** the GeoJSON FeatureCollection (matching ``POST /domains``, + whose body is a ``FeatureCollection`` directly), extended with the + resource-metadata fields. No ``type`` discriminator: the URL + ``/features/layerset/geojson`` already discriminates layersets from + road/water uploads. + + ``name`` overrides the optional GeoJSON ``name`` member inherited from + ``LayersetFeatureCollection`` — the FeatureCollection's name doubles as the + resource name, exactly as ``CreateDomainRequestBody`` treats it. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Feature | HTTPValidationError + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/features/create_osm_road_feature.py b/fastfuels_sdk/v2/client_library/api/features/create_osm_road_feature.py new file mode 100644 index 0000000..f704b27 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/features/create_osm_road_feature.py @@ -0,0 +1,310 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.create_osm_road_feature_request import CreateOsmRoadFeatureRequest +from ...models.feature import Feature +from ...models.http_validation_error import HTTPValidationError +from ...types import Response + + +def _get_kwargs( + domain_id: str, + *, + body: CreateOsmRoadFeatureRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/domains/{domain_id}/features/road/osm".format( + domain_id=quote(str(domain_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Feature | HTTPValidationError | None: + if response.status_code == 201: + response_201 = Feature.from_dict(response.json()) + + return response_201 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Feature | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateOsmRoadFeatureRequest, +) -> Response[Feature | HTTPValidationError]: + r"""Create a road feature from OpenStreetMap + + # Create OSM Road Feature + + Generates a polygon representation of the road network within the specified + domain using data from OpenStreetMap (OSM). + + The backend worker will: + 1. Fetch the bounding box for the target domain. + 2. Query OpenStreetMap for linear road segments (`highway=*`). + 3. Dynamically buffer the line strings into realistic polygon areas + based on their specific OSM classification (e.g., motorways receive + a wider buffer than residential streets or trails). + 4. Save the resulting GeoJSON to the features bucket. + + ## Request Body + + - **name**: (optional) Name for the road feature. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing features. + - **extent_buffer_m**: (optional) Distance in meters to expand the domain + extent outward before clipping fetched roads. Lets roads that exit the + domain at the boundary extend slightly past the edge, providing context + for visualization and downstream operations. Applied in the domain's + projected CRS. If omitted, roads are clipped exactly to the domain + boundary. Range: 0–100 meters. + + ## Response + + Returns the created Feature resource with status ``\"pending\"``. The + backend worker will process the OSM extraction asynchronously and update + status to ``\"completed\"`` when ready. + + Args: + domain_id (str): + body (CreateOsmRoadFeatureRequest): Request body for creating a road feature via + OpenStreetMap. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Feature | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateOsmRoadFeatureRequest, +) -> Feature | HTTPValidationError | None: + r"""Create a road feature from OpenStreetMap + + # Create OSM Road Feature + + Generates a polygon representation of the road network within the specified + domain using data from OpenStreetMap (OSM). + + The backend worker will: + 1. Fetch the bounding box for the target domain. + 2. Query OpenStreetMap for linear road segments (`highway=*`). + 3. Dynamically buffer the line strings into realistic polygon areas + based on their specific OSM classification (e.g., motorways receive + a wider buffer than residential streets or trails). + 4. Save the resulting GeoJSON to the features bucket. + + ## Request Body + + - **name**: (optional) Name for the road feature. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing features. + - **extent_buffer_m**: (optional) Distance in meters to expand the domain + extent outward before clipping fetched roads. Lets roads that exit the + domain at the boundary extend slightly past the edge, providing context + for visualization and downstream operations. Applied in the domain's + projected CRS. If omitted, roads are clipped exactly to the domain + boundary. Range: 0–100 meters. + + ## Response + + Returns the created Feature resource with status ``\"pending\"``. The + backend worker will process the OSM extraction asynchronously and update + status to ``\"completed\"`` when ready. + + Args: + domain_id (str): + body (CreateOsmRoadFeatureRequest): Request body for creating a road feature via + OpenStreetMap. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Feature | HTTPValidationError + """ + + return sync_detailed( + domain_id=domain_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateOsmRoadFeatureRequest, +) -> Response[Feature | HTTPValidationError]: + r"""Create a road feature from OpenStreetMap + + # Create OSM Road Feature + + Generates a polygon representation of the road network within the specified + domain using data from OpenStreetMap (OSM). + + The backend worker will: + 1. Fetch the bounding box for the target domain. + 2. Query OpenStreetMap for linear road segments (`highway=*`). + 3. Dynamically buffer the line strings into realistic polygon areas + based on their specific OSM classification (e.g., motorways receive + a wider buffer than residential streets or trails). + 4. Save the resulting GeoJSON to the features bucket. + + ## Request Body + + - **name**: (optional) Name for the road feature. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing features. + - **extent_buffer_m**: (optional) Distance in meters to expand the domain + extent outward before clipping fetched roads. Lets roads that exit the + domain at the boundary extend slightly past the edge, providing context + for visualization and downstream operations. Applied in the domain's + projected CRS. If omitted, roads are clipped exactly to the domain + boundary. Range: 0–100 meters. + + ## Response + + Returns the created Feature resource with status ``\"pending\"``. The + backend worker will process the OSM extraction asynchronously and update + status to ``\"completed\"`` when ready. + + Args: + domain_id (str): + body (CreateOsmRoadFeatureRequest): Request body for creating a road feature via + OpenStreetMap. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Feature | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateOsmRoadFeatureRequest, +) -> Feature | HTTPValidationError | None: + r"""Create a road feature from OpenStreetMap + + # Create OSM Road Feature + + Generates a polygon representation of the road network within the specified + domain using data from OpenStreetMap (OSM). + + The backend worker will: + 1. Fetch the bounding box for the target domain. + 2. Query OpenStreetMap for linear road segments (`highway=*`). + 3. Dynamically buffer the line strings into realistic polygon areas + based on their specific OSM classification (e.g., motorways receive + a wider buffer than residential streets or trails). + 4. Save the resulting GeoJSON to the features bucket. + + ## Request Body + + - **name**: (optional) Name for the road feature. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing features. + - **extent_buffer_m**: (optional) Distance in meters to expand the domain + extent outward before clipping fetched roads. Lets roads that exit the + domain at the boundary extend slightly past the edge, providing context + for visualization and downstream operations. Applied in the domain's + projected CRS. If omitted, roads are clipped exactly to the domain + boundary. Range: 0–100 meters. + + ## Response + + Returns the created Feature resource with status ``\"pending\"``. The + backend worker will process the OSM extraction asynchronously and update + status to ``\"completed\"`` when ready. + + Args: + domain_id (str): + body (CreateOsmRoadFeatureRequest): Request body for creating a road feature via + OpenStreetMap. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Feature | HTTPValidationError + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/features/create_osm_water_feature.py b/fastfuels_sdk/v2/client_library/api/features/create_osm_water_feature.py new file mode 100644 index 0000000..cb0ebaf --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/features/create_osm_water_feature.py @@ -0,0 +1,310 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.create_osm_water_feature_request import CreateOsmWaterFeatureRequest +from ...models.feature import Feature +from ...models.http_validation_error import HTTPValidationError +from ...types import Response + + +def _get_kwargs( + domain_id: str, + *, + body: CreateOsmWaterFeatureRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/domains/{domain_id}/features/water/osm".format( + domain_id=quote(str(domain_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Feature | HTTPValidationError | None: + if response.status_code == 201: + response_201 = Feature.from_dict(response.json()) + + return response_201 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Feature | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateOsmWaterFeatureRequest, +) -> Response[Feature | HTTPValidationError]: + r"""Create a water feature from OpenStreetMap + + # Create OSM Water Feature + + Generates a polygon representation of water bodies and waterways within the + specified domain using data from OpenStreetMap (OSM). + + The backend worker will: + 1. Fetch the bounding box for the target domain. + 2. Query OpenStreetMap for water features (e.g., `water=*`, `waterway=*`, `natural=water`). + 3. Extract existing polygon features (lakes, ponds, wide rivers). + 4. Dynamically buffer linear water features (streams, creeks, narrow rivers) + into polygon areas based on their specific OSM classification. + 5. Merge the geometries and save the resulting GeoJSON to the features bucket. + + ## Request Body + + - **name**: (optional) Name for the water feature. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing features. + - **extent_buffer_m**: (optional) Distance in meters to expand the domain + extent outward before clipping fetched water features. Lets streams and + rivers that exit the domain at the boundary extend slightly past the + edge, providing context for visualization and downstream operations. + Applied in the domain's projected CRS. If omitted, water features are + clipped exactly to the domain boundary. Range: 0–100 meters. + + ## Response + + Returns the created Feature resource with status ``\"pending\"``. The + backend worker will process the OSM extraction asynchronously and update + status to ``\"completed\"`` when ready. + + Args: + domain_id (str): + body (CreateOsmWaterFeatureRequest): Request body for creating a water feature via + OpenStreetMap. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Feature | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateOsmWaterFeatureRequest, +) -> Feature | HTTPValidationError | None: + r"""Create a water feature from OpenStreetMap + + # Create OSM Water Feature + + Generates a polygon representation of water bodies and waterways within the + specified domain using data from OpenStreetMap (OSM). + + The backend worker will: + 1. Fetch the bounding box for the target domain. + 2. Query OpenStreetMap for water features (e.g., `water=*`, `waterway=*`, `natural=water`). + 3. Extract existing polygon features (lakes, ponds, wide rivers). + 4. Dynamically buffer linear water features (streams, creeks, narrow rivers) + into polygon areas based on their specific OSM classification. + 5. Merge the geometries and save the resulting GeoJSON to the features bucket. + + ## Request Body + + - **name**: (optional) Name for the water feature. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing features. + - **extent_buffer_m**: (optional) Distance in meters to expand the domain + extent outward before clipping fetched water features. Lets streams and + rivers that exit the domain at the boundary extend slightly past the + edge, providing context for visualization and downstream operations. + Applied in the domain's projected CRS. If omitted, water features are + clipped exactly to the domain boundary. Range: 0–100 meters. + + ## Response + + Returns the created Feature resource with status ``\"pending\"``. The + backend worker will process the OSM extraction asynchronously and update + status to ``\"completed\"`` when ready. + + Args: + domain_id (str): + body (CreateOsmWaterFeatureRequest): Request body for creating a water feature via + OpenStreetMap. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Feature | HTTPValidationError + """ + + return sync_detailed( + domain_id=domain_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateOsmWaterFeatureRequest, +) -> Response[Feature | HTTPValidationError]: + r"""Create a water feature from OpenStreetMap + + # Create OSM Water Feature + + Generates a polygon representation of water bodies and waterways within the + specified domain using data from OpenStreetMap (OSM). + + The backend worker will: + 1. Fetch the bounding box for the target domain. + 2. Query OpenStreetMap for water features (e.g., `water=*`, `waterway=*`, `natural=water`). + 3. Extract existing polygon features (lakes, ponds, wide rivers). + 4. Dynamically buffer linear water features (streams, creeks, narrow rivers) + into polygon areas based on their specific OSM classification. + 5. Merge the geometries and save the resulting GeoJSON to the features bucket. + + ## Request Body + + - **name**: (optional) Name for the water feature. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing features. + - **extent_buffer_m**: (optional) Distance in meters to expand the domain + extent outward before clipping fetched water features. Lets streams and + rivers that exit the domain at the boundary extend slightly past the + edge, providing context for visualization and downstream operations. + Applied in the domain's projected CRS. If omitted, water features are + clipped exactly to the domain boundary. Range: 0–100 meters. + + ## Response + + Returns the created Feature resource with status ``\"pending\"``. The + backend worker will process the OSM extraction asynchronously and update + status to ``\"completed\"`` when ready. + + Args: + domain_id (str): + body (CreateOsmWaterFeatureRequest): Request body for creating a water feature via + OpenStreetMap. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Feature | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateOsmWaterFeatureRequest, +) -> Feature | HTTPValidationError | None: + r"""Create a water feature from OpenStreetMap + + # Create OSM Water Feature + + Generates a polygon representation of water bodies and waterways within the + specified domain using data from OpenStreetMap (OSM). + + The backend worker will: + 1. Fetch the bounding box for the target domain. + 2. Query OpenStreetMap for water features (e.g., `water=*`, `waterway=*`, `natural=water`). + 3. Extract existing polygon features (lakes, ponds, wide rivers). + 4. Dynamically buffer linear water features (streams, creeks, narrow rivers) + into polygon areas based on their specific OSM classification. + 5. Merge the geometries and save the resulting GeoJSON to the features bucket. + + ## Request Body + + - **name**: (optional) Name for the water feature. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing features. + - **extent_buffer_m**: (optional) Distance in meters to expand the domain + extent outward before clipping fetched water features. Lets streams and + rivers that exit the domain at the boundary extend slightly past the + edge, providing context for visualization and downstream operations. + Applied in the domain's projected CRS. If omitted, water features are + clipped exactly to the domain boundary. Range: 0–100 meters. + + ## Response + + Returns the created Feature resource with status ``\"pending\"``. The + backend worker will process the OSM extraction asynchronously and update + status to ``\"completed\"`` when ready. + + Args: + domain_id (str): + body (CreateOsmWaterFeatureRequest): Request body for creating a water feature via + OpenStreetMap. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Feature | HTTPValidationError + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/features/delete_feature.py b/fastfuels_sdk/v2/client_library/api/features/delete_feature.py new file mode 100644 index 0000000..ff51d83 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/features/delete_feature.py @@ -0,0 +1,189 @@ +from http import HTTPStatus +from typing import Any, cast +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.http_validation_error import HTTPValidationError +from ...types import Response + + +def _get_kwargs( + domain_id: str, + feature_id: str, +) -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "delete", + "url": "/domains/{domain_id}/features/{feature_id}".format( + domain_id=quote(str(domain_id), safe=""), + feature_id=quote(str(feature_id), safe=""), + ), + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Any | HTTPValidationError | None: + if response.status_code == 204: + response_204 = cast(Any, None) + return response_204 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Any | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + feature_id: str, + *, + client: AuthenticatedClient, +) -> Response[Any | HTTPValidationError]: + """Delete a feature + + # Delete Feature Endpoint + + Permanently deletes a feature resource by its unique identifier. + + Args: + domain_id (str): + feature_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + feature_id=feature_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + feature_id: str, + *, + client: AuthenticatedClient, +) -> Any | HTTPValidationError | None: + """Delete a feature + + # Delete Feature Endpoint + + Permanently deletes a feature resource by its unique identifier. + + Args: + domain_id (str): + feature_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Any | HTTPValidationError + """ + + return sync_detailed( + domain_id=domain_id, + feature_id=feature_id, + client=client, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + feature_id: str, + *, + client: AuthenticatedClient, +) -> Response[Any | HTTPValidationError]: + """Delete a feature + + # Delete Feature Endpoint + + Permanently deletes a feature resource by its unique identifier. + + Args: + domain_id (str): + feature_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + feature_id=feature_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + feature_id: str, + *, + client: AuthenticatedClient, +) -> Any | HTTPValidationError | None: + """Delete a feature + + # Delete Feature Endpoint + + Permanently deletes a feature resource by its unique identifier. + + Args: + domain_id (str): + feature_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Any | HTTPValidationError + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + feature_id=feature_id, + client=client, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/features/get_feature.py b/fastfuels_sdk/v2/client_library/api/features/get_feature.py new file mode 100644 index 0000000..b460e80 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/features/get_feature.py @@ -0,0 +1,243 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.feature import Feature +from ...models.http_validation_error import HTTPValidationError +from ...types import Response + + +def _get_kwargs( + domain_id: str, + feature_id: str, +) -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/domains/{domain_id}/features/{feature_id}".format( + domain_id=quote(str(domain_id), safe=""), + feature_id=quote(str(feature_id), safe=""), + ), + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Feature | HTTPValidationError | None: + if response.status_code == 200: + response_200 = Feature.from_dict(response.json()) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Feature | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + feature_id: str, + *, + client: AuthenticatedClient, +) -> Response[Feature | HTTPValidationError]: + """Get a feature by ID + + # Get Feature Endpoint + + Retrieves a specific feature resource by its unique identifier. + + ## Path Parameters + + - **domain_id**: (string) The domain the feature belongs to. + - **feature_id**: (string) The unique identifier of the feature. + + ## Response + + Returns the feature resource. + + ## Error Responses + + - **404 Not Found**: The feature does not exist or the user does not have access. + + Args: + domain_id (str): + feature_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Feature | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + feature_id=feature_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + feature_id: str, + *, + client: AuthenticatedClient, +) -> Feature | HTTPValidationError | None: + """Get a feature by ID + + # Get Feature Endpoint + + Retrieves a specific feature resource by its unique identifier. + + ## Path Parameters + + - **domain_id**: (string) The domain the feature belongs to. + - **feature_id**: (string) The unique identifier of the feature. + + ## Response + + Returns the feature resource. + + ## Error Responses + + - **404 Not Found**: The feature does not exist or the user does not have access. + + Args: + domain_id (str): + feature_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Feature | HTTPValidationError + """ + + return sync_detailed( + domain_id=domain_id, + feature_id=feature_id, + client=client, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + feature_id: str, + *, + client: AuthenticatedClient, +) -> Response[Feature | HTTPValidationError]: + """Get a feature by ID + + # Get Feature Endpoint + + Retrieves a specific feature resource by its unique identifier. + + ## Path Parameters + + - **domain_id**: (string) The domain the feature belongs to. + - **feature_id**: (string) The unique identifier of the feature. + + ## Response + + Returns the feature resource. + + ## Error Responses + + - **404 Not Found**: The feature does not exist or the user does not have access. + + Args: + domain_id (str): + feature_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Feature | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + feature_id=feature_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + feature_id: str, + *, + client: AuthenticatedClient, +) -> Feature | HTTPValidationError | None: + """Get a feature by ID + + # Get Feature Endpoint + + Retrieves a specific feature resource by its unique identifier. + + ## Path Parameters + + - **domain_id**: (string) The domain the feature belongs to. + - **feature_id**: (string) The unique identifier of the feature. + + ## Response + + Returns the feature resource. + + ## Error Responses + + - **404 Not Found**: The feature does not exist or the user does not have access. + + Args: + domain_id (str): + feature_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Feature | HTTPValidationError + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + feature_id=feature_id, + client=client, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/features/get_feature_data_metadata.py b/fastfuels_sdk/v2/client_library/api/features/get_feature_data_metadata.py new file mode 100644 index 0000000..e4222a8 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/features/get_feature_data_metadata.py @@ -0,0 +1,371 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.feature_data_metadata import FeatureDataMetadata +from ...models.http_validation_error import HTTPValidationError +from ...types import Response + + +def _get_kwargs( + domain_id: str, + feature_id: str, +) -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/domains/{domain_id}/features/{feature_id}/data/metadata".format( + domain_id=quote(str(domain_id), safe=""), + feature_id=quote(str(feature_id), safe=""), + ), + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> FeatureDataMetadata | HTTPValidationError | None: + if response.status_code == 200: + response_200 = FeatureDataMetadata.from_dict(response.json()) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[FeatureDataMetadata | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + feature_id: str, + *, + client: AuthenticatedClient, +) -> Response[FeatureDataMetadata | HTTPValidationError]: + r"""Get feature data partition layout + + # Get Feature Data Metadata + + Returns the partition layout for a completed feature's data blob. Use + this to discover how many partitions exist before streaming them via + `GET /domains/{domain_id}/features/{feature_id}/data/{partition_index}`. + + ## Path Parameters + + - **domain_id**: The domain the feature belongs to. + - **feature_id**: The unique identifier of the feature. + + ## Response + + JSON object describing the partition layout of the underlying + GeoParquet blob: + + ```json + { + \"total_features\": 5400, + \"partition_count\": 6, + \"partitions\": [ + {\"index\": 0, \"num_features\": 1000}, + {\"index\": 1, \"num_features\": 1000}, + {\"index\": 2, \"num_features\": 1000}, + {\"index\": 3, \"num_features\": 1000}, + {\"index\": 4, \"num_features\": 1000}, + {\"index\": 5, \"num_features\": 400} + ] + } + ``` + + - **total_features**: Total number of features across all partitions. + - **partition_count**: Number of valid `partition_index` values. Iterate + from `0` to `partition_count - 1` to retrieve every feature exactly + once, in source order. + - **partitions**: Per-partition row counts read directly from the + GeoParquet footer. Sum of `num_features` equals `total_features`. + + A feature with zero features has `partition_count = 0` and an empty + `partitions` list — no `/data/{partition_index}` calls are valid in + that case. + + ## Error Responses + + - **404 Not Found**: Feature does not exist or is not accessible to the + caller. + - **422 Unprocessable Entity**: Feature is not in `completed` status, or + the underlying GeoParquet blob is missing / malformed. + + Args: + domain_id (str): + feature_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[FeatureDataMetadata | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + feature_id=feature_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + feature_id: str, + *, + client: AuthenticatedClient, +) -> FeatureDataMetadata | HTTPValidationError | None: + r"""Get feature data partition layout + + # Get Feature Data Metadata + + Returns the partition layout for a completed feature's data blob. Use + this to discover how many partitions exist before streaming them via + `GET /domains/{domain_id}/features/{feature_id}/data/{partition_index}`. + + ## Path Parameters + + - **domain_id**: The domain the feature belongs to. + - **feature_id**: The unique identifier of the feature. + + ## Response + + JSON object describing the partition layout of the underlying + GeoParquet blob: + + ```json + { + \"total_features\": 5400, + \"partition_count\": 6, + \"partitions\": [ + {\"index\": 0, \"num_features\": 1000}, + {\"index\": 1, \"num_features\": 1000}, + {\"index\": 2, \"num_features\": 1000}, + {\"index\": 3, \"num_features\": 1000}, + {\"index\": 4, \"num_features\": 1000}, + {\"index\": 5, \"num_features\": 400} + ] + } + ``` + + - **total_features**: Total number of features across all partitions. + - **partition_count**: Number of valid `partition_index` values. Iterate + from `0` to `partition_count - 1` to retrieve every feature exactly + once, in source order. + - **partitions**: Per-partition row counts read directly from the + GeoParquet footer. Sum of `num_features` equals `total_features`. + + A feature with zero features has `partition_count = 0` and an empty + `partitions` list — no `/data/{partition_index}` calls are valid in + that case. + + ## Error Responses + + - **404 Not Found**: Feature does not exist or is not accessible to the + caller. + - **422 Unprocessable Entity**: Feature is not in `completed` status, or + the underlying GeoParquet blob is missing / malformed. + + Args: + domain_id (str): + feature_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + FeatureDataMetadata | HTTPValidationError + """ + + return sync_detailed( + domain_id=domain_id, + feature_id=feature_id, + client=client, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + feature_id: str, + *, + client: AuthenticatedClient, +) -> Response[FeatureDataMetadata | HTTPValidationError]: + r"""Get feature data partition layout + + # Get Feature Data Metadata + + Returns the partition layout for a completed feature's data blob. Use + this to discover how many partitions exist before streaming them via + `GET /domains/{domain_id}/features/{feature_id}/data/{partition_index}`. + + ## Path Parameters + + - **domain_id**: The domain the feature belongs to. + - **feature_id**: The unique identifier of the feature. + + ## Response + + JSON object describing the partition layout of the underlying + GeoParquet blob: + + ```json + { + \"total_features\": 5400, + \"partition_count\": 6, + \"partitions\": [ + {\"index\": 0, \"num_features\": 1000}, + {\"index\": 1, \"num_features\": 1000}, + {\"index\": 2, \"num_features\": 1000}, + {\"index\": 3, \"num_features\": 1000}, + {\"index\": 4, \"num_features\": 1000}, + {\"index\": 5, \"num_features\": 400} + ] + } + ``` + + - **total_features**: Total number of features across all partitions. + - **partition_count**: Number of valid `partition_index` values. Iterate + from `0` to `partition_count - 1` to retrieve every feature exactly + once, in source order. + - **partitions**: Per-partition row counts read directly from the + GeoParquet footer. Sum of `num_features` equals `total_features`. + + A feature with zero features has `partition_count = 0` and an empty + `partitions` list — no `/data/{partition_index}` calls are valid in + that case. + + ## Error Responses + + - **404 Not Found**: Feature does not exist or is not accessible to the + caller. + - **422 Unprocessable Entity**: Feature is not in `completed` status, or + the underlying GeoParquet blob is missing / malformed. + + Args: + domain_id (str): + feature_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[FeatureDataMetadata | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + feature_id=feature_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + feature_id: str, + *, + client: AuthenticatedClient, +) -> FeatureDataMetadata | HTTPValidationError | None: + r"""Get feature data partition layout + + # Get Feature Data Metadata + + Returns the partition layout for a completed feature's data blob. Use + this to discover how many partitions exist before streaming them via + `GET /domains/{domain_id}/features/{feature_id}/data/{partition_index}`. + + ## Path Parameters + + - **domain_id**: The domain the feature belongs to. + - **feature_id**: The unique identifier of the feature. + + ## Response + + JSON object describing the partition layout of the underlying + GeoParquet blob: + + ```json + { + \"total_features\": 5400, + \"partition_count\": 6, + \"partitions\": [ + {\"index\": 0, \"num_features\": 1000}, + {\"index\": 1, \"num_features\": 1000}, + {\"index\": 2, \"num_features\": 1000}, + {\"index\": 3, \"num_features\": 1000}, + {\"index\": 4, \"num_features\": 1000}, + {\"index\": 5, \"num_features\": 400} + ] + } + ``` + + - **total_features**: Total number of features across all partitions. + - **partition_count**: Number of valid `partition_index` values. Iterate + from `0` to `partition_count - 1` to retrieve every feature exactly + once, in source order. + - **partitions**: Per-partition row counts read directly from the + GeoParquet footer. Sum of `num_features` equals `total_features`. + + A feature with zero features has `partition_count = 0` and an empty + `partitions` list — no `/data/{partition_index}` calls are valid in + that case. + + ## Error Responses + + - **404 Not Found**: Feature does not exist or is not accessible to the + caller. + - **422 Unprocessable Entity**: Feature is not in `completed` status, or + the underlying GeoParquet blob is missing / malformed. + + Args: + domain_id (str): + feature_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + FeatureDataMetadata | HTTPValidationError + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + feature_id=feature_id, + client=client, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/features/get_feature_data_partition.py b/fastfuels_sdk/v2/client_library/api/features/get_feature_data_partition.py new file mode 100644 index 0000000..c495579 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/features/get_feature_data_partition.py @@ -0,0 +1,331 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.http_validation_error import HTTPValidationError +from ...types import Response + + +def _get_kwargs( + domain_id: str, + feature_id: str, + partition_index: int, +) -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/domains/{domain_id}/features/{feature_id}/data/{partition_index}".format( + domain_id=quote(str(domain_id), safe=""), + feature_id=quote(str(feature_id), safe=""), + partition_index=quote(str(partition_index), safe=""), + ), + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Any | HTTPValidationError | None: + if response.status_code == 200: + response_200 = response.json() + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Any | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + feature_id: str, + partition_index: int, + *, + client: AuthenticatedClient, +) -> Response[Any | HTTPValidationError]: + """Get one partition of feature data as GeoJSON + + # Get Feature Data Partition + + Returns one partition of a completed feature's data as a self-contained + GeoJSON `FeatureCollection`. To stream the full collection, first call + `GET /domains/{domain_id}/features/{feature_id}/data/metadata` to + discover `partition_count`, then GET this endpoint for each + `partition_index` from `0` to `partition_count - 1`. The concatenated + `features` arrays reproduce the source feature list in source order. + + ## Path Parameters + + - **domain_id**: The domain the feature belongs to. + - **feature_id**: The unique identifier of the feature. + - **partition_index**: Zero-indexed partition number. Must be `< partition_count` + from `/data/metadata`. + + ## Response + + `application/geo+json` body — a valid GeoJSON `FeatureCollection` + containing up to `partition_size` features. Each feature's `properties` + and `geometry` round-trip from the source GeoParquet via geopandas. + + ## Error Responses + + - **404 Not Found**: Feature does not exist or is not accessible to the + caller. + - **413 Content Too Large**: Serialized partition exceeds the 30 MB + response cap. Partition size is fixed server-side and cannot be + adjusted by re-creating the feature; contact + `support.fastfuels@silvxlabs.com` so the partition size can be + reduced for your feature. + - **422 Unprocessable Entity**: `partition_index` is past the last + partition, the feature is not in `completed` status, or the + underlying GeoParquet blob is missing / malformed. + + Args: + domain_id (str): + feature_id (str): + partition_index (int): Zero-indexed partition number. Valid range is `0` ≤ + `partition_index` < `partition_count` from `GET /data/metadata`. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + feature_id=feature_id, + partition_index=partition_index, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + feature_id: str, + partition_index: int, + *, + client: AuthenticatedClient, +) -> Any | HTTPValidationError | None: + """Get one partition of feature data as GeoJSON + + # Get Feature Data Partition + + Returns one partition of a completed feature's data as a self-contained + GeoJSON `FeatureCollection`. To stream the full collection, first call + `GET /domains/{domain_id}/features/{feature_id}/data/metadata` to + discover `partition_count`, then GET this endpoint for each + `partition_index` from `0` to `partition_count - 1`. The concatenated + `features` arrays reproduce the source feature list in source order. + + ## Path Parameters + + - **domain_id**: The domain the feature belongs to. + - **feature_id**: The unique identifier of the feature. + - **partition_index**: Zero-indexed partition number. Must be `< partition_count` + from `/data/metadata`. + + ## Response + + `application/geo+json` body — a valid GeoJSON `FeatureCollection` + containing up to `partition_size` features. Each feature's `properties` + and `geometry` round-trip from the source GeoParquet via geopandas. + + ## Error Responses + + - **404 Not Found**: Feature does not exist or is not accessible to the + caller. + - **413 Content Too Large**: Serialized partition exceeds the 30 MB + response cap. Partition size is fixed server-side and cannot be + adjusted by re-creating the feature; contact + `support.fastfuels@silvxlabs.com` so the partition size can be + reduced for your feature. + - **422 Unprocessable Entity**: `partition_index` is past the last + partition, the feature is not in `completed` status, or the + underlying GeoParquet blob is missing / malformed. + + Args: + domain_id (str): + feature_id (str): + partition_index (int): Zero-indexed partition number. Valid range is `0` ≤ + `partition_index` < `partition_count` from `GET /data/metadata`. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Any | HTTPValidationError + """ + + return sync_detailed( + domain_id=domain_id, + feature_id=feature_id, + partition_index=partition_index, + client=client, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + feature_id: str, + partition_index: int, + *, + client: AuthenticatedClient, +) -> Response[Any | HTTPValidationError]: + """Get one partition of feature data as GeoJSON + + # Get Feature Data Partition + + Returns one partition of a completed feature's data as a self-contained + GeoJSON `FeatureCollection`. To stream the full collection, first call + `GET /domains/{domain_id}/features/{feature_id}/data/metadata` to + discover `partition_count`, then GET this endpoint for each + `partition_index` from `0` to `partition_count - 1`. The concatenated + `features` arrays reproduce the source feature list in source order. + + ## Path Parameters + + - **domain_id**: The domain the feature belongs to. + - **feature_id**: The unique identifier of the feature. + - **partition_index**: Zero-indexed partition number. Must be `< partition_count` + from `/data/metadata`. + + ## Response + + `application/geo+json` body — a valid GeoJSON `FeatureCollection` + containing up to `partition_size` features. Each feature's `properties` + and `geometry` round-trip from the source GeoParquet via geopandas. + + ## Error Responses + + - **404 Not Found**: Feature does not exist or is not accessible to the + caller. + - **413 Content Too Large**: Serialized partition exceeds the 30 MB + response cap. Partition size is fixed server-side and cannot be + adjusted by re-creating the feature; contact + `support.fastfuels@silvxlabs.com` so the partition size can be + reduced for your feature. + - **422 Unprocessable Entity**: `partition_index` is past the last + partition, the feature is not in `completed` status, or the + underlying GeoParquet blob is missing / malformed. + + Args: + domain_id (str): + feature_id (str): + partition_index (int): Zero-indexed partition number. Valid range is `0` ≤ + `partition_index` < `partition_count` from `GET /data/metadata`. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + feature_id=feature_id, + partition_index=partition_index, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + feature_id: str, + partition_index: int, + *, + client: AuthenticatedClient, +) -> Any | HTTPValidationError | None: + """Get one partition of feature data as GeoJSON + + # Get Feature Data Partition + + Returns one partition of a completed feature's data as a self-contained + GeoJSON `FeatureCollection`. To stream the full collection, first call + `GET /domains/{domain_id}/features/{feature_id}/data/metadata` to + discover `partition_count`, then GET this endpoint for each + `partition_index` from `0` to `partition_count - 1`. The concatenated + `features` arrays reproduce the source feature list in source order. + + ## Path Parameters + + - **domain_id**: The domain the feature belongs to. + - **feature_id**: The unique identifier of the feature. + - **partition_index**: Zero-indexed partition number. Must be `< partition_count` + from `/data/metadata`. + + ## Response + + `application/geo+json` body — a valid GeoJSON `FeatureCollection` + containing up to `partition_size` features. Each feature's `properties` + and `geometry` round-trip from the source GeoParquet via geopandas. + + ## Error Responses + + - **404 Not Found**: Feature does not exist or is not accessible to the + caller. + - **413 Content Too Large**: Serialized partition exceeds the 30 MB + response cap. Partition size is fixed server-side and cannot be + adjusted by re-creating the feature; contact + `support.fastfuels@silvxlabs.com` so the partition size can be + reduced for your feature. + - **422 Unprocessable Entity**: `partition_index` is past the last + partition, the feature is not in `completed` status, or the + underlying GeoParquet blob is missing / malformed. + + Args: + domain_id (str): + feature_id (str): + partition_index (int): Zero-indexed partition number. Valid range is `0` ≤ + `partition_index` < `partition_count` from `GET /data/metadata`. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Any | HTTPValidationError + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + feature_id=feature_id, + partition_index=partition_index, + client=client, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/features/list_features.py b/fastfuels_sdk/v2/client_library/api/features/list_features.py new file mode 100644 index 0000000..2551cdc --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/features/list_features.py @@ -0,0 +1,406 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.feature_sort_field import FeatureSortField +from ...models.feature_type import FeatureType +from ...models.http_validation_error import HTTPValidationError +from ...models.list_features_response import ListFeaturesResponse +from ...models.sort_order import SortOrder +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + domain_id: str, + *, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: FeatureSortField | None | Unset = UNSET, + sort_order: None | SortOrder | Unset = UNSET, + type_: FeatureType | None | Unset = UNSET, + product: None | str | Unset = UNSET, + tag: None | str | Unset = UNSET, +) -> dict[str, Any]: + + params: dict[str, Any] = {} + + params["page"] = page + + params["size"] = size + + json_sort_by: None | str | Unset + if isinstance(sort_by, Unset): + json_sort_by = UNSET + elif isinstance(sort_by, FeatureSortField): + json_sort_by = sort_by.value + else: + json_sort_by = sort_by + params["sort_by"] = json_sort_by + + json_sort_order: None | str | Unset + if isinstance(sort_order, Unset): + json_sort_order = UNSET + elif isinstance(sort_order, SortOrder): + json_sort_order = sort_order.value + else: + json_sort_order = sort_order + params["sort_order"] = json_sort_order + + json_type_: None | str | Unset + if isinstance(type_, Unset): + json_type_ = UNSET + elif isinstance(type_, FeatureType): + json_type_ = type_.value + else: + json_type_ = type_ + params["type"] = json_type_ + + json_product: None | str | Unset + if isinstance(product, Unset): + json_product = UNSET + else: + json_product = product + params["product"] = json_product + + json_tag: None | str | Unset + if isinstance(tag, Unset): + json_tag = UNSET + else: + json_tag = tag + params["tag"] = json_tag + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/domains/{domain_id}/features".format( + domain_id=quote(str(domain_id), safe=""), + ), + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> HTTPValidationError | ListFeaturesResponse | None: + if response.status_code == 200: + response_200 = ListFeaturesResponse.from_dict(response.json()) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[HTTPValidationError | ListFeaturesResponse]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: FeatureSortField | None | Unset = UNSET, + sort_order: None | SortOrder | Unset = UNSET, + type_: FeatureType | None | Unset = UNSET, + product: None | str | Unset = UNSET, + tag: None | str | Unset = UNSET, +) -> Response[HTTPValidationError | ListFeaturesResponse]: + """List all features + + # List Features Endpoint + + Retrieves a paginated list of all features within a domain belonging to + the authenticated user. + + ## Path Parameters + + - **domain_id**: (string) The domain to list features for. + + ## Query Parameters + + - **page**: (integer, optional) Page number (zero-indexed). Default: 0. + - **size**: (integer, optional) Items per page (1-1000). Default: 100. + - **sort_by**: (string, optional) Field to sort by: `created_on`, `modified_on`, `name`. + - **sort_order**: (string, optional) Sort direction: `ascending`, `descending`. + - **type**: (string, optional) Filter by entity type (e.g., `road`). + - **product**: (string, optional) Filter by source product (e.g., `osm`). + - **tag**: (string, optional) Filter features that contain this tag. + + ## Response + + Returns a paginated list of features with metadata. + + Args: + domain_id (str): + page (int | Unset): The page number to retrieve (zero-indexed). Default: 0. + size (int | Unset): The number of features to retrieve per page. Default: 100. + sort_by (FeatureSortField | None | Unset): The field to sort results by. + sort_order (None | SortOrder | Unset): The order to sort results (ascending or + descending). + type_ (FeatureType | None | Unset): Filter features by entity type (e.g., 'road', + 'water'). + product (None | str | Unset): Filter features by source product (e.g., 'osm'). + tag (None | str | Unset): Filter features that contain this tag. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | ListFeaturesResponse] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + page=page, + size=size, + sort_by=sort_by, + sort_order=sort_order, + type_=type_, + product=product, + tag=tag, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: FeatureSortField | None | Unset = UNSET, + sort_order: None | SortOrder | Unset = UNSET, + type_: FeatureType | None | Unset = UNSET, + product: None | str | Unset = UNSET, + tag: None | str | Unset = UNSET, +) -> HTTPValidationError | ListFeaturesResponse | None: + """List all features + + # List Features Endpoint + + Retrieves a paginated list of all features within a domain belonging to + the authenticated user. + + ## Path Parameters + + - **domain_id**: (string) The domain to list features for. + + ## Query Parameters + + - **page**: (integer, optional) Page number (zero-indexed). Default: 0. + - **size**: (integer, optional) Items per page (1-1000). Default: 100. + - **sort_by**: (string, optional) Field to sort by: `created_on`, `modified_on`, `name`. + - **sort_order**: (string, optional) Sort direction: `ascending`, `descending`. + - **type**: (string, optional) Filter by entity type (e.g., `road`). + - **product**: (string, optional) Filter by source product (e.g., `osm`). + - **tag**: (string, optional) Filter features that contain this tag. + + ## Response + + Returns a paginated list of features with metadata. + + Args: + domain_id (str): + page (int | Unset): The page number to retrieve (zero-indexed). Default: 0. + size (int | Unset): The number of features to retrieve per page. Default: 100. + sort_by (FeatureSortField | None | Unset): The field to sort results by. + sort_order (None | SortOrder | Unset): The order to sort results (ascending or + descending). + type_ (FeatureType | None | Unset): Filter features by entity type (e.g., 'road', + 'water'). + product (None | str | Unset): Filter features by source product (e.g., 'osm'). + tag (None | str | Unset): Filter features that contain this tag. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | ListFeaturesResponse + """ + + return sync_detailed( + domain_id=domain_id, + client=client, + page=page, + size=size, + sort_by=sort_by, + sort_order=sort_order, + type_=type_, + product=product, + tag=tag, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: FeatureSortField | None | Unset = UNSET, + sort_order: None | SortOrder | Unset = UNSET, + type_: FeatureType | None | Unset = UNSET, + product: None | str | Unset = UNSET, + tag: None | str | Unset = UNSET, +) -> Response[HTTPValidationError | ListFeaturesResponse]: + """List all features + + # List Features Endpoint + + Retrieves a paginated list of all features within a domain belonging to + the authenticated user. + + ## Path Parameters + + - **domain_id**: (string) The domain to list features for. + + ## Query Parameters + + - **page**: (integer, optional) Page number (zero-indexed). Default: 0. + - **size**: (integer, optional) Items per page (1-1000). Default: 100. + - **sort_by**: (string, optional) Field to sort by: `created_on`, `modified_on`, `name`. + - **sort_order**: (string, optional) Sort direction: `ascending`, `descending`. + - **type**: (string, optional) Filter by entity type (e.g., `road`). + - **product**: (string, optional) Filter by source product (e.g., `osm`). + - **tag**: (string, optional) Filter features that contain this tag. + + ## Response + + Returns a paginated list of features with metadata. + + Args: + domain_id (str): + page (int | Unset): The page number to retrieve (zero-indexed). Default: 0. + size (int | Unset): The number of features to retrieve per page. Default: 100. + sort_by (FeatureSortField | None | Unset): The field to sort results by. + sort_order (None | SortOrder | Unset): The order to sort results (ascending or + descending). + type_ (FeatureType | None | Unset): Filter features by entity type (e.g., 'road', + 'water'). + product (None | str | Unset): Filter features by source product (e.g., 'osm'). + tag (None | str | Unset): Filter features that contain this tag. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | ListFeaturesResponse] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + page=page, + size=size, + sort_by=sort_by, + sort_order=sort_order, + type_=type_, + product=product, + tag=tag, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: FeatureSortField | None | Unset = UNSET, + sort_order: None | SortOrder | Unset = UNSET, + type_: FeatureType | None | Unset = UNSET, + product: None | str | Unset = UNSET, + tag: None | str | Unset = UNSET, +) -> HTTPValidationError | ListFeaturesResponse | None: + """List all features + + # List Features Endpoint + + Retrieves a paginated list of all features within a domain belonging to + the authenticated user. + + ## Path Parameters + + - **domain_id**: (string) The domain to list features for. + + ## Query Parameters + + - **page**: (integer, optional) Page number (zero-indexed). Default: 0. + - **size**: (integer, optional) Items per page (1-1000). Default: 100. + - **sort_by**: (string, optional) Field to sort by: `created_on`, `modified_on`, `name`. + - **sort_order**: (string, optional) Sort direction: `ascending`, `descending`. + - **type**: (string, optional) Filter by entity type (e.g., `road`). + - **product**: (string, optional) Filter by source product (e.g., `osm`). + - **tag**: (string, optional) Filter features that contain this tag. + + ## Response + + Returns a paginated list of features with metadata. + + Args: + domain_id (str): + page (int | Unset): The page number to retrieve (zero-indexed). Default: 0. + size (int | Unset): The number of features to retrieve per page. Default: 100. + sort_by (FeatureSortField | None | Unset): The field to sort results by. + sort_order (None | SortOrder | Unset): The order to sort results (ascending or + descending). + type_ (FeatureType | None | Unset): Filter features by entity type (e.g., 'road', + 'water'). + product (None | str | Unset): Filter features by source product (e.g., 'osm'). + tag (None | str | Unset): Filter features that contain this tag. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | ListFeaturesResponse + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + client=client, + page=page, + size=size, + sort_by=sort_by, + sort_order=sort_order, + type_=type_, + product=product, + tag=tag, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/features/list_features_cross_domain.py b/fastfuels_sdk/v2/client_library/api/features/list_features_cross_domain.py new file mode 100644 index 0000000..bb0e06f --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/features/list_features_cross_domain.py @@ -0,0 +1,374 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.feature_sort_field import FeatureSortField +from ...models.feature_type import FeatureType +from ...models.http_validation_error import HTTPValidationError +from ...models.list_features_response import ListFeaturesResponse +from ...models.sort_order import SortOrder +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + *, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: FeatureSortField | None | Unset = UNSET, + sort_order: None | SortOrder | Unset = UNSET, + type_: FeatureType | None | Unset = UNSET, + product: None | str | Unset = UNSET, + tag: None | str | Unset = UNSET, +) -> dict[str, Any]: + + params: dict[str, Any] = {} + + params["page"] = page + + params["size"] = size + + json_sort_by: None | str | Unset + if isinstance(sort_by, Unset): + json_sort_by = UNSET + elif isinstance(sort_by, FeatureSortField): + json_sort_by = sort_by.value + else: + json_sort_by = sort_by + params["sort_by"] = json_sort_by + + json_sort_order: None | str | Unset + if isinstance(sort_order, Unset): + json_sort_order = UNSET + elif isinstance(sort_order, SortOrder): + json_sort_order = sort_order.value + else: + json_sort_order = sort_order + params["sort_order"] = json_sort_order + + json_type_: None | str | Unset + if isinstance(type_, Unset): + json_type_ = UNSET + elif isinstance(type_, FeatureType): + json_type_ = type_.value + else: + json_type_ = type_ + params["type"] = json_type_ + + json_product: None | str | Unset + if isinstance(product, Unset): + json_product = UNSET + else: + json_product = product + params["product"] = json_product + + json_tag: None | str | Unset + if isinstance(tag, Unset): + json_tag = UNSET + else: + json_tag = tag + params["tag"] = json_tag + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/domains/-/features", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> HTTPValidationError | ListFeaturesResponse | None: + if response.status_code == 200: + response_200 = ListFeaturesResponse.from_dict(response.json()) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[HTTPValidationError | ListFeaturesResponse]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: FeatureSortField | None | Unset = UNSET, + sort_order: None | SortOrder | Unset = UNSET, + type_: FeatureType | None | Unset = UNSET, + product: None | str | Unset = UNSET, + tag: None | str | Unset = UNSET, +) -> Response[HTTPValidationError | ListFeaturesResponse]: + """List features across all domains + + # List Features Endpoint + + Retrieves a paginated list of all features across all domains belonging to + the authenticated user. + + ## Query Parameters + + - **page**: (integer, optional) Page number (zero-indexed). Default: 0. + - **size**: (integer, optional) Items per page (1-1000). Default: 100. + - **sort_by**: (string, optional) Field to sort by: `created_on`, `modified_on`, `name`. + - **sort_order**: (string, optional) Sort direction: `ascending`, `descending`. + - **type**: (string, optional) Filter by entity type (e.g., `road`). + - **product**: (string, optional) Filter by source product (e.g., `osm`). + - **tag**: (string, optional) Filter features that contain this tag. + + ## Response + + Returns a paginated list of features with metadata. + + Args: + page (int | Unset): The page number to retrieve (zero-indexed). Default: 0. + size (int | Unset): The number of features to retrieve per page. Default: 100. + sort_by (FeatureSortField | None | Unset): The field to sort results by. + sort_order (None | SortOrder | Unset): The order to sort results (ascending or + descending). + type_ (FeatureType | None | Unset): Filter features by entity type (e.g., 'road', + 'water'). + product (None | str | Unset): Filter features by source product (e.g., 'osm'). + tag (None | str | Unset): Filter features that contain this tag. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | ListFeaturesResponse] + """ + + kwargs = _get_kwargs( + page=page, + size=size, + sort_by=sort_by, + sort_order=sort_order, + type_=type_, + product=product, + tag=tag, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: FeatureSortField | None | Unset = UNSET, + sort_order: None | SortOrder | Unset = UNSET, + type_: FeatureType | None | Unset = UNSET, + product: None | str | Unset = UNSET, + tag: None | str | Unset = UNSET, +) -> HTTPValidationError | ListFeaturesResponse | None: + """List features across all domains + + # List Features Endpoint + + Retrieves a paginated list of all features across all domains belonging to + the authenticated user. + + ## Query Parameters + + - **page**: (integer, optional) Page number (zero-indexed). Default: 0. + - **size**: (integer, optional) Items per page (1-1000). Default: 100. + - **sort_by**: (string, optional) Field to sort by: `created_on`, `modified_on`, `name`. + - **sort_order**: (string, optional) Sort direction: `ascending`, `descending`. + - **type**: (string, optional) Filter by entity type (e.g., `road`). + - **product**: (string, optional) Filter by source product (e.g., `osm`). + - **tag**: (string, optional) Filter features that contain this tag. + + ## Response + + Returns a paginated list of features with metadata. + + Args: + page (int | Unset): The page number to retrieve (zero-indexed). Default: 0. + size (int | Unset): The number of features to retrieve per page. Default: 100. + sort_by (FeatureSortField | None | Unset): The field to sort results by. + sort_order (None | SortOrder | Unset): The order to sort results (ascending or + descending). + type_ (FeatureType | None | Unset): Filter features by entity type (e.g., 'road', + 'water'). + product (None | str | Unset): Filter features by source product (e.g., 'osm'). + tag (None | str | Unset): Filter features that contain this tag. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | ListFeaturesResponse + """ + + return sync_detailed( + client=client, + page=page, + size=size, + sort_by=sort_by, + sort_order=sort_order, + type_=type_, + product=product, + tag=tag, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: FeatureSortField | None | Unset = UNSET, + sort_order: None | SortOrder | Unset = UNSET, + type_: FeatureType | None | Unset = UNSET, + product: None | str | Unset = UNSET, + tag: None | str | Unset = UNSET, +) -> Response[HTTPValidationError | ListFeaturesResponse]: + """List features across all domains + + # List Features Endpoint + + Retrieves a paginated list of all features across all domains belonging to + the authenticated user. + + ## Query Parameters + + - **page**: (integer, optional) Page number (zero-indexed). Default: 0. + - **size**: (integer, optional) Items per page (1-1000). Default: 100. + - **sort_by**: (string, optional) Field to sort by: `created_on`, `modified_on`, `name`. + - **sort_order**: (string, optional) Sort direction: `ascending`, `descending`. + - **type**: (string, optional) Filter by entity type (e.g., `road`). + - **product**: (string, optional) Filter by source product (e.g., `osm`). + - **tag**: (string, optional) Filter features that contain this tag. + + ## Response + + Returns a paginated list of features with metadata. + + Args: + page (int | Unset): The page number to retrieve (zero-indexed). Default: 0. + size (int | Unset): The number of features to retrieve per page. Default: 100. + sort_by (FeatureSortField | None | Unset): The field to sort results by. + sort_order (None | SortOrder | Unset): The order to sort results (ascending or + descending). + type_ (FeatureType | None | Unset): Filter features by entity type (e.g., 'road', + 'water'). + product (None | str | Unset): Filter features by source product (e.g., 'osm'). + tag (None | str | Unset): Filter features that contain this tag. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | ListFeaturesResponse] + """ + + kwargs = _get_kwargs( + page=page, + size=size, + sort_by=sort_by, + sort_order=sort_order, + type_=type_, + product=product, + tag=tag, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: FeatureSortField | None | Unset = UNSET, + sort_order: None | SortOrder | Unset = UNSET, + type_: FeatureType | None | Unset = UNSET, + product: None | str | Unset = UNSET, + tag: None | str | Unset = UNSET, +) -> HTTPValidationError | ListFeaturesResponse | None: + """List features across all domains + + # List Features Endpoint + + Retrieves a paginated list of all features across all domains belonging to + the authenticated user. + + ## Query Parameters + + - **page**: (integer, optional) Page number (zero-indexed). Default: 0. + - **size**: (integer, optional) Items per page (1-1000). Default: 100. + - **sort_by**: (string, optional) Field to sort by: `created_on`, `modified_on`, `name`. + - **sort_order**: (string, optional) Sort direction: `ascending`, `descending`. + - **type**: (string, optional) Filter by entity type (e.g., `road`). + - **product**: (string, optional) Filter by source product (e.g., `osm`). + - **tag**: (string, optional) Filter features that contain this tag. + + ## Response + + Returns a paginated list of features with metadata. + + Args: + page (int | Unset): The page number to retrieve (zero-indexed). Default: 0. + size (int | Unset): The number of features to retrieve per page. Default: 100. + sort_by (FeatureSortField | None | Unset): The field to sort results by. + sort_order (None | SortOrder | Unset): The order to sort results (ascending or + descending). + type_ (FeatureType | None | Unset): Filter features by entity type (e.g., 'road', + 'water'). + product (None | str | Unset): Filter features by source product (e.g., 'osm'). + tag (None | str | Unset): Filter features that contain this tag. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | ListFeaturesResponse + """ + + return ( + await asyncio_detailed( + client=client, + page=page, + size=size, + sort_by=sort_by, + sort_order=sort_order, + type_=type_, + product=product, + tag=tag, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/features/update_feature.py b/fastfuels_sdk/v2/client_library/api/features/update_feature.py new file mode 100644 index 0000000..e648638 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/features/update_feature.py @@ -0,0 +1,320 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.feature import Feature +from ...models.http_validation_error import HTTPValidationError +from ...models.update_feature_request_body import UpdateFeatureRequestBody +from ...types import Response + + +def _get_kwargs( + domain_id: str, + feature_id: str, + *, + body: UpdateFeatureRequestBody, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "patch", + "url": "/domains/{domain_id}/features/{feature_id}".format( + domain_id=quote(str(domain_id), safe=""), + feature_id=quote(str(feature_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Feature | HTTPValidationError | None: + if response.status_code == 200: + response_200 = Feature.from_dict(response.json()) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Feature | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + feature_id: str, + *, + client: AuthenticatedClient, + body: UpdateFeatureRequestBody, +) -> Response[Feature | HTTPValidationError]: + """Update a feature + + # Update Feature Endpoint + + Updates the metadata of an existing feature resource. Only the fields + provided in the request body will be modified. + + ## Path Parameters + + - **domain_id**: (string) The domain the feature belongs to. + - **feature_id**: (string) The unique identifier of the feature. + + ## Request Body + + All fields are optional: + + - **name**: (string) New name for the feature. + - **description**: (string) New description. + - **tags**: (array of strings) New tags (replaces existing). + + ## What Cannot Be Updated + + The following fields are immutable: + + - **id**, **domain_id**, **type**, **source**, **georeference** + - **created_on** (creation timestamp is permanent) + + The **modified_on** field is automatically updated. + + ## Response + + Returns the updated feature resource. + + Args: + domain_id (str): + feature_id (str): + body (UpdateFeatureRequestBody): Request body for updating feature metadata. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Feature | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + feature_id=feature_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + feature_id: str, + *, + client: AuthenticatedClient, + body: UpdateFeatureRequestBody, +) -> Feature | HTTPValidationError | None: + """Update a feature + + # Update Feature Endpoint + + Updates the metadata of an existing feature resource. Only the fields + provided in the request body will be modified. + + ## Path Parameters + + - **domain_id**: (string) The domain the feature belongs to. + - **feature_id**: (string) The unique identifier of the feature. + + ## Request Body + + All fields are optional: + + - **name**: (string) New name for the feature. + - **description**: (string) New description. + - **tags**: (array of strings) New tags (replaces existing). + + ## What Cannot Be Updated + + The following fields are immutable: + + - **id**, **domain_id**, **type**, **source**, **georeference** + - **created_on** (creation timestamp is permanent) + + The **modified_on** field is automatically updated. + + ## Response + + Returns the updated feature resource. + + Args: + domain_id (str): + feature_id (str): + body (UpdateFeatureRequestBody): Request body for updating feature metadata. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Feature | HTTPValidationError + """ + + return sync_detailed( + domain_id=domain_id, + feature_id=feature_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + feature_id: str, + *, + client: AuthenticatedClient, + body: UpdateFeatureRequestBody, +) -> Response[Feature | HTTPValidationError]: + """Update a feature + + # Update Feature Endpoint + + Updates the metadata of an existing feature resource. Only the fields + provided in the request body will be modified. + + ## Path Parameters + + - **domain_id**: (string) The domain the feature belongs to. + - **feature_id**: (string) The unique identifier of the feature. + + ## Request Body + + All fields are optional: + + - **name**: (string) New name for the feature. + - **description**: (string) New description. + - **tags**: (array of strings) New tags (replaces existing). + + ## What Cannot Be Updated + + The following fields are immutable: + + - **id**, **domain_id**, **type**, **source**, **georeference** + - **created_on** (creation timestamp is permanent) + + The **modified_on** field is automatically updated. + + ## Response + + Returns the updated feature resource. + + Args: + domain_id (str): + feature_id (str): + body (UpdateFeatureRequestBody): Request body for updating feature metadata. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Feature | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + feature_id=feature_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + feature_id: str, + *, + client: AuthenticatedClient, + body: UpdateFeatureRequestBody, +) -> Feature | HTTPValidationError | None: + """Update a feature + + # Update Feature Endpoint + + Updates the metadata of an existing feature resource. Only the fields + provided in the request body will be modified. + + ## Path Parameters + + - **domain_id**: (string) The domain the feature belongs to. + - **feature_id**: (string) The unique identifier of the feature. + + ## Request Body + + All fields are optional: + + - **name**: (string) New name for the feature. + - **description**: (string) New description. + - **tags**: (array of strings) New tags (replaces existing). + + ## What Cannot Be Updated + + The following fields are immutable: + + - **id**, **domain_id**, **type**, **source**, **georeference** + - **created_on** (creation timestamp is permanent) + + The **modified_on** field is automatically updated. + + ## Response + + Returns the updated feature resource. + + Args: + domain_id (str): + feature_id (str): + body (UpdateFeatureRequestBody): Request body for updating feature metadata. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Feature | HTTPValidationError + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + feature_id=feature_id, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/grids/__init__.py b/fastfuels_sdk/v2/client_library/api/grids/__init__.py new file mode 100644 index 0000000..2d7c0b2 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/grids/__init__.py @@ -0,0 +1 @@ +"""Contains endpoint functions for accessing the API""" diff --git a/fastfuels_sdk/v2/client_library/api/grids/check_3dep_coverage.py b/fastfuels_sdk/v2/client_library/api/grids/check_3dep_coverage.py new file mode 100644 index 0000000..c751d4b --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/grids/check_3dep_coverage.py @@ -0,0 +1,251 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.http_validation_error import HTTPValidationError +from ...models.three_dep_coverage_response import ThreeDepCoverageResponse +from ...models.three_dep_resolution import ThreeDepResolution +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + domain_id: str, + *, + resolution: ThreeDepResolution | Unset = UNSET, +) -> dict[str, Any]: + + params: dict[str, Any] = {} + + json_resolution: int | Unset = UNSET + if not isinstance(resolution, Unset): + json_resolution = resolution.value + + params["resolution"] = json_resolution + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/domains/{domain_id}/grids/topography/3dep/coverage".format( + domain_id=quote(str(domain_id), safe=""), + ), + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> HTTPValidationError | ThreeDepCoverageResponse | None: + if response.status_code == 200: + response_200 = ThreeDepCoverageResponse.from_dict(response.json()) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[HTTPValidationError | ThreeDepCoverageResponse]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + resolution: ThreeDepResolution | Unset = UNSET, +) -> Response[HTTPValidationError | ThreeDepCoverageResponse]: + """Check 3DEP tile coverage for a domain + + # Check 3DEP Tile Coverage + + Immediate pre-flight check that reports which 3DEP tiles are available + for the domain at the requested resolution. Use this before creating a + 3DEP grid to avoid waiting for async processing only to discover a + coverage gap — especially useful for 1m (S1M) data where coverage is + regional. + + ## Query Parameters + + - **resolution**: Resolution in meters: 1, 10, or 30. Default: 1. + + ## Response + + Returns tile availability, count, URLs, and (for 1m) acquisition dates. + + Args: + domain_id (str): + resolution (ThreeDepResolution | Unset): Available resolutions for 3DEP data (meters). + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | ThreeDepCoverageResponse] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + resolution=resolution, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + *, + client: AuthenticatedClient, + resolution: ThreeDepResolution | Unset = UNSET, +) -> HTTPValidationError | ThreeDepCoverageResponse | None: + """Check 3DEP tile coverage for a domain + + # Check 3DEP Tile Coverage + + Immediate pre-flight check that reports which 3DEP tiles are available + for the domain at the requested resolution. Use this before creating a + 3DEP grid to avoid waiting for async processing only to discover a + coverage gap — especially useful for 1m (S1M) data where coverage is + regional. + + ## Query Parameters + + - **resolution**: Resolution in meters: 1, 10, or 30. Default: 1. + + ## Response + + Returns tile availability, count, URLs, and (for 1m) acquisition dates. + + Args: + domain_id (str): + resolution (ThreeDepResolution | Unset): Available resolutions for 3DEP data (meters). + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | ThreeDepCoverageResponse + """ + + return sync_detailed( + domain_id=domain_id, + client=client, + resolution=resolution, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + resolution: ThreeDepResolution | Unset = UNSET, +) -> Response[HTTPValidationError | ThreeDepCoverageResponse]: + """Check 3DEP tile coverage for a domain + + # Check 3DEP Tile Coverage + + Immediate pre-flight check that reports which 3DEP tiles are available + for the domain at the requested resolution. Use this before creating a + 3DEP grid to avoid waiting for async processing only to discover a + coverage gap — especially useful for 1m (S1M) data where coverage is + regional. + + ## Query Parameters + + - **resolution**: Resolution in meters: 1, 10, or 30. Default: 1. + + ## Response + + Returns tile availability, count, URLs, and (for 1m) acquisition dates. + + Args: + domain_id (str): + resolution (ThreeDepResolution | Unset): Available resolutions for 3DEP data (meters). + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | ThreeDepCoverageResponse] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + resolution=resolution, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + *, + client: AuthenticatedClient, + resolution: ThreeDepResolution | Unset = UNSET, +) -> HTTPValidationError | ThreeDepCoverageResponse | None: + """Check 3DEP tile coverage for a domain + + # Check 3DEP Tile Coverage + + Immediate pre-flight check that reports which 3DEP tiles are available + for the domain at the requested resolution. Use this before creating a + 3DEP grid to avoid waiting for async processing only to discover a + coverage gap — especially useful for 1m (S1M) data where coverage is + regional. + + ## Query Parameters + + - **resolution**: Resolution in meters: 1, 10, or 30. Default: 1. + + ## Response + + Returns tile availability, count, URLs, and (for 1m) acquisition dates. + + Args: + domain_id (str): + resolution (ThreeDepResolution | Unset): Available resolutions for 3DEP data (meters). + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | ThreeDepCoverageResponse + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + client=client, + resolution=resolution, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/grids/create_3dep_topography.py b/fastfuels_sdk/v2/client_library/api/grids/create_3dep_topography.py new file mode 100644 index 0000000..62fcd5e --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/grids/create_3dep_topography.py @@ -0,0 +1,344 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.create_three_dep_topography_request import ( + CreateThreeDepTopographyRequest, +) +from ...models.grid import Grid +from ...models.http_validation_error import HTTPValidationError +from ...types import Response + + +def _get_kwargs( + domain_id: str, + *, + body: CreateThreeDepTopographyRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/domains/{domain_id}/grids/topography/3dep".format( + domain_id=quote(str(domain_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Grid | HTTPValidationError | None: + if response.status_code == 201: + response_201 = Grid.from_dict(response.json()) + + return response_201 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Grid | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateThreeDepTopographyRequest, +) -> Response[Grid | HTTPValidationError]: + r"""Create a grid from 3DEP topographic data + + # Create 3DEP Topography Grid + + Creates a grid with topographic data from USGS 3DEP at selectable resolution. + + Available resolutions: + - **1m**: Seamless 1-meter (S1M). Coverage varies by region; areas without + S1M data will return a COVERAGE_ERROR. + - **10m**: 1/3 arc-second seamless (default) + - **30m**: 1 arc-second seamless + + Available bands: + - **elevation**: meters above sea level (default) + - **slope**: degrees (0-90) + - **aspect**: degrees clockwise from north (0-360) + + Slope and aspect are computed locally from the DEM using Horn's method. + + ## Request Body + + - **source_resolution**: (optional) Source product family in meters: + 1, 10, or 30. Default: 10. To change the *output* cell size, set + ``alignment.resolution``. + - **bands**: (optional) Which bands to include. Default: elevation only. + - **alignment**: (optional) Output alignment target. See alignment docs. + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + + ## Response + + Returns the created Grid resource with status \"pending\". The backend will + fetch the data and update status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreateThreeDepTopographyRequest): Request to create a grid from 3DEP topographic + data. + + Returns a grid with one or more continuous bands: elevation (m), + slope (degrees), and/or aspect (degrees). + + `source_resolution` selects the 3DEP product family (1m, 10m, or 30m). + To change the *output* cell size, set ``alignment.resolution`` instead. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateThreeDepTopographyRequest, +) -> Grid | HTTPValidationError | None: + r"""Create a grid from 3DEP topographic data + + # Create 3DEP Topography Grid + + Creates a grid with topographic data from USGS 3DEP at selectable resolution. + + Available resolutions: + - **1m**: Seamless 1-meter (S1M). Coverage varies by region; areas without + S1M data will return a COVERAGE_ERROR. + - **10m**: 1/3 arc-second seamless (default) + - **30m**: 1 arc-second seamless + + Available bands: + - **elevation**: meters above sea level (default) + - **slope**: degrees (0-90) + - **aspect**: degrees clockwise from north (0-360) + + Slope and aspect are computed locally from the DEM using Horn's method. + + ## Request Body + + - **source_resolution**: (optional) Source product family in meters: + 1, 10, or 30. Default: 10. To change the *output* cell size, set + ``alignment.resolution``. + - **bands**: (optional) Which bands to include. Default: elevation only. + - **alignment**: (optional) Output alignment target. See alignment docs. + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + + ## Response + + Returns the created Grid resource with status \"pending\". The backend will + fetch the data and update status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreateThreeDepTopographyRequest): Request to create a grid from 3DEP topographic + data. + + Returns a grid with one or more continuous bands: elevation (m), + slope (degrees), and/or aspect (degrees). + + `source_resolution` selects the 3DEP product family (1m, 10m, or 30m). + To change the *output* cell size, set ``alignment.resolution`` instead. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError + """ + + return sync_detailed( + domain_id=domain_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateThreeDepTopographyRequest, +) -> Response[Grid | HTTPValidationError]: + r"""Create a grid from 3DEP topographic data + + # Create 3DEP Topography Grid + + Creates a grid with topographic data from USGS 3DEP at selectable resolution. + + Available resolutions: + - **1m**: Seamless 1-meter (S1M). Coverage varies by region; areas without + S1M data will return a COVERAGE_ERROR. + - **10m**: 1/3 arc-second seamless (default) + - **30m**: 1 arc-second seamless + + Available bands: + - **elevation**: meters above sea level (default) + - **slope**: degrees (0-90) + - **aspect**: degrees clockwise from north (0-360) + + Slope and aspect are computed locally from the DEM using Horn's method. + + ## Request Body + + - **source_resolution**: (optional) Source product family in meters: + 1, 10, or 30. Default: 10. To change the *output* cell size, set + ``alignment.resolution``. + - **bands**: (optional) Which bands to include. Default: elevation only. + - **alignment**: (optional) Output alignment target. See alignment docs. + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + + ## Response + + Returns the created Grid resource with status \"pending\". The backend will + fetch the data and update status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreateThreeDepTopographyRequest): Request to create a grid from 3DEP topographic + data. + + Returns a grid with one or more continuous bands: elevation (m), + slope (degrees), and/or aspect (degrees). + + `source_resolution` selects the 3DEP product family (1m, 10m, or 30m). + To change the *output* cell size, set ``alignment.resolution`` instead. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateThreeDepTopographyRequest, +) -> Grid | HTTPValidationError | None: + r"""Create a grid from 3DEP topographic data + + # Create 3DEP Topography Grid + + Creates a grid with topographic data from USGS 3DEP at selectable resolution. + + Available resolutions: + - **1m**: Seamless 1-meter (S1M). Coverage varies by region; areas without + S1M data will return a COVERAGE_ERROR. + - **10m**: 1/3 arc-second seamless (default) + - **30m**: 1 arc-second seamless + + Available bands: + - **elevation**: meters above sea level (default) + - **slope**: degrees (0-90) + - **aspect**: degrees clockwise from north (0-360) + + Slope and aspect are computed locally from the DEM using Horn's method. + + ## Request Body + + - **source_resolution**: (optional) Source product family in meters: + 1, 10, or 30. Default: 10. To change the *output* cell size, set + ``alignment.resolution``. + - **bands**: (optional) Which bands to include. Default: elevation only. + - **alignment**: (optional) Output alignment target. See alignment docs. + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + + ## Response + + Returns the created Grid resource with status \"pending\". The backend will + fetch the data and update status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreateThreeDepTopographyRequest): Request to create a grid from 3DEP topographic + data. + + Returns a grid with one or more continuous bands: elevation (m), + slope (degrees), and/or aspect (degrees). + + `source_resolution` selects the 3DEP product family (1m, 10m, or 30m). + To change the *output* cell size, set ``alignment.resolution`` instead. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/grids/create_fbfm40_lookup.py b/fastfuels_sdk/v2/client_library/api/grids/create_fbfm40_lookup.py new file mode 100644 index 0000000..cef9331 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/grids/create_fbfm40_lookup.py @@ -0,0 +1,410 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.create_fbfm_40_lookup_request import CreateFbfm40LookupRequest +from ...models.grid import Grid +from ...models.http_validation_error import HTTPValidationError +from ...types import Response + + +def _get_kwargs( + domain_id: str, + *, + body: CreateFbfm40LookupRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/domains/{domain_id}/grids/lookup/fbfm40".format( + domain_id=quote(str(domain_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Grid | HTTPValidationError | None: + if response.status_code == 201: + response_201 = Grid.from_dict(response.json()) + + return response_201 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Grid | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateFbfm40LookupRequest, +) -> Response[Grid | HTTPValidationError]: + r"""Create a grid by looking up FBFM40 fuel parameters + + # Create FBFM40 Lookup Grid + + Converts FBFM40 fuel model codes to fuel parameters using Scott-Burgan 40 + lookup tables. + + Takes a source grid containing categorical FBFM codes (from + `/grids/fbfm40/landfire`) and produces a new grid with the requested + continuous fuel parameters. + + ## Request Body + + - **source_grid_id**: (required) Grid containing FBFM40 codes. + - **bands**: (required) Bands to look up. Valid values: + - `fuel_load.1hr`, `fuel_load.10hr`, `fuel_load.100hr` - Dead fuel loads (kg/m**2) + - `fuel_load.live_herb`, `fuel_load.live_woody` - Live fuel loads (kg/m**2) + - `savr.1hr`, `savr.10hr`, `savr.100hr` - Dead fuel SAV ratios (1/m) + - `savr.live_herb`, `savr.live_woody` - Live fuel SAV ratios (1/m) + - `fuel_depth` - Fuel bed depth (m) + - **source_band**: (optional) Band in source grid containing FBFM codes. Defaults to `\"fbfm\"`. + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + + ## Valid FBFM40 Codes + + The source grid must contain only valid Scott-Burgan 40 fuel model codes. + The 46 valid codes are: + + - **NB** (non-burnable): 91, 92, 93, 98, 99 + - **GR** (grass): 101–109 + - **GS** (grass-shrub): 121–124 + - **SH** (shrub): 141–149 + - **TU** (timber-understory): 161–165 + - **TL** (timber litter): 181–189 + - **SB** (slash-blowdown): 201–204 + + If any cell in the source grid contains a code not in this set (including 0 + or nodata), the job will fail with an `INVALID_FBFM_CODES` error listing + the invalid codes found. + + ## Response + + Returns the created Grid with status \"pending\". The backend applies the + lookup transformation and updates status to \"completed\" when ready. + + ## Notes + + - Domain is propagated from the source grid (derived grids carry the + same domain reference as their source). + - The output grid inherits georeference from the source grid. + - Non-burnable codes (NB1–NB9) produce zero values for all bands. + - All output values are in metric units (converted from SB40 imperial values). + + Args: + domain_id (str): + body (CreateFbfm40LookupRequest): Request to create a grid by looking up FBFM40 fuel + parameters. + + Unlike entry-point grid creation requests, domain_id is not required + because derived grids carry the same domain reference as their source. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateFbfm40LookupRequest, +) -> Grid | HTTPValidationError | None: + r"""Create a grid by looking up FBFM40 fuel parameters + + # Create FBFM40 Lookup Grid + + Converts FBFM40 fuel model codes to fuel parameters using Scott-Burgan 40 + lookup tables. + + Takes a source grid containing categorical FBFM codes (from + `/grids/fbfm40/landfire`) and produces a new grid with the requested + continuous fuel parameters. + + ## Request Body + + - **source_grid_id**: (required) Grid containing FBFM40 codes. + - **bands**: (required) Bands to look up. Valid values: + - `fuel_load.1hr`, `fuel_load.10hr`, `fuel_load.100hr` - Dead fuel loads (kg/m**2) + - `fuel_load.live_herb`, `fuel_load.live_woody` - Live fuel loads (kg/m**2) + - `savr.1hr`, `savr.10hr`, `savr.100hr` - Dead fuel SAV ratios (1/m) + - `savr.live_herb`, `savr.live_woody` - Live fuel SAV ratios (1/m) + - `fuel_depth` - Fuel bed depth (m) + - **source_band**: (optional) Band in source grid containing FBFM codes. Defaults to `\"fbfm\"`. + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + + ## Valid FBFM40 Codes + + The source grid must contain only valid Scott-Burgan 40 fuel model codes. + The 46 valid codes are: + + - **NB** (non-burnable): 91, 92, 93, 98, 99 + - **GR** (grass): 101–109 + - **GS** (grass-shrub): 121–124 + - **SH** (shrub): 141–149 + - **TU** (timber-understory): 161–165 + - **TL** (timber litter): 181–189 + - **SB** (slash-blowdown): 201–204 + + If any cell in the source grid contains a code not in this set (including 0 + or nodata), the job will fail with an `INVALID_FBFM_CODES` error listing + the invalid codes found. + + ## Response + + Returns the created Grid with status \"pending\". The backend applies the + lookup transformation and updates status to \"completed\" when ready. + + ## Notes + + - Domain is propagated from the source grid (derived grids carry the + same domain reference as their source). + - The output grid inherits georeference from the source grid. + - Non-burnable codes (NB1–NB9) produce zero values for all bands. + - All output values are in metric units (converted from SB40 imperial values). + + Args: + domain_id (str): + body (CreateFbfm40LookupRequest): Request to create a grid by looking up FBFM40 fuel + parameters. + + Unlike entry-point grid creation requests, domain_id is not required + because derived grids carry the same domain reference as their source. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError + """ + + return sync_detailed( + domain_id=domain_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateFbfm40LookupRequest, +) -> Response[Grid | HTTPValidationError]: + r"""Create a grid by looking up FBFM40 fuel parameters + + # Create FBFM40 Lookup Grid + + Converts FBFM40 fuel model codes to fuel parameters using Scott-Burgan 40 + lookup tables. + + Takes a source grid containing categorical FBFM codes (from + `/grids/fbfm40/landfire`) and produces a new grid with the requested + continuous fuel parameters. + + ## Request Body + + - **source_grid_id**: (required) Grid containing FBFM40 codes. + - **bands**: (required) Bands to look up. Valid values: + - `fuel_load.1hr`, `fuel_load.10hr`, `fuel_load.100hr` - Dead fuel loads (kg/m**2) + - `fuel_load.live_herb`, `fuel_load.live_woody` - Live fuel loads (kg/m**2) + - `savr.1hr`, `savr.10hr`, `savr.100hr` - Dead fuel SAV ratios (1/m) + - `savr.live_herb`, `savr.live_woody` - Live fuel SAV ratios (1/m) + - `fuel_depth` - Fuel bed depth (m) + - **source_band**: (optional) Band in source grid containing FBFM codes. Defaults to `\"fbfm\"`. + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + + ## Valid FBFM40 Codes + + The source grid must contain only valid Scott-Burgan 40 fuel model codes. + The 46 valid codes are: + + - **NB** (non-burnable): 91, 92, 93, 98, 99 + - **GR** (grass): 101–109 + - **GS** (grass-shrub): 121–124 + - **SH** (shrub): 141–149 + - **TU** (timber-understory): 161–165 + - **TL** (timber litter): 181–189 + - **SB** (slash-blowdown): 201–204 + + If any cell in the source grid contains a code not in this set (including 0 + or nodata), the job will fail with an `INVALID_FBFM_CODES` error listing + the invalid codes found. + + ## Response + + Returns the created Grid with status \"pending\". The backend applies the + lookup transformation and updates status to \"completed\" when ready. + + ## Notes + + - Domain is propagated from the source grid (derived grids carry the + same domain reference as their source). + - The output grid inherits georeference from the source grid. + - Non-burnable codes (NB1–NB9) produce zero values for all bands. + - All output values are in metric units (converted from SB40 imperial values). + + Args: + domain_id (str): + body (CreateFbfm40LookupRequest): Request to create a grid by looking up FBFM40 fuel + parameters. + + Unlike entry-point grid creation requests, domain_id is not required + because derived grids carry the same domain reference as their source. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateFbfm40LookupRequest, +) -> Grid | HTTPValidationError | None: + r"""Create a grid by looking up FBFM40 fuel parameters + + # Create FBFM40 Lookup Grid + + Converts FBFM40 fuel model codes to fuel parameters using Scott-Burgan 40 + lookup tables. + + Takes a source grid containing categorical FBFM codes (from + `/grids/fbfm40/landfire`) and produces a new grid with the requested + continuous fuel parameters. + + ## Request Body + + - **source_grid_id**: (required) Grid containing FBFM40 codes. + - **bands**: (required) Bands to look up. Valid values: + - `fuel_load.1hr`, `fuel_load.10hr`, `fuel_load.100hr` - Dead fuel loads (kg/m**2) + - `fuel_load.live_herb`, `fuel_load.live_woody` - Live fuel loads (kg/m**2) + - `savr.1hr`, `savr.10hr`, `savr.100hr` - Dead fuel SAV ratios (1/m) + - `savr.live_herb`, `savr.live_woody` - Live fuel SAV ratios (1/m) + - `fuel_depth` - Fuel bed depth (m) + - **source_band**: (optional) Band in source grid containing FBFM codes. Defaults to `\"fbfm\"`. + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + + ## Valid FBFM40 Codes + + The source grid must contain only valid Scott-Burgan 40 fuel model codes. + The 46 valid codes are: + + - **NB** (non-burnable): 91, 92, 93, 98, 99 + - **GR** (grass): 101–109 + - **GS** (grass-shrub): 121–124 + - **SH** (shrub): 141–149 + - **TU** (timber-understory): 161–165 + - **TL** (timber litter): 181–189 + - **SB** (slash-blowdown): 201–204 + + If any cell in the source grid contains a code not in this set (including 0 + or nodata), the job will fail with an `INVALID_FBFM_CODES` error listing + the invalid codes found. + + ## Response + + Returns the created Grid with status \"pending\". The backend applies the + lookup transformation and updates status to \"completed\" when ready. + + ## Notes + + - Domain is propagated from the source grid (derived grids carry the + same domain reference as their source). + - The output grid inherits georeference from the source grid. + - Non-burnable codes (NB1–NB9) produce zero values for all bands. + - All output values are in metric units (converted from SB40 imperial values). + + Args: + domain_id (str): + body (CreateFbfm40LookupRequest): Request to create a grid by looking up FBFM40 fuel + parameters. + + Unlike entry-point grid creation requests, domain_id is not required + because derived grids carry the same domain reference as their source. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/grids/create_geotiff_upload.py b/fastfuels_sdk/v2/client_library/api/grids/create_geotiff_upload.py new file mode 100644 index 0000000..591bd2b --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/grids/create_geotiff_upload.py @@ -0,0 +1,310 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.create_geo_tiff_upload_request import CreateGeoTIFFUploadRequest +from ...models.grid_upload_created_response import GridUploadCreatedResponse +from ...models.http_validation_error import HTTPValidationError +from ...types import Response + + +def _get_kwargs( + domain_id: str, + *, + body: CreateGeoTIFFUploadRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/domains/{domain_id}/grids/upload/geotiff".format( + domain_id=quote(str(domain_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> GridUploadCreatedResponse | HTTPValidationError | None: + if response.status_code == 201: + response_201 = GridUploadCreatedResponse.from_dict(response.json()) + + return response_201 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[GridUploadCreatedResponse | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateGeoTIFFUploadRequest, +) -> Response[GridUploadCreatedResponse | HTTPValidationError]: + """Create a grid from a direct GeoTIFF upload + + # Create Upload Grid (GeoTIFF) + + Creates a grid resource and returns a signed URL for uploading a GeoTIFF + directly to GCS. The upload must use HTTP PUT with Content-Type: image/tiff. + + When the upload completes, the uploader service processes the file + automatically via Eventarc and updates the grid status to `completed` + (or `failed` on error). + + ## Band Definitions + + The `bands` array maps 1:1 to GeoTIFF raster bands in order: + `bands[0]` → GeoTIFF band 1, `bands[1]` → GeoTIFF band 2, etc. + Each band key becomes a variable name in the output Zarr store. + + ## CRS Handling + + The GeoTIFF must have a CRS set and must match the domain CRS. A mismatch + fails with `CRS_MISMATCH`; reproject the GeoTIFF (e.g., `gdalwarp -t_srs`) + before uploading. + + ## Buffer Cells + + `num_buffer_cells` (default 0) keeps extra cells around the domain extent + in the stored grid. The uploaded GeoTIFF must cover the domain bbox + expanded by `num_buffer_cells * native_pixel_size` on each side; pixels + beyond that expanded extent are clipped away. + + ## File requirements + + Single or multi-band GeoTIFF (`.tif`, `.tiff`). Maximum 1 GB. + + Args: + domain_id (str): + body (CreateGeoTIFFUploadRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[GridUploadCreatedResponse | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateGeoTIFFUploadRequest, +) -> GridUploadCreatedResponse | HTTPValidationError | None: + """Create a grid from a direct GeoTIFF upload + + # Create Upload Grid (GeoTIFF) + + Creates a grid resource and returns a signed URL for uploading a GeoTIFF + directly to GCS. The upload must use HTTP PUT with Content-Type: image/tiff. + + When the upload completes, the uploader service processes the file + automatically via Eventarc and updates the grid status to `completed` + (or `failed` on error). + + ## Band Definitions + + The `bands` array maps 1:1 to GeoTIFF raster bands in order: + `bands[0]` → GeoTIFF band 1, `bands[1]` → GeoTIFF band 2, etc. + Each band key becomes a variable name in the output Zarr store. + + ## CRS Handling + + The GeoTIFF must have a CRS set and must match the domain CRS. A mismatch + fails with `CRS_MISMATCH`; reproject the GeoTIFF (e.g., `gdalwarp -t_srs`) + before uploading. + + ## Buffer Cells + + `num_buffer_cells` (default 0) keeps extra cells around the domain extent + in the stored grid. The uploaded GeoTIFF must cover the domain bbox + expanded by `num_buffer_cells * native_pixel_size` on each side; pixels + beyond that expanded extent are clipped away. + + ## File requirements + + Single or multi-band GeoTIFF (`.tif`, `.tiff`). Maximum 1 GB. + + Args: + domain_id (str): + body (CreateGeoTIFFUploadRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + GridUploadCreatedResponse | HTTPValidationError + """ + + return sync_detailed( + domain_id=domain_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateGeoTIFFUploadRequest, +) -> Response[GridUploadCreatedResponse | HTTPValidationError]: + """Create a grid from a direct GeoTIFF upload + + # Create Upload Grid (GeoTIFF) + + Creates a grid resource and returns a signed URL for uploading a GeoTIFF + directly to GCS. The upload must use HTTP PUT with Content-Type: image/tiff. + + When the upload completes, the uploader service processes the file + automatically via Eventarc and updates the grid status to `completed` + (or `failed` on error). + + ## Band Definitions + + The `bands` array maps 1:1 to GeoTIFF raster bands in order: + `bands[0]` → GeoTIFF band 1, `bands[1]` → GeoTIFF band 2, etc. + Each band key becomes a variable name in the output Zarr store. + + ## CRS Handling + + The GeoTIFF must have a CRS set and must match the domain CRS. A mismatch + fails with `CRS_MISMATCH`; reproject the GeoTIFF (e.g., `gdalwarp -t_srs`) + before uploading. + + ## Buffer Cells + + `num_buffer_cells` (default 0) keeps extra cells around the domain extent + in the stored grid. The uploaded GeoTIFF must cover the domain bbox + expanded by `num_buffer_cells * native_pixel_size` on each side; pixels + beyond that expanded extent are clipped away. + + ## File requirements + + Single or multi-band GeoTIFF (`.tif`, `.tiff`). Maximum 1 GB. + + Args: + domain_id (str): + body (CreateGeoTIFFUploadRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[GridUploadCreatedResponse | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateGeoTIFFUploadRequest, +) -> GridUploadCreatedResponse | HTTPValidationError | None: + """Create a grid from a direct GeoTIFF upload + + # Create Upload Grid (GeoTIFF) + + Creates a grid resource and returns a signed URL for uploading a GeoTIFF + directly to GCS. The upload must use HTTP PUT with Content-Type: image/tiff. + + When the upload completes, the uploader service processes the file + automatically via Eventarc and updates the grid status to `completed` + (or `failed` on error). + + ## Band Definitions + + The `bands` array maps 1:1 to GeoTIFF raster bands in order: + `bands[0]` → GeoTIFF band 1, `bands[1]` → GeoTIFF band 2, etc. + Each band key becomes a variable name in the output Zarr store. + + ## CRS Handling + + The GeoTIFF must have a CRS set and must match the domain CRS. A mismatch + fails with `CRS_MISMATCH`; reproject the GeoTIFF (e.g., `gdalwarp -t_srs`) + before uploading. + + ## Buffer Cells + + `num_buffer_cells` (default 0) keeps extra cells around the domain extent + in the stored grid. The uploaded GeoTIFF must cover the domain bbox + expanded by `num_buffer_cells * native_pixel_size` on each side; pixels + beyond that expanded extent are clipped away. + + ## File requirements + + Single or multi-band GeoTIFF (`.tif`, `.tiff`). Maximum 1 GB. + + Args: + domain_id (str): + body (CreateGeoTIFFUploadRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + GridUploadCreatedResponse | HTTPValidationError + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/grids/create_grid_export.py b/fastfuels_sdk/v2/client_library/api/grids/create_grid_export.py new file mode 100644 index 0000000..6f83cc9 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/grids/create_grid_export.py @@ -0,0 +1,275 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.export import Export +from ...models.export_grid_request import ExportGridRequest +from ...models.grid_export_format import GridExportFormat +from ...models.http_validation_error import HTTPValidationError +from ...types import Response + + +def _get_kwargs( + domain_id: str, + grid_id: str, + format_: GridExportFormat, + *, + body: ExportGridRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/domains/{domain_id}/grids/{grid_id}/exports/{format_}".format( + domain_id=quote(str(domain_id), safe=""), + grid_id=quote(str(grid_id), safe=""), + format_=quote(str(format_), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Export | HTTPValidationError | None: + if response.status_code == 201: + response_201 = Export.from_dict(response.json()) + + return response_201 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Export | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + grid_id: str, + format_: GridExportFormat, + *, + client: AuthenticatedClient, + body: ExportGridRequest, +) -> Response[Export | HTTPValidationError]: + """Export a grid + + Export a grid to the specified format. + + Supported formats: `geotiff`, `zarr` (zipped), `netcdf` (CF-1.13). + `geotiff` supports 2D grids only; use `netcdf` or `zarr` for 3D voxel + grids. + + The grid must belong to this domain and have status `completed`. + If `bands` is specified, only those bands are included; otherwise + all bands are exported. + + Returns an Export resource with status `pending`. Poll + `GET /exports/{export_id}` until status is `completed` to get the + signed download URL. + + Args: + domain_id (str): + grid_id (str): + format_ (GridExportFormat): Supported grid export formats. + body (ExportGridRequest): Request body for creating a grid export. + + Used at: POST /domains/{domain_id}/grids/{grid_id}/exports/{format} + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Export | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + grid_id=grid_id, + format_=format_, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + grid_id: str, + format_: GridExportFormat, + *, + client: AuthenticatedClient, + body: ExportGridRequest, +) -> Export | HTTPValidationError | None: + """Export a grid + + Export a grid to the specified format. + + Supported formats: `geotiff`, `zarr` (zipped), `netcdf` (CF-1.13). + `geotiff` supports 2D grids only; use `netcdf` or `zarr` for 3D voxel + grids. + + The grid must belong to this domain and have status `completed`. + If `bands` is specified, only those bands are included; otherwise + all bands are exported. + + Returns an Export resource with status `pending`. Poll + `GET /exports/{export_id}` until status is `completed` to get the + signed download URL. + + Args: + domain_id (str): + grid_id (str): + format_ (GridExportFormat): Supported grid export formats. + body (ExportGridRequest): Request body for creating a grid export. + + Used at: POST /domains/{domain_id}/grids/{grid_id}/exports/{format} + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Export | HTTPValidationError + """ + + return sync_detailed( + domain_id=domain_id, + grid_id=grid_id, + format_=format_, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + grid_id: str, + format_: GridExportFormat, + *, + client: AuthenticatedClient, + body: ExportGridRequest, +) -> Response[Export | HTTPValidationError]: + """Export a grid + + Export a grid to the specified format. + + Supported formats: `geotiff`, `zarr` (zipped), `netcdf` (CF-1.13). + `geotiff` supports 2D grids only; use `netcdf` or `zarr` for 3D voxel + grids. + + The grid must belong to this domain and have status `completed`. + If `bands` is specified, only those bands are included; otherwise + all bands are exported. + + Returns an Export resource with status `pending`. Poll + `GET /exports/{export_id}` until status is `completed` to get the + signed download URL. + + Args: + domain_id (str): + grid_id (str): + format_ (GridExportFormat): Supported grid export formats. + body (ExportGridRequest): Request body for creating a grid export. + + Used at: POST /domains/{domain_id}/grids/{grid_id}/exports/{format} + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Export | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + grid_id=grid_id, + format_=format_, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + grid_id: str, + format_: GridExportFormat, + *, + client: AuthenticatedClient, + body: ExportGridRequest, +) -> Export | HTTPValidationError | None: + """Export a grid + + Export a grid to the specified format. + + Supported formats: `geotiff`, `zarr` (zipped), `netcdf` (CF-1.13). + `geotiff` supports 2D grids only; use `netcdf` or `zarr` for 3D voxel + grids. + + The grid must belong to this domain and have status `completed`. + If `bands` is specified, only those bands are included; otherwise + all bands are exported. + + Returns an Export resource with status `pending`. Poll + `GET /exports/{export_id}` until status is `completed` to get the + signed download URL. + + Args: + domain_id (str): + grid_id (str): + format_ (GridExportFormat): Supported grid export formats. + body (ExportGridRequest): Request body for creating a grid export. + + Used at: POST /domains/{domain_id}/grids/{grid_id}/exports/{format} + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Export | HTTPValidationError + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + grid_id=grid_id, + format_=format_, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/grids/create_landfire_canopy.py b/fastfuels_sdk/v2/client_library/api/grids/create_landfire_canopy.py new file mode 100644 index 0000000..9032b97 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/grids/create_landfire_canopy.py @@ -0,0 +1,330 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.create_landfire_canopy_request import CreateLandfireCanopyRequest +from ...models.grid import Grid +from ...models.http_validation_error import HTTPValidationError +from ...types import Response + + +def _get_kwargs( + domain_id: str, + *, + body: CreateLandfireCanopyRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/domains/{domain_id}/grids/canopy/landfire".format( + domain_id=quote(str(domain_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Grid | HTTPValidationError | None: + if response.status_code == 201: + response_201 = Grid.from_dict(response.json()) + + return response_201 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Grid | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateLandfireCanopyRequest, +) -> Response[Grid | HTTPValidationError]: + r"""Create a grid from LANDFIRE canopy data + + # Create LANDFIRE Canopy Grid + + Creates a grid with canopy fuel data from LANDFIRE at 30m resolution + (CONUS). + + Available bands: + - **chm**: canopy height in meters + - **cbd**: canopy bulk density in kg/m**3 + - **cbh**: canopy base height in meters + - **cc**: canopy cover in percent (0-100) + + By default all four bands are included. Use the `bands` field to select + a subset. + + ## Request Body + + - **bands**: (optional) Which bands to include. Default: all four. + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + - **version**: (optional) LANDFIRE version. Default: \"2024\". + + ## Response + + Returns the created Grid resource with status \"pending\". The backend will + fetch the data and update status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreateLandfireCanopyRequest): Request to create a grid from LANDFIRE canopy data. + + Returns a grid with one or more continuous canopy bands at 30m + resolution (CONUS): + - chm: Canopy height (m) + - cbd: Canopy bulk density (kg/m**3) + - cbh: Canopy base height (m) + - cc: Canopy cover (%) + + Bands are validated against the canopy band vocabulary and may not be + duplicated. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateLandfireCanopyRequest, +) -> Grid | HTTPValidationError | None: + r"""Create a grid from LANDFIRE canopy data + + # Create LANDFIRE Canopy Grid + + Creates a grid with canopy fuel data from LANDFIRE at 30m resolution + (CONUS). + + Available bands: + - **chm**: canopy height in meters + - **cbd**: canopy bulk density in kg/m**3 + - **cbh**: canopy base height in meters + - **cc**: canopy cover in percent (0-100) + + By default all four bands are included. Use the `bands` field to select + a subset. + + ## Request Body + + - **bands**: (optional) Which bands to include. Default: all four. + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + - **version**: (optional) LANDFIRE version. Default: \"2024\". + + ## Response + + Returns the created Grid resource with status \"pending\". The backend will + fetch the data and update status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreateLandfireCanopyRequest): Request to create a grid from LANDFIRE canopy data. + + Returns a grid with one or more continuous canopy bands at 30m + resolution (CONUS): + - chm: Canopy height (m) + - cbd: Canopy bulk density (kg/m**3) + - cbh: Canopy base height (m) + - cc: Canopy cover (%) + + Bands are validated against the canopy band vocabulary and may not be + duplicated. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError + """ + + return sync_detailed( + domain_id=domain_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateLandfireCanopyRequest, +) -> Response[Grid | HTTPValidationError]: + r"""Create a grid from LANDFIRE canopy data + + # Create LANDFIRE Canopy Grid + + Creates a grid with canopy fuel data from LANDFIRE at 30m resolution + (CONUS). + + Available bands: + - **chm**: canopy height in meters + - **cbd**: canopy bulk density in kg/m**3 + - **cbh**: canopy base height in meters + - **cc**: canopy cover in percent (0-100) + + By default all four bands are included. Use the `bands` field to select + a subset. + + ## Request Body + + - **bands**: (optional) Which bands to include. Default: all four. + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + - **version**: (optional) LANDFIRE version. Default: \"2024\". + + ## Response + + Returns the created Grid resource with status \"pending\". The backend will + fetch the data and update status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreateLandfireCanopyRequest): Request to create a grid from LANDFIRE canopy data. + + Returns a grid with one or more continuous canopy bands at 30m + resolution (CONUS): + - chm: Canopy height (m) + - cbd: Canopy bulk density (kg/m**3) + - cbh: Canopy base height (m) + - cc: Canopy cover (%) + + Bands are validated against the canopy band vocabulary and may not be + duplicated. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateLandfireCanopyRequest, +) -> Grid | HTTPValidationError | None: + r"""Create a grid from LANDFIRE canopy data + + # Create LANDFIRE Canopy Grid + + Creates a grid with canopy fuel data from LANDFIRE at 30m resolution + (CONUS). + + Available bands: + - **chm**: canopy height in meters + - **cbd**: canopy bulk density in kg/m**3 + - **cbh**: canopy base height in meters + - **cc**: canopy cover in percent (0-100) + + By default all four bands are included. Use the `bands` field to select + a subset. + + ## Request Body + + - **bands**: (optional) Which bands to include. Default: all four. + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + - **version**: (optional) LANDFIRE version. Default: \"2024\". + + ## Response + + Returns the created Grid resource with status \"pending\". The backend will + fetch the data and update status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreateLandfireCanopyRequest): Request to create a grid from LANDFIRE canopy data. + + Returns a grid with one or more continuous canopy bands at 30m + resolution (CONUS): + - chm: Canopy height (m) + - cbd: Canopy bulk density (kg/m**3) + - cbh: Canopy base height (m) + - cc: Canopy cover (%) + + Bands are validated against the canopy band vocabulary and may not be + duplicated. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/grids/create_landfire_fbfm40.py b/fastfuels_sdk/v2/client_library/api/grids/create_landfire_fbfm40.py new file mode 100644 index 0000000..dfffb44 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/grids/create_landfire_fbfm40.py @@ -0,0 +1,282 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.create_landfire_fbfm_40_request import CreateLandfireFbfm40Request +from ...models.grid import Grid +from ...models.http_validation_error import HTTPValidationError +from ...types import Response + + +def _get_kwargs( + domain_id: str, + *, + body: CreateLandfireFbfm40Request, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/domains/{domain_id}/grids/fbfm40/landfire".format( + domain_id=quote(str(domain_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Grid | HTTPValidationError | None: + if response.status_code == 201: + response_201 = Grid.from_dict(response.json()) + + return response_201 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Grid | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateLandfireFbfm40Request, +) -> Response[Grid | HTTPValidationError]: + r"""Create a grid from LANDFIRE FBFM40 + + # Create LANDFIRE FBFM40 Grid + + Creates a grid with FBFM40 fuel model codes from LANDFIRE at 30m resolution. + + The grid contains a single categorical band (`fbfm`) with Scott-Burgan 40 + fuel model codes (e.g., GR1, TL3, SH5). + + To convert fuel model codes to fuel parameters (fuel loads, SAV, depth), + use the `/grids/lookup/fbfm40` endpoint. + + ## Request Body + + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + - **version**: (optional) LANDFIRE version. Default: \"2024\". + + ## Response + + Returns the created Grid resource with status \"pending\". The backend will + fetch the data and update status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreateLandfireFbfm40Request): Request to create a grid from LANDFIRE FBFM40. + + Returns a single-band grid with categorical fuel model codes. + To convert codes to fuel parameters, use /grids/lookup/fbfm40. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateLandfireFbfm40Request, +) -> Grid | HTTPValidationError | None: + r"""Create a grid from LANDFIRE FBFM40 + + # Create LANDFIRE FBFM40 Grid + + Creates a grid with FBFM40 fuel model codes from LANDFIRE at 30m resolution. + + The grid contains a single categorical band (`fbfm`) with Scott-Burgan 40 + fuel model codes (e.g., GR1, TL3, SH5). + + To convert fuel model codes to fuel parameters (fuel loads, SAV, depth), + use the `/grids/lookup/fbfm40` endpoint. + + ## Request Body + + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + - **version**: (optional) LANDFIRE version. Default: \"2024\". + + ## Response + + Returns the created Grid resource with status \"pending\". The backend will + fetch the data and update status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreateLandfireFbfm40Request): Request to create a grid from LANDFIRE FBFM40. + + Returns a single-band grid with categorical fuel model codes. + To convert codes to fuel parameters, use /grids/lookup/fbfm40. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError + """ + + return sync_detailed( + domain_id=domain_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateLandfireFbfm40Request, +) -> Response[Grid | HTTPValidationError]: + r"""Create a grid from LANDFIRE FBFM40 + + # Create LANDFIRE FBFM40 Grid + + Creates a grid with FBFM40 fuel model codes from LANDFIRE at 30m resolution. + + The grid contains a single categorical band (`fbfm`) with Scott-Burgan 40 + fuel model codes (e.g., GR1, TL3, SH5). + + To convert fuel model codes to fuel parameters (fuel loads, SAV, depth), + use the `/grids/lookup/fbfm40` endpoint. + + ## Request Body + + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + - **version**: (optional) LANDFIRE version. Default: \"2024\". + + ## Response + + Returns the created Grid resource with status \"pending\". The backend will + fetch the data and update status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreateLandfireFbfm40Request): Request to create a grid from LANDFIRE FBFM40. + + Returns a single-band grid with categorical fuel model codes. + To convert codes to fuel parameters, use /grids/lookup/fbfm40. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateLandfireFbfm40Request, +) -> Grid | HTTPValidationError | None: + r"""Create a grid from LANDFIRE FBFM40 + + # Create LANDFIRE FBFM40 Grid + + Creates a grid with FBFM40 fuel model codes from LANDFIRE at 30m resolution. + + The grid contains a single categorical band (`fbfm`) with Scott-Burgan 40 + fuel model codes (e.g., GR1, TL3, SH5). + + To convert fuel model codes to fuel parameters (fuel loads, SAV, depth), + use the `/grids/lookup/fbfm40` endpoint. + + ## Request Body + + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + - **version**: (optional) LANDFIRE version. Default: \"2024\". + + ## Response + + Returns the created Grid resource with status \"pending\". The backend will + fetch the data and update status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreateLandfireFbfm40Request): Request to create a grid from LANDFIRE FBFM40. + + Returns a single-band grid with categorical fuel model codes. + To convert codes to fuel parameters, use /grids/lookup/fbfm40. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/grids/create_landfire_fccs.py b/fastfuels_sdk/v2/client_library/api/grids/create_landfire_fccs.py new file mode 100644 index 0000000..3ae3c88 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/grids/create_landfire_fccs.py @@ -0,0 +1,290 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.create_landfire_fccs_request import CreateLandfireFccsRequest +from ...models.grid import Grid +from ...models.http_validation_error import HTTPValidationError +from ...types import Response + + +def _get_kwargs( + domain_id: str, + *, + body: CreateLandfireFccsRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/domains/{domain_id}/grids/fccs/landfire".format( + domain_id=quote(str(domain_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Grid | HTTPValidationError | None: + if response.status_code == 201: + response_201 = Grid.from_dict(response.json()) + + return response_201 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Grid | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateLandfireFccsRequest, +) -> Response[Grid | HTTPValidationError]: + r"""Create a grid from LANDFIRE FCCS + + # Create LANDFIRE FCCS Grid + + Creates a grid with FCCS fuelbed IDs from LANDFIRE at 30m resolution. + + The grid contains a single categorical band (`fccs`) with fuel + classification system fuelbed IDs (e.g., 26, 598, 34721). + + To convert fuelbed IDs to fuel parameters (fuel loads, SAV, depth), + use the `/grids/lookup/fccs` endpoint. + + ## Request Body + + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + - **version**: (optional) LANDFIRE version. Default: \"2023\". + - **remove_bare_ground**: (optional) Remove bare ground cells (fuelbed ID 0), + replaced by neighboring majority. Default: False. + + ## Response + + Returns the created Grid resource with status \"pending\". The backend will + fetch the data and update status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreateLandfireFccsRequest): Request to create a grid from LANDFIRE FCCS. + + Returns a single-band grid with categorical fuelbed IDs. + To convert IDs to fuel parameters, use /grids/lookup/fccs. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateLandfireFccsRequest, +) -> Grid | HTTPValidationError | None: + r"""Create a grid from LANDFIRE FCCS + + # Create LANDFIRE FCCS Grid + + Creates a grid with FCCS fuelbed IDs from LANDFIRE at 30m resolution. + + The grid contains a single categorical band (`fccs`) with fuel + classification system fuelbed IDs (e.g., 26, 598, 34721). + + To convert fuelbed IDs to fuel parameters (fuel loads, SAV, depth), + use the `/grids/lookup/fccs` endpoint. + + ## Request Body + + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + - **version**: (optional) LANDFIRE version. Default: \"2023\". + - **remove_bare_ground**: (optional) Remove bare ground cells (fuelbed ID 0), + replaced by neighboring majority. Default: False. + + ## Response + + Returns the created Grid resource with status \"pending\". The backend will + fetch the data and update status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreateLandfireFccsRequest): Request to create a grid from LANDFIRE FCCS. + + Returns a single-band grid with categorical fuelbed IDs. + To convert IDs to fuel parameters, use /grids/lookup/fccs. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError + """ + + return sync_detailed( + domain_id=domain_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateLandfireFccsRequest, +) -> Response[Grid | HTTPValidationError]: + r"""Create a grid from LANDFIRE FCCS + + # Create LANDFIRE FCCS Grid + + Creates a grid with FCCS fuelbed IDs from LANDFIRE at 30m resolution. + + The grid contains a single categorical band (`fccs`) with fuel + classification system fuelbed IDs (e.g., 26, 598, 34721). + + To convert fuelbed IDs to fuel parameters (fuel loads, SAV, depth), + use the `/grids/lookup/fccs` endpoint. + + ## Request Body + + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + - **version**: (optional) LANDFIRE version. Default: \"2023\". + - **remove_bare_ground**: (optional) Remove bare ground cells (fuelbed ID 0), + replaced by neighboring majority. Default: False. + + ## Response + + Returns the created Grid resource with status \"pending\". The backend will + fetch the data and update status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreateLandfireFccsRequest): Request to create a grid from LANDFIRE FCCS. + + Returns a single-band grid with categorical fuelbed IDs. + To convert IDs to fuel parameters, use /grids/lookup/fccs. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateLandfireFccsRequest, +) -> Grid | HTTPValidationError | None: + r"""Create a grid from LANDFIRE FCCS + + # Create LANDFIRE FCCS Grid + + Creates a grid with FCCS fuelbed IDs from LANDFIRE at 30m resolution. + + The grid contains a single categorical band (`fccs`) with fuel + classification system fuelbed IDs (e.g., 26, 598, 34721). + + To convert fuelbed IDs to fuel parameters (fuel loads, SAV, depth), + use the `/grids/lookup/fccs` endpoint. + + ## Request Body + + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + - **version**: (optional) LANDFIRE version. Default: \"2023\". + - **remove_bare_ground**: (optional) Remove bare ground cells (fuelbed ID 0), + replaced by neighboring majority. Default: False. + + ## Response + + Returns the created Grid resource with status \"pending\". The backend will + fetch the data and update status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreateLandfireFccsRequest): Request to create a grid from LANDFIRE FCCS. + + Returns a single-band grid with categorical fuelbed IDs. + To convert IDs to fuel parameters, use /grids/lookup/fccs. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/grids/create_landfire_topography.py b/fastfuels_sdk/v2/client_library/api/grids/create_landfire_topography.py new file mode 100644 index 0000000..4b6240d --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/grids/create_landfire_topography.py @@ -0,0 +1,298 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.create_landfire_topography_request import CreateLandfireTopographyRequest +from ...models.grid import Grid +from ...models.http_validation_error import HTTPValidationError +from ...types import Response + + +def _get_kwargs( + domain_id: str, + *, + body: CreateLandfireTopographyRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/domains/{domain_id}/grids/topography/landfire".format( + domain_id=quote(str(domain_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Grid | HTTPValidationError | None: + if response.status_code == 201: + response_201 = Grid.from_dict(response.json()) + + return response_201 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Grid | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateLandfireTopographyRequest, +) -> Response[Grid | HTTPValidationError]: + r"""Create a grid from LANDFIRE topographic data + + # Create LANDFIRE Topography Grid + + Creates a grid with topographic data from LANDFIRE at 30m resolution. + + Available bands: + - **elevation**: meters above sea level + - **slope**: degrees (0-90) + - **aspect**: degrees clockwise from north (0-360) + + By default all three bands are included. Use the `bands` field to select + a subset. + + ## Request Body + + - **bands**: (optional) Which bands to include. Default: all three. + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + - **version**: (optional) LANDFIRE version. Default: \"2020\". + + ## Response + + Returns the created Grid resource with status \"pending\". The backend will + fetch the data and update status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreateLandfireTopographyRequest): Request to create a grid from LANDFIRE topographic + data. + + Returns a grid with one or more continuous bands: elevation (m), + slope (degrees), and/or aspect (degrees). + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateLandfireTopographyRequest, +) -> Grid | HTTPValidationError | None: + r"""Create a grid from LANDFIRE topographic data + + # Create LANDFIRE Topography Grid + + Creates a grid with topographic data from LANDFIRE at 30m resolution. + + Available bands: + - **elevation**: meters above sea level + - **slope**: degrees (0-90) + - **aspect**: degrees clockwise from north (0-360) + + By default all three bands are included. Use the `bands` field to select + a subset. + + ## Request Body + + - **bands**: (optional) Which bands to include. Default: all three. + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + - **version**: (optional) LANDFIRE version. Default: \"2020\". + + ## Response + + Returns the created Grid resource with status \"pending\". The backend will + fetch the data and update status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreateLandfireTopographyRequest): Request to create a grid from LANDFIRE topographic + data. + + Returns a grid with one or more continuous bands: elevation (m), + slope (degrees), and/or aspect (degrees). + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError + """ + + return sync_detailed( + domain_id=domain_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateLandfireTopographyRequest, +) -> Response[Grid | HTTPValidationError]: + r"""Create a grid from LANDFIRE topographic data + + # Create LANDFIRE Topography Grid + + Creates a grid with topographic data from LANDFIRE at 30m resolution. + + Available bands: + - **elevation**: meters above sea level + - **slope**: degrees (0-90) + - **aspect**: degrees clockwise from north (0-360) + + By default all three bands are included. Use the `bands` field to select + a subset. + + ## Request Body + + - **bands**: (optional) Which bands to include. Default: all three. + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + - **version**: (optional) LANDFIRE version. Default: \"2020\". + + ## Response + + Returns the created Grid resource with status \"pending\". The backend will + fetch the data and update status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreateLandfireTopographyRequest): Request to create a grid from LANDFIRE topographic + data. + + Returns a grid with one or more continuous bands: elevation (m), + slope (degrees), and/or aspect (degrees). + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateLandfireTopographyRequest, +) -> Grid | HTTPValidationError | None: + r"""Create a grid from LANDFIRE topographic data + + # Create LANDFIRE Topography Grid + + Creates a grid with topographic data from LANDFIRE at 30m resolution. + + Available bands: + - **elevation**: meters above sea level + - **slope**: degrees (0-90) + - **aspect**: degrees clockwise from north (0-360) + + By default all three bands are included. Use the `bands` field to select + a subset. + + ## Request Body + + - **bands**: (optional) Which bands to include. Default: all three. + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + - **version**: (optional) LANDFIRE version. Default: \"2020\". + + ## Response + + Returns the created Grid resource with status \"pending\". The backend will + fetch the data and update status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreateLandfireTopographyRequest): Request to create a grid from LANDFIRE topographic + data. + + Returns a grid with one or more continuous bands: elevation (m), + slope (degrees), and/or aspect (degrees). + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/grids/create_layerset_rasterize.py b/fastfuels_sdk/v2/client_library/api/grids/create_layerset_rasterize.py new file mode 100644 index 0000000..45ff27b --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/grids/create_layerset_rasterize.py @@ -0,0 +1,314 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.create_layerset_rasterize_request import CreateLayersetRasterizeRequest +from ...models.grid import Grid +from ...models.http_validation_error import HTTPValidationError +from ...types import Response + + +def _get_kwargs( + domain_id: str, + *, + body: CreateLayersetRasterizeRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/domains/{domain_id}/grids/rasterize/layerset".format( + domain_id=quote(str(domain_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Grid | HTTPValidationError | None: + if response.status_code == 201: + response_201 = Grid.from_dict(response.json()) + + return response_201 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Grid | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateLayersetRasterizeRequest, +) -> Response[Grid | HTTPValidationError]: + """Create a grid by rasterizing a layerset + + # Create Layerset-Rasterized Grid + + Rasterizes a previously-uploaded fuelbed layerset into a grid aligned + to the domain (default) or to a target grid. + + The `layerset_id` must reference a Feature uploaded for this domain via + `POST /domains/{domain_id}/features/layerset` and owned by the caller. + + ## Request Body + + - **layerset_id**: (required) Feature ID of the layerset to rasterize. + - **overlap_method**: (optional) Per-cell reduction when polygons of the + same `fuel_type` overlap a single cell. One of `mean`, `max`, `min`. + Default: `mean`. (Loading is always summed across overlapping polygons + regardless of this setting.) + - **alignment**: (optional) See alignment docs. Default: anchored to domain. + - **extent_buffer_cells**: (optional) Buffer in result-grid cells around + the domain extent. Cells inside the buffered extent that fall outside + polygon coverage are populated with the rasterizer's fill value. + - **name**, **description**, **tags**, **modifications**: standard grid metadata. + + ## Response + + Returns the created Grid resource with status `pending`. The backend + fetches the layerset GeoJSON from GCS, rasterizes it, and updates the + status to `completed` when ready. + + Args: + domain_id (str): + body (CreateLayersetRasterizeRequest): Request to create a grid by rasterizing a + previously-uploaded layerset. + + The referenced layerset must be an existing Feature owned by the caller, + uploaded via ``POST /domains/{id}/features/layerset``. The worker fetches + the GeoJSON from GCS at job time; a fresh upload produces a new + ``feature_id``, so the reference is effectively immutable. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateLayersetRasterizeRequest, +) -> Grid | HTTPValidationError | None: + """Create a grid by rasterizing a layerset + + # Create Layerset-Rasterized Grid + + Rasterizes a previously-uploaded fuelbed layerset into a grid aligned + to the domain (default) or to a target grid. + + The `layerset_id` must reference a Feature uploaded for this domain via + `POST /domains/{domain_id}/features/layerset` and owned by the caller. + + ## Request Body + + - **layerset_id**: (required) Feature ID of the layerset to rasterize. + - **overlap_method**: (optional) Per-cell reduction when polygons of the + same `fuel_type` overlap a single cell. One of `mean`, `max`, `min`. + Default: `mean`. (Loading is always summed across overlapping polygons + regardless of this setting.) + - **alignment**: (optional) See alignment docs. Default: anchored to domain. + - **extent_buffer_cells**: (optional) Buffer in result-grid cells around + the domain extent. Cells inside the buffered extent that fall outside + polygon coverage are populated with the rasterizer's fill value. + - **name**, **description**, **tags**, **modifications**: standard grid metadata. + + ## Response + + Returns the created Grid resource with status `pending`. The backend + fetches the layerset GeoJSON from GCS, rasterizes it, and updates the + status to `completed` when ready. + + Args: + domain_id (str): + body (CreateLayersetRasterizeRequest): Request to create a grid by rasterizing a + previously-uploaded layerset. + + The referenced layerset must be an existing Feature owned by the caller, + uploaded via ``POST /domains/{id}/features/layerset``. The worker fetches + the GeoJSON from GCS at job time; a fresh upload produces a new + ``feature_id``, so the reference is effectively immutable. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError + """ + + return sync_detailed( + domain_id=domain_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateLayersetRasterizeRequest, +) -> Response[Grid | HTTPValidationError]: + """Create a grid by rasterizing a layerset + + # Create Layerset-Rasterized Grid + + Rasterizes a previously-uploaded fuelbed layerset into a grid aligned + to the domain (default) or to a target grid. + + The `layerset_id` must reference a Feature uploaded for this domain via + `POST /domains/{domain_id}/features/layerset` and owned by the caller. + + ## Request Body + + - **layerset_id**: (required) Feature ID of the layerset to rasterize. + - **overlap_method**: (optional) Per-cell reduction when polygons of the + same `fuel_type` overlap a single cell. One of `mean`, `max`, `min`. + Default: `mean`. (Loading is always summed across overlapping polygons + regardless of this setting.) + - **alignment**: (optional) See alignment docs. Default: anchored to domain. + - **extent_buffer_cells**: (optional) Buffer in result-grid cells around + the domain extent. Cells inside the buffered extent that fall outside + polygon coverage are populated with the rasterizer's fill value. + - **name**, **description**, **tags**, **modifications**: standard grid metadata. + + ## Response + + Returns the created Grid resource with status `pending`. The backend + fetches the layerset GeoJSON from GCS, rasterizes it, and updates the + status to `completed` when ready. + + Args: + domain_id (str): + body (CreateLayersetRasterizeRequest): Request to create a grid by rasterizing a + previously-uploaded layerset. + + The referenced layerset must be an existing Feature owned by the caller, + uploaded via ``POST /domains/{id}/features/layerset``. The worker fetches + the GeoJSON from GCS at job time; a fresh upload produces a new + ``feature_id``, so the reference is effectively immutable. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateLayersetRasterizeRequest, +) -> Grid | HTTPValidationError | None: + """Create a grid by rasterizing a layerset + + # Create Layerset-Rasterized Grid + + Rasterizes a previously-uploaded fuelbed layerset into a grid aligned + to the domain (default) or to a target grid. + + The `layerset_id` must reference a Feature uploaded for this domain via + `POST /domains/{domain_id}/features/layerset` and owned by the caller. + + ## Request Body + + - **layerset_id**: (required) Feature ID of the layerset to rasterize. + - **overlap_method**: (optional) Per-cell reduction when polygons of the + same `fuel_type` overlap a single cell. One of `mean`, `max`, `min`. + Default: `mean`. (Loading is always summed across overlapping polygons + regardless of this setting.) + - **alignment**: (optional) See alignment docs. Default: anchored to domain. + - **extent_buffer_cells**: (optional) Buffer in result-grid cells around + the domain extent. Cells inside the buffered extent that fall outside + polygon coverage are populated with the rasterizer's fill value. + - **name**, **description**, **tags**, **modifications**: standard grid metadata. + + ## Response + + Returns the created Grid resource with status `pending`. The backend + fetches the layerset GeoJSON from GCS, rasterizes it, and updates the + status to `completed` when ready. + + Args: + domain_id (str): + body (CreateLayersetRasterizeRequest): Request to create a grid by rasterizing a + previously-uploaded layerset. + + The referenced layerset must be an existing Feature owned by the caller, + uploaded via ``POST /domains/{id}/features/layerset``. The worker fetches + the GeoJSON from GCS at job time; a fresh upload produces a new + ``feature_id``, so the reference is effectively immutable. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/grids/create_meta_chm.py b/fastfuels_sdk/v2/client_library/api/grids/create_meta_chm.py new file mode 100644 index 0000000..daa540e --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/grids/create_meta_chm.py @@ -0,0 +1,274 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.create_meta_chm_request import CreateMetaChmRequest +from ...models.grid import Grid +from ...models.http_validation_error import HTTPValidationError +from ...types import Response + + +def _get_kwargs( + domain_id: str, + *, + body: CreateMetaChmRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/domains/{domain_id}/grids/canopy/meta".format( + domain_id=quote(str(domain_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Grid | HTTPValidationError | None: + if response.status_code == 201: + response_201 = Grid.from_dict(response.json()) + + return response_201 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Grid | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateMetaChmRequest, +) -> Response[Grid | HTTPValidationError]: + r"""Create a grid from Meta CHM + + # Create Meta CHM Grid + + Creates a grid with canopy height data from Meta's global canopy height + model at ~1m resolution. + + ## Request Body + + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + - **version**: (optional) Meta CHM version. Default: \"2\". + - **1**: Tolan, J. et al. (2024). Very high resolution canopy height maps from RGB imagery. + - **2**: Brandt, J. et al. (2026). CHMv2: Improvements in Global Canopy Height Mapping using + DINOv3. + + ## Response + + Returns the created Grid resource with status \"pending\". The backend will + fetch the data and update status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreateMetaChmRequest): Request to create a grid from Meta CHM. + + Returns a grid with a single continuous band: + - chm: Canopy height in meters + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateMetaChmRequest, +) -> Grid | HTTPValidationError | None: + r"""Create a grid from Meta CHM + + # Create Meta CHM Grid + + Creates a grid with canopy height data from Meta's global canopy height + model at ~1m resolution. + + ## Request Body + + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + - **version**: (optional) Meta CHM version. Default: \"2\". + - **1**: Tolan, J. et al. (2024). Very high resolution canopy height maps from RGB imagery. + - **2**: Brandt, J. et al. (2026). CHMv2: Improvements in Global Canopy Height Mapping using + DINOv3. + + ## Response + + Returns the created Grid resource with status \"pending\". The backend will + fetch the data and update status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreateMetaChmRequest): Request to create a grid from Meta CHM. + + Returns a grid with a single continuous band: + - chm: Canopy height in meters + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError + """ + + return sync_detailed( + domain_id=domain_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateMetaChmRequest, +) -> Response[Grid | HTTPValidationError]: + r"""Create a grid from Meta CHM + + # Create Meta CHM Grid + + Creates a grid with canopy height data from Meta's global canopy height + model at ~1m resolution. + + ## Request Body + + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + - **version**: (optional) Meta CHM version. Default: \"2\". + - **1**: Tolan, J. et al. (2024). Very high resolution canopy height maps from RGB imagery. + - **2**: Brandt, J. et al. (2026). CHMv2: Improvements in Global Canopy Height Mapping using + DINOv3. + + ## Response + + Returns the created Grid resource with status \"pending\". The backend will + fetch the data and update status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreateMetaChmRequest): Request to create a grid from Meta CHM. + + Returns a grid with a single continuous band: + - chm: Canopy height in meters + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateMetaChmRequest, +) -> Grid | HTTPValidationError | None: + r"""Create a grid from Meta CHM + + # Create Meta CHM Grid + + Creates a grid with canopy height data from Meta's global canopy height + model at ~1m resolution. + + ## Request Body + + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + - **version**: (optional) Meta CHM version. Default: \"2\". + - **1**: Tolan, J. et al. (2024). Very high resolution canopy height maps from RGB imagery. + - **2**: Brandt, J. et al. (2026). CHMv2: Improvements in Global Canopy Height Mapping using + DINOv3. + + ## Response + + Returns the created Grid resource with status \"pending\". The backend will + fetch the data and update status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreateMetaChmRequest): Request to create a grid from Meta CHM. + + Returns a grid with a single continuous band: + - chm: Canopy height in meters + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/grids/create_naip_chm.py b/fastfuels_sdk/v2/client_library/api/grids/create_naip_chm.py new file mode 100644 index 0000000..f792d11 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/grids/create_naip_chm.py @@ -0,0 +1,254 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.create_naip_chm_request import CreateNaipChmRequest +from ...models.grid import Grid +from ...models.http_validation_error import HTTPValidationError +from ...types import Response + + +def _get_kwargs( + domain_id: str, + *, + body: CreateNaipChmRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/domains/{domain_id}/grids/canopy/naip".format( + domain_id=quote(str(domain_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Grid | HTTPValidationError | None: + if response.status_code == 201: + response_201 = Grid.from_dict(response.json()) + + return response_201 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Grid | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateNaipChmRequest, +) -> Response[Grid | HTTPValidationError]: + r"""Create a grid from NAIP CHM + + # Create NAIP CHM Grid + + Creates a grid with canopy height data from the NAIP high-resolution + canopy height model at ~0.6m resolution (CONUS). + + ## Request Body + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + + ## Response + + Returns the created Grid resource with status \"pending\". The backend will + fetch the data and update status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreateNaipChmRequest): Request to create a grid from NAIP CHM. + + Returns a grid with a single continuous band: + - chm: Canopy height in meters + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateNaipChmRequest, +) -> Grid | HTTPValidationError | None: + r"""Create a grid from NAIP CHM + + # Create NAIP CHM Grid + + Creates a grid with canopy height data from the NAIP high-resolution + canopy height model at ~0.6m resolution (CONUS). + + ## Request Body + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + + ## Response + + Returns the created Grid resource with status \"pending\". The backend will + fetch the data and update status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreateNaipChmRequest): Request to create a grid from NAIP CHM. + + Returns a grid with a single continuous band: + - chm: Canopy height in meters + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError + """ + + return sync_detailed( + domain_id=domain_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateNaipChmRequest, +) -> Response[Grid | HTTPValidationError]: + r"""Create a grid from NAIP CHM + + # Create NAIP CHM Grid + + Creates a grid with canopy height data from the NAIP high-resolution + canopy height model at ~0.6m resolution (CONUS). + + ## Request Body + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + + ## Response + + Returns the created Grid resource with status \"pending\". The backend will + fetch the data and update status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreateNaipChmRequest): Request to create a grid from NAIP CHM. + + Returns a grid with a single continuous band: + - chm: Canopy height in meters + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateNaipChmRequest, +) -> Grid | HTTPValidationError | None: + r"""Create a grid from NAIP CHM + + # Create NAIP CHM Grid + + Creates a grid with canopy height data from the NAIP high-resolution + canopy height model at ~0.6m resolution (CONUS). + + ## Request Body + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + + ## Response + + Returns the created Grid resource with status \"pending\". The backend will + fetch the data and update status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreateNaipChmRequest): Request to create a grid from NAIP CHM. + + Returns a grid with a single continuous band: + - chm: Canopy height in meters + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/grids/create_netcdf_upload.py b/fastfuels_sdk/v2/client_library/api/grids/create_netcdf_upload.py new file mode 100644 index 0000000..335cbfb --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/grids/create_netcdf_upload.py @@ -0,0 +1,402 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.create_netcdf_upload_request import CreateNetcdfUploadRequest +from ...models.grid_upload_created_response import GridUploadCreatedResponse +from ...models.http_validation_error import HTTPValidationError +from ...types import Response + + +def _get_kwargs( + domain_id: str, + *, + body: CreateNetcdfUploadRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/domains/{domain_id}/grids/upload/netcdf".format( + domain_id=quote(str(domain_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> GridUploadCreatedResponse | HTTPValidationError | None: + if response.status_code == 201: + response_201 = GridUploadCreatedResponse.from_dict(response.json()) + + return response_201 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[GridUploadCreatedResponse | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateNetcdfUploadRequest, +) -> Response[GridUploadCreatedResponse | HTTPValidationError]: + r"""Create a grid from a direct netCDF upload + + # Create Upload Grid (netCDF) + + Creates a grid resource and returns a signed URL for uploading a + CF-conformant netCDF directly to GCS. The upload must use HTTP PUT + with `Content-Type: application/x-netcdf`. + + When the upload completes, the uploader service processes the file + automatically via Eventarc and updates the grid status to `completed` + (or `failed` on error). + + ## Bands + + Unlike the GeoTIFF route, the request body has **no `bands` field**. + netCDF data variable names are the canonical band keys — they are + extracted directly from the file and become the variable names in the + output Zarr store. Per-band `units` (if set on the variable) and dtype + drive the stored band metadata. + + ## Dimensions + + Each data variable must have dims exactly `(\"y\",\"x\")` (2D) or + `(\"z\",\"y\",\"x\")` (3D) in that order. Mixed-rank datasets are rejected + with `WRONG_DIMS`. + + ## CRS + + The dataset must carry a CF `grid_mapping` (typically `spatial_ref`) + that matches the domain CRS. Missing CRS fails with `MISSING_CRS`; + mismatched CRS fails with `CRS_MISMATCH`. No auto-reproject. + + ## Units + + If a data variable has a `units` attribute it must be in canonical + UDUNITS-2 ASCII form with `**` exponents (e.g. `kg/m**3`, `1/m`, `%`). + Non-canonical forms (`kg/m³`, `kg/m^3`, `kg/m3`) fail with + `INVALID_UNITS`. See docs/units.md. + + ## Z axis (3D only) + + - `z.attrs[\"positive\"]` must equal `\"up\"`. `\"down\"` is rejected + (`MISSING_Z_POSITIVE`). + - z-coordinates must be uniformly spaced. Non-uniform spacing is + rejected with `NONUNIFORM_Z`. + + ## Buffer cells + + `num_buffer_cells` (default 0) keeps extra cells around the domain + extent in the stored grid. The uploaded netCDF must cover the domain + bbox expanded by `num_buffer_cells * native_pixel_size` on each side; + pixels beyond that expanded extent are clipped away. + + ## File requirements + + CF-conformant netCDF (`.nc`). Maximum 1 GB. + + Args: + domain_id (str): + body (CreateNetcdfUploadRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[GridUploadCreatedResponse | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateNetcdfUploadRequest, +) -> GridUploadCreatedResponse | HTTPValidationError | None: + r"""Create a grid from a direct netCDF upload + + # Create Upload Grid (netCDF) + + Creates a grid resource and returns a signed URL for uploading a + CF-conformant netCDF directly to GCS. The upload must use HTTP PUT + with `Content-Type: application/x-netcdf`. + + When the upload completes, the uploader service processes the file + automatically via Eventarc and updates the grid status to `completed` + (or `failed` on error). + + ## Bands + + Unlike the GeoTIFF route, the request body has **no `bands` field**. + netCDF data variable names are the canonical band keys — they are + extracted directly from the file and become the variable names in the + output Zarr store. Per-band `units` (if set on the variable) and dtype + drive the stored band metadata. + + ## Dimensions + + Each data variable must have dims exactly `(\"y\",\"x\")` (2D) or + `(\"z\",\"y\",\"x\")` (3D) in that order. Mixed-rank datasets are rejected + with `WRONG_DIMS`. + + ## CRS + + The dataset must carry a CF `grid_mapping` (typically `spatial_ref`) + that matches the domain CRS. Missing CRS fails with `MISSING_CRS`; + mismatched CRS fails with `CRS_MISMATCH`. No auto-reproject. + + ## Units + + If a data variable has a `units` attribute it must be in canonical + UDUNITS-2 ASCII form with `**` exponents (e.g. `kg/m**3`, `1/m`, `%`). + Non-canonical forms (`kg/m³`, `kg/m^3`, `kg/m3`) fail with + `INVALID_UNITS`. See docs/units.md. + + ## Z axis (3D only) + + - `z.attrs[\"positive\"]` must equal `\"up\"`. `\"down\"` is rejected + (`MISSING_Z_POSITIVE`). + - z-coordinates must be uniformly spaced. Non-uniform spacing is + rejected with `NONUNIFORM_Z`. + + ## Buffer cells + + `num_buffer_cells` (default 0) keeps extra cells around the domain + extent in the stored grid. The uploaded netCDF must cover the domain + bbox expanded by `num_buffer_cells * native_pixel_size` on each side; + pixels beyond that expanded extent are clipped away. + + ## File requirements + + CF-conformant netCDF (`.nc`). Maximum 1 GB. + + Args: + domain_id (str): + body (CreateNetcdfUploadRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + GridUploadCreatedResponse | HTTPValidationError + """ + + return sync_detailed( + domain_id=domain_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateNetcdfUploadRequest, +) -> Response[GridUploadCreatedResponse | HTTPValidationError]: + r"""Create a grid from a direct netCDF upload + + # Create Upload Grid (netCDF) + + Creates a grid resource and returns a signed URL for uploading a + CF-conformant netCDF directly to GCS. The upload must use HTTP PUT + with `Content-Type: application/x-netcdf`. + + When the upload completes, the uploader service processes the file + automatically via Eventarc and updates the grid status to `completed` + (or `failed` on error). + + ## Bands + + Unlike the GeoTIFF route, the request body has **no `bands` field**. + netCDF data variable names are the canonical band keys — they are + extracted directly from the file and become the variable names in the + output Zarr store. Per-band `units` (if set on the variable) and dtype + drive the stored band metadata. + + ## Dimensions + + Each data variable must have dims exactly `(\"y\",\"x\")` (2D) or + `(\"z\",\"y\",\"x\")` (3D) in that order. Mixed-rank datasets are rejected + with `WRONG_DIMS`. + + ## CRS + + The dataset must carry a CF `grid_mapping` (typically `spatial_ref`) + that matches the domain CRS. Missing CRS fails with `MISSING_CRS`; + mismatched CRS fails with `CRS_MISMATCH`. No auto-reproject. + + ## Units + + If a data variable has a `units` attribute it must be in canonical + UDUNITS-2 ASCII form with `**` exponents (e.g. `kg/m**3`, `1/m`, `%`). + Non-canonical forms (`kg/m³`, `kg/m^3`, `kg/m3`) fail with + `INVALID_UNITS`. See docs/units.md. + + ## Z axis (3D only) + + - `z.attrs[\"positive\"]` must equal `\"up\"`. `\"down\"` is rejected + (`MISSING_Z_POSITIVE`). + - z-coordinates must be uniformly spaced. Non-uniform spacing is + rejected with `NONUNIFORM_Z`. + + ## Buffer cells + + `num_buffer_cells` (default 0) keeps extra cells around the domain + extent in the stored grid. The uploaded netCDF must cover the domain + bbox expanded by `num_buffer_cells * native_pixel_size` on each side; + pixels beyond that expanded extent are clipped away. + + ## File requirements + + CF-conformant netCDF (`.nc`). Maximum 1 GB. + + Args: + domain_id (str): + body (CreateNetcdfUploadRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[GridUploadCreatedResponse | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateNetcdfUploadRequest, +) -> GridUploadCreatedResponse | HTTPValidationError | None: + r"""Create a grid from a direct netCDF upload + + # Create Upload Grid (netCDF) + + Creates a grid resource and returns a signed URL for uploading a + CF-conformant netCDF directly to GCS. The upload must use HTTP PUT + with `Content-Type: application/x-netcdf`. + + When the upload completes, the uploader service processes the file + automatically via Eventarc and updates the grid status to `completed` + (or `failed` on error). + + ## Bands + + Unlike the GeoTIFF route, the request body has **no `bands` field**. + netCDF data variable names are the canonical band keys — they are + extracted directly from the file and become the variable names in the + output Zarr store. Per-band `units` (if set on the variable) and dtype + drive the stored band metadata. + + ## Dimensions + + Each data variable must have dims exactly `(\"y\",\"x\")` (2D) or + `(\"z\",\"y\",\"x\")` (3D) in that order. Mixed-rank datasets are rejected + with `WRONG_DIMS`. + + ## CRS + + The dataset must carry a CF `grid_mapping` (typically `spatial_ref`) + that matches the domain CRS. Missing CRS fails with `MISSING_CRS`; + mismatched CRS fails with `CRS_MISMATCH`. No auto-reproject. + + ## Units + + If a data variable has a `units` attribute it must be in canonical + UDUNITS-2 ASCII form with `**` exponents (e.g. `kg/m**3`, `1/m`, `%`). + Non-canonical forms (`kg/m³`, `kg/m^3`, `kg/m3`) fail with + `INVALID_UNITS`. See docs/units.md. + + ## Z axis (3D only) + + - `z.attrs[\"positive\"]` must equal `\"up\"`. `\"down\"` is rejected + (`MISSING_Z_POSITIVE`). + - z-coordinates must be uniformly spaced. Non-uniform spacing is + rejected with `NONUNIFORM_Z`. + + ## Buffer cells + + `num_buffer_cells` (default 0) keeps extra cells around the domain + extent in the stored grid. The uploaded netCDF must cover the domain + bbox expanded by `num_buffer_cells * native_pixel_size` on each side; + pixels beyond that expanded extent are clipped away. + + ## File requirements + + CF-conformant netCDF (`.nc`). Maximum 1 GB. + + Args: + domain_id (str): + body (CreateNetcdfUploadRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + GridUploadCreatedResponse | HTTPValidationError + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/grids/create_quicfire_export.py b/fastfuels_sdk/v2/client_library/api/grids/create_quicfire_export.py new file mode 100644 index 0000000..654304e --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/grids/create_quicfire_export.py @@ -0,0 +1,274 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.export import Export +from ...models.http_validation_error import HTTPValidationError +from ...models.quicfire_export_request import QuicfireExportRequest +from ...types import Response + + +def _get_kwargs( + domain_id: str, + *, + body: QuicfireExportRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/domains/{domain_id}/grids/exports/quicfire".format( + domain_id=quote(str(domain_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Export | HTTPValidationError | None: + if response.status_code == 201: + response_201 = Export.from_dict(response.json()) + + return response_201 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Export | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: QuicfireExportRequest, +) -> Response[Export | HTTPValidationError]: + """Export combined fuel + topography grids to QUIC-Fire format + + Bundle surface fuel + canopy fuel + (optional) topography grids into a + QUIC-Fire-loadable zip archive. + + The output zip contains `treesrhof.dat`, `treesmoist.dat`, + `treesfueldepth.dat`, `metadata.json`, and `domain.geojson` always; plus + `topo.dat` when a topography role is provided, plus `treesss.dat` when + both canopy and surface SAVR roles are provided. + + Returns an Export resource with status `pending`. Poll + `GET /exports/{export_id}` until status is `completed` to retrieve the + signed download URL. + + Args: + domain_id (str): + body (QuicfireExportRequest): Request body for creating a QUIC-Fire combined export. + + Five required roles produce `treesrhof.dat`, `treesmoist.dat`, and + `treesfueldepth.dat`. `topography` (optional) produces `topo.dat`. The + SAVR pair (optional, both-or-neither) produces `treesss.dat`. + + The fire grid is defined by the `alignment` field — either the Domain + bounding box padded to `(dx, dy)` (with `dz` vertical), or the lattice + of an existing grid. Every role grid must be lattice-aligned to this + fire grid and cover its full extent; otherwise the request is rejected. + The exporter only crops oversized roles by integer slicing — it never + resamples or reprojects. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Export | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + *, + client: AuthenticatedClient, + body: QuicfireExportRequest, +) -> Export | HTTPValidationError | None: + """Export combined fuel + topography grids to QUIC-Fire format + + Bundle surface fuel + canopy fuel + (optional) topography grids into a + QUIC-Fire-loadable zip archive. + + The output zip contains `treesrhof.dat`, `treesmoist.dat`, + `treesfueldepth.dat`, `metadata.json`, and `domain.geojson` always; plus + `topo.dat` when a topography role is provided, plus `treesss.dat` when + both canopy and surface SAVR roles are provided. + + Returns an Export resource with status `pending`. Poll + `GET /exports/{export_id}` until status is `completed` to retrieve the + signed download URL. + + Args: + domain_id (str): + body (QuicfireExportRequest): Request body for creating a QUIC-Fire combined export. + + Five required roles produce `treesrhof.dat`, `treesmoist.dat`, and + `treesfueldepth.dat`. `topography` (optional) produces `topo.dat`. The + SAVR pair (optional, both-or-neither) produces `treesss.dat`. + + The fire grid is defined by the `alignment` field — either the Domain + bounding box padded to `(dx, dy)` (with `dz` vertical), or the lattice + of an existing grid. Every role grid must be lattice-aligned to this + fire grid and cover its full extent; otherwise the request is rejected. + The exporter only crops oversized roles by integer slicing — it never + resamples or reprojects. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Export | HTTPValidationError + """ + + return sync_detailed( + domain_id=domain_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: QuicfireExportRequest, +) -> Response[Export | HTTPValidationError]: + """Export combined fuel + topography grids to QUIC-Fire format + + Bundle surface fuel + canopy fuel + (optional) topography grids into a + QUIC-Fire-loadable zip archive. + + The output zip contains `treesrhof.dat`, `treesmoist.dat`, + `treesfueldepth.dat`, `metadata.json`, and `domain.geojson` always; plus + `topo.dat` when a topography role is provided, plus `treesss.dat` when + both canopy and surface SAVR roles are provided. + + Returns an Export resource with status `pending`. Poll + `GET /exports/{export_id}` until status is `completed` to retrieve the + signed download URL. + + Args: + domain_id (str): + body (QuicfireExportRequest): Request body for creating a QUIC-Fire combined export. + + Five required roles produce `treesrhof.dat`, `treesmoist.dat`, and + `treesfueldepth.dat`. `topography` (optional) produces `topo.dat`. The + SAVR pair (optional, both-or-neither) produces `treesss.dat`. + + The fire grid is defined by the `alignment` field — either the Domain + bounding box padded to `(dx, dy)` (with `dz` vertical), or the lattice + of an existing grid. Every role grid must be lattice-aligned to this + fire grid and cover its full extent; otherwise the request is rejected. + The exporter only crops oversized roles by integer slicing — it never + resamples or reprojects. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Export | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + *, + client: AuthenticatedClient, + body: QuicfireExportRequest, +) -> Export | HTTPValidationError | None: + """Export combined fuel + topography grids to QUIC-Fire format + + Bundle surface fuel + canopy fuel + (optional) topography grids into a + QUIC-Fire-loadable zip archive. + + The output zip contains `treesrhof.dat`, `treesmoist.dat`, + `treesfueldepth.dat`, `metadata.json`, and `domain.geojson` always; plus + `topo.dat` when a topography role is provided, plus `treesss.dat` when + both canopy and surface SAVR roles are provided. + + Returns an Export resource with status `pending`. Poll + `GET /exports/{export_id}` until status is `completed` to retrieve the + signed download URL. + + Args: + domain_id (str): + body (QuicfireExportRequest): Request body for creating a QUIC-Fire combined export. + + Five required roles produce `treesrhof.dat`, `treesmoist.dat`, and + `treesfueldepth.dat`. `topography` (optional) produces `topo.dat`. The + SAVR pair (optional, both-or-neither) produces `treesss.dat`. + + The fire grid is defined by the `alignment` field — either the Domain + bounding box padded to `(dx, dy)` (with `dz` vertical), or the lattice + of an existing grid. Every role grid must be lattice-aligned to this + fire grid and cover its full extent; otherwise the request is rejected. + The exporter only crops oversized roles by integer slicing — it never + resamples or reprojects. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Export | HTTPValidationError + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/grids/create_resample.py b/fastfuels_sdk/v2/client_library/api/grids/create_resample.py new file mode 100644 index 0000000..948ddb7 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/grids/create_resample.py @@ -0,0 +1,314 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.create_resample_request import CreateResampleRequest +from ...models.grid import Grid +from ...models.http_validation_error import HTTPValidationError +from ...types import Response + + +def _get_kwargs( + domain_id: str, + *, + body: CreateResampleRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/domains/{domain_id}/grids/resample".format( + domain_id=quote(str(domain_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Grid | HTTPValidationError | None: + if response.status_code == 201: + response_201 = Grid.from_dict(response.json()) + + return response_201 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Grid | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateResampleRequest, +) -> Response[Grid | HTTPValidationError]: + r"""Create a grid by resampling an existing grid + + # Create Resampled Grid + + Resamples an existing grid to a new spatial resolution and/or anchor. + This is the key operation for unifying grids on a common lattice + (e.g., LANDFIRE 30m to 2m for QUIC-Fire input). + + The resampled grid propagates ``domain_id`` and bands from the source grid. + + ## Request Body + + - **source_grid_id**: (required) Grid to resample. Must have status + \"completed\" and a georeference. + - **alignment**: Output alignment target. Default ``target=\"domain\"``. + ``alignment.resolution`` is required for ``target=\"domain\"`` and + ``target=\"native\"``; optional for ``target=\"grid\"`` (defaults to the + target grid's exact transform/shape). + - **method_overrides**: (optional) Per-band resampling method overrides. + - **name**, **description**, **tags**: (optional) + + ## Response + + Returns the created Grid with status \"pending\". The backend performs the + resampling and updates status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreateResampleRequest): Request to create a grid by resampling an existing grid. + + Unlike entry-point grid creation requests, ``domain_id`` is not required + because derived grids carry the same domain reference as their source. + + The ``alignment`` field controls the output lattice. ``alignment.resolution`` + is required for ``target="domain"`` and ``target="native"``; for + ``target="grid"`` it is optional (defaults to the target grid's exact + transform/shape; if supplied, keeps the target's CRS and origin and + recomputes shape at the new cell size). + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateResampleRequest, +) -> Grid | HTTPValidationError | None: + r"""Create a grid by resampling an existing grid + + # Create Resampled Grid + + Resamples an existing grid to a new spatial resolution and/or anchor. + This is the key operation for unifying grids on a common lattice + (e.g., LANDFIRE 30m to 2m for QUIC-Fire input). + + The resampled grid propagates ``domain_id`` and bands from the source grid. + + ## Request Body + + - **source_grid_id**: (required) Grid to resample. Must have status + \"completed\" and a georeference. + - **alignment**: Output alignment target. Default ``target=\"domain\"``. + ``alignment.resolution`` is required for ``target=\"domain\"`` and + ``target=\"native\"``; optional for ``target=\"grid\"`` (defaults to the + target grid's exact transform/shape). + - **method_overrides**: (optional) Per-band resampling method overrides. + - **name**, **description**, **tags**: (optional) + + ## Response + + Returns the created Grid with status \"pending\". The backend performs the + resampling and updates status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreateResampleRequest): Request to create a grid by resampling an existing grid. + + Unlike entry-point grid creation requests, ``domain_id`` is not required + because derived grids carry the same domain reference as their source. + + The ``alignment`` field controls the output lattice. ``alignment.resolution`` + is required for ``target="domain"`` and ``target="native"``; for + ``target="grid"`` it is optional (defaults to the target grid's exact + transform/shape; if supplied, keeps the target's CRS and origin and + recomputes shape at the new cell size). + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError + """ + + return sync_detailed( + domain_id=domain_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateResampleRequest, +) -> Response[Grid | HTTPValidationError]: + r"""Create a grid by resampling an existing grid + + # Create Resampled Grid + + Resamples an existing grid to a new spatial resolution and/or anchor. + This is the key operation for unifying grids on a common lattice + (e.g., LANDFIRE 30m to 2m for QUIC-Fire input). + + The resampled grid propagates ``domain_id`` and bands from the source grid. + + ## Request Body + + - **source_grid_id**: (required) Grid to resample. Must have status + \"completed\" and a georeference. + - **alignment**: Output alignment target. Default ``target=\"domain\"``. + ``alignment.resolution`` is required for ``target=\"domain\"`` and + ``target=\"native\"``; optional for ``target=\"grid\"`` (defaults to the + target grid's exact transform/shape). + - **method_overrides**: (optional) Per-band resampling method overrides. + - **name**, **description**, **tags**: (optional) + + ## Response + + Returns the created Grid with status \"pending\". The backend performs the + resampling and updates status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreateResampleRequest): Request to create a grid by resampling an existing grid. + + Unlike entry-point grid creation requests, ``domain_id`` is not required + because derived grids carry the same domain reference as their source. + + The ``alignment`` field controls the output lattice. ``alignment.resolution`` + is required for ``target="domain"`` and ``target="native"``; for + ``target="grid"`` it is optional (defaults to the target grid's exact + transform/shape; if supplied, keeps the target's CRS and origin and + recomputes shape at the new cell size). + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateResampleRequest, +) -> Grid | HTTPValidationError | None: + r"""Create a grid by resampling an existing grid + + # Create Resampled Grid + + Resamples an existing grid to a new spatial resolution and/or anchor. + This is the key operation for unifying grids on a common lattice + (e.g., LANDFIRE 30m to 2m for QUIC-Fire input). + + The resampled grid propagates ``domain_id`` and bands from the source grid. + + ## Request Body + + - **source_grid_id**: (required) Grid to resample. Must have status + \"completed\" and a georeference. + - **alignment**: Output alignment target. Default ``target=\"domain\"``. + ``alignment.resolution`` is required for ``target=\"domain\"`` and + ``target=\"native\"``; optional for ``target=\"grid\"`` (defaults to the + target grid's exact transform/shape). + - **method_overrides**: (optional) Per-band resampling method overrides. + - **name**, **description**, **tags**: (optional) + + ## Response + + Returns the created Grid with status \"pending\". The backend performs the + resampling and updates status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreateResampleRequest): Request to create a grid by resampling an existing grid. + + Unlike entry-point grid creation requests, ``domain_id`` is not required + because derived grids carry the same domain reference as their source. + + The ``alignment`` field controls the output lattice. ``alignment.resolution`` + is required for ``target="domain"`` and ``target="native"``; for + ``target="grid"`` it is optional (defaults to the target grid's exact + transform/shape; if supplied, keeps the target's CRS and origin and + recomputes shape at the new cell size). + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/grids/create_tree_inventory_grid.py b/fastfuels_sdk/v2/client_library/api/grids/create_tree_inventory_grid.py new file mode 100644 index 0000000..5904b80 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/grids/create_tree_inventory_grid.py @@ -0,0 +1,382 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.create_tree_inventory_request import CreateTreeInventoryRequest +from ...models.grid import Grid +from ...models.http_validation_error import HTTPValidationError +from ...types import Response + + +def _get_kwargs( + domain_id: str, + *, + body: CreateTreeInventoryRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/domains/{domain_id}/grids/voxelize/inventory/tree".format( + domain_id=quote(str(domain_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Grid | HTTPValidationError | None: + if response.status_code == 201: + response_201 = Grid.from_dict(response.json()) + + return response_201 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Grid | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateTreeInventoryRequest, +) -> Response[Grid | HTTPValidationError]: + r"""Create a 3D tree fuel grid from a tree inventory + + # Create Tree Inventory Grid + + Voxelizes a tree inventory into a 3D canopy fuel grid. Each tree's crown + is discretized onto the voxel grid using a species-specific crown profile + model, and per-voxel fuel properties (bulk density, moisture, SAV) are + computed from biomass and moisture models. + + This is a 3D grid product — resampling and modifications are not + supported. Apply modifications to the source inventory before voxelizing. + + ## Request Body + + - **source_inventory_id**: (required) ID of a completed tree inventory. + - **resolution**: (optional) Voxel resolution in meters. Defaults to + `{\"horizontal\": 2.0, \"vertical\": 1.0}`. All components must be positive. + - **bands**: (optional) Which output bands to produce. Defaults to + `[\"bulk_density.foliage.live\"]`. Must be non-empty and contain no + duplicates. Branchwood and fine bands are accepted by the API, but + Treevox currently fails those jobs with a not-implemented processing + error. + - **crown_profile_model**: (optional) Crown geometry model. One of + `purves` (default) or `beta`. + - **biomass_source**: (optional) Biomass source and requested components. The + default uses NSVB allometry for foliage. Inventory-column sources must + provide per-tree kg values for each requested direct component. + - **max_crown_radius_source**: (optional) Source of each tree's maximum + crown radius. Defaults to the crown profile model's allometric value; + pass `{\"type\": \"inventory_column\", \"column\": }` to read a per-tree + maximum radius (m) from an inventory column (e.g. derived from LiDAR). + The crown profile model still controls the crown shape — only the peak + radius is rescaled. + - **moisture_model**: (optional) Live/dead fuel moisture configuration. + Required shape: `{\"live\": {\"method\": \"uniform\", \"value\": }}` + and/or `{\"dead\": {\"method\": \"uniform\", \"value\": }}`. + Applied only when matching `fuel_moisture.*` bands are requested. Live + defaults to 100%; dead defaults to 10%. + - **name**, **description**, **tags**: (optional) Standard metadata. + + ## Response + + Returns the created Grid resource with status `\"pending\"` and + `georeference: null`. The Treevox backend performs voxelization + asynchronously and updates the grid to `\"completed\"` with a + `Georeference3D` when done. + + Args: + domain_id (str): + body (CreateTreeInventoryRequest): Request body for creating a tree fuel grid from a tree + inventory. + + Does not extend CreateGridRequestBase because 3D grids do not support + modifications — modifications must be applied to the inventory before + voxelization, not to the resulting voxel grid. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateTreeInventoryRequest, +) -> Grid | HTTPValidationError | None: + r"""Create a 3D tree fuel grid from a tree inventory + + # Create Tree Inventory Grid + + Voxelizes a tree inventory into a 3D canopy fuel grid. Each tree's crown + is discretized onto the voxel grid using a species-specific crown profile + model, and per-voxel fuel properties (bulk density, moisture, SAV) are + computed from biomass and moisture models. + + This is a 3D grid product — resampling and modifications are not + supported. Apply modifications to the source inventory before voxelizing. + + ## Request Body + + - **source_inventory_id**: (required) ID of a completed tree inventory. + - **resolution**: (optional) Voxel resolution in meters. Defaults to + `{\"horizontal\": 2.0, \"vertical\": 1.0}`. All components must be positive. + - **bands**: (optional) Which output bands to produce. Defaults to + `[\"bulk_density.foliage.live\"]`. Must be non-empty and contain no + duplicates. Branchwood and fine bands are accepted by the API, but + Treevox currently fails those jobs with a not-implemented processing + error. + - **crown_profile_model**: (optional) Crown geometry model. One of + `purves` (default) or `beta`. + - **biomass_source**: (optional) Biomass source and requested components. The + default uses NSVB allometry for foliage. Inventory-column sources must + provide per-tree kg values for each requested direct component. + - **max_crown_radius_source**: (optional) Source of each tree's maximum + crown radius. Defaults to the crown profile model's allometric value; + pass `{\"type\": \"inventory_column\", \"column\": }` to read a per-tree + maximum radius (m) from an inventory column (e.g. derived from LiDAR). + The crown profile model still controls the crown shape — only the peak + radius is rescaled. + - **moisture_model**: (optional) Live/dead fuel moisture configuration. + Required shape: `{\"live\": {\"method\": \"uniform\", \"value\": }}` + and/or `{\"dead\": {\"method\": \"uniform\", \"value\": }}`. + Applied only when matching `fuel_moisture.*` bands are requested. Live + defaults to 100%; dead defaults to 10%. + - **name**, **description**, **tags**: (optional) Standard metadata. + + ## Response + + Returns the created Grid resource with status `\"pending\"` and + `georeference: null`. The Treevox backend performs voxelization + asynchronously and updates the grid to `\"completed\"` with a + `Georeference3D` when done. + + Args: + domain_id (str): + body (CreateTreeInventoryRequest): Request body for creating a tree fuel grid from a tree + inventory. + + Does not extend CreateGridRequestBase because 3D grids do not support + modifications — modifications must be applied to the inventory before + voxelization, not to the resulting voxel grid. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError + """ + + return sync_detailed( + domain_id=domain_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateTreeInventoryRequest, +) -> Response[Grid | HTTPValidationError]: + r"""Create a 3D tree fuel grid from a tree inventory + + # Create Tree Inventory Grid + + Voxelizes a tree inventory into a 3D canopy fuel grid. Each tree's crown + is discretized onto the voxel grid using a species-specific crown profile + model, and per-voxel fuel properties (bulk density, moisture, SAV) are + computed from biomass and moisture models. + + This is a 3D grid product — resampling and modifications are not + supported. Apply modifications to the source inventory before voxelizing. + + ## Request Body + + - **source_inventory_id**: (required) ID of a completed tree inventory. + - **resolution**: (optional) Voxel resolution in meters. Defaults to + `{\"horizontal\": 2.0, \"vertical\": 1.0}`. All components must be positive. + - **bands**: (optional) Which output bands to produce. Defaults to + `[\"bulk_density.foliage.live\"]`. Must be non-empty and contain no + duplicates. Branchwood and fine bands are accepted by the API, but + Treevox currently fails those jobs with a not-implemented processing + error. + - **crown_profile_model**: (optional) Crown geometry model. One of + `purves` (default) or `beta`. + - **biomass_source**: (optional) Biomass source and requested components. The + default uses NSVB allometry for foliage. Inventory-column sources must + provide per-tree kg values for each requested direct component. + - **max_crown_radius_source**: (optional) Source of each tree's maximum + crown radius. Defaults to the crown profile model's allometric value; + pass `{\"type\": \"inventory_column\", \"column\": }` to read a per-tree + maximum radius (m) from an inventory column (e.g. derived from LiDAR). + The crown profile model still controls the crown shape — only the peak + radius is rescaled. + - **moisture_model**: (optional) Live/dead fuel moisture configuration. + Required shape: `{\"live\": {\"method\": \"uniform\", \"value\": }}` + and/or `{\"dead\": {\"method\": \"uniform\", \"value\": }}`. + Applied only when matching `fuel_moisture.*` bands are requested. Live + defaults to 100%; dead defaults to 10%. + - **name**, **description**, **tags**: (optional) Standard metadata. + + ## Response + + Returns the created Grid resource with status `\"pending\"` and + `georeference: null`. The Treevox backend performs voxelization + asynchronously and updates the grid to `\"completed\"` with a + `Georeference3D` when done. + + Args: + domain_id (str): + body (CreateTreeInventoryRequest): Request body for creating a tree fuel grid from a tree + inventory. + + Does not extend CreateGridRequestBase because 3D grids do not support + modifications — modifications must be applied to the inventory before + voxelization, not to the resulting voxel grid. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateTreeInventoryRequest, +) -> Grid | HTTPValidationError | None: + r"""Create a 3D tree fuel grid from a tree inventory + + # Create Tree Inventory Grid + + Voxelizes a tree inventory into a 3D canopy fuel grid. Each tree's crown + is discretized onto the voxel grid using a species-specific crown profile + model, and per-voxel fuel properties (bulk density, moisture, SAV) are + computed from biomass and moisture models. + + This is a 3D grid product — resampling and modifications are not + supported. Apply modifications to the source inventory before voxelizing. + + ## Request Body + + - **source_inventory_id**: (required) ID of a completed tree inventory. + - **resolution**: (optional) Voxel resolution in meters. Defaults to + `{\"horizontal\": 2.0, \"vertical\": 1.0}`. All components must be positive. + - **bands**: (optional) Which output bands to produce. Defaults to + `[\"bulk_density.foliage.live\"]`. Must be non-empty and contain no + duplicates. Branchwood and fine bands are accepted by the API, but + Treevox currently fails those jobs with a not-implemented processing + error. + - **crown_profile_model**: (optional) Crown geometry model. One of + `purves` (default) or `beta`. + - **biomass_source**: (optional) Biomass source and requested components. The + default uses NSVB allometry for foliage. Inventory-column sources must + provide per-tree kg values for each requested direct component. + - **max_crown_radius_source**: (optional) Source of each tree's maximum + crown radius. Defaults to the crown profile model's allometric value; + pass `{\"type\": \"inventory_column\", \"column\": }` to read a per-tree + maximum radius (m) from an inventory column (e.g. derived from LiDAR). + The crown profile model still controls the crown shape — only the peak + radius is rescaled. + - **moisture_model**: (optional) Live/dead fuel moisture configuration. + Required shape: `{\"live\": {\"method\": \"uniform\", \"value\": }}` + and/or `{\"dead\": {\"method\": \"uniform\", \"value\": }}`. + Applied only when matching `fuel_moisture.*` bands are requested. Live + defaults to 100%; dead defaults to 10%. + - **name**, **description**, **tags**: (optional) Standard metadata. + + ## Response + + Returns the created Grid resource with status `\"pending\"` and + `georeference: null`. The Treevox backend performs voxelization + asynchronously and updates the grid to `\"completed\"` with a + `Georeference3D` when done. + + Args: + domain_id (str): + body (CreateTreeInventoryRequest): Request body for creating a tree fuel grid from a tree + inventory. + + Does not extend CreateGridRequestBase because 3D grids do not support + modifications — modifications must be applied to the inventory before + voxelization, not to the resulting voxel grid. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/grids/create_treemap.py b/fastfuels_sdk/v2/client_library/api/grids/create_treemap.py new file mode 100644 index 0000000..ebbf004 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/grids/create_treemap.py @@ -0,0 +1,294 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.create_tree_map_request import CreateTreeMapRequest +from ...models.grid import Grid +from ...models.http_validation_error import HTTPValidationError +from ...types import Response + + +def _get_kwargs( + domain_id: str, + *, + body: CreateTreeMapRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/domains/{domain_id}/grids/pim/treemap".format( + domain_id=quote(str(domain_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Grid | HTTPValidationError | None: + if response.status_code == 201: + response_201 = Grid.from_dict(response.json()) + + return response_201 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Grid | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateTreeMapRequest, +) -> Response[Grid | HTTPValidationError]: + r"""Create a grid from TreeMap + + # Create TreeMap Grid + + Creates a grid with plot imputation data from TreeMap at 30m resolution. + + Each pixel contains a plot ID that maps to FIA tree records. Available bands: + - **tm_id**: TreeMap raster pixel values (small integers, 1-70K) + - **plt_cn**: FIA plot condition number (large integers, derived from tree table) + + By default only `tm_id` is included. Use the `bands` field to also request + `plt_cn`. + + ## Request Body + + - **bands**: (optional) Which bands to include. Default: [\"tm_id\"]. + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + - **version**: (optional) TreeMap version. Default: \"2022\". + + ## Response + + Returns the created Grid resource with status \"pending\". The backend will + fetch the data and update status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreateTreeMapRequest): Request to create a grid from TreeMap. + + Returns a grid with one or two categorical bands: + - tm_id: TreeMap raster pixel values (always available) + - plt_cn: FIA plot condition number (optional, derived from tree table) + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateTreeMapRequest, +) -> Grid | HTTPValidationError | None: + r"""Create a grid from TreeMap + + # Create TreeMap Grid + + Creates a grid with plot imputation data from TreeMap at 30m resolution. + + Each pixel contains a plot ID that maps to FIA tree records. Available bands: + - **tm_id**: TreeMap raster pixel values (small integers, 1-70K) + - **plt_cn**: FIA plot condition number (large integers, derived from tree table) + + By default only `tm_id` is included. Use the `bands` field to also request + `plt_cn`. + + ## Request Body + + - **bands**: (optional) Which bands to include. Default: [\"tm_id\"]. + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + - **version**: (optional) TreeMap version. Default: \"2022\". + + ## Response + + Returns the created Grid resource with status \"pending\". The backend will + fetch the data and update status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreateTreeMapRequest): Request to create a grid from TreeMap. + + Returns a grid with one or two categorical bands: + - tm_id: TreeMap raster pixel values (always available) + - plt_cn: FIA plot condition number (optional, derived from tree table) + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError + """ + + return sync_detailed( + domain_id=domain_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateTreeMapRequest, +) -> Response[Grid | HTTPValidationError]: + r"""Create a grid from TreeMap + + # Create TreeMap Grid + + Creates a grid with plot imputation data from TreeMap at 30m resolution. + + Each pixel contains a plot ID that maps to FIA tree records. Available bands: + - **tm_id**: TreeMap raster pixel values (small integers, 1-70K) + - **plt_cn**: FIA plot condition number (large integers, derived from tree table) + + By default only `tm_id` is included. Use the `bands` field to also request + `plt_cn`. + + ## Request Body + + - **bands**: (optional) Which bands to include. Default: [\"tm_id\"]. + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + - **version**: (optional) TreeMap version. Default: \"2022\". + + ## Response + + Returns the created Grid resource with status \"pending\". The backend will + fetch the data and update status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreateTreeMapRequest): Request to create a grid from TreeMap. + + Returns a grid with one or two categorical bands: + - tm_id: TreeMap raster pixel values (always available) + - plt_cn: FIA plot condition number (optional, derived from tree table) + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateTreeMapRequest, +) -> Grid | HTTPValidationError | None: + r"""Create a grid from TreeMap + + # Create TreeMap Grid + + Creates a grid with plot imputation data from TreeMap at 30m resolution. + + Each pixel contains a plot ID that maps to FIA tree records. Available bands: + - **tm_id**: TreeMap raster pixel values (small integers, 1-70K) + - **plt_cn**: FIA plot condition number (large integers, derived from tree table) + + By default only `tm_id` is included. Use the `bands` field to also request + `plt_cn`. + + ## Request Body + + - **bands**: (optional) Which bands to include. Default: [\"tm_id\"]. + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + - **version**: (optional) TreeMap version. Default: \"2022\". + + ## Response + + Returns the created Grid resource with status \"pending\". The backend will + fetch the data and update status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreateTreeMapRequest): Request to create a grid from TreeMap. + + Returns a grid with one or two categorical bands: + - tm_id: TreeMap raster pixel values (always available) + - plt_cn: FIA plot condition number (optional, derived from tree table) + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/grids/create_uniform_grid.py b/fastfuels_sdk/v2/client_library/api/grids/create_uniform_grid.py new file mode 100644 index 0000000..816e8ad --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/grids/create_uniform_grid.py @@ -0,0 +1,330 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.create_uniform_request import CreateUniformRequest +from ...models.grid import Grid +from ...models.http_validation_error import HTTPValidationError +from ...types import Response + + +def _get_kwargs( + domain_id: str, + *, + body: CreateUniformRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/domains/{domain_id}/grids/uniform".format( + domain_id=quote(str(domain_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Grid | HTTPValidationError | None: + if response.status_code == 201: + response_201 = Grid.from_dict(response.json()) + + return response_201 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Grid | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateUniformRequest, +) -> Response[Grid | HTTPValidationError]: + r"""Create a uniform (constant-value) grid + + # Create Uniform Grid + + Creates a grid where every cell is filled with a constant value for each + specified band. Useful for fuel moisture scenarios, constant fuel loads, + and other spatially-uniform inputs. + + ## Request Body + + - **resolution**: (required) Grid resolution in meters (>= 1). No default + since uniform grids have no \"native resolution.\" + - **bands**: (required) One or more bands, each with a key and value. + Band keys must be unique. + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + + ## Available Bands + + **Fuel moisture** (unit: %): `fuel_moisture.1hr`, `fuel_moisture.10hr`, + `fuel_moisture.100hr`, `fuel_moisture.live_herb`, `fuel_moisture.live_woody` + + **Curing** (unit: %): `curing` + + **Fuel load** (unit: kg/m**2): `fuel_load.1hr`, `fuel_load.10hr`, + `fuel_load.100hr`, `fuel_load.live_herb`, `fuel_load.live_woody` + + **Fuel depth** (unit: m): `fuel_depth` + + ## Response + + Returns the created Grid resource with status \"pending\". The backend will + generate the data and update status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreateUniformRequest): Request to create a uniform (constant-value) grid. + + Each band fills the entire domain with a single value at the specified + resolution. No default resolution — it must be explicitly provided since + uniform grids have no "native resolution." + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateUniformRequest, +) -> Grid | HTTPValidationError | None: + r"""Create a uniform (constant-value) grid + + # Create Uniform Grid + + Creates a grid where every cell is filled with a constant value for each + specified band. Useful for fuel moisture scenarios, constant fuel loads, + and other spatially-uniform inputs. + + ## Request Body + + - **resolution**: (required) Grid resolution in meters (>= 1). No default + since uniform grids have no \"native resolution.\" + - **bands**: (required) One or more bands, each with a key and value. + Band keys must be unique. + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + + ## Available Bands + + **Fuel moisture** (unit: %): `fuel_moisture.1hr`, `fuel_moisture.10hr`, + `fuel_moisture.100hr`, `fuel_moisture.live_herb`, `fuel_moisture.live_woody` + + **Curing** (unit: %): `curing` + + **Fuel load** (unit: kg/m**2): `fuel_load.1hr`, `fuel_load.10hr`, + `fuel_load.100hr`, `fuel_load.live_herb`, `fuel_load.live_woody` + + **Fuel depth** (unit: m): `fuel_depth` + + ## Response + + Returns the created Grid resource with status \"pending\". The backend will + generate the data and update status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreateUniformRequest): Request to create a uniform (constant-value) grid. + + Each band fills the entire domain with a single value at the specified + resolution. No default resolution — it must be explicitly provided since + uniform grids have no "native resolution." + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError + """ + + return sync_detailed( + domain_id=domain_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateUniformRequest, +) -> Response[Grid | HTTPValidationError]: + r"""Create a uniform (constant-value) grid + + # Create Uniform Grid + + Creates a grid where every cell is filled with a constant value for each + specified band. Useful for fuel moisture scenarios, constant fuel loads, + and other spatially-uniform inputs. + + ## Request Body + + - **resolution**: (required) Grid resolution in meters (>= 1). No default + since uniform grids have no \"native resolution.\" + - **bands**: (required) One or more bands, each with a key and value. + Band keys must be unique. + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + + ## Available Bands + + **Fuel moisture** (unit: %): `fuel_moisture.1hr`, `fuel_moisture.10hr`, + `fuel_moisture.100hr`, `fuel_moisture.live_herb`, `fuel_moisture.live_woody` + + **Curing** (unit: %): `curing` + + **Fuel load** (unit: kg/m**2): `fuel_load.1hr`, `fuel_load.10hr`, + `fuel_load.100hr`, `fuel_load.live_herb`, `fuel_load.live_woody` + + **Fuel depth** (unit: m): `fuel_depth` + + ## Response + + Returns the created Grid resource with status \"pending\". The backend will + generate the data and update status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreateUniformRequest): Request to create a uniform (constant-value) grid. + + Each band fills the entire domain with a single value at the specified + resolution. No default resolution — it must be explicitly provided since + uniform grids have no "native resolution." + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateUniformRequest, +) -> Grid | HTTPValidationError | None: + r"""Create a uniform (constant-value) grid + + # Create Uniform Grid + + Creates a grid where every cell is filled with a constant value for each + specified band. Useful for fuel moisture scenarios, constant fuel loads, + and other spatially-uniform inputs. + + ## Request Body + + - **resolution**: (required) Grid resolution in meters (>= 1). No default + since uniform grids have no \"native resolution.\" + - **bands**: (required) One or more bands, each with a key and value. + Band keys must be unique. + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + + ## Available Bands + + **Fuel moisture** (unit: %): `fuel_moisture.1hr`, `fuel_moisture.10hr`, + `fuel_moisture.100hr`, `fuel_moisture.live_herb`, `fuel_moisture.live_woody` + + **Curing** (unit: %): `curing` + + **Fuel load** (unit: kg/m**2): `fuel_load.1hr`, `fuel_load.10hr`, + `fuel_load.100hr`, `fuel_load.live_herb`, `fuel_load.live_woody` + + **Fuel depth** (unit: m): `fuel_depth` + + ## Response + + Returns the created Grid resource with status \"pending\". The backend will + generate the data and update status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreateUniformRequest): Request to create a uniform (constant-value) grid. + + Each band fills the entire domain with a single value at the specified + resolution. No default resolution — it must be explicitly provided since + uniform grids have no "native resolution." + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/grids/delete_grid.py b/fastfuels_sdk/v2/client_library/api/grids/delete_grid.py new file mode 100644 index 0000000..075883b --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/grids/delete_grid.py @@ -0,0 +1,245 @@ +from http import HTTPStatus +from typing import Any, cast +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.http_validation_error import HTTPValidationError +from ...types import Response + + +def _get_kwargs( + domain_id: str, + grid_id: str, +) -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "delete", + "url": "/domains/{domain_id}/grids/{grid_id}".format( + domain_id=quote(str(domain_id), safe=""), + grid_id=quote(str(grid_id), safe=""), + ), + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Any | HTTPValidationError | None: + if response.status_code == 204: + response_204 = cast(Any, None) + return response_204 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Any | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + grid_id: str, + *, + client: AuthenticatedClient, +) -> Response[Any | HTTPValidationError]: + """Delete a grid + + # Delete Grid Endpoint + + Permanently deletes a grid resource by its unique identifier. + This action cannot be undone. + + ## Path Parameters + + - **domain_id**: (string) The domain the grid belongs to. + - **grid_id**: (string) The unique identifier of the grid. + + ## Response + + Returns HTTP 204 No Content with an empty response body. + + ## Error Responses + + - **404 Not Found**: The grid does not exist or the user does not have access. + + Args: + domain_id (str): + grid_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + grid_id=grid_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + grid_id: str, + *, + client: AuthenticatedClient, +) -> Any | HTTPValidationError | None: + """Delete a grid + + # Delete Grid Endpoint + + Permanently deletes a grid resource by its unique identifier. + This action cannot be undone. + + ## Path Parameters + + - **domain_id**: (string) The domain the grid belongs to. + - **grid_id**: (string) The unique identifier of the grid. + + ## Response + + Returns HTTP 204 No Content with an empty response body. + + ## Error Responses + + - **404 Not Found**: The grid does not exist or the user does not have access. + + Args: + domain_id (str): + grid_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Any | HTTPValidationError + """ + + return sync_detailed( + domain_id=domain_id, + grid_id=grid_id, + client=client, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + grid_id: str, + *, + client: AuthenticatedClient, +) -> Response[Any | HTTPValidationError]: + """Delete a grid + + # Delete Grid Endpoint + + Permanently deletes a grid resource by its unique identifier. + This action cannot be undone. + + ## Path Parameters + + - **domain_id**: (string) The domain the grid belongs to. + - **grid_id**: (string) The unique identifier of the grid. + + ## Response + + Returns HTTP 204 No Content with an empty response body. + + ## Error Responses + + - **404 Not Found**: The grid does not exist or the user does not have access. + + Args: + domain_id (str): + grid_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + grid_id=grid_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + grid_id: str, + *, + client: AuthenticatedClient, +) -> Any | HTTPValidationError | None: + """Delete a grid + + # Delete Grid Endpoint + + Permanently deletes a grid resource by its unique identifier. + This action cannot be undone. + + ## Path Parameters + + - **domain_id**: (string) The domain the grid belongs to. + - **grid_id**: (string) The unique identifier of the grid. + + ## Response + + Returns HTTP 204 No Content with an empty response body. + + ## Error Responses + + - **404 Not Found**: The grid does not exist or the user does not have access. + + Args: + domain_id (str): + grid_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Any | HTTPValidationError + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + grid_id=grid_id, + client=client, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/grids/get_chunk_metadata.py b/fastfuels_sdk/v2/client_library/api/grids/get_chunk_metadata.py new file mode 100644 index 0000000..1355624 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/grids/get_chunk_metadata.py @@ -0,0 +1,313 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.grid_data_chunk_metadata import GridDataChunkMetadata +from ...models.http_validation_error import HTTPValidationError +from ...types import Response + + +def _get_kwargs( + domain_id: str, + grid_id: str, + chunk_index: int, +) -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/domains/{domain_id}/grids/{grid_id}/chunks/{chunk_index}".format( + domain_id=quote(str(domain_id), safe=""), + grid_id=quote(str(grid_id), safe=""), + chunk_index=quote(str(chunk_index), safe=""), + ), + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> GridDataChunkMetadata | HTTPValidationError | None: + if response.status_code == 200: + response_200 = GridDataChunkMetadata.from_dict(response.json()) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[GridDataChunkMetadata | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + grid_id: str, + chunk_index: int, + *, + client: AuthenticatedClient, +) -> Response[GridDataChunkMetadata | HTTPValidationError]: + """Get chunk metadata + + # Get Chunk Metadata Endpoint + + Retrieves the shape, pixel offset, and affine transform for a single 2D or + 3D chunk of a completed grid. This is a lightweight call — no raster data + is read. + + ## Path Parameters + + - **domain_id**: (string) The domain the grid belongs to. + - **grid_id**: (string) The unique identifier of the grid. + - **chunk_index**: (integer) Zero-based flat chunk index. 2D grids use + y,x order. 3D grids use z,y,x order. + + ## Response + + Returns chunk metadata: + + - **index**: The chunk index. + - **shape**: 2D `(height, width)` or 3D `(z, height, width)`. Edge chunks + may be smaller than the grid's chunk shape. + - **offset**: 2D `(row, column)` or 3D `(z, row, column)` pixel offset of + the chunk within the full grid. + - **transform**: Six-element affine transform for the chunk's spatial extent. + - **z_origin**, **z_resolution**: Present only for 3D grids. + + ## Error Responses + + - **404 Not Found**: The grid does not exist, is not completed, or the user + does not have access. + - **422 Unprocessable Entity**: The chunk index is out of range. + + Args: + domain_id (str): + grid_id (str): + chunk_index (int): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[GridDataChunkMetadata | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + grid_id=grid_id, + chunk_index=chunk_index, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + grid_id: str, + chunk_index: int, + *, + client: AuthenticatedClient, +) -> GridDataChunkMetadata | HTTPValidationError | None: + """Get chunk metadata + + # Get Chunk Metadata Endpoint + + Retrieves the shape, pixel offset, and affine transform for a single 2D or + 3D chunk of a completed grid. This is a lightweight call — no raster data + is read. + + ## Path Parameters + + - **domain_id**: (string) The domain the grid belongs to. + - **grid_id**: (string) The unique identifier of the grid. + - **chunk_index**: (integer) Zero-based flat chunk index. 2D grids use + y,x order. 3D grids use z,y,x order. + + ## Response + + Returns chunk metadata: + + - **index**: The chunk index. + - **shape**: 2D `(height, width)` or 3D `(z, height, width)`. Edge chunks + may be smaller than the grid's chunk shape. + - **offset**: 2D `(row, column)` or 3D `(z, row, column)` pixel offset of + the chunk within the full grid. + - **transform**: Six-element affine transform for the chunk's spatial extent. + - **z_origin**, **z_resolution**: Present only for 3D grids. + + ## Error Responses + + - **404 Not Found**: The grid does not exist, is not completed, or the user + does not have access. + - **422 Unprocessable Entity**: The chunk index is out of range. + + Args: + domain_id (str): + grid_id (str): + chunk_index (int): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + GridDataChunkMetadata | HTTPValidationError + """ + + return sync_detailed( + domain_id=domain_id, + grid_id=grid_id, + chunk_index=chunk_index, + client=client, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + grid_id: str, + chunk_index: int, + *, + client: AuthenticatedClient, +) -> Response[GridDataChunkMetadata | HTTPValidationError]: + """Get chunk metadata + + # Get Chunk Metadata Endpoint + + Retrieves the shape, pixel offset, and affine transform for a single 2D or + 3D chunk of a completed grid. This is a lightweight call — no raster data + is read. + + ## Path Parameters + + - **domain_id**: (string) The domain the grid belongs to. + - **grid_id**: (string) The unique identifier of the grid. + - **chunk_index**: (integer) Zero-based flat chunk index. 2D grids use + y,x order. 3D grids use z,y,x order. + + ## Response + + Returns chunk metadata: + + - **index**: The chunk index. + - **shape**: 2D `(height, width)` or 3D `(z, height, width)`. Edge chunks + may be smaller than the grid's chunk shape. + - **offset**: 2D `(row, column)` or 3D `(z, row, column)` pixel offset of + the chunk within the full grid. + - **transform**: Six-element affine transform for the chunk's spatial extent. + - **z_origin**, **z_resolution**: Present only for 3D grids. + + ## Error Responses + + - **404 Not Found**: The grid does not exist, is not completed, or the user + does not have access. + - **422 Unprocessable Entity**: The chunk index is out of range. + + Args: + domain_id (str): + grid_id (str): + chunk_index (int): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[GridDataChunkMetadata | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + grid_id=grid_id, + chunk_index=chunk_index, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + grid_id: str, + chunk_index: int, + *, + client: AuthenticatedClient, +) -> GridDataChunkMetadata | HTTPValidationError | None: + """Get chunk metadata + + # Get Chunk Metadata Endpoint + + Retrieves the shape, pixel offset, and affine transform for a single 2D or + 3D chunk of a completed grid. This is a lightweight call — no raster data + is read. + + ## Path Parameters + + - **domain_id**: (string) The domain the grid belongs to. + - **grid_id**: (string) The unique identifier of the grid. + - **chunk_index**: (integer) Zero-based flat chunk index. 2D grids use + y,x order. 3D grids use z,y,x order. + + ## Response + + Returns chunk metadata: + + - **index**: The chunk index. + - **shape**: 2D `(height, width)` or 3D `(z, height, width)`. Edge chunks + may be smaller than the grid's chunk shape. + - **offset**: 2D `(row, column)` or 3D `(z, row, column)` pixel offset of + the chunk within the full grid. + - **transform**: Six-element affine transform for the chunk's spatial extent. + - **z_origin**, **z_resolution**: Present only for 3D grids. + + ## Error Responses + + - **404 Not Found**: The grid does not exist, is not completed, or the user + does not have access. + - **422 Unprocessable Entity**: The chunk index is out of range. + + Args: + domain_id (str): + grid_id (str): + chunk_index (int): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + GridDataChunkMetadata | HTTPValidationError + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + grid_id=grid_id, + chunk_index=chunk_index, + client=client, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/grids/get_grid.py b/fastfuels_sdk/v2/client_library/api/grids/get_grid.py new file mode 100644 index 0000000..3ea6ce3 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/grids/get_grid.py @@ -0,0 +1,243 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.grid import Grid +from ...models.http_validation_error import HTTPValidationError +from ...types import Response + + +def _get_kwargs( + domain_id: str, + grid_id: str, +) -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/domains/{domain_id}/grids/{grid_id}".format( + domain_id=quote(str(domain_id), safe=""), + grid_id=quote(str(grid_id), safe=""), + ), + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Grid | HTTPValidationError | None: + if response.status_code == 200: + response_200 = Grid.from_dict(response.json()) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Grid | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + grid_id: str, + *, + client: AuthenticatedClient, +) -> Response[Grid | HTTPValidationError]: + """Get a grid by ID + + # Get Grid Endpoint + + Retrieves a specific grid resource by its unique identifier. + + ## Path Parameters + + - **domain_id**: (string) The domain the grid belongs to. + - **grid_id**: (string) The unique 32-character hex identifier of the grid. + + ## Response + + Returns the grid resource. + + ## Error Responses + + - **404 Not Found**: The grid does not exist or the user does not have access. + + Args: + domain_id (str): + grid_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + grid_id=grid_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + grid_id: str, + *, + client: AuthenticatedClient, +) -> Grid | HTTPValidationError | None: + """Get a grid by ID + + # Get Grid Endpoint + + Retrieves a specific grid resource by its unique identifier. + + ## Path Parameters + + - **domain_id**: (string) The domain the grid belongs to. + - **grid_id**: (string) The unique 32-character hex identifier of the grid. + + ## Response + + Returns the grid resource. + + ## Error Responses + + - **404 Not Found**: The grid does not exist or the user does not have access. + + Args: + domain_id (str): + grid_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError + """ + + return sync_detailed( + domain_id=domain_id, + grid_id=grid_id, + client=client, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + grid_id: str, + *, + client: AuthenticatedClient, +) -> Response[Grid | HTTPValidationError]: + """Get a grid by ID + + # Get Grid Endpoint + + Retrieves a specific grid resource by its unique identifier. + + ## Path Parameters + + - **domain_id**: (string) The domain the grid belongs to. + - **grid_id**: (string) The unique 32-character hex identifier of the grid. + + ## Response + + Returns the grid resource. + + ## Error Responses + + - **404 Not Found**: The grid does not exist or the user does not have access. + + Args: + domain_id (str): + grid_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + grid_id=grid_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + grid_id: str, + *, + client: AuthenticatedClient, +) -> Grid | HTTPValidationError | None: + """Get a grid by ID + + # Get Grid Endpoint + + Retrieves a specific grid resource by its unique identifier. + + ## Path Parameters + + - **domain_id**: (string) The domain the grid belongs to. + - **grid_id**: (string) The unique 32-character hex identifier of the grid. + + ## Response + + Returns the grid resource. + + ## Error Responses + + - **404 Not Found**: The grid does not exist or the user does not have access. + + Args: + domain_id (str): + grid_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + grid_id=grid_id, + client=client, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/grids/get_grid_data_binary.py b/fastfuels_sdk/v2/client_library/api/grids/get_grid_data_binary.py new file mode 100644 index 0000000..aa38447 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/grids/get_grid_data_binary.py @@ -0,0 +1,515 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.grid_data_array_format import GridDataArrayFormat +from ...models.grid_data_order import GridDataOrder +from ...models.http_validation_error import HTTPValidationError +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + domain_id: str, + grid_id: str, + band: str, + chunk_index: int, + *, + array_format: GridDataArrayFormat | Unset = UNSET, + order: GridDataOrder | Unset = UNSET, +) -> dict[str, Any]: + + params: dict[str, Any] = {} + + json_array_format: str | Unset = UNSET + if not isinstance(array_format, Unset): + json_array_format = array_format.value + + params["array_format"] = json_array_format + + json_order: str | Unset = UNSET + if not isinstance(order, Unset): + json_order = order.value + + params["order"] = json_order + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/domains/{domain_id}/grids/{grid_id}/data/{band}/{chunk_index}/binary".format( + domain_id=quote(str(domain_id), safe=""), + grid_id=quote(str(grid_id), safe=""), + band=quote(str(band), safe=""), + chunk_index=quote(str(chunk_index), safe=""), + ), + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Any | HTTPValidationError | None: + if response.status_code == 200: + response_200 = response.json() + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Any | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + grid_id: str, + band: str, + chunk_index: int, + *, + client: AuthenticatedClient, + array_format: GridDataArrayFormat | Unset = UNSET, + order: GridDataOrder | Unset = UNSET, +) -> Response[Any | HTTPValidationError]: + """Get band data for a chunk (binary) + + # Get Grid Data (binary) + + Returns the values of a single band within a single chunk of a completed + grid as raw bytes, with shape and type metadata in `X-Data-*` response + headers. Use this when you need the most compact wire format and intend + to deserialize into a typed array on the client. + + For a structured JSON payload, use the JSON variant of this endpoint + (drop the trailing `/binary`). + + ## Path Parameters + + - **domain_id**: The domain the grid belongs to. + - **grid_id**: The grid identifier. + - **band**: Band key to read (must be present in the grid's `bands`). + - **chunk_index**: Zero-based flat chunk index. 2D grids index in (y, x); + 3D grids index in (z, y, x). The chunk's shape, offset, and affine + transform are returned alongside the data — no separate metadata call + is required. (`GET …/chunks/{chunk_index}` is still available for + clients that want to lay out chunks before fetching data.) + + ## Query Parameters + + - **array_format**: `dense` (default) or `sparse`. Sparse compresses out + cells equal to the band's fill value, returning only the non-fill + entries. + - **order**: Flattening order — `C` (row-major, default) or `F` + (column-major). + + ## Response + + All variants return `application/octet-stream` with these common headers: + + - `X-Data-Shape`: comma-separated chunk dimensions (e.g., `47,61`). + - `X-Data-Order`: flattening order used (`C` or `F`). + - `X-Data-Format`: `dense` or `sparse`. + - `X-Data-Offset`: comma-separated pixel offset of this chunk within + the full grid (2D: `row,col`; 3D: `z,row,col`). + - `X-Data-Transform`: comma-separated six-element affine transform for + the chunk's spatial extent. + - `X-Data-Z-Origin`, `X-Data-Z-Resolution`: present only for 3D grids. + + **Dense** (`array_format=dense`): body is the flattened cells as raw + bytes. + + - `X-Data-Dtype`: numeric type of the cells (e.g., `float32`). + + **Sparse** (`array_format=sparse`): body is the index array bytes + immediately followed by the value array bytes. + + - `X-Data-NNZ`: number of non-fill entries (length of both arrays). + - `X-Data-Index-Dtype`: numeric type of the index array (`int32`). + - `X-Data-Value-Dtype`: numeric type of the value array. + - `X-Data-Fill-Value`: the band's fill value (stringified). Omitted when + the band does not define a fill value, in which case every cell is + listed and no compression has been applied. + + Slice the response body at `NNZ * sizeof(index_dtype)` to separate + indices from values. + + ## Errors + + - **404**: Grid not found, not completed, or not accessible. + - **422**: Band does not exist on this grid, or chunk index out of range. + - **413**: Response would exceed the size limit. Try `array_format=sparse` + or a smaller chunk. + + Args: + domain_id (str): + grid_id (str): + band (str): + chunk_index (int): + array_format (GridDataArrayFormat | Unset): + order (GridDataOrder | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + grid_id=grid_id, + band=band, + chunk_index=chunk_index, + array_format=array_format, + order=order, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + grid_id: str, + band: str, + chunk_index: int, + *, + client: AuthenticatedClient, + array_format: GridDataArrayFormat | Unset = UNSET, + order: GridDataOrder | Unset = UNSET, +) -> Any | HTTPValidationError | None: + """Get band data for a chunk (binary) + + # Get Grid Data (binary) + + Returns the values of a single band within a single chunk of a completed + grid as raw bytes, with shape and type metadata in `X-Data-*` response + headers. Use this when you need the most compact wire format and intend + to deserialize into a typed array on the client. + + For a structured JSON payload, use the JSON variant of this endpoint + (drop the trailing `/binary`). + + ## Path Parameters + + - **domain_id**: The domain the grid belongs to. + - **grid_id**: The grid identifier. + - **band**: Band key to read (must be present in the grid's `bands`). + - **chunk_index**: Zero-based flat chunk index. 2D grids index in (y, x); + 3D grids index in (z, y, x). The chunk's shape, offset, and affine + transform are returned alongside the data — no separate metadata call + is required. (`GET …/chunks/{chunk_index}` is still available for + clients that want to lay out chunks before fetching data.) + + ## Query Parameters + + - **array_format**: `dense` (default) or `sparse`. Sparse compresses out + cells equal to the band's fill value, returning only the non-fill + entries. + - **order**: Flattening order — `C` (row-major, default) or `F` + (column-major). + + ## Response + + All variants return `application/octet-stream` with these common headers: + + - `X-Data-Shape`: comma-separated chunk dimensions (e.g., `47,61`). + - `X-Data-Order`: flattening order used (`C` or `F`). + - `X-Data-Format`: `dense` or `sparse`. + - `X-Data-Offset`: comma-separated pixel offset of this chunk within + the full grid (2D: `row,col`; 3D: `z,row,col`). + - `X-Data-Transform`: comma-separated six-element affine transform for + the chunk's spatial extent. + - `X-Data-Z-Origin`, `X-Data-Z-Resolution`: present only for 3D grids. + + **Dense** (`array_format=dense`): body is the flattened cells as raw + bytes. + + - `X-Data-Dtype`: numeric type of the cells (e.g., `float32`). + + **Sparse** (`array_format=sparse`): body is the index array bytes + immediately followed by the value array bytes. + + - `X-Data-NNZ`: number of non-fill entries (length of both arrays). + - `X-Data-Index-Dtype`: numeric type of the index array (`int32`). + - `X-Data-Value-Dtype`: numeric type of the value array. + - `X-Data-Fill-Value`: the band's fill value (stringified). Omitted when + the band does not define a fill value, in which case every cell is + listed and no compression has been applied. + + Slice the response body at `NNZ * sizeof(index_dtype)` to separate + indices from values. + + ## Errors + + - **404**: Grid not found, not completed, or not accessible. + - **422**: Band does not exist on this grid, or chunk index out of range. + - **413**: Response would exceed the size limit. Try `array_format=sparse` + or a smaller chunk. + + Args: + domain_id (str): + grid_id (str): + band (str): + chunk_index (int): + array_format (GridDataArrayFormat | Unset): + order (GridDataOrder | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Any | HTTPValidationError + """ + + return sync_detailed( + domain_id=domain_id, + grid_id=grid_id, + band=band, + chunk_index=chunk_index, + client=client, + array_format=array_format, + order=order, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + grid_id: str, + band: str, + chunk_index: int, + *, + client: AuthenticatedClient, + array_format: GridDataArrayFormat | Unset = UNSET, + order: GridDataOrder | Unset = UNSET, +) -> Response[Any | HTTPValidationError]: + """Get band data for a chunk (binary) + + # Get Grid Data (binary) + + Returns the values of a single band within a single chunk of a completed + grid as raw bytes, with shape and type metadata in `X-Data-*` response + headers. Use this when you need the most compact wire format and intend + to deserialize into a typed array on the client. + + For a structured JSON payload, use the JSON variant of this endpoint + (drop the trailing `/binary`). + + ## Path Parameters + + - **domain_id**: The domain the grid belongs to. + - **grid_id**: The grid identifier. + - **band**: Band key to read (must be present in the grid's `bands`). + - **chunk_index**: Zero-based flat chunk index. 2D grids index in (y, x); + 3D grids index in (z, y, x). The chunk's shape, offset, and affine + transform are returned alongside the data — no separate metadata call + is required. (`GET …/chunks/{chunk_index}` is still available for + clients that want to lay out chunks before fetching data.) + + ## Query Parameters + + - **array_format**: `dense` (default) or `sparse`. Sparse compresses out + cells equal to the band's fill value, returning only the non-fill + entries. + - **order**: Flattening order — `C` (row-major, default) or `F` + (column-major). + + ## Response + + All variants return `application/octet-stream` with these common headers: + + - `X-Data-Shape`: comma-separated chunk dimensions (e.g., `47,61`). + - `X-Data-Order`: flattening order used (`C` or `F`). + - `X-Data-Format`: `dense` or `sparse`. + - `X-Data-Offset`: comma-separated pixel offset of this chunk within + the full grid (2D: `row,col`; 3D: `z,row,col`). + - `X-Data-Transform`: comma-separated six-element affine transform for + the chunk's spatial extent. + - `X-Data-Z-Origin`, `X-Data-Z-Resolution`: present only for 3D grids. + + **Dense** (`array_format=dense`): body is the flattened cells as raw + bytes. + + - `X-Data-Dtype`: numeric type of the cells (e.g., `float32`). + + **Sparse** (`array_format=sparse`): body is the index array bytes + immediately followed by the value array bytes. + + - `X-Data-NNZ`: number of non-fill entries (length of both arrays). + - `X-Data-Index-Dtype`: numeric type of the index array (`int32`). + - `X-Data-Value-Dtype`: numeric type of the value array. + - `X-Data-Fill-Value`: the band's fill value (stringified). Omitted when + the band does not define a fill value, in which case every cell is + listed and no compression has been applied. + + Slice the response body at `NNZ * sizeof(index_dtype)` to separate + indices from values. + + ## Errors + + - **404**: Grid not found, not completed, or not accessible. + - **422**: Band does not exist on this grid, or chunk index out of range. + - **413**: Response would exceed the size limit. Try `array_format=sparse` + or a smaller chunk. + + Args: + domain_id (str): + grid_id (str): + band (str): + chunk_index (int): + array_format (GridDataArrayFormat | Unset): + order (GridDataOrder | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + grid_id=grid_id, + band=band, + chunk_index=chunk_index, + array_format=array_format, + order=order, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + grid_id: str, + band: str, + chunk_index: int, + *, + client: AuthenticatedClient, + array_format: GridDataArrayFormat | Unset = UNSET, + order: GridDataOrder | Unset = UNSET, +) -> Any | HTTPValidationError | None: + """Get band data for a chunk (binary) + + # Get Grid Data (binary) + + Returns the values of a single band within a single chunk of a completed + grid as raw bytes, with shape and type metadata in `X-Data-*` response + headers. Use this when you need the most compact wire format and intend + to deserialize into a typed array on the client. + + For a structured JSON payload, use the JSON variant of this endpoint + (drop the trailing `/binary`). + + ## Path Parameters + + - **domain_id**: The domain the grid belongs to. + - **grid_id**: The grid identifier. + - **band**: Band key to read (must be present in the grid's `bands`). + - **chunk_index**: Zero-based flat chunk index. 2D grids index in (y, x); + 3D grids index in (z, y, x). The chunk's shape, offset, and affine + transform are returned alongside the data — no separate metadata call + is required. (`GET …/chunks/{chunk_index}` is still available for + clients that want to lay out chunks before fetching data.) + + ## Query Parameters + + - **array_format**: `dense` (default) or `sparse`. Sparse compresses out + cells equal to the band's fill value, returning only the non-fill + entries. + - **order**: Flattening order — `C` (row-major, default) or `F` + (column-major). + + ## Response + + All variants return `application/octet-stream` with these common headers: + + - `X-Data-Shape`: comma-separated chunk dimensions (e.g., `47,61`). + - `X-Data-Order`: flattening order used (`C` or `F`). + - `X-Data-Format`: `dense` or `sparse`. + - `X-Data-Offset`: comma-separated pixel offset of this chunk within + the full grid (2D: `row,col`; 3D: `z,row,col`). + - `X-Data-Transform`: comma-separated six-element affine transform for + the chunk's spatial extent. + - `X-Data-Z-Origin`, `X-Data-Z-Resolution`: present only for 3D grids. + + **Dense** (`array_format=dense`): body is the flattened cells as raw + bytes. + + - `X-Data-Dtype`: numeric type of the cells (e.g., `float32`). + + **Sparse** (`array_format=sparse`): body is the index array bytes + immediately followed by the value array bytes. + + - `X-Data-NNZ`: number of non-fill entries (length of both arrays). + - `X-Data-Index-Dtype`: numeric type of the index array (`int32`). + - `X-Data-Value-Dtype`: numeric type of the value array. + - `X-Data-Fill-Value`: the band's fill value (stringified). Omitted when + the band does not define a fill value, in which case every cell is + listed and no compression has been applied. + + Slice the response body at `NNZ * sizeof(index_dtype)` to separate + indices from values. + + ## Errors + + - **404**: Grid not found, not completed, or not accessible. + - **422**: Band does not exist on this grid, or chunk index out of range. + - **413**: Response would exceed the size limit. Try `array_format=sparse` + or a smaller chunk. + + Args: + domain_id (str): + grid_id (str): + band (str): + chunk_index (int): + array_format (GridDataArrayFormat | Unset): + order (GridDataOrder | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Any | HTTPValidationError + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + grid_id=grid_id, + band=band, + chunk_index=chunk_index, + client=client, + array_format=array_format, + order=order, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/grids/get_grid_data_json.py b/fastfuels_sdk/v2/client_library/api/grids/get_grid_data_json.py new file mode 100644 index 0000000..e8b50c1 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/grids/get_grid_data_json.py @@ -0,0 +1,457 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.grid_data_array_format import GridDataArrayFormat +from ...models.grid_data_order import GridDataOrder +from ...models.grid_data_response import GridDataResponse +from ...models.http_validation_error import HTTPValidationError +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + domain_id: str, + grid_id: str, + band: str, + chunk_index: int, + *, + array_format: GridDataArrayFormat | Unset = UNSET, + order: GridDataOrder | Unset = UNSET, +) -> dict[str, Any]: + + params: dict[str, Any] = {} + + json_array_format: str | Unset = UNSET + if not isinstance(array_format, Unset): + json_array_format = array_format.value + + params["array_format"] = json_array_format + + json_order: str | Unset = UNSET + if not isinstance(order, Unset): + json_order = order.value + + params["order"] = json_order + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/domains/{domain_id}/grids/{grid_id}/data/{band}/{chunk_index}".format( + domain_id=quote(str(domain_id), safe=""), + grid_id=quote(str(grid_id), safe=""), + band=quote(str(band), safe=""), + chunk_index=quote(str(chunk_index), safe=""), + ), + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> GridDataResponse | HTTPValidationError | None: + if response.status_code == 200: + response_200 = GridDataResponse.from_dict(response.json()) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[GridDataResponse | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + grid_id: str, + band: str, + chunk_index: int, + *, + client: AuthenticatedClient, + array_format: GridDataArrayFormat | Unset = UNSET, + order: GridDataOrder | Unset = UNSET, +) -> Response[GridDataResponse | HTTPValidationError]: + r"""Get band data for a chunk (JSON) + + # Get Grid Data (JSON) + + Returns the values of a single band within a single chunk of a completed + grid as a JSON payload — either dense values or a sparse representation, + selected via `array_format`. + + For raw bytes (smaller, faster to parse), use the `/binary` variant of + this endpoint. + + ## Path Parameters + + - **domain_id**: The domain the grid belongs to. + - **grid_id**: The grid identifier. + - **band**: Band key to read (must be present in the grid's `bands`). + - **chunk_index**: Zero-based flat chunk index. 2D grids index in (y, x); + 3D grids index in (z, y, x). The chunk's shape, offset, and affine + transform are returned alongside the data — no separate metadata call + is required. (`GET …/chunks/{chunk_index}` is still available for + clients that want to lay out chunks before fetching data.) + + ## Query Parameters + + - **array_format**: `dense` (default) or `sparse`. Sparse compresses out + cells equal to the band's fill value, returning only the non-fill + entries. + - **order**: Flattening order — `C` (row-major, default) or `F` + (column-major). + + ## Response + + Both variants share `shape` (chunk dimensions), `order` (flattening + order used for the values), and `metadata` (the chunk's index, shape, + offset, and affine transform — and `z_origin`/`z_resolution` for 3D + grids). The CRS does not vary per chunk; read it from the grid's + `georeference.crs`. + + **Dense** (`array_format=dense`): `data.format = \"dense\"`, `data.values` + is a flat list of all cells. + + **Sparse** (`array_format=sparse`): `data.format = \"sparse\"`, + `data.indices` are flat positions of non-fill cells, `data.values` are + their values. `data.fill_value` is the band's fill value, or `null` if + the band does not define one — in which case every cell is listed and no + compression has been applied. + + ## Errors + + - **404**: Grid not found, not completed, or not accessible. + - **422**: Band does not exist on this grid, or chunk index out of range. + - **413**: Response would exceed the size limit. Try `array_format=sparse`, + the `/binary` variant, or a smaller chunk. + + Args: + domain_id (str): + grid_id (str): + band (str): + chunk_index (int): + array_format (GridDataArrayFormat | Unset): + order (GridDataOrder | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[GridDataResponse | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + grid_id=grid_id, + band=band, + chunk_index=chunk_index, + array_format=array_format, + order=order, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + grid_id: str, + band: str, + chunk_index: int, + *, + client: AuthenticatedClient, + array_format: GridDataArrayFormat | Unset = UNSET, + order: GridDataOrder | Unset = UNSET, +) -> GridDataResponse | HTTPValidationError | None: + r"""Get band data for a chunk (JSON) + + # Get Grid Data (JSON) + + Returns the values of a single band within a single chunk of a completed + grid as a JSON payload — either dense values or a sparse representation, + selected via `array_format`. + + For raw bytes (smaller, faster to parse), use the `/binary` variant of + this endpoint. + + ## Path Parameters + + - **domain_id**: The domain the grid belongs to. + - **grid_id**: The grid identifier. + - **band**: Band key to read (must be present in the grid's `bands`). + - **chunk_index**: Zero-based flat chunk index. 2D grids index in (y, x); + 3D grids index in (z, y, x). The chunk's shape, offset, and affine + transform are returned alongside the data — no separate metadata call + is required. (`GET …/chunks/{chunk_index}` is still available for + clients that want to lay out chunks before fetching data.) + + ## Query Parameters + + - **array_format**: `dense` (default) or `sparse`. Sparse compresses out + cells equal to the band's fill value, returning only the non-fill + entries. + - **order**: Flattening order — `C` (row-major, default) or `F` + (column-major). + + ## Response + + Both variants share `shape` (chunk dimensions), `order` (flattening + order used for the values), and `metadata` (the chunk's index, shape, + offset, and affine transform — and `z_origin`/`z_resolution` for 3D + grids). The CRS does not vary per chunk; read it from the grid's + `georeference.crs`. + + **Dense** (`array_format=dense`): `data.format = \"dense\"`, `data.values` + is a flat list of all cells. + + **Sparse** (`array_format=sparse`): `data.format = \"sparse\"`, + `data.indices` are flat positions of non-fill cells, `data.values` are + their values. `data.fill_value` is the band's fill value, or `null` if + the band does not define one — in which case every cell is listed and no + compression has been applied. + + ## Errors + + - **404**: Grid not found, not completed, or not accessible. + - **422**: Band does not exist on this grid, or chunk index out of range. + - **413**: Response would exceed the size limit. Try `array_format=sparse`, + the `/binary` variant, or a smaller chunk. + + Args: + domain_id (str): + grid_id (str): + band (str): + chunk_index (int): + array_format (GridDataArrayFormat | Unset): + order (GridDataOrder | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + GridDataResponse | HTTPValidationError + """ + + return sync_detailed( + domain_id=domain_id, + grid_id=grid_id, + band=band, + chunk_index=chunk_index, + client=client, + array_format=array_format, + order=order, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + grid_id: str, + band: str, + chunk_index: int, + *, + client: AuthenticatedClient, + array_format: GridDataArrayFormat | Unset = UNSET, + order: GridDataOrder | Unset = UNSET, +) -> Response[GridDataResponse | HTTPValidationError]: + r"""Get band data for a chunk (JSON) + + # Get Grid Data (JSON) + + Returns the values of a single band within a single chunk of a completed + grid as a JSON payload — either dense values or a sparse representation, + selected via `array_format`. + + For raw bytes (smaller, faster to parse), use the `/binary` variant of + this endpoint. + + ## Path Parameters + + - **domain_id**: The domain the grid belongs to. + - **grid_id**: The grid identifier. + - **band**: Band key to read (must be present in the grid's `bands`). + - **chunk_index**: Zero-based flat chunk index. 2D grids index in (y, x); + 3D grids index in (z, y, x). The chunk's shape, offset, and affine + transform are returned alongside the data — no separate metadata call + is required. (`GET …/chunks/{chunk_index}` is still available for + clients that want to lay out chunks before fetching data.) + + ## Query Parameters + + - **array_format**: `dense` (default) or `sparse`. Sparse compresses out + cells equal to the band's fill value, returning only the non-fill + entries. + - **order**: Flattening order — `C` (row-major, default) or `F` + (column-major). + + ## Response + + Both variants share `shape` (chunk dimensions), `order` (flattening + order used for the values), and `metadata` (the chunk's index, shape, + offset, and affine transform — and `z_origin`/`z_resolution` for 3D + grids). The CRS does not vary per chunk; read it from the grid's + `georeference.crs`. + + **Dense** (`array_format=dense`): `data.format = \"dense\"`, `data.values` + is a flat list of all cells. + + **Sparse** (`array_format=sparse`): `data.format = \"sparse\"`, + `data.indices` are flat positions of non-fill cells, `data.values` are + their values. `data.fill_value` is the band's fill value, or `null` if + the band does not define one — in which case every cell is listed and no + compression has been applied. + + ## Errors + + - **404**: Grid not found, not completed, or not accessible. + - **422**: Band does not exist on this grid, or chunk index out of range. + - **413**: Response would exceed the size limit. Try `array_format=sparse`, + the `/binary` variant, or a smaller chunk. + + Args: + domain_id (str): + grid_id (str): + band (str): + chunk_index (int): + array_format (GridDataArrayFormat | Unset): + order (GridDataOrder | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[GridDataResponse | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + grid_id=grid_id, + band=band, + chunk_index=chunk_index, + array_format=array_format, + order=order, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + grid_id: str, + band: str, + chunk_index: int, + *, + client: AuthenticatedClient, + array_format: GridDataArrayFormat | Unset = UNSET, + order: GridDataOrder | Unset = UNSET, +) -> GridDataResponse | HTTPValidationError | None: + r"""Get band data for a chunk (JSON) + + # Get Grid Data (JSON) + + Returns the values of a single band within a single chunk of a completed + grid as a JSON payload — either dense values or a sparse representation, + selected via `array_format`. + + For raw bytes (smaller, faster to parse), use the `/binary` variant of + this endpoint. + + ## Path Parameters + + - **domain_id**: The domain the grid belongs to. + - **grid_id**: The grid identifier. + - **band**: Band key to read (must be present in the grid's `bands`). + - **chunk_index**: Zero-based flat chunk index. 2D grids index in (y, x); + 3D grids index in (z, y, x). The chunk's shape, offset, and affine + transform are returned alongside the data — no separate metadata call + is required. (`GET …/chunks/{chunk_index}` is still available for + clients that want to lay out chunks before fetching data.) + + ## Query Parameters + + - **array_format**: `dense` (default) or `sparse`. Sparse compresses out + cells equal to the band's fill value, returning only the non-fill + entries. + - **order**: Flattening order — `C` (row-major, default) or `F` + (column-major). + + ## Response + + Both variants share `shape` (chunk dimensions), `order` (flattening + order used for the values), and `metadata` (the chunk's index, shape, + offset, and affine transform — and `z_origin`/`z_resolution` for 3D + grids). The CRS does not vary per chunk; read it from the grid's + `georeference.crs`. + + **Dense** (`array_format=dense`): `data.format = \"dense\"`, `data.values` + is a flat list of all cells. + + **Sparse** (`array_format=sparse`): `data.format = \"sparse\"`, + `data.indices` are flat positions of non-fill cells, `data.values` are + their values. `data.fill_value` is the band's fill value, or `null` if + the band does not define one — in which case every cell is listed and no + compression has been applied. + + ## Errors + + - **404**: Grid not found, not completed, or not accessible. + - **422**: Band does not exist on this grid, or chunk index out of range. + - **413**: Response would exceed the size limit. Try `array_format=sparse`, + the `/binary` variant, or a smaller chunk. + + Args: + domain_id (str): + grid_id (str): + band (str): + chunk_index (int): + array_format (GridDataArrayFormat | Unset): + order (GridDataOrder | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + GridDataResponse | HTTPValidationError + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + grid_id=grid_id, + band=band, + chunk_index=chunk_index, + client=client, + array_format=array_format, + order=order, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/grids/list_grids.py b/fastfuels_sdk/v2/client_library/api/grids/list_grids.py new file mode 100644 index 0000000..a2fae31 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/grids/list_grids.py @@ -0,0 +1,403 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.grid_sort_field import GridSortField +from ...models.http_validation_error import HTTPValidationError +from ...models.list_grids_response import ListGridsResponse +from ...models.sort_order import SortOrder +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + domain_id: str, + *, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: GridSortField | None | Unset = UNSET, + sort_order: None | SortOrder | Unset = UNSET, + source: None | str | Unset = UNSET, + product: None | str | Unset = UNSET, + tag: None | str | Unset = UNSET, +) -> dict[str, Any]: + + params: dict[str, Any] = {} + + params["page"] = page + + params["size"] = size + + json_sort_by: None | str | Unset + if isinstance(sort_by, Unset): + json_sort_by = UNSET + elif isinstance(sort_by, GridSortField): + json_sort_by = sort_by.value + else: + json_sort_by = sort_by + params["sort_by"] = json_sort_by + + json_sort_order: None | str | Unset + if isinstance(sort_order, Unset): + json_sort_order = UNSET + elif isinstance(sort_order, SortOrder): + json_sort_order = sort_order.value + else: + json_sort_order = sort_order + params["sort_order"] = json_sort_order + + json_source: None | str | Unset + if isinstance(source, Unset): + json_source = UNSET + else: + json_source = source + params["source"] = json_source + + json_product: None | str | Unset + if isinstance(product, Unset): + json_product = UNSET + else: + json_product = product + params["product"] = json_product + + json_tag: None | str | Unset + if isinstance(tag, Unset): + json_tag = UNSET + else: + json_tag = tag + params["tag"] = json_tag + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/domains/{domain_id}/grids".format( + domain_id=quote(str(domain_id), safe=""), + ), + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> HTTPValidationError | ListGridsResponse | None: + if response.status_code == 200: + response_200 = ListGridsResponse.from_dict(response.json()) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[HTTPValidationError | ListGridsResponse]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: GridSortField | None | Unset = UNSET, + sort_order: None | SortOrder | Unset = UNSET, + source: None | str | Unset = UNSET, + product: None | str | Unset = UNSET, + tag: None | str | Unset = UNSET, +) -> Response[HTTPValidationError | ListGridsResponse]: + """List all grids + + # List Grids Endpoint + + Retrieves a paginated list of all grids within a domain belonging to the + authenticated user. + + ## Path Parameters + + - **domain_id**: (string) The domain to list grids for. + + ## Query Parameters + + - **page**: (integer, optional) Page number (zero-indexed). Default: 0. + - **size**: (integer, optional) Items per page (1-1000). Default: 100. + - **sort_by**: (string, optional) Field to sort by: `created_on`, `modified_on`, `name`. + - **sort_order**: (string, optional) Sort direction: `ascending`, `descending`. + - **source**: (string, optional) Filter grids by source name (e.g., `landfire`, `3dep`). + - **product**: (string, optional) Filter grids by source product (e.g., `fbfm40`, `topography`). + - **tag**: (string, optional) Filter grids that contain this tag. + + ## Response + + Returns a paginated list of grids with metadata. + + Args: + domain_id (str): + page (int | Unset): The page number to retrieve (zero-indexed). Default: 0. + size (int | Unset): The number of grids to retrieve per page. Default: 100. + sort_by (GridSortField | None | Unset): The field to sort results by. + sort_order (None | SortOrder | Unset): The order to sort results (ascending or + descending). + source (None | str | Unset): Filter grids by source name (e.g., 'landfire', '3dep'). + product (None | str | Unset): Filter grids by source product (e.g., 'fbfm40', + 'topography'). Requires source filter. + tag (None | str | Unset): Filter grids that contain this tag. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | ListGridsResponse] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + page=page, + size=size, + sort_by=sort_by, + sort_order=sort_order, + source=source, + product=product, + tag=tag, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: GridSortField | None | Unset = UNSET, + sort_order: None | SortOrder | Unset = UNSET, + source: None | str | Unset = UNSET, + product: None | str | Unset = UNSET, + tag: None | str | Unset = UNSET, +) -> HTTPValidationError | ListGridsResponse | None: + """List all grids + + # List Grids Endpoint + + Retrieves a paginated list of all grids within a domain belonging to the + authenticated user. + + ## Path Parameters + + - **domain_id**: (string) The domain to list grids for. + + ## Query Parameters + + - **page**: (integer, optional) Page number (zero-indexed). Default: 0. + - **size**: (integer, optional) Items per page (1-1000). Default: 100. + - **sort_by**: (string, optional) Field to sort by: `created_on`, `modified_on`, `name`. + - **sort_order**: (string, optional) Sort direction: `ascending`, `descending`. + - **source**: (string, optional) Filter grids by source name (e.g., `landfire`, `3dep`). + - **product**: (string, optional) Filter grids by source product (e.g., `fbfm40`, `topography`). + - **tag**: (string, optional) Filter grids that contain this tag. + + ## Response + + Returns a paginated list of grids with metadata. + + Args: + domain_id (str): + page (int | Unset): The page number to retrieve (zero-indexed). Default: 0. + size (int | Unset): The number of grids to retrieve per page. Default: 100. + sort_by (GridSortField | None | Unset): The field to sort results by. + sort_order (None | SortOrder | Unset): The order to sort results (ascending or + descending). + source (None | str | Unset): Filter grids by source name (e.g., 'landfire', '3dep'). + product (None | str | Unset): Filter grids by source product (e.g., 'fbfm40', + 'topography'). Requires source filter. + tag (None | str | Unset): Filter grids that contain this tag. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | ListGridsResponse + """ + + return sync_detailed( + domain_id=domain_id, + client=client, + page=page, + size=size, + sort_by=sort_by, + sort_order=sort_order, + source=source, + product=product, + tag=tag, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: GridSortField | None | Unset = UNSET, + sort_order: None | SortOrder | Unset = UNSET, + source: None | str | Unset = UNSET, + product: None | str | Unset = UNSET, + tag: None | str | Unset = UNSET, +) -> Response[HTTPValidationError | ListGridsResponse]: + """List all grids + + # List Grids Endpoint + + Retrieves a paginated list of all grids within a domain belonging to the + authenticated user. + + ## Path Parameters + + - **domain_id**: (string) The domain to list grids for. + + ## Query Parameters + + - **page**: (integer, optional) Page number (zero-indexed). Default: 0. + - **size**: (integer, optional) Items per page (1-1000). Default: 100. + - **sort_by**: (string, optional) Field to sort by: `created_on`, `modified_on`, `name`. + - **sort_order**: (string, optional) Sort direction: `ascending`, `descending`. + - **source**: (string, optional) Filter grids by source name (e.g., `landfire`, `3dep`). + - **product**: (string, optional) Filter grids by source product (e.g., `fbfm40`, `topography`). + - **tag**: (string, optional) Filter grids that contain this tag. + + ## Response + + Returns a paginated list of grids with metadata. + + Args: + domain_id (str): + page (int | Unset): The page number to retrieve (zero-indexed). Default: 0. + size (int | Unset): The number of grids to retrieve per page. Default: 100. + sort_by (GridSortField | None | Unset): The field to sort results by. + sort_order (None | SortOrder | Unset): The order to sort results (ascending or + descending). + source (None | str | Unset): Filter grids by source name (e.g., 'landfire', '3dep'). + product (None | str | Unset): Filter grids by source product (e.g., 'fbfm40', + 'topography'). Requires source filter. + tag (None | str | Unset): Filter grids that contain this tag. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | ListGridsResponse] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + page=page, + size=size, + sort_by=sort_by, + sort_order=sort_order, + source=source, + product=product, + tag=tag, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: GridSortField | None | Unset = UNSET, + sort_order: None | SortOrder | Unset = UNSET, + source: None | str | Unset = UNSET, + product: None | str | Unset = UNSET, + tag: None | str | Unset = UNSET, +) -> HTTPValidationError | ListGridsResponse | None: + """List all grids + + # List Grids Endpoint + + Retrieves a paginated list of all grids within a domain belonging to the + authenticated user. + + ## Path Parameters + + - **domain_id**: (string) The domain to list grids for. + + ## Query Parameters + + - **page**: (integer, optional) Page number (zero-indexed). Default: 0. + - **size**: (integer, optional) Items per page (1-1000). Default: 100. + - **sort_by**: (string, optional) Field to sort by: `created_on`, `modified_on`, `name`. + - **sort_order**: (string, optional) Sort direction: `ascending`, `descending`. + - **source**: (string, optional) Filter grids by source name (e.g., `landfire`, `3dep`). + - **product**: (string, optional) Filter grids by source product (e.g., `fbfm40`, `topography`). + - **tag**: (string, optional) Filter grids that contain this tag. + + ## Response + + Returns a paginated list of grids with metadata. + + Args: + domain_id (str): + page (int | Unset): The page number to retrieve (zero-indexed). Default: 0. + size (int | Unset): The number of grids to retrieve per page. Default: 100. + sort_by (GridSortField | None | Unset): The field to sort results by. + sort_order (None | SortOrder | Unset): The order to sort results (ascending or + descending). + source (None | str | Unset): Filter grids by source name (e.g., 'landfire', '3dep'). + product (None | str | Unset): Filter grids by source product (e.g., 'fbfm40', + 'topography'). Requires source filter. + tag (None | str | Unset): Filter grids that contain this tag. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | ListGridsResponse + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + client=client, + page=page, + size=size, + sort_by=sort_by, + sort_order=sort_order, + source=source, + product=product, + tag=tag, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/grids/list_grids_cross_domain.py b/fastfuels_sdk/v2/client_library/api/grids/list_grids_cross_domain.py new file mode 100644 index 0000000..94ec944 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/grids/list_grids_cross_domain.py @@ -0,0 +1,371 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.grid_sort_field import GridSortField +from ...models.http_validation_error import HTTPValidationError +from ...models.list_grids_response import ListGridsResponse +from ...models.sort_order import SortOrder +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + *, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: GridSortField | None | Unset = UNSET, + sort_order: None | SortOrder | Unset = UNSET, + source: None | str | Unset = UNSET, + product: None | str | Unset = UNSET, + tag: None | str | Unset = UNSET, +) -> dict[str, Any]: + + params: dict[str, Any] = {} + + params["page"] = page + + params["size"] = size + + json_sort_by: None | str | Unset + if isinstance(sort_by, Unset): + json_sort_by = UNSET + elif isinstance(sort_by, GridSortField): + json_sort_by = sort_by.value + else: + json_sort_by = sort_by + params["sort_by"] = json_sort_by + + json_sort_order: None | str | Unset + if isinstance(sort_order, Unset): + json_sort_order = UNSET + elif isinstance(sort_order, SortOrder): + json_sort_order = sort_order.value + else: + json_sort_order = sort_order + params["sort_order"] = json_sort_order + + json_source: None | str | Unset + if isinstance(source, Unset): + json_source = UNSET + else: + json_source = source + params["source"] = json_source + + json_product: None | str | Unset + if isinstance(product, Unset): + json_product = UNSET + else: + json_product = product + params["product"] = json_product + + json_tag: None | str | Unset + if isinstance(tag, Unset): + json_tag = UNSET + else: + json_tag = tag + params["tag"] = json_tag + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/domains/-/grids", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> HTTPValidationError | ListGridsResponse | None: + if response.status_code == 200: + response_200 = ListGridsResponse.from_dict(response.json()) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[HTTPValidationError | ListGridsResponse]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: GridSortField | None | Unset = UNSET, + sort_order: None | SortOrder | Unset = UNSET, + source: None | str | Unset = UNSET, + product: None | str | Unset = UNSET, + tag: None | str | Unset = UNSET, +) -> Response[HTTPValidationError | ListGridsResponse]: + """List grids across all domains + + # List Grids Across All Domains Endpoint + + Retrieves a paginated list of all grids across all domains belonging to the + authenticated user. + + ## Query Parameters + + - **page**: (integer, optional) Page number (zero-indexed). Default: 0. + - **size**: (integer, optional) Items per page (1-1000). Default: 100. + - **sort_by**: (string, optional) Field to sort by: `created_on`, `modified_on`, `name`. + - **sort_order**: (string, optional) Sort direction: `ascending`, `descending`. + - **source**: (string, optional) Filter grids by source name (e.g., `landfire`, `3dep`). + - **product**: (string, optional) Filter grids by source product (e.g., `fbfm40`, `topography`). + - **tag**: (string, optional) Filter grids that contain this tag. + + ## Response + + Returns a paginated list of grids with metadata. + + Args: + page (int | Unset): The page number to retrieve (zero-indexed). Default: 0. + size (int | Unset): The number of grids to retrieve per page. Default: 100. + sort_by (GridSortField | None | Unset): The field to sort results by. + sort_order (None | SortOrder | Unset): The order to sort results (ascending or + descending). + source (None | str | Unset): Filter grids by source name (e.g., 'landfire', '3dep'). + product (None | str | Unset): Filter grids by source product (e.g., 'fbfm40', + 'topography'). Requires source filter. + tag (None | str | Unset): Filter grids that contain this tag. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | ListGridsResponse] + """ + + kwargs = _get_kwargs( + page=page, + size=size, + sort_by=sort_by, + sort_order=sort_order, + source=source, + product=product, + tag=tag, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: GridSortField | None | Unset = UNSET, + sort_order: None | SortOrder | Unset = UNSET, + source: None | str | Unset = UNSET, + product: None | str | Unset = UNSET, + tag: None | str | Unset = UNSET, +) -> HTTPValidationError | ListGridsResponse | None: + """List grids across all domains + + # List Grids Across All Domains Endpoint + + Retrieves a paginated list of all grids across all domains belonging to the + authenticated user. + + ## Query Parameters + + - **page**: (integer, optional) Page number (zero-indexed). Default: 0. + - **size**: (integer, optional) Items per page (1-1000). Default: 100. + - **sort_by**: (string, optional) Field to sort by: `created_on`, `modified_on`, `name`. + - **sort_order**: (string, optional) Sort direction: `ascending`, `descending`. + - **source**: (string, optional) Filter grids by source name (e.g., `landfire`, `3dep`). + - **product**: (string, optional) Filter grids by source product (e.g., `fbfm40`, `topography`). + - **tag**: (string, optional) Filter grids that contain this tag. + + ## Response + + Returns a paginated list of grids with metadata. + + Args: + page (int | Unset): The page number to retrieve (zero-indexed). Default: 0. + size (int | Unset): The number of grids to retrieve per page. Default: 100. + sort_by (GridSortField | None | Unset): The field to sort results by. + sort_order (None | SortOrder | Unset): The order to sort results (ascending or + descending). + source (None | str | Unset): Filter grids by source name (e.g., 'landfire', '3dep'). + product (None | str | Unset): Filter grids by source product (e.g., 'fbfm40', + 'topography'). Requires source filter. + tag (None | str | Unset): Filter grids that contain this tag. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | ListGridsResponse + """ + + return sync_detailed( + client=client, + page=page, + size=size, + sort_by=sort_by, + sort_order=sort_order, + source=source, + product=product, + tag=tag, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: GridSortField | None | Unset = UNSET, + sort_order: None | SortOrder | Unset = UNSET, + source: None | str | Unset = UNSET, + product: None | str | Unset = UNSET, + tag: None | str | Unset = UNSET, +) -> Response[HTTPValidationError | ListGridsResponse]: + """List grids across all domains + + # List Grids Across All Domains Endpoint + + Retrieves a paginated list of all grids across all domains belonging to the + authenticated user. + + ## Query Parameters + + - **page**: (integer, optional) Page number (zero-indexed). Default: 0. + - **size**: (integer, optional) Items per page (1-1000). Default: 100. + - **sort_by**: (string, optional) Field to sort by: `created_on`, `modified_on`, `name`. + - **sort_order**: (string, optional) Sort direction: `ascending`, `descending`. + - **source**: (string, optional) Filter grids by source name (e.g., `landfire`, `3dep`). + - **product**: (string, optional) Filter grids by source product (e.g., `fbfm40`, `topography`). + - **tag**: (string, optional) Filter grids that contain this tag. + + ## Response + + Returns a paginated list of grids with metadata. + + Args: + page (int | Unset): The page number to retrieve (zero-indexed). Default: 0. + size (int | Unset): The number of grids to retrieve per page. Default: 100. + sort_by (GridSortField | None | Unset): The field to sort results by. + sort_order (None | SortOrder | Unset): The order to sort results (ascending or + descending). + source (None | str | Unset): Filter grids by source name (e.g., 'landfire', '3dep'). + product (None | str | Unset): Filter grids by source product (e.g., 'fbfm40', + 'topography'). Requires source filter. + tag (None | str | Unset): Filter grids that contain this tag. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | ListGridsResponse] + """ + + kwargs = _get_kwargs( + page=page, + size=size, + sort_by=sort_by, + sort_order=sort_order, + source=source, + product=product, + tag=tag, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: GridSortField | None | Unset = UNSET, + sort_order: None | SortOrder | Unset = UNSET, + source: None | str | Unset = UNSET, + product: None | str | Unset = UNSET, + tag: None | str | Unset = UNSET, +) -> HTTPValidationError | ListGridsResponse | None: + """List grids across all domains + + # List Grids Across All Domains Endpoint + + Retrieves a paginated list of all grids across all domains belonging to the + authenticated user. + + ## Query Parameters + + - **page**: (integer, optional) Page number (zero-indexed). Default: 0. + - **size**: (integer, optional) Items per page (1-1000). Default: 100. + - **sort_by**: (string, optional) Field to sort by: `created_on`, `modified_on`, `name`. + - **sort_order**: (string, optional) Sort direction: `ascending`, `descending`. + - **source**: (string, optional) Filter grids by source name (e.g., `landfire`, `3dep`). + - **product**: (string, optional) Filter grids by source product (e.g., `fbfm40`, `topography`). + - **tag**: (string, optional) Filter grids that contain this tag. + + ## Response + + Returns a paginated list of grids with metadata. + + Args: + page (int | Unset): The page number to retrieve (zero-indexed). Default: 0. + size (int | Unset): The number of grids to retrieve per page. Default: 100. + sort_by (GridSortField | None | Unset): The field to sort results by. + sort_order (None | SortOrder | Unset): The order to sort results (ascending or + descending). + source (None | str | Unset): Filter grids by source name (e.g., 'landfire', '3dep'). + product (None | str | Unset): Filter grids by source product (e.g., 'fbfm40', + 'topography'). Requires source filter. + tag (None | str | Unset): Filter grids that contain this tag. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | ListGridsResponse + """ + + return ( + await asyncio_detailed( + client=client, + page=page, + size=size, + sort_by=sort_by, + sort_order=sort_order, + source=source, + product=product, + tag=tag, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/grids/update_grid.py b/fastfuels_sdk/v2/client_library/api/grids/update_grid.py new file mode 100644 index 0000000..9f57cd8 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/grids/update_grid.py @@ -0,0 +1,328 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.grid import Grid +from ...models.http_validation_error import HTTPValidationError +from ...models.update_grid_request_body import UpdateGridRequestBody +from ...types import Response + + +def _get_kwargs( + domain_id: str, + grid_id: str, + *, + body: UpdateGridRequestBody, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "patch", + "url": "/domains/{domain_id}/grids/{grid_id}".format( + domain_id=quote(str(domain_id), safe=""), + grid_id=quote(str(grid_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Grid | HTTPValidationError | None: + if response.status_code == 200: + response_200 = Grid.from_dict(response.json()) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Grid | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + grid_id: str, + *, + client: AuthenticatedClient, + body: UpdateGridRequestBody, +) -> Response[Grid | HTTPValidationError]: + """Update a grid + + # Update Grid Endpoint + + Updates the metadata of an existing grid resource. Only the fields provided + in the request body will be modified. + + ## Path Parameters + + - **domain_id**: (string) The domain the grid belongs to. + - **grid_id**: (string) The unique identifier of the grid. + + ## Request Body + + All fields are optional: + + - **name**: (string) New name for the grid. + - **description**: (string) New description. + - **tags**: (array of strings) New tags (replaces existing). + + ## What Cannot Be Updated + + The following fields are immutable: + + - **id**, **domain_id**, **source**, **modifications**, **bands**, **georeference** + - **created_on** (creation timestamp is permanent) + - **checksum** (changes only when the grid's content is rebuilt, never via + metadata updates) + + The **modified_on** field is automatically updated. + + ## Response + + Returns the updated grid resource. + + Args: + domain_id (str): + grid_id (str): + body (UpdateGridRequestBody): Request body for updating grid metadata. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + grid_id=grid_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + grid_id: str, + *, + client: AuthenticatedClient, + body: UpdateGridRequestBody, +) -> Grid | HTTPValidationError | None: + """Update a grid + + # Update Grid Endpoint + + Updates the metadata of an existing grid resource. Only the fields provided + in the request body will be modified. + + ## Path Parameters + + - **domain_id**: (string) The domain the grid belongs to. + - **grid_id**: (string) The unique identifier of the grid. + + ## Request Body + + All fields are optional: + + - **name**: (string) New name for the grid. + - **description**: (string) New description. + - **tags**: (array of strings) New tags (replaces existing). + + ## What Cannot Be Updated + + The following fields are immutable: + + - **id**, **domain_id**, **source**, **modifications**, **bands**, **georeference** + - **created_on** (creation timestamp is permanent) + - **checksum** (changes only when the grid's content is rebuilt, never via + metadata updates) + + The **modified_on** field is automatically updated. + + ## Response + + Returns the updated grid resource. + + Args: + domain_id (str): + grid_id (str): + body (UpdateGridRequestBody): Request body for updating grid metadata. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError + """ + + return sync_detailed( + domain_id=domain_id, + grid_id=grid_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + grid_id: str, + *, + client: AuthenticatedClient, + body: UpdateGridRequestBody, +) -> Response[Grid | HTTPValidationError]: + """Update a grid + + # Update Grid Endpoint + + Updates the metadata of an existing grid resource. Only the fields provided + in the request body will be modified. + + ## Path Parameters + + - **domain_id**: (string) The domain the grid belongs to. + - **grid_id**: (string) The unique identifier of the grid. + + ## Request Body + + All fields are optional: + + - **name**: (string) New name for the grid. + - **description**: (string) New description. + - **tags**: (array of strings) New tags (replaces existing). + + ## What Cannot Be Updated + + The following fields are immutable: + + - **id**, **domain_id**, **source**, **modifications**, **bands**, **georeference** + - **created_on** (creation timestamp is permanent) + - **checksum** (changes only when the grid's content is rebuilt, never via + metadata updates) + + The **modified_on** field is automatically updated. + + ## Response + + Returns the updated grid resource. + + Args: + domain_id (str): + grid_id (str): + body (UpdateGridRequestBody): Request body for updating grid metadata. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + grid_id=grid_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + grid_id: str, + *, + client: AuthenticatedClient, + body: UpdateGridRequestBody, +) -> Grid | HTTPValidationError | None: + """Update a grid + + # Update Grid Endpoint + + Updates the metadata of an existing grid resource. Only the fields provided + in the request body will be modified. + + ## Path Parameters + + - **domain_id**: (string) The domain the grid belongs to. + - **grid_id**: (string) The unique identifier of the grid. + + ## Request Body + + All fields are optional: + + - **name**: (string) New name for the grid. + - **description**: (string) New description. + - **tags**: (array of strings) New tags (replaces existing). + + ## What Cannot Be Updated + + The following fields are immutable: + + - **id**, **domain_id**, **source**, **modifications**, **bands**, **georeference** + - **created_on** (creation timestamp is permanent) + - **checksum** (changes only when the grid's content is rebuilt, never via + metadata updates) + + The **modified_on** field is automatically updated. + + ## Response + + Returns the updated grid resource. + + Args: + domain_id (str): + grid_id (str): + body (UpdateGridRequestBody): Request body for updating grid metadata. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + grid_id=grid_id, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/index/__init__.py b/fastfuels_sdk/v2/client_library/api/index/__init__.py new file mode 100644 index 0000000..2d7c0b2 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/index/__init__.py @@ -0,0 +1 @@ +"""Contains endpoint functions for accessing the API""" diff --git a/fastfuels_sdk/v2/client_library/api/index/index.py b/fastfuels_sdk/v2/client_library/api/index/index.py new file mode 100644 index 0000000..7a87374 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/index/index.py @@ -0,0 +1,91 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...types import Response + + +def _get_kwargs() -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/", + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Any | None: + if response.status_code == 200: + return None + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Any]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient | Client, +) -> Response[Any]: + """Welcome / service metadata + + Returns service metadata, documentation links, and the current deployment status. Useful as a + liveness check and as a starting point for discovering the API. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any] + """ + + kwargs = _get_kwargs() + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, +) -> Response[Any]: + """Welcome / service metadata + + Returns service metadata, documentation links, and the current deployment status. Useful as a + liveness check and as a starting point for discovering the API. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any] + """ + + kwargs = _get_kwargs() + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) diff --git a/fastfuels_sdk/v2/client_library/api/inventories/__init__.py b/fastfuels_sdk/v2/client_library/api/inventories/__init__.py new file mode 100644 index 0000000..2d7c0b2 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/inventories/__init__.py @@ -0,0 +1 @@ +"""Contains endpoint functions for accessing the API""" diff --git a/fastfuels_sdk/v2/client_library/api/inventories/create_chm_inventory.py b/fastfuels_sdk/v2/client_library/api/inventories/create_chm_inventory.py new file mode 100644 index 0000000..42e2ba2 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/inventories/create_chm_inventory.py @@ -0,0 +1,290 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.create_chm_inventory_request import CreateChmInventoryRequest +from ...models.http_validation_error import HTTPValidationError +from ...models.inventory import Inventory +from ...types import Response + + +def _get_kwargs( + domain_id: str, + *, + body: CreateChmInventoryRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/domains/{domain_id}/inventories/tree/chm".format( + domain_id=quote(str(domain_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> HTTPValidationError | Inventory | None: + if response.status_code == 201: + response_201 = Inventory.from_dict(response.json()) + + return response_201 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[HTTPValidationError | Inventory]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateChmInventoryRequest, +) -> Response[HTTPValidationError | Inventory]: + r"""Create an inventory from a Canopy Height Model (CHM) + + # Create CHM Extraction Inventory + + Extracts individual tree records from a Canopy Height Model (CHM) grid + using a specified stem isolation algorithm. + + Currently supports two algorithms: + 1. **Local Maximum Filtering (LMF)**: Sweeps a fixed circular window across the CHM. + 2. **Variable Window Filtering (VWF)**: Sweeps a dynamic window that scales in size based on the + height of the canopy, allowing for better detection of mixed stand structures. + + ## Request Body + + - **source_chm_grid_id**: (required) ID of a completed CHM grid. + - **algorithm**: (optional) Configuration for the stem isolation algorithm. Must specify `\"name\": + \"lmf\"` or `\"name\": \"vwf\"`. Defaults to LMF. + - **type**: (optional) Entity type. Default: ``\"tree\"``. + - **name**: (optional) Name for the inventory. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing inventories. + + ## Response + + Returns the created Inventory resource with status ``\"pending\"``. The + backend (Standgen) will process the extraction asynchronously and update + status to ``\"completed\"`` when ready. + + Args: + domain_id (str): + body (CreateChmInventoryRequest): Request body for creating an inventory via CHM + extraction. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | Inventory] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateChmInventoryRequest, +) -> HTTPValidationError | Inventory | None: + r"""Create an inventory from a Canopy Height Model (CHM) + + # Create CHM Extraction Inventory + + Extracts individual tree records from a Canopy Height Model (CHM) grid + using a specified stem isolation algorithm. + + Currently supports two algorithms: + 1. **Local Maximum Filtering (LMF)**: Sweeps a fixed circular window across the CHM. + 2. **Variable Window Filtering (VWF)**: Sweeps a dynamic window that scales in size based on the + height of the canopy, allowing for better detection of mixed stand structures. + + ## Request Body + + - **source_chm_grid_id**: (required) ID of a completed CHM grid. + - **algorithm**: (optional) Configuration for the stem isolation algorithm. Must specify `\"name\": + \"lmf\"` or `\"name\": \"vwf\"`. Defaults to LMF. + - **type**: (optional) Entity type. Default: ``\"tree\"``. + - **name**: (optional) Name for the inventory. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing inventories. + + ## Response + + Returns the created Inventory resource with status ``\"pending\"``. The + backend (Standgen) will process the extraction asynchronously and update + status to ``\"completed\"`` when ready. + + Args: + domain_id (str): + body (CreateChmInventoryRequest): Request body for creating an inventory via CHM + extraction. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | Inventory + """ + + return sync_detailed( + domain_id=domain_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateChmInventoryRequest, +) -> Response[HTTPValidationError | Inventory]: + r"""Create an inventory from a Canopy Height Model (CHM) + + # Create CHM Extraction Inventory + + Extracts individual tree records from a Canopy Height Model (CHM) grid + using a specified stem isolation algorithm. + + Currently supports two algorithms: + 1. **Local Maximum Filtering (LMF)**: Sweeps a fixed circular window across the CHM. + 2. **Variable Window Filtering (VWF)**: Sweeps a dynamic window that scales in size based on the + height of the canopy, allowing for better detection of mixed stand structures. + + ## Request Body + + - **source_chm_grid_id**: (required) ID of a completed CHM grid. + - **algorithm**: (optional) Configuration for the stem isolation algorithm. Must specify `\"name\": + \"lmf\"` or `\"name\": \"vwf\"`. Defaults to LMF. + - **type**: (optional) Entity type. Default: ``\"tree\"``. + - **name**: (optional) Name for the inventory. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing inventories. + + ## Response + + Returns the created Inventory resource with status ``\"pending\"``. The + backend (Standgen) will process the extraction asynchronously and update + status to ``\"completed\"`` when ready. + + Args: + domain_id (str): + body (CreateChmInventoryRequest): Request body for creating an inventory via CHM + extraction. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | Inventory] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateChmInventoryRequest, +) -> HTTPValidationError | Inventory | None: + r"""Create an inventory from a Canopy Height Model (CHM) + + # Create CHM Extraction Inventory + + Extracts individual tree records from a Canopy Height Model (CHM) grid + using a specified stem isolation algorithm. + + Currently supports two algorithms: + 1. **Local Maximum Filtering (LMF)**: Sweeps a fixed circular window across the CHM. + 2. **Variable Window Filtering (VWF)**: Sweeps a dynamic window that scales in size based on the + height of the canopy, allowing for better detection of mixed stand structures. + + ## Request Body + + - **source_chm_grid_id**: (required) ID of a completed CHM grid. + - **algorithm**: (optional) Configuration for the stem isolation algorithm. Must specify `\"name\": + \"lmf\"` or `\"name\": \"vwf\"`. Defaults to LMF. + - **type**: (optional) Entity type. Default: ``\"tree\"``. + - **name**: (optional) Name for the inventory. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing inventories. + + ## Response + + Returns the created Inventory resource with status ``\"pending\"``. The + backend (Standgen) will process the extraction asynchronously and update + status to ``\"completed\"`` when ready. + + Args: + domain_id (str): + body (CreateChmInventoryRequest): Request body for creating an inventory via CHM + extraction. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | Inventory + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/inventories/create_inventory_export.py b/fastfuels_sdk/v2/client_library/api/inventories/create_inventory_export.py new file mode 100644 index 0000000..adc408a --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/inventories/create_inventory_export.py @@ -0,0 +1,267 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.export import Export +from ...models.export_inventory_request import ExportInventoryRequest +from ...models.http_validation_error import HTTPValidationError +from ...models.inventory_export_format import InventoryExportFormat +from ...types import Response + + +def _get_kwargs( + domain_id: str, + inventory_id: str, + format_: InventoryExportFormat, + *, + body: ExportInventoryRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/domains/{domain_id}/inventories/{inventory_id}/exports/{format_}".format( + domain_id=quote(str(domain_id), safe=""), + inventory_id=quote(str(inventory_id), safe=""), + format_=quote(str(format_), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Export | HTTPValidationError | None: + if response.status_code == 201: + response_201 = Export.from_dict(response.json()) + + return response_201 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Export | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + inventory_id: str, + format_: InventoryExportFormat, + *, + client: AuthenticatedClient, + body: ExportInventoryRequest, +) -> Response[Export | HTTPValidationError]: + """Export an inventory + + Export an inventory to the specified format. + + Supported formats: `parquet` (zipped), `csv`, `geojson`, `geopackage`. + + The inventory must belong to this domain and have status `completed`. + If `columns` is specified, only those columns are included; otherwise + all columns are exported. + + Returns an Export resource with status `pending`. Poll + `GET /exports/{export_id}` until status is `completed` to get the + signed download URL. + + Args: + domain_id (str): + inventory_id (str): + format_ (InventoryExportFormat): Supported inventory export formats. + body (ExportInventoryRequest): Request body for creating an inventory export. + + Used at: POST /domains/{domain_id}/inventories/{inventory_id}/exports/{format} + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Export | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + inventory_id=inventory_id, + format_=format_, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + inventory_id: str, + format_: InventoryExportFormat, + *, + client: AuthenticatedClient, + body: ExportInventoryRequest, +) -> Export | HTTPValidationError | None: + """Export an inventory + + Export an inventory to the specified format. + + Supported formats: `parquet` (zipped), `csv`, `geojson`, `geopackage`. + + The inventory must belong to this domain and have status `completed`. + If `columns` is specified, only those columns are included; otherwise + all columns are exported. + + Returns an Export resource with status `pending`. Poll + `GET /exports/{export_id}` until status is `completed` to get the + signed download URL. + + Args: + domain_id (str): + inventory_id (str): + format_ (InventoryExportFormat): Supported inventory export formats. + body (ExportInventoryRequest): Request body for creating an inventory export. + + Used at: POST /domains/{domain_id}/inventories/{inventory_id}/exports/{format} + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Export | HTTPValidationError + """ + + return sync_detailed( + domain_id=domain_id, + inventory_id=inventory_id, + format_=format_, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + inventory_id: str, + format_: InventoryExportFormat, + *, + client: AuthenticatedClient, + body: ExportInventoryRequest, +) -> Response[Export | HTTPValidationError]: + """Export an inventory + + Export an inventory to the specified format. + + Supported formats: `parquet` (zipped), `csv`, `geojson`, `geopackage`. + + The inventory must belong to this domain and have status `completed`. + If `columns` is specified, only those columns are included; otherwise + all columns are exported. + + Returns an Export resource with status `pending`. Poll + `GET /exports/{export_id}` until status is `completed` to get the + signed download URL. + + Args: + domain_id (str): + inventory_id (str): + format_ (InventoryExportFormat): Supported inventory export formats. + body (ExportInventoryRequest): Request body for creating an inventory export. + + Used at: POST /domains/{domain_id}/inventories/{inventory_id}/exports/{format} + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Export | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + inventory_id=inventory_id, + format_=format_, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + inventory_id: str, + format_: InventoryExportFormat, + *, + client: AuthenticatedClient, + body: ExportInventoryRequest, +) -> Export | HTTPValidationError | None: + """Export an inventory + + Export an inventory to the specified format. + + Supported formats: `parquet` (zipped), `csv`, `geojson`, `geopackage`. + + The inventory must belong to this domain and have status `completed`. + If `columns` is specified, only those columns are included; otherwise + all columns are exported. + + Returns an Export resource with status `pending`. Poll + `GET /exports/{export_id}` until status is `completed` to get the + signed download URL. + + Args: + domain_id (str): + inventory_id (str): + format_ (InventoryExportFormat): Supported inventory export formats. + body (ExportInventoryRequest): Request body for creating an inventory export. + + Used at: POST /domains/{domain_id}/inventories/{inventory_id}/exports/{format} + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Export | HTTPValidationError + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + inventory_id=inventory_id, + format_=format_, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/inventories/create_inventory_upload.py b/fastfuels_sdk/v2/client_library/api/inventories/create_inventory_upload.py new file mode 100644 index 0000000..dd46d70 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/inventories/create_inventory_upload.py @@ -0,0 +1,278 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.create_inventory_upload_request import CreateInventoryUploadRequest +from ...models.http_validation_error import HTTPValidationError +from ...models.inventory_upload_created_response import InventoryUploadCreatedResponse +from ...types import Response + + +def _get_kwargs( + domain_id: str, + *, + body: CreateInventoryUploadRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/domains/{domain_id}/inventories/tree/upload".format( + domain_id=quote(str(domain_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> HTTPValidationError | InventoryUploadCreatedResponse | None: + if response.status_code == 201: + response_201 = InventoryUploadCreatedResponse.from_dict(response.json()) + + return response_201 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[HTTPValidationError | InventoryUploadCreatedResponse]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateInventoryUploadRequest, +) -> Response[HTTPValidationError | InventoryUploadCreatedResponse]: + """Create an inventory from a direct file upload + + # Create Upload Inventory + + Creates an inventory resource and returns a signed URL for uploading the + source file directly to GCS. The upload must use HTTP PUT with the + Content-Type header matching the value in the response. + + When the upload completes, the uploader service processes the file + automatically via Eventarc and updates the inventory status to + `completed` (or `failed` on error). + + ## Supported Formats + + - **csv**: Comma-separated values. Coordinates must already be in the + domain's CRS. + - **geojson**: GeoJSON FeatureCollection with Point or MultiPoint + geometries. Reprojected to domain CRS automatically. + - **geopackage**: OGC GeoPackage. Reprojected to domain CRS automatically. + + ## Column Mapping + + Use the `columns` field to map v2 column names to the column names in + your file. Omit entries where the file already uses v2 names. Required + in the file: `x`, `y`, `height`. + + Args: + domain_id (str): + body (CreateInventoryUploadRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | InventoryUploadCreatedResponse] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateInventoryUploadRequest, +) -> HTTPValidationError | InventoryUploadCreatedResponse | None: + """Create an inventory from a direct file upload + + # Create Upload Inventory + + Creates an inventory resource and returns a signed URL for uploading the + source file directly to GCS. The upload must use HTTP PUT with the + Content-Type header matching the value in the response. + + When the upload completes, the uploader service processes the file + automatically via Eventarc and updates the inventory status to + `completed` (or `failed` on error). + + ## Supported Formats + + - **csv**: Comma-separated values. Coordinates must already be in the + domain's CRS. + - **geojson**: GeoJSON FeatureCollection with Point or MultiPoint + geometries. Reprojected to domain CRS automatically. + - **geopackage**: OGC GeoPackage. Reprojected to domain CRS automatically. + + ## Column Mapping + + Use the `columns` field to map v2 column names to the column names in + your file. Omit entries where the file already uses v2 names. Required + in the file: `x`, `y`, `height`. + + Args: + domain_id (str): + body (CreateInventoryUploadRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | InventoryUploadCreatedResponse + """ + + return sync_detailed( + domain_id=domain_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateInventoryUploadRequest, +) -> Response[HTTPValidationError | InventoryUploadCreatedResponse]: + """Create an inventory from a direct file upload + + # Create Upload Inventory + + Creates an inventory resource and returns a signed URL for uploading the + source file directly to GCS. The upload must use HTTP PUT with the + Content-Type header matching the value in the response. + + When the upload completes, the uploader service processes the file + automatically via Eventarc and updates the inventory status to + `completed` (or `failed` on error). + + ## Supported Formats + + - **csv**: Comma-separated values. Coordinates must already be in the + domain's CRS. + - **geojson**: GeoJSON FeatureCollection with Point or MultiPoint + geometries. Reprojected to domain CRS automatically. + - **geopackage**: OGC GeoPackage. Reprojected to domain CRS automatically. + + ## Column Mapping + + Use the `columns` field to map v2 column names to the column names in + your file. Omit entries where the file already uses v2 names. Required + in the file: `x`, `y`, `height`. + + Args: + domain_id (str): + body (CreateInventoryUploadRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | InventoryUploadCreatedResponse] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateInventoryUploadRequest, +) -> HTTPValidationError | InventoryUploadCreatedResponse | None: + """Create an inventory from a direct file upload + + # Create Upload Inventory + + Creates an inventory resource and returns a signed URL for uploading the + source file directly to GCS. The upload must use HTTP PUT with the + Content-Type header matching the value in the response. + + When the upload completes, the uploader service processes the file + automatically via Eventarc and updates the inventory status to + `completed` (or `failed` on error). + + ## Supported Formats + + - **csv**: Comma-separated values. Coordinates must already be in the + domain's CRS. + - **geojson**: GeoJSON FeatureCollection with Point or MultiPoint + geometries. Reprojected to domain CRS automatically. + - **geopackage**: OGC GeoPackage. Reprojected to domain CRS automatically. + + ## Column Mapping + + Use the `columns` field to map v2 column names to the column names in + your file. Omit entries where the file already uses v2 names. Required + in the file: `x`, `y`, `height`. + + Args: + domain_id (str): + body (CreateInventoryUploadRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | InventoryUploadCreatedResponse + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/inventories/create_pim_inventory.py b/fastfuels_sdk/v2/client_library/api/inventories/create_pim_inventory.py new file mode 100644 index 0000000..aea0a3c --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/inventories/create_pim_inventory.py @@ -0,0 +1,366 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.create_pim_inventory_request import CreatePimInventoryRequest +from ...models.http_validation_error import HTTPValidationError +from ...models.inventory import Inventory +from ...types import Response + + +def _get_kwargs( + domain_id: str, + *, + body: CreatePimInventoryRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/domains/{domain_id}/inventories/tree/pim".format( + domain_id=quote(str(domain_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> HTTPValidationError | Inventory | None: + if response.status_code == 201: + response_201 = Inventory.from_dict(response.json()) + + return response_201 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[HTTPValidationError | Inventory]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreatePimInventoryRequest, +) -> Response[HTTPValidationError | Inventory]: + r"""Create an inventory from PIM expansion + + # Create PIM Expansion Inventory + + Expands a Plot Imputation Map (PIM) grid into individual tree records + with spatial coordinates. + + A PIM grid maps each 30m cell to an FIA plot ID. This endpoint takes + that mapping and generates a full tree inventory using an inhomogeneous + Poisson point process: + + 1. Tree density (trees per area) is interpolated from plot-level data + onto a sub-cell grid (15m resolution) + 2. Plot IDs are assigned to each sub-cell via nearest-neighbor + interpolation (Voronoi tessellation) + 3. For each sub-cell, a Poisson-distributed random count of trees is + drawn from the local density + 4. Trees are sampled from the assigned plot's tree list, weighted by + trees-per-area (TPA) + 5. Each tree receives a random coordinate within its sub-cell + + The result is a spatially explicit tree inventory that preserves the + species composition and size distributions of the FIA plots while + producing realistic spatial patterns. + + The PIM endpoint is source-agnostic: it works the same regardless of + whether the source grid is from TreeMap, BIGMAP, or FSE. The grid's + own ``source`` field carries that lineage. + + ## Request Body + + - **source_pim_grid_id**: (required) ID of a completed PIM grid. + - **seed**: (optional) Random seed for reproducibility. Generated + randomly if omitted. + - **point_process**: (optional) Spatial point process for coordinate + assignment. Default: ``\"inhomogeneous_poisson\"``. + - **type**: (optional) Entity type. Default: ``\"tree\"``. + - **name**: (optional) Name for the inventory. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing inventories. + + ## Response + + Returns the created Inventory resource with status ``\"pending\"``. The + backend (Standgen) will process the expansion asynchronously and update + status to ``\"completed\"`` when ready. + + Args: + domain_id (str): + body (CreatePimInventoryRequest): Request body for creating an inventory via PIM + expansion. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | Inventory] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreatePimInventoryRequest, +) -> HTTPValidationError | Inventory | None: + r"""Create an inventory from PIM expansion + + # Create PIM Expansion Inventory + + Expands a Plot Imputation Map (PIM) grid into individual tree records + with spatial coordinates. + + A PIM grid maps each 30m cell to an FIA plot ID. This endpoint takes + that mapping and generates a full tree inventory using an inhomogeneous + Poisson point process: + + 1. Tree density (trees per area) is interpolated from plot-level data + onto a sub-cell grid (15m resolution) + 2. Plot IDs are assigned to each sub-cell via nearest-neighbor + interpolation (Voronoi tessellation) + 3. For each sub-cell, a Poisson-distributed random count of trees is + drawn from the local density + 4. Trees are sampled from the assigned plot's tree list, weighted by + trees-per-area (TPA) + 5. Each tree receives a random coordinate within its sub-cell + + The result is a spatially explicit tree inventory that preserves the + species composition and size distributions of the FIA plots while + producing realistic spatial patterns. + + The PIM endpoint is source-agnostic: it works the same regardless of + whether the source grid is from TreeMap, BIGMAP, or FSE. The grid's + own ``source`` field carries that lineage. + + ## Request Body + + - **source_pim_grid_id**: (required) ID of a completed PIM grid. + - **seed**: (optional) Random seed for reproducibility. Generated + randomly if omitted. + - **point_process**: (optional) Spatial point process for coordinate + assignment. Default: ``\"inhomogeneous_poisson\"``. + - **type**: (optional) Entity type. Default: ``\"tree\"``. + - **name**: (optional) Name for the inventory. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing inventories. + + ## Response + + Returns the created Inventory resource with status ``\"pending\"``. The + backend (Standgen) will process the expansion asynchronously and update + status to ``\"completed\"`` when ready. + + Args: + domain_id (str): + body (CreatePimInventoryRequest): Request body for creating an inventory via PIM + expansion. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | Inventory + """ + + return sync_detailed( + domain_id=domain_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreatePimInventoryRequest, +) -> Response[HTTPValidationError | Inventory]: + r"""Create an inventory from PIM expansion + + # Create PIM Expansion Inventory + + Expands a Plot Imputation Map (PIM) grid into individual tree records + with spatial coordinates. + + A PIM grid maps each 30m cell to an FIA plot ID. This endpoint takes + that mapping and generates a full tree inventory using an inhomogeneous + Poisson point process: + + 1. Tree density (trees per area) is interpolated from plot-level data + onto a sub-cell grid (15m resolution) + 2. Plot IDs are assigned to each sub-cell via nearest-neighbor + interpolation (Voronoi tessellation) + 3. For each sub-cell, a Poisson-distributed random count of trees is + drawn from the local density + 4. Trees are sampled from the assigned plot's tree list, weighted by + trees-per-area (TPA) + 5. Each tree receives a random coordinate within its sub-cell + + The result is a spatially explicit tree inventory that preserves the + species composition and size distributions of the FIA plots while + producing realistic spatial patterns. + + The PIM endpoint is source-agnostic: it works the same regardless of + whether the source grid is from TreeMap, BIGMAP, or FSE. The grid's + own ``source`` field carries that lineage. + + ## Request Body + + - **source_pim_grid_id**: (required) ID of a completed PIM grid. + - **seed**: (optional) Random seed for reproducibility. Generated + randomly if omitted. + - **point_process**: (optional) Spatial point process for coordinate + assignment. Default: ``\"inhomogeneous_poisson\"``. + - **type**: (optional) Entity type. Default: ``\"tree\"``. + - **name**: (optional) Name for the inventory. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing inventories. + + ## Response + + Returns the created Inventory resource with status ``\"pending\"``. The + backend (Standgen) will process the expansion asynchronously and update + status to ``\"completed\"`` when ready. + + Args: + domain_id (str): + body (CreatePimInventoryRequest): Request body for creating an inventory via PIM + expansion. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | Inventory] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreatePimInventoryRequest, +) -> HTTPValidationError | Inventory | None: + r"""Create an inventory from PIM expansion + + # Create PIM Expansion Inventory + + Expands a Plot Imputation Map (PIM) grid into individual tree records + with spatial coordinates. + + A PIM grid maps each 30m cell to an FIA plot ID. This endpoint takes + that mapping and generates a full tree inventory using an inhomogeneous + Poisson point process: + + 1. Tree density (trees per area) is interpolated from plot-level data + onto a sub-cell grid (15m resolution) + 2. Plot IDs are assigned to each sub-cell via nearest-neighbor + interpolation (Voronoi tessellation) + 3. For each sub-cell, a Poisson-distributed random count of trees is + drawn from the local density + 4. Trees are sampled from the assigned plot's tree list, weighted by + trees-per-area (TPA) + 5. Each tree receives a random coordinate within its sub-cell + + The result is a spatially explicit tree inventory that preserves the + species composition and size distributions of the FIA plots while + producing realistic spatial patterns. + + The PIM endpoint is source-agnostic: it works the same regardless of + whether the source grid is from TreeMap, BIGMAP, or FSE. The grid's + own ``source`` field carries that lineage. + + ## Request Body + + - **source_pim_grid_id**: (required) ID of a completed PIM grid. + - **seed**: (optional) Random seed for reproducibility. Generated + randomly if omitted. + - **point_process**: (optional) Spatial point process for coordinate + assignment. Default: ``\"inhomogeneous_poisson\"``. + - **type**: (optional) Entity type. Default: ``\"tree\"``. + - **name**: (optional) Name for the inventory. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing inventories. + + ## Response + + Returns the created Inventory resource with status ``\"pending\"``. The + backend (Standgen) will process the expansion asynchronously and update + status to ``\"completed\"`` when ready. + + Args: + domain_id (str): + body (CreatePimInventoryRequest): Request body for creating an inventory via PIM + expansion. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | Inventory + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/inventories/delete_inventory.py b/fastfuels_sdk/v2/client_library/api/inventories/delete_inventory.py new file mode 100644 index 0000000..e759207 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/inventories/delete_inventory.py @@ -0,0 +1,245 @@ +from http import HTTPStatus +from typing import Any, cast +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.http_validation_error import HTTPValidationError +from ...types import Response + + +def _get_kwargs( + domain_id: str, + inventory_id: str, +) -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "delete", + "url": "/domains/{domain_id}/inventories/{inventory_id}".format( + domain_id=quote(str(domain_id), safe=""), + inventory_id=quote(str(inventory_id), safe=""), + ), + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Any | HTTPValidationError | None: + if response.status_code == 204: + response_204 = cast(Any, None) + return response_204 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Any | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + inventory_id: str, + *, + client: AuthenticatedClient, +) -> Response[Any | HTTPValidationError]: + """Delete an inventory + + # Delete Inventory Endpoint + + Permanently deletes an inventory resource by its unique identifier. + This action cannot be undone. + + ## Path Parameters + + - **domain_id**: (string) The domain the inventory belongs to. + - **inventory_id**: (string) The unique identifier of the inventory. + + ## Response + + Returns HTTP 204 No Content with an empty response body. + + ## Error Responses + + - **404 Not Found**: The inventory does not exist or the user does not have access. + + Args: + domain_id (str): + inventory_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + inventory_id=inventory_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + inventory_id: str, + *, + client: AuthenticatedClient, +) -> Any | HTTPValidationError | None: + """Delete an inventory + + # Delete Inventory Endpoint + + Permanently deletes an inventory resource by its unique identifier. + This action cannot be undone. + + ## Path Parameters + + - **domain_id**: (string) The domain the inventory belongs to. + - **inventory_id**: (string) The unique identifier of the inventory. + + ## Response + + Returns HTTP 204 No Content with an empty response body. + + ## Error Responses + + - **404 Not Found**: The inventory does not exist or the user does not have access. + + Args: + domain_id (str): + inventory_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Any | HTTPValidationError + """ + + return sync_detailed( + domain_id=domain_id, + inventory_id=inventory_id, + client=client, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + inventory_id: str, + *, + client: AuthenticatedClient, +) -> Response[Any | HTTPValidationError]: + """Delete an inventory + + # Delete Inventory Endpoint + + Permanently deletes an inventory resource by its unique identifier. + This action cannot be undone. + + ## Path Parameters + + - **domain_id**: (string) The domain the inventory belongs to. + - **inventory_id**: (string) The unique identifier of the inventory. + + ## Response + + Returns HTTP 204 No Content with an empty response body. + + ## Error Responses + + - **404 Not Found**: The inventory does not exist or the user does not have access. + + Args: + domain_id (str): + inventory_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + inventory_id=inventory_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + inventory_id: str, + *, + client: AuthenticatedClient, +) -> Any | HTTPValidationError | None: + """Delete an inventory + + # Delete Inventory Endpoint + + Permanently deletes an inventory resource by its unique identifier. + This action cannot be undone. + + ## Path Parameters + + - **domain_id**: (string) The domain the inventory belongs to. + - **inventory_id**: (string) The unique identifier of the inventory. + + ## Response + + Returns HTTP 204 No Content with an empty response body. + + ## Error Responses + + - **404 Not Found**: The inventory does not exist or the user does not have access. + + Args: + domain_id (str): + inventory_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Any | HTTPValidationError + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + inventory_id=inventory_id, + client=client, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/inventories/duplicate_inventory.py b/fastfuels_sdk/v2/client_library/api/inventories/duplicate_inventory.py new file mode 100644 index 0000000..faf94cc --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/inventories/duplicate_inventory.py @@ -0,0 +1,339 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.duplicate_inventory_request import DuplicateInventoryRequest +from ...models.http_validation_error import HTTPValidationError +from ...models.inventory import Inventory +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + domain_id: str, + inventory_id: str, + *, + body: DuplicateInventoryRequest | None | Unset = UNSET, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/domains/{domain_id}/inventories/{inventory_id}/duplicate".format( + domain_id=quote(str(domain_id), safe=""), + inventory_id=quote(str(inventory_id), safe=""), + ), + } + + if isinstance(body, DuplicateInventoryRequest): + _kwargs["json"] = body.to_dict() + else: + _kwargs["json"] = body + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> HTTPValidationError | Inventory | None: + if response.status_code == 201: + response_201 = Inventory.from_dict(response.json()) + + return response_201 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[HTTPValidationError | Inventory]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + inventory_id: str, + *, + client: AuthenticatedClient, + body: DuplicateInventoryRequest | None | Unset = UNSET, +) -> Response[HTTPValidationError | Inventory]: + r"""Duplicate an inventory + + # Duplicate an Inventory + + Creates an independent **copy** of a completed inventory under a new ID. + Use this to branch a scenario: duplicate, then edit the copy in place while + the original stays untouched. + + This is a true clone, not a re-derivation. The finished data is byte-copied; + no regeneration is performed. The copy carries over the source's `source`, + `modifications`, `treatments`, `columns`, `georeference`, and `checksum` + verbatim — only its `id` and timestamps differ. + + ## Request Body (optional) + + All fields are optional. Any field omitted is carried over from the source. + + - **name**: Name for the copy. + - **description**: Description for the copy. + - **tags**: Tags for the copy. + + Send no body at all to copy the metadata unchanged. + + ## Response + + Returns the new Inventory with status `\"pending\"`. The data is copied in the + background; the status transitions to `\"completed\"` once the copy finishes + (or `\"failed\"` if it does not). Data endpoints (`/data`) become available + only after the copy completes. The source inventory is unchanged. + + ## Error Responses + + - **404 Not Found**: The source inventory does not exist, is not owned by the + caller, or is not in this domain. + - **422 Unprocessable Content**: The source inventory exists but is not yet + `completed`, so there is no finished artifact to copy. + + Args: + domain_id (str): + inventory_id (str): + body (DuplicateInventoryRequest | None | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | Inventory] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + inventory_id=inventory_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + inventory_id: str, + *, + client: AuthenticatedClient, + body: DuplicateInventoryRequest | None | Unset = UNSET, +) -> HTTPValidationError | Inventory | None: + r"""Duplicate an inventory + + # Duplicate an Inventory + + Creates an independent **copy** of a completed inventory under a new ID. + Use this to branch a scenario: duplicate, then edit the copy in place while + the original stays untouched. + + This is a true clone, not a re-derivation. The finished data is byte-copied; + no regeneration is performed. The copy carries over the source's `source`, + `modifications`, `treatments`, `columns`, `georeference`, and `checksum` + verbatim — only its `id` and timestamps differ. + + ## Request Body (optional) + + All fields are optional. Any field omitted is carried over from the source. + + - **name**: Name for the copy. + - **description**: Description for the copy. + - **tags**: Tags for the copy. + + Send no body at all to copy the metadata unchanged. + + ## Response + + Returns the new Inventory with status `\"pending\"`. The data is copied in the + background; the status transitions to `\"completed\"` once the copy finishes + (or `\"failed\"` if it does not). Data endpoints (`/data`) become available + only after the copy completes. The source inventory is unchanged. + + ## Error Responses + + - **404 Not Found**: The source inventory does not exist, is not owned by the + caller, or is not in this domain. + - **422 Unprocessable Content**: The source inventory exists but is not yet + `completed`, so there is no finished artifact to copy. + + Args: + domain_id (str): + inventory_id (str): + body (DuplicateInventoryRequest | None | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | Inventory + """ + + return sync_detailed( + domain_id=domain_id, + inventory_id=inventory_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + inventory_id: str, + *, + client: AuthenticatedClient, + body: DuplicateInventoryRequest | None | Unset = UNSET, +) -> Response[HTTPValidationError | Inventory]: + r"""Duplicate an inventory + + # Duplicate an Inventory + + Creates an independent **copy** of a completed inventory under a new ID. + Use this to branch a scenario: duplicate, then edit the copy in place while + the original stays untouched. + + This is a true clone, not a re-derivation. The finished data is byte-copied; + no regeneration is performed. The copy carries over the source's `source`, + `modifications`, `treatments`, `columns`, `georeference`, and `checksum` + verbatim — only its `id` and timestamps differ. + + ## Request Body (optional) + + All fields are optional. Any field omitted is carried over from the source. + + - **name**: Name for the copy. + - **description**: Description for the copy. + - **tags**: Tags for the copy. + + Send no body at all to copy the metadata unchanged. + + ## Response + + Returns the new Inventory with status `\"pending\"`. The data is copied in the + background; the status transitions to `\"completed\"` once the copy finishes + (or `\"failed\"` if it does not). Data endpoints (`/data`) become available + only after the copy completes. The source inventory is unchanged. + + ## Error Responses + + - **404 Not Found**: The source inventory does not exist, is not owned by the + caller, or is not in this domain. + - **422 Unprocessable Content**: The source inventory exists but is not yet + `completed`, so there is no finished artifact to copy. + + Args: + domain_id (str): + inventory_id (str): + body (DuplicateInventoryRequest | None | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | Inventory] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + inventory_id=inventory_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + inventory_id: str, + *, + client: AuthenticatedClient, + body: DuplicateInventoryRequest | None | Unset = UNSET, +) -> HTTPValidationError | Inventory | None: + r"""Duplicate an inventory + + # Duplicate an Inventory + + Creates an independent **copy** of a completed inventory under a new ID. + Use this to branch a scenario: duplicate, then edit the copy in place while + the original stays untouched. + + This is a true clone, not a re-derivation. The finished data is byte-copied; + no regeneration is performed. The copy carries over the source's `source`, + `modifications`, `treatments`, `columns`, `georeference`, and `checksum` + verbatim — only its `id` and timestamps differ. + + ## Request Body (optional) + + All fields are optional. Any field omitted is carried over from the source. + + - **name**: Name for the copy. + - **description**: Description for the copy. + - **tags**: Tags for the copy. + + Send no body at all to copy the metadata unchanged. + + ## Response + + Returns the new Inventory with status `\"pending\"`. The data is copied in the + background; the status transitions to `\"completed\"` once the copy finishes + (or `\"failed\"` if it does not). Data endpoints (`/data`) become available + only after the copy completes. The source inventory is unchanged. + + ## Error Responses + + - **404 Not Found**: The source inventory does not exist, is not owned by the + caller, or is not in this domain. + - **422 Unprocessable Content**: The source inventory exists but is not yet + `completed`, so there is no finished artifact to copy. + + Args: + domain_id (str): + inventory_id (str): + body (DuplicateInventoryRequest | None | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | Inventory + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + inventory_id=inventory_id, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/inventories/get_inventory.py b/fastfuels_sdk/v2/client_library/api/inventories/get_inventory.py new file mode 100644 index 0000000..cca238a --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/inventories/get_inventory.py @@ -0,0 +1,243 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.http_validation_error import HTTPValidationError +from ...models.inventory import Inventory +from ...types import Response + + +def _get_kwargs( + domain_id: str, + inventory_id: str, +) -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/domains/{domain_id}/inventories/{inventory_id}".format( + domain_id=quote(str(domain_id), safe=""), + inventory_id=quote(str(inventory_id), safe=""), + ), + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> HTTPValidationError | Inventory | None: + if response.status_code == 200: + response_200 = Inventory.from_dict(response.json()) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[HTTPValidationError | Inventory]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + inventory_id: str, + *, + client: AuthenticatedClient, +) -> Response[HTTPValidationError | Inventory]: + """Get an inventory by ID + + # Get Inventory Endpoint + + Retrieves a specific inventory resource by its unique identifier. + + ## Path Parameters + + - **domain_id**: (string) The domain the inventory belongs to. + - **inventory_id**: (string) The unique 32-character hex identifier of the inventory. + + ## Response + + Returns the inventory resource. + + ## Error Responses + + - **404 Not Found**: The inventory does not exist or the user does not have access. + + Args: + domain_id (str): + inventory_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | Inventory] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + inventory_id=inventory_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + inventory_id: str, + *, + client: AuthenticatedClient, +) -> HTTPValidationError | Inventory | None: + """Get an inventory by ID + + # Get Inventory Endpoint + + Retrieves a specific inventory resource by its unique identifier. + + ## Path Parameters + + - **domain_id**: (string) The domain the inventory belongs to. + - **inventory_id**: (string) The unique 32-character hex identifier of the inventory. + + ## Response + + Returns the inventory resource. + + ## Error Responses + + - **404 Not Found**: The inventory does not exist or the user does not have access. + + Args: + domain_id (str): + inventory_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | Inventory + """ + + return sync_detailed( + domain_id=domain_id, + inventory_id=inventory_id, + client=client, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + inventory_id: str, + *, + client: AuthenticatedClient, +) -> Response[HTTPValidationError | Inventory]: + """Get an inventory by ID + + # Get Inventory Endpoint + + Retrieves a specific inventory resource by its unique identifier. + + ## Path Parameters + + - **domain_id**: (string) The domain the inventory belongs to. + - **inventory_id**: (string) The unique 32-character hex identifier of the inventory. + + ## Response + + Returns the inventory resource. + + ## Error Responses + + - **404 Not Found**: The inventory does not exist or the user does not have access. + + Args: + domain_id (str): + inventory_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | Inventory] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + inventory_id=inventory_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + inventory_id: str, + *, + client: AuthenticatedClient, +) -> HTTPValidationError | Inventory | None: + """Get an inventory by ID + + # Get Inventory Endpoint + + Retrieves a specific inventory resource by its unique identifier. + + ## Path Parameters + + - **domain_id**: (string) The domain the inventory belongs to. + - **inventory_id**: (string) The unique 32-character hex identifier of the inventory. + + ## Response + + Returns the inventory resource. + + ## Error Responses + + - **404 Not Found**: The inventory does not exist or the user does not have access. + + Args: + domain_id (str): + inventory_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | Inventory + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + inventory_id=inventory_id, + client=client, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/inventories/get_inventory_data.py b/fastfuels_sdk/v2/client_library/api/inventories/get_inventory_data.py new file mode 100644 index 0000000..f5b7a87 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/inventories/get_inventory_data.py @@ -0,0 +1,387 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.http_validation_error import HTTPValidationError +from ...models.inventory_data_format import InventoryDataFormat +from ...models.inventory_data_response import InventoryDataResponse +from ...models.inventory_json_orientation import InventoryJsonOrientation +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + domain_id: str, + inventory_id: str, + partition_index: int, + *, + format_: InventoryDataFormat | Unset = UNSET, + json_orientation: InventoryJsonOrientation | Unset = UNSET, + columns: None | str | Unset = UNSET, +) -> dict[str, Any]: + + params: dict[str, Any] = {} + + json_format_: str | Unset = UNSET + if not isinstance(format_, Unset): + json_format_ = format_.value + + params["format"] = json_format_ + + json_json_orientation: str | Unset = UNSET + if not isinstance(json_orientation, Unset): + json_json_orientation = json_orientation.value + + params["json_orientation"] = json_json_orientation + + json_columns: None | str | Unset + if isinstance(columns, Unset): + json_columns = UNSET + else: + json_columns = columns + params["columns"] = json_columns + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/domains/{domain_id}/inventories/{inventory_id}/data/{partition_index}".format( + domain_id=quote(str(domain_id), safe=""), + inventory_id=quote(str(inventory_id), safe=""), + partition_index=quote(str(partition_index), safe=""), + ), + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> HTTPValidationError | InventoryDataResponse | None: + if response.status_code == 200: + response_200 = InventoryDataResponse.from_dict(response.json()) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[HTTPValidationError | InventoryDataResponse]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + inventory_id: str, + partition_index: int, + *, + client: AuthenticatedClient, + format_: InventoryDataFormat | Unset = UNSET, + json_orientation: InventoryJsonOrientation | Unset = UNSET, + columns: None | str | Unset = UNSET, +) -> Response[HTTPValidationError | InventoryDataResponse]: + """Get inventory data for a partition + + # Get Inventory Data + + Reads a single partition of a completed inventory's Parquet data on GCS. + Returns the tree records as JSON (split or records orientation) or CSV. + + ## Path Parameters + + - **domain_id**: The domain the inventory belongs to. + - **inventory_id**: The unique identifier of the inventory. + - **partition_index**: Zero-based partition index. + + ## Query Parameters + + - **format**: Response format: `json` (default) or `csv`. + - **json_orientation**: JSON layout: `split` (default, compact) or + `records` (self-describing). Ignored for CSV. + - **columns**: Comma-separated column subset (default: all). + + ## Response + + **JSON split** (default): column names + 2D array of values. + + **JSON records**: list of row objects. + + **CSV**: `text/csv` body with metadata in response headers + `X-Partition-Index`, `X-Row-Count`, `X-Total-Rows`, `X-Num-Partitions`. + + ## Error Responses + + - **404 Not Found**: Inventory does not exist or user does not have access. + - **422 Unprocessable Entity**: Inventory not completed, partition index + out of range, invalid column names, or metadata not available. + + Args: + domain_id (str): + inventory_id (str): + partition_index (int): Zero-based partition index. + format_ (InventoryDataFormat | Unset): + json_orientation (InventoryJsonOrientation | Unset): + columns (None | str | Unset): Comma-separated column subset. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | InventoryDataResponse] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + inventory_id=inventory_id, + partition_index=partition_index, + format_=format_, + json_orientation=json_orientation, + columns=columns, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + inventory_id: str, + partition_index: int, + *, + client: AuthenticatedClient, + format_: InventoryDataFormat | Unset = UNSET, + json_orientation: InventoryJsonOrientation | Unset = UNSET, + columns: None | str | Unset = UNSET, +) -> HTTPValidationError | InventoryDataResponse | None: + """Get inventory data for a partition + + # Get Inventory Data + + Reads a single partition of a completed inventory's Parquet data on GCS. + Returns the tree records as JSON (split or records orientation) or CSV. + + ## Path Parameters + + - **domain_id**: The domain the inventory belongs to. + - **inventory_id**: The unique identifier of the inventory. + - **partition_index**: Zero-based partition index. + + ## Query Parameters + + - **format**: Response format: `json` (default) or `csv`. + - **json_orientation**: JSON layout: `split` (default, compact) or + `records` (self-describing). Ignored for CSV. + - **columns**: Comma-separated column subset (default: all). + + ## Response + + **JSON split** (default): column names + 2D array of values. + + **JSON records**: list of row objects. + + **CSV**: `text/csv` body with metadata in response headers + `X-Partition-Index`, `X-Row-Count`, `X-Total-Rows`, `X-Num-Partitions`. + + ## Error Responses + + - **404 Not Found**: Inventory does not exist or user does not have access. + - **422 Unprocessable Entity**: Inventory not completed, partition index + out of range, invalid column names, or metadata not available. + + Args: + domain_id (str): + inventory_id (str): + partition_index (int): Zero-based partition index. + format_ (InventoryDataFormat | Unset): + json_orientation (InventoryJsonOrientation | Unset): + columns (None | str | Unset): Comma-separated column subset. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | InventoryDataResponse + """ + + return sync_detailed( + domain_id=domain_id, + inventory_id=inventory_id, + partition_index=partition_index, + client=client, + format_=format_, + json_orientation=json_orientation, + columns=columns, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + inventory_id: str, + partition_index: int, + *, + client: AuthenticatedClient, + format_: InventoryDataFormat | Unset = UNSET, + json_orientation: InventoryJsonOrientation | Unset = UNSET, + columns: None | str | Unset = UNSET, +) -> Response[HTTPValidationError | InventoryDataResponse]: + """Get inventory data for a partition + + # Get Inventory Data + + Reads a single partition of a completed inventory's Parquet data on GCS. + Returns the tree records as JSON (split or records orientation) or CSV. + + ## Path Parameters + + - **domain_id**: The domain the inventory belongs to. + - **inventory_id**: The unique identifier of the inventory. + - **partition_index**: Zero-based partition index. + + ## Query Parameters + + - **format**: Response format: `json` (default) or `csv`. + - **json_orientation**: JSON layout: `split` (default, compact) or + `records` (self-describing). Ignored for CSV. + - **columns**: Comma-separated column subset (default: all). + + ## Response + + **JSON split** (default): column names + 2D array of values. + + **JSON records**: list of row objects. + + **CSV**: `text/csv` body with metadata in response headers + `X-Partition-Index`, `X-Row-Count`, `X-Total-Rows`, `X-Num-Partitions`. + + ## Error Responses + + - **404 Not Found**: Inventory does not exist or user does not have access. + - **422 Unprocessable Entity**: Inventory not completed, partition index + out of range, invalid column names, or metadata not available. + + Args: + domain_id (str): + inventory_id (str): + partition_index (int): Zero-based partition index. + format_ (InventoryDataFormat | Unset): + json_orientation (InventoryJsonOrientation | Unset): + columns (None | str | Unset): Comma-separated column subset. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | InventoryDataResponse] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + inventory_id=inventory_id, + partition_index=partition_index, + format_=format_, + json_orientation=json_orientation, + columns=columns, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + inventory_id: str, + partition_index: int, + *, + client: AuthenticatedClient, + format_: InventoryDataFormat | Unset = UNSET, + json_orientation: InventoryJsonOrientation | Unset = UNSET, + columns: None | str | Unset = UNSET, +) -> HTTPValidationError | InventoryDataResponse | None: + """Get inventory data for a partition + + # Get Inventory Data + + Reads a single partition of a completed inventory's Parquet data on GCS. + Returns the tree records as JSON (split or records orientation) or CSV. + + ## Path Parameters + + - **domain_id**: The domain the inventory belongs to. + - **inventory_id**: The unique identifier of the inventory. + - **partition_index**: Zero-based partition index. + + ## Query Parameters + + - **format**: Response format: `json` (default) or `csv`. + - **json_orientation**: JSON layout: `split` (default, compact) or + `records` (self-describing). Ignored for CSV. + - **columns**: Comma-separated column subset (default: all). + + ## Response + + **JSON split** (default): column names + 2D array of values. + + **JSON records**: list of row objects. + + **CSV**: `text/csv` body with metadata in response headers + `X-Partition-Index`, `X-Row-Count`, `X-Total-Rows`, `X-Num-Partitions`. + + ## Error Responses + + - **404 Not Found**: Inventory does not exist or user does not have access. + - **422 Unprocessable Entity**: Inventory not completed, partition index + out of range, invalid column names, or metadata not available. + + Args: + domain_id (str): + inventory_id (str): + partition_index (int): Zero-based partition index. + format_ (InventoryDataFormat | Unset): + json_orientation (InventoryJsonOrientation | Unset): + columns (None | str | Unset): Comma-separated column subset. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | InventoryDataResponse + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + inventory_id=inventory_id, + partition_index=partition_index, + client=client, + format_=format_, + json_orientation=json_orientation, + columns=columns, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/inventories/get_inventory_data_metadata.py b/fastfuels_sdk/v2/client_library/api/inventories/get_inventory_data_metadata.py new file mode 100644 index 0000000..5e59aff --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/inventories/get_inventory_data_metadata.py @@ -0,0 +1,275 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.http_validation_error import HTTPValidationError +from ...models.inventory_data_metadata import InventoryDataMetadata +from ...types import Response + + +def _get_kwargs( + domain_id: str, + inventory_id: str, +) -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/domains/{domain_id}/inventories/{inventory_id}/data/metadata".format( + domain_id=quote(str(domain_id), safe=""), + inventory_id=quote(str(inventory_id), safe=""), + ), + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> HTTPValidationError | InventoryDataMetadata | None: + if response.status_code == 200: + response_200 = InventoryDataMetadata.from_dict(response.json()) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[HTTPValidationError | InventoryDataMetadata]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + inventory_id: str, + *, + client: AuthenticatedClient, +) -> Response[HTTPValidationError | InventoryDataMetadata]: + """Get inventory data metadata + + # Get Inventory Data Metadata + + Returns partition count, total rows, per-partition row counts, and column + names for a completed inventory. Reads only the `_metadata` file from GCS + (cached after first access). + + ## Path Parameters + + - **domain_id**: The domain the inventory belongs to. + - **inventory_id**: The unique identifier of the inventory. + + ## Response + + - **inventory_id**: The inventory ID. + - **num_partitions**: Number of Parquet partitions. + - **total_rows**: Total row count across all partitions. + - **columns**: List of column names. + - **partitions**: Per-partition index and row count. + + ## Error Responses + + - **404 Not Found**: Inventory does not exist or user does not have access. + - **422 Unprocessable Entity**: Inventory is not completed, or metadata + file is not available. + + Args: + domain_id (str): + inventory_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | InventoryDataMetadata] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + inventory_id=inventory_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + inventory_id: str, + *, + client: AuthenticatedClient, +) -> HTTPValidationError | InventoryDataMetadata | None: + """Get inventory data metadata + + # Get Inventory Data Metadata + + Returns partition count, total rows, per-partition row counts, and column + names for a completed inventory. Reads only the `_metadata` file from GCS + (cached after first access). + + ## Path Parameters + + - **domain_id**: The domain the inventory belongs to. + - **inventory_id**: The unique identifier of the inventory. + + ## Response + + - **inventory_id**: The inventory ID. + - **num_partitions**: Number of Parquet partitions. + - **total_rows**: Total row count across all partitions. + - **columns**: List of column names. + - **partitions**: Per-partition index and row count. + + ## Error Responses + + - **404 Not Found**: Inventory does not exist or user does not have access. + - **422 Unprocessable Entity**: Inventory is not completed, or metadata + file is not available. + + Args: + domain_id (str): + inventory_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | InventoryDataMetadata + """ + + return sync_detailed( + domain_id=domain_id, + inventory_id=inventory_id, + client=client, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + inventory_id: str, + *, + client: AuthenticatedClient, +) -> Response[HTTPValidationError | InventoryDataMetadata]: + """Get inventory data metadata + + # Get Inventory Data Metadata + + Returns partition count, total rows, per-partition row counts, and column + names for a completed inventory. Reads only the `_metadata` file from GCS + (cached after first access). + + ## Path Parameters + + - **domain_id**: The domain the inventory belongs to. + - **inventory_id**: The unique identifier of the inventory. + + ## Response + + - **inventory_id**: The inventory ID. + - **num_partitions**: Number of Parquet partitions. + - **total_rows**: Total row count across all partitions. + - **columns**: List of column names. + - **partitions**: Per-partition index and row count. + + ## Error Responses + + - **404 Not Found**: Inventory does not exist or user does not have access. + - **422 Unprocessable Entity**: Inventory is not completed, or metadata + file is not available. + + Args: + domain_id (str): + inventory_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | InventoryDataMetadata] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + inventory_id=inventory_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + inventory_id: str, + *, + client: AuthenticatedClient, +) -> HTTPValidationError | InventoryDataMetadata | None: + """Get inventory data metadata + + # Get Inventory Data Metadata + + Returns partition count, total rows, per-partition row counts, and column + names for a completed inventory. Reads only the `_metadata` file from GCS + (cached after first access). + + ## Path Parameters + + - **domain_id**: The domain the inventory belongs to. + - **inventory_id**: The unique identifier of the inventory. + + ## Response + + - **inventory_id**: The inventory ID. + - **num_partitions**: Number of Parquet partitions. + - **total_rows**: Total row count across all partitions. + - **columns**: List of column names. + - **partitions**: Per-partition index and row count. + + ## Error Responses + + - **404 Not Found**: Inventory does not exist or user does not have access. + - **422 Unprocessable Entity**: Inventory is not completed, or metadata + file is not available. + + Args: + domain_id (str): + inventory_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | InventoryDataMetadata + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + inventory_id=inventory_id, + client=client, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/inventories/list_inventories.py b/fastfuels_sdk/v2/client_library/api/inventories/list_inventories.py new file mode 100644 index 0000000..4d2dc87 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/inventories/list_inventories.py @@ -0,0 +1,402 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.http_validation_error import HTTPValidationError +from ...models.inventory_sort_field import InventorySortField +from ...models.inventory_type import InventoryType +from ...models.list_inventories_response import ListInventoriesResponse +from ...models.sort_order import SortOrder +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + domain_id: str, + *, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: InventorySortField | None | Unset = UNSET, + sort_order: None | SortOrder | Unset = UNSET, + type_: InventoryType | None | Unset = UNSET, + source: None | str | Unset = UNSET, + tag: None | str | Unset = UNSET, +) -> dict[str, Any]: + + params: dict[str, Any] = {} + + params["page"] = page + + params["size"] = size + + json_sort_by: None | str | Unset + if isinstance(sort_by, Unset): + json_sort_by = UNSET + elif isinstance(sort_by, InventorySortField): + json_sort_by = sort_by.value + else: + json_sort_by = sort_by + params["sort_by"] = json_sort_by + + json_sort_order: None | str | Unset + if isinstance(sort_order, Unset): + json_sort_order = UNSET + elif isinstance(sort_order, SortOrder): + json_sort_order = sort_order.value + else: + json_sort_order = sort_order + params["sort_order"] = json_sort_order + + json_type_: None | str | Unset + if isinstance(type_, Unset): + json_type_ = UNSET + elif isinstance(type_, InventoryType): + json_type_ = type_.value + else: + json_type_ = type_ + params["type"] = json_type_ + + json_source: None | str | Unset + if isinstance(source, Unset): + json_source = UNSET + else: + json_source = source + params["source"] = json_source + + json_tag: None | str | Unset + if isinstance(tag, Unset): + json_tag = UNSET + else: + json_tag = tag + params["tag"] = json_tag + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/domains/{domain_id}/inventories".format( + domain_id=quote(str(domain_id), safe=""), + ), + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> HTTPValidationError | ListInventoriesResponse | None: + if response.status_code == 200: + response_200 = ListInventoriesResponse.from_dict(response.json()) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[HTTPValidationError | ListInventoriesResponse]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: InventorySortField | None | Unset = UNSET, + sort_order: None | SortOrder | Unset = UNSET, + type_: InventoryType | None | Unset = UNSET, + source: None | str | Unset = UNSET, + tag: None | str | Unset = UNSET, +) -> Response[HTTPValidationError | ListInventoriesResponse]: + """List all inventories + + # List Inventories Endpoint + + Retrieves a paginated list of all inventories within a domain belonging to + the authenticated user. + + ## Path Parameters + + - **domain_id**: (string) The domain to list inventories for. + + ## Query Parameters + + - **page**: (integer, optional) Page number (zero-indexed). Default: 0. + - **size**: (integer, optional) Items per page (1-1000). Default: 100. + - **sort_by**: (string, optional) Field to sort by: `created_on`, `modified_on`, `name`. + - **sort_order**: (string, optional) Sort direction: `ascending`, `descending`. + - **type**: (string, optional) Filter by entity type (e.g., `tree`). + - **source**: (string, optional) Filter by source name (e.g., `pim`). + - **tag**: (string, optional) Filter inventories that contain this tag. + + ## Response + + Returns a paginated list of inventories with metadata. + + Args: + domain_id (str): + page (int | Unset): The page number to retrieve (zero-indexed). Default: 0. + size (int | Unset): The number of inventories to retrieve per page. Default: 100. + sort_by (InventorySortField | None | Unset): The field to sort results by. + sort_order (None | SortOrder | Unset): The order to sort results (ascending or + descending). + type_ (InventoryType | None | Unset): Filter inventories by entity type (e.g., 'tree'). + source (None | str | Unset): Filter inventories by source name (e.g., 'pim'). + tag (None | str | Unset): Filter inventories that contain this tag. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | ListInventoriesResponse] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + page=page, + size=size, + sort_by=sort_by, + sort_order=sort_order, + type_=type_, + source=source, + tag=tag, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: InventorySortField | None | Unset = UNSET, + sort_order: None | SortOrder | Unset = UNSET, + type_: InventoryType | None | Unset = UNSET, + source: None | str | Unset = UNSET, + tag: None | str | Unset = UNSET, +) -> HTTPValidationError | ListInventoriesResponse | None: + """List all inventories + + # List Inventories Endpoint + + Retrieves a paginated list of all inventories within a domain belonging to + the authenticated user. + + ## Path Parameters + + - **domain_id**: (string) The domain to list inventories for. + + ## Query Parameters + + - **page**: (integer, optional) Page number (zero-indexed). Default: 0. + - **size**: (integer, optional) Items per page (1-1000). Default: 100. + - **sort_by**: (string, optional) Field to sort by: `created_on`, `modified_on`, `name`. + - **sort_order**: (string, optional) Sort direction: `ascending`, `descending`. + - **type**: (string, optional) Filter by entity type (e.g., `tree`). + - **source**: (string, optional) Filter by source name (e.g., `pim`). + - **tag**: (string, optional) Filter inventories that contain this tag. + + ## Response + + Returns a paginated list of inventories with metadata. + + Args: + domain_id (str): + page (int | Unset): The page number to retrieve (zero-indexed). Default: 0. + size (int | Unset): The number of inventories to retrieve per page. Default: 100. + sort_by (InventorySortField | None | Unset): The field to sort results by. + sort_order (None | SortOrder | Unset): The order to sort results (ascending or + descending). + type_ (InventoryType | None | Unset): Filter inventories by entity type (e.g., 'tree'). + source (None | str | Unset): Filter inventories by source name (e.g., 'pim'). + tag (None | str | Unset): Filter inventories that contain this tag. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | ListInventoriesResponse + """ + + return sync_detailed( + domain_id=domain_id, + client=client, + page=page, + size=size, + sort_by=sort_by, + sort_order=sort_order, + type_=type_, + source=source, + tag=tag, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: InventorySortField | None | Unset = UNSET, + sort_order: None | SortOrder | Unset = UNSET, + type_: InventoryType | None | Unset = UNSET, + source: None | str | Unset = UNSET, + tag: None | str | Unset = UNSET, +) -> Response[HTTPValidationError | ListInventoriesResponse]: + """List all inventories + + # List Inventories Endpoint + + Retrieves a paginated list of all inventories within a domain belonging to + the authenticated user. + + ## Path Parameters + + - **domain_id**: (string) The domain to list inventories for. + + ## Query Parameters + + - **page**: (integer, optional) Page number (zero-indexed). Default: 0. + - **size**: (integer, optional) Items per page (1-1000). Default: 100. + - **sort_by**: (string, optional) Field to sort by: `created_on`, `modified_on`, `name`. + - **sort_order**: (string, optional) Sort direction: `ascending`, `descending`. + - **type**: (string, optional) Filter by entity type (e.g., `tree`). + - **source**: (string, optional) Filter by source name (e.g., `pim`). + - **tag**: (string, optional) Filter inventories that contain this tag. + + ## Response + + Returns a paginated list of inventories with metadata. + + Args: + domain_id (str): + page (int | Unset): The page number to retrieve (zero-indexed). Default: 0. + size (int | Unset): The number of inventories to retrieve per page. Default: 100. + sort_by (InventorySortField | None | Unset): The field to sort results by. + sort_order (None | SortOrder | Unset): The order to sort results (ascending or + descending). + type_ (InventoryType | None | Unset): Filter inventories by entity type (e.g., 'tree'). + source (None | str | Unset): Filter inventories by source name (e.g., 'pim'). + tag (None | str | Unset): Filter inventories that contain this tag. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | ListInventoriesResponse] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + page=page, + size=size, + sort_by=sort_by, + sort_order=sort_order, + type_=type_, + source=source, + tag=tag, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: InventorySortField | None | Unset = UNSET, + sort_order: None | SortOrder | Unset = UNSET, + type_: InventoryType | None | Unset = UNSET, + source: None | str | Unset = UNSET, + tag: None | str | Unset = UNSET, +) -> HTTPValidationError | ListInventoriesResponse | None: + """List all inventories + + # List Inventories Endpoint + + Retrieves a paginated list of all inventories within a domain belonging to + the authenticated user. + + ## Path Parameters + + - **domain_id**: (string) The domain to list inventories for. + + ## Query Parameters + + - **page**: (integer, optional) Page number (zero-indexed). Default: 0. + - **size**: (integer, optional) Items per page (1-1000). Default: 100. + - **sort_by**: (string, optional) Field to sort by: `created_on`, `modified_on`, `name`. + - **sort_order**: (string, optional) Sort direction: `ascending`, `descending`. + - **type**: (string, optional) Filter by entity type (e.g., `tree`). + - **source**: (string, optional) Filter by source name (e.g., `pim`). + - **tag**: (string, optional) Filter inventories that contain this tag. + + ## Response + + Returns a paginated list of inventories with metadata. + + Args: + domain_id (str): + page (int | Unset): The page number to retrieve (zero-indexed). Default: 0. + size (int | Unset): The number of inventories to retrieve per page. Default: 100. + sort_by (InventorySortField | None | Unset): The field to sort results by. + sort_order (None | SortOrder | Unset): The order to sort results (ascending or + descending). + type_ (InventoryType | None | Unset): Filter inventories by entity type (e.g., 'tree'). + source (None | str | Unset): Filter inventories by source name (e.g., 'pim'). + tag (None | str | Unset): Filter inventories that contain this tag. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | ListInventoriesResponse + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + client=client, + page=page, + size=size, + sort_by=sort_by, + sort_order=sort_order, + type_=type_, + source=source, + tag=tag, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/inventories/list_inventories_cross_domain.py b/fastfuels_sdk/v2/client_library/api/inventories/list_inventories_cross_domain.py new file mode 100644 index 0000000..1168015 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/inventories/list_inventories_cross_domain.py @@ -0,0 +1,370 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.http_validation_error import HTTPValidationError +from ...models.inventory_sort_field import InventorySortField +from ...models.inventory_type import InventoryType +from ...models.list_inventories_response import ListInventoriesResponse +from ...models.sort_order import SortOrder +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + *, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: InventorySortField | None | Unset = UNSET, + sort_order: None | SortOrder | Unset = UNSET, + type_: InventoryType | None | Unset = UNSET, + source: None | str | Unset = UNSET, + tag: None | str | Unset = UNSET, +) -> dict[str, Any]: + + params: dict[str, Any] = {} + + params["page"] = page + + params["size"] = size + + json_sort_by: None | str | Unset + if isinstance(sort_by, Unset): + json_sort_by = UNSET + elif isinstance(sort_by, InventorySortField): + json_sort_by = sort_by.value + else: + json_sort_by = sort_by + params["sort_by"] = json_sort_by + + json_sort_order: None | str | Unset + if isinstance(sort_order, Unset): + json_sort_order = UNSET + elif isinstance(sort_order, SortOrder): + json_sort_order = sort_order.value + else: + json_sort_order = sort_order + params["sort_order"] = json_sort_order + + json_type_: None | str | Unset + if isinstance(type_, Unset): + json_type_ = UNSET + elif isinstance(type_, InventoryType): + json_type_ = type_.value + else: + json_type_ = type_ + params["type"] = json_type_ + + json_source: None | str | Unset + if isinstance(source, Unset): + json_source = UNSET + else: + json_source = source + params["source"] = json_source + + json_tag: None | str | Unset + if isinstance(tag, Unset): + json_tag = UNSET + else: + json_tag = tag + params["tag"] = json_tag + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/domains/-/inventories", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> HTTPValidationError | ListInventoriesResponse | None: + if response.status_code == 200: + response_200 = ListInventoriesResponse.from_dict(response.json()) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[HTTPValidationError | ListInventoriesResponse]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: InventorySortField | None | Unset = UNSET, + sort_order: None | SortOrder | Unset = UNSET, + type_: InventoryType | None | Unset = UNSET, + source: None | str | Unset = UNSET, + tag: None | str | Unset = UNSET, +) -> Response[HTTPValidationError | ListInventoriesResponse]: + """List inventories across all domains + + # List Inventories Endpoint + + Retrieves a paginated list of all inventories across all domains belonging to + the authenticated user. + + ## Query Parameters + + - **page**: (integer, optional) Page number (zero-indexed). Default: 0. + - **size**: (integer, optional) Items per page (1-1000). Default: 100. + - **sort_by**: (string, optional) Field to sort by: `created_on`, `modified_on`, `name`. + - **sort_order**: (string, optional) Sort direction: `ascending`, `descending`. + - **type**: (string, optional) Filter by entity type (e.g., `tree`). + - **source**: (string, optional) Filter by source name (e.g., `pim`). + - **tag**: (string, optional) Filter inventories that contain this tag. + + ## Response + + Returns a paginated list of inventories with metadata. + + Args: + page (int | Unset): The page number to retrieve (zero-indexed). Default: 0. + size (int | Unset): The number of inventories to retrieve per page. Default: 100. + sort_by (InventorySortField | None | Unset): The field to sort results by. + sort_order (None | SortOrder | Unset): The order to sort results (ascending or + descending). + type_ (InventoryType | None | Unset): Filter inventories by entity type (e.g., 'tree'). + source (None | str | Unset): Filter inventories by source name (e.g., 'pim'). + tag (None | str | Unset): Filter inventories that contain this tag. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | ListInventoriesResponse] + """ + + kwargs = _get_kwargs( + page=page, + size=size, + sort_by=sort_by, + sort_order=sort_order, + type_=type_, + source=source, + tag=tag, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: InventorySortField | None | Unset = UNSET, + sort_order: None | SortOrder | Unset = UNSET, + type_: InventoryType | None | Unset = UNSET, + source: None | str | Unset = UNSET, + tag: None | str | Unset = UNSET, +) -> HTTPValidationError | ListInventoriesResponse | None: + """List inventories across all domains + + # List Inventories Endpoint + + Retrieves a paginated list of all inventories across all domains belonging to + the authenticated user. + + ## Query Parameters + + - **page**: (integer, optional) Page number (zero-indexed). Default: 0. + - **size**: (integer, optional) Items per page (1-1000). Default: 100. + - **sort_by**: (string, optional) Field to sort by: `created_on`, `modified_on`, `name`. + - **sort_order**: (string, optional) Sort direction: `ascending`, `descending`. + - **type**: (string, optional) Filter by entity type (e.g., `tree`). + - **source**: (string, optional) Filter by source name (e.g., `pim`). + - **tag**: (string, optional) Filter inventories that contain this tag. + + ## Response + + Returns a paginated list of inventories with metadata. + + Args: + page (int | Unset): The page number to retrieve (zero-indexed). Default: 0. + size (int | Unset): The number of inventories to retrieve per page. Default: 100. + sort_by (InventorySortField | None | Unset): The field to sort results by. + sort_order (None | SortOrder | Unset): The order to sort results (ascending or + descending). + type_ (InventoryType | None | Unset): Filter inventories by entity type (e.g., 'tree'). + source (None | str | Unset): Filter inventories by source name (e.g., 'pim'). + tag (None | str | Unset): Filter inventories that contain this tag. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | ListInventoriesResponse + """ + + return sync_detailed( + client=client, + page=page, + size=size, + sort_by=sort_by, + sort_order=sort_order, + type_=type_, + source=source, + tag=tag, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: InventorySortField | None | Unset = UNSET, + sort_order: None | SortOrder | Unset = UNSET, + type_: InventoryType | None | Unset = UNSET, + source: None | str | Unset = UNSET, + tag: None | str | Unset = UNSET, +) -> Response[HTTPValidationError | ListInventoriesResponse]: + """List inventories across all domains + + # List Inventories Endpoint + + Retrieves a paginated list of all inventories across all domains belonging to + the authenticated user. + + ## Query Parameters + + - **page**: (integer, optional) Page number (zero-indexed). Default: 0. + - **size**: (integer, optional) Items per page (1-1000). Default: 100. + - **sort_by**: (string, optional) Field to sort by: `created_on`, `modified_on`, `name`. + - **sort_order**: (string, optional) Sort direction: `ascending`, `descending`. + - **type**: (string, optional) Filter by entity type (e.g., `tree`). + - **source**: (string, optional) Filter by source name (e.g., `pim`). + - **tag**: (string, optional) Filter inventories that contain this tag. + + ## Response + + Returns a paginated list of inventories with metadata. + + Args: + page (int | Unset): The page number to retrieve (zero-indexed). Default: 0. + size (int | Unset): The number of inventories to retrieve per page. Default: 100. + sort_by (InventorySortField | None | Unset): The field to sort results by. + sort_order (None | SortOrder | Unset): The order to sort results (ascending or + descending). + type_ (InventoryType | None | Unset): Filter inventories by entity type (e.g., 'tree'). + source (None | str | Unset): Filter inventories by source name (e.g., 'pim'). + tag (None | str | Unset): Filter inventories that contain this tag. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | ListInventoriesResponse] + """ + + kwargs = _get_kwargs( + page=page, + size=size, + sort_by=sort_by, + sort_order=sort_order, + type_=type_, + source=source, + tag=tag, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: InventorySortField | None | Unset = UNSET, + sort_order: None | SortOrder | Unset = UNSET, + type_: InventoryType | None | Unset = UNSET, + source: None | str | Unset = UNSET, + tag: None | str | Unset = UNSET, +) -> HTTPValidationError | ListInventoriesResponse | None: + """List inventories across all domains + + # List Inventories Endpoint + + Retrieves a paginated list of all inventories across all domains belonging to + the authenticated user. + + ## Query Parameters + + - **page**: (integer, optional) Page number (zero-indexed). Default: 0. + - **size**: (integer, optional) Items per page (1-1000). Default: 100. + - **sort_by**: (string, optional) Field to sort by: `created_on`, `modified_on`, `name`. + - **sort_order**: (string, optional) Sort direction: `ascending`, `descending`. + - **type**: (string, optional) Filter by entity type (e.g., `tree`). + - **source**: (string, optional) Filter by source name (e.g., `pim`). + - **tag**: (string, optional) Filter inventories that contain this tag. + + ## Response + + Returns a paginated list of inventories with metadata. + + Args: + page (int | Unset): The page number to retrieve (zero-indexed). Default: 0. + size (int | Unset): The number of inventories to retrieve per page. Default: 100. + sort_by (InventorySortField | None | Unset): The field to sort results by. + sort_order (None | SortOrder | Unset): The order to sort results (ascending or + descending). + type_ (InventoryType | None | Unset): Filter inventories by entity type (e.g., 'tree'). + source (None | str | Unset): Filter inventories by source name (e.g., 'pim'). + tag (None | str | Unset): Filter inventories that contain this tag. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | ListInventoriesResponse + """ + + return ( + await asyncio_detailed( + client=client, + page=page, + size=size, + sort_by=sort_by, + sort_order=sort_order, + type_=type_, + source=source, + tag=tag, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/inventories/update_inventory.py b/fastfuels_sdk/v2/client_library/api/inventories/update_inventory.py new file mode 100644 index 0000000..de02648 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/inventories/update_inventory.py @@ -0,0 +1,328 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.http_validation_error import HTTPValidationError +from ...models.inventory import Inventory +from ...models.update_inventory_request_body import UpdateInventoryRequestBody +from ...types import Response + + +def _get_kwargs( + domain_id: str, + inventory_id: str, + *, + body: UpdateInventoryRequestBody, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "patch", + "url": "/domains/{domain_id}/inventories/{inventory_id}".format( + domain_id=quote(str(domain_id), safe=""), + inventory_id=quote(str(inventory_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> HTTPValidationError | Inventory | None: + if response.status_code == 200: + response_200 = Inventory.from_dict(response.json()) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[HTTPValidationError | Inventory]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + inventory_id: str, + *, + client: AuthenticatedClient, + body: UpdateInventoryRequestBody, +) -> Response[HTTPValidationError | Inventory]: + """Update an inventory + + # Update Inventory Endpoint + + Updates the metadata of an existing inventory resource. Only the fields + provided in the request body will be modified. + + ## Path Parameters + + - **domain_id**: (string) The domain the inventory belongs to. + - **inventory_id**: (string) The unique identifier of the inventory. + + ## Request Body + + All fields are optional: + + - **name**: (string) New name for the inventory. + - **description**: (string) New description. + - **tags**: (array of strings) New tags (replaces existing). + + ## What Cannot Be Updated + + The following fields are immutable: + + - **id**, **domain_id**, **type**, **source**, **modifications**, **georeference** + - **created_on** (creation timestamp is permanent) + - **checksum** (changes only when the inventory's content is rebuilt, never + via metadata updates) + + The **modified_on** field is automatically updated. + + ## Response + + Returns the updated inventory resource. + + Args: + domain_id (str): + inventory_id (str): + body (UpdateInventoryRequestBody): Request body for updating inventory metadata. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | Inventory] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + inventory_id=inventory_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + inventory_id: str, + *, + client: AuthenticatedClient, + body: UpdateInventoryRequestBody, +) -> HTTPValidationError | Inventory | None: + """Update an inventory + + # Update Inventory Endpoint + + Updates the metadata of an existing inventory resource. Only the fields + provided in the request body will be modified. + + ## Path Parameters + + - **domain_id**: (string) The domain the inventory belongs to. + - **inventory_id**: (string) The unique identifier of the inventory. + + ## Request Body + + All fields are optional: + + - **name**: (string) New name for the inventory. + - **description**: (string) New description. + - **tags**: (array of strings) New tags (replaces existing). + + ## What Cannot Be Updated + + The following fields are immutable: + + - **id**, **domain_id**, **type**, **source**, **modifications**, **georeference** + - **created_on** (creation timestamp is permanent) + - **checksum** (changes only when the inventory's content is rebuilt, never + via metadata updates) + + The **modified_on** field is automatically updated. + + ## Response + + Returns the updated inventory resource. + + Args: + domain_id (str): + inventory_id (str): + body (UpdateInventoryRequestBody): Request body for updating inventory metadata. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | Inventory + """ + + return sync_detailed( + domain_id=domain_id, + inventory_id=inventory_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + inventory_id: str, + *, + client: AuthenticatedClient, + body: UpdateInventoryRequestBody, +) -> Response[HTTPValidationError | Inventory]: + """Update an inventory + + # Update Inventory Endpoint + + Updates the metadata of an existing inventory resource. Only the fields + provided in the request body will be modified. + + ## Path Parameters + + - **domain_id**: (string) The domain the inventory belongs to. + - **inventory_id**: (string) The unique identifier of the inventory. + + ## Request Body + + All fields are optional: + + - **name**: (string) New name for the inventory. + - **description**: (string) New description. + - **tags**: (array of strings) New tags (replaces existing). + + ## What Cannot Be Updated + + The following fields are immutable: + + - **id**, **domain_id**, **type**, **source**, **modifications**, **georeference** + - **created_on** (creation timestamp is permanent) + - **checksum** (changes only when the inventory's content is rebuilt, never + via metadata updates) + + The **modified_on** field is automatically updated. + + ## Response + + Returns the updated inventory resource. + + Args: + domain_id (str): + inventory_id (str): + body (UpdateInventoryRequestBody): Request body for updating inventory metadata. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | Inventory] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + inventory_id=inventory_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + inventory_id: str, + *, + client: AuthenticatedClient, + body: UpdateInventoryRequestBody, +) -> HTTPValidationError | Inventory | None: + """Update an inventory + + # Update Inventory Endpoint + + Updates the metadata of an existing inventory resource. Only the fields + provided in the request body will be modified. + + ## Path Parameters + + - **domain_id**: (string) The domain the inventory belongs to. + - **inventory_id**: (string) The unique identifier of the inventory. + + ## Request Body + + All fields are optional: + + - **name**: (string) New name for the inventory. + - **description**: (string) New description. + - **tags**: (array of strings) New tags (replaces existing). + + ## What Cannot Be Updated + + The following fields are immutable: + + - **id**, **domain_id**, **type**, **source**, **modifications**, **georeference** + - **created_on** (creation timestamp is permanent) + - **checksum** (changes only when the inventory's content is rebuilt, never + via metadata updates) + + The **modified_on** field is automatically updated. + + ## Response + + Returns the updated inventory resource. + + Args: + domain_id (str): + inventory_id (str): + body (UpdateInventoryRequestBody): Request body for updating inventory metadata. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | Inventory + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + inventory_id=inventory_id, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/keys/__init__.py b/fastfuels_sdk/v2/client_library/api/keys/__init__.py new file mode 100644 index 0000000..2d7c0b2 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/keys/__init__.py @@ -0,0 +1 @@ +"""Contains endpoint functions for accessing the API""" diff --git a/fastfuels_sdk/v2/client_library/api/keys/create_key.py b/fastfuels_sdk/v2/client_library/api/keys/create_key.py new file mode 100644 index 0000000..a2e2cec --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/keys/create_key.py @@ -0,0 +1,186 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.create_key_request import CreateKeyRequest +from ...models.create_key_response import CreateKeyResponse +from ...models.http_validation_error import HTTPValidationError +from ...types import Response + + +def _get_kwargs( + *, + body: CreateKeyRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/keys", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> CreateKeyResponse | HTTPValidationError | None: + if response.status_code == 201: + response_201 = CreateKeyResponse.from_dict(response.json()) + + return response_201 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[CreateKeyResponse | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + body: CreateKeyRequest, +) -> Response[CreateKeyResponse | HTTPValidationError]: + """Create API key + + Create a new API key for programmatic access. + + Returns the key secret exactly once. The secret cannot be retrieved again — + only its SHA-256 hash (the key ID) is stored. + + Args: + body (CreateKeyRequest): Request body for creating an API key. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[CreateKeyResponse | HTTPValidationError] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + body: CreateKeyRequest, +) -> CreateKeyResponse | HTTPValidationError | None: + """Create API key + + Create a new API key for programmatic access. + + Returns the key secret exactly once. The secret cannot be retrieved again — + only its SHA-256 hash (the key ID) is stored. + + Args: + body (CreateKeyRequest): Request body for creating an API key. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + CreateKeyResponse | HTTPValidationError + """ + + return sync_detailed( + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + body: CreateKeyRequest, +) -> Response[CreateKeyResponse | HTTPValidationError]: + """Create API key + + Create a new API key for programmatic access. + + Returns the key secret exactly once. The secret cannot be retrieved again — + only its SHA-256 hash (the key ID) is stored. + + Args: + body (CreateKeyRequest): Request body for creating an API key. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[CreateKeyResponse | HTTPValidationError] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + body: CreateKeyRequest, +) -> CreateKeyResponse | HTTPValidationError | None: + """Create API key + + Create a new API key for programmatic access. + + Returns the key secret exactly once. The secret cannot be retrieved again — + only its SHA-256 hash (the key ID) is stored. + + Args: + body (CreateKeyRequest): Request body for creating an API key. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + CreateKeyResponse | HTTPValidationError + """ + + return ( + await asyncio_detailed( + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/keys/delete_key.py b/fastfuels_sdk/v2/client_library/api/keys/delete_key.py new file mode 100644 index 0000000..2c00d17 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/keys/delete_key.py @@ -0,0 +1,167 @@ +from http import HTTPStatus +from typing import Any, cast +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.http_validation_error import HTTPValidationError +from ...types import Response + + +def _get_kwargs( + key_id: str, +) -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "delete", + "url": "/keys/{key_id}".format( + key_id=quote(str(key_id), safe=""), + ), + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Any | HTTPValidationError | None: + if response.status_code == 204: + response_204 = cast(Any, None) + return response_204 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Any | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + key_id: str, + *, + client: AuthenticatedClient, +) -> Response[Any | HTTPValidationError]: + """Delete API key + + Delete an API key with ownership check. Clears the auth cache. + + Args: + key_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any | HTTPValidationError] + """ + + kwargs = _get_kwargs( + key_id=key_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + key_id: str, + *, + client: AuthenticatedClient, +) -> Any | HTTPValidationError | None: + """Delete API key + + Delete an API key with ownership check. Clears the auth cache. + + Args: + key_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Any | HTTPValidationError + """ + + return sync_detailed( + key_id=key_id, + client=client, + ).parsed + + +async def asyncio_detailed( + key_id: str, + *, + client: AuthenticatedClient, +) -> Response[Any | HTTPValidationError]: + """Delete API key + + Delete an API key with ownership check. Clears the auth cache. + + Args: + key_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any | HTTPValidationError] + """ + + kwargs = _get_kwargs( + key_id=key_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + key_id: str, + *, + client: AuthenticatedClient, +) -> Any | HTTPValidationError | None: + """Delete API key + + Delete an API key with ownership check. Clears the auth cache. + + Args: + key_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Any | HTTPValidationError + """ + + return ( + await asyncio_detailed( + key_id=key_id, + client=client, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/keys/get_key_by_id.py b/fastfuels_sdk/v2/client_library/api/keys/get_key_by_id.py new file mode 100644 index 0000000..e9af2b8 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/keys/get_key_by_id.py @@ -0,0 +1,169 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.http_validation_error import HTTPValidationError +from ...models.key import Key +from ...types import Response + + +def _get_kwargs( + key_id: str, +) -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/keys/{key_id}".format( + key_id=quote(str(key_id), safe=""), + ), + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> HTTPValidationError | Key | None: + if response.status_code == 200: + response_200 = Key.from_dict(response.json()) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[HTTPValidationError | Key]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + key_id: str, + *, + client: AuthenticatedClient, +) -> Response[HTTPValidationError | Key]: + """Get API key + + Get an API key by ID with two-tier ownership check. + + Args: + key_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | Key] + """ + + kwargs = _get_kwargs( + key_id=key_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + key_id: str, + *, + client: AuthenticatedClient, +) -> HTTPValidationError | Key | None: + """Get API key + + Get an API key by ID with two-tier ownership check. + + Args: + key_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | Key + """ + + return sync_detailed( + key_id=key_id, + client=client, + ).parsed + + +async def asyncio_detailed( + key_id: str, + *, + client: AuthenticatedClient, +) -> Response[HTTPValidationError | Key]: + """Get API key + + Get an API key by ID with two-tier ownership check. + + Args: + key_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | Key] + """ + + kwargs = _get_kwargs( + key_id=key_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + key_id: str, + *, + client: AuthenticatedClient, +) -> HTTPValidationError | Key | None: + """Get API key + + Get an API key by ID with two-tier ownership check. + + Args: + key_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | Key + """ + + return ( + await asyncio_detailed( + key_id=key_id, + client=client, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/keys/list_keys.py b/fastfuels_sdk/v2/client_library/api/keys/list_keys.py new file mode 100644 index 0000000..73729da --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/keys/list_keys.py @@ -0,0 +1,189 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.http_validation_error import HTTPValidationError +from ...models.list_keys_response import ListKeysResponse +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + *, + page: int | Unset = 0, + size: int | Unset = 100, +) -> dict[str, Any]: + + params: dict[str, Any] = {} + + params["page"] = page + + params["size"] = size + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/keys", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> HTTPValidationError | ListKeysResponse | None: + if response.status_code == 200: + response_200 = ListKeysResponse.from_dict(response.json()) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[HTTPValidationError | ListKeysResponse]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, +) -> Response[HTTPValidationError | ListKeysResponse]: + """List API keys + + List API keys accessible to the authenticated user or application. + + Args: + page (int | Unset): Default: 0. + size (int | Unset): Default: 100. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | ListKeysResponse] + """ + + kwargs = _get_kwargs( + page=page, + size=size, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, +) -> HTTPValidationError | ListKeysResponse | None: + """List API keys + + List API keys accessible to the authenticated user or application. + + Args: + page (int | Unset): Default: 0. + size (int | Unset): Default: 100. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | ListKeysResponse + """ + + return sync_detailed( + client=client, + page=page, + size=size, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, +) -> Response[HTTPValidationError | ListKeysResponse]: + """List API keys + + List API keys accessible to the authenticated user or application. + + Args: + page (int | Unset): Default: 0. + size (int | Unset): Default: 100. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | ListKeysResponse] + """ + + kwargs = _get_kwargs( + page=page, + size=size, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, +) -> HTTPValidationError | ListKeysResponse | None: + """List API keys + + List API keys accessible to the authenticated user or application. + + Args: + page (int | Unset): Default: 0. + size (int | Unset): Default: 100. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | ListKeysResponse + """ + + return ( + await asyncio_detailed( + client=client, + page=page, + size=size, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/client.py b/fastfuels_sdk/v2/client_library/client.py new file mode 100644 index 0000000..0ab1589 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/client.py @@ -0,0 +1,282 @@ +import ssl +from typing import Any + +import httpx +from attrs import define, evolve, field + + +@define +class Client: + """A class for keeping track of data related to the API + + The following are accepted as keyword arguments and will be used to construct httpx Clients internally: + + ``base_url``: The base URL for the API, all requests are made to a relative path to this URL + + ``cookies``: A dictionary of cookies to be sent with every request + + ``headers``: A dictionary of headers to be sent with every request + + ``timeout``: The maximum amount of a time a request can take. API functions will raise + httpx.TimeoutException if this is exceeded. + + ``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production, + but can be set to False for testing purposes. + + ``follow_redirects``: Whether or not to follow redirects. Default value is False. + + ``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor. + + + Attributes: + raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a + status code that was not documented in the source OpenAPI document. Can also be provided as a keyword + argument to the constructor. + """ + + raise_on_unexpected_status: bool = field(default=False, kw_only=True) + _base_url: str = field(alias="base_url") + _cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies") + _headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers") + _timeout: httpx.Timeout | None = field(default=None, kw_only=True, alias="timeout") + _verify_ssl: str | bool | ssl.SSLContext = field( + default=True, kw_only=True, alias="verify_ssl" + ) + _follow_redirects: bool = field( + default=False, kw_only=True, alias="follow_redirects" + ) + _httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias="httpx_args") + _client: httpx.Client | None = field(default=None, init=False) + _async_client: httpx.AsyncClient | None = field(default=None, init=False) + + def with_headers(self, headers: dict[str, str]) -> "Client": + """Get a new client matching this one with additional headers""" + if self._client is not None: + self._client.headers.update(headers) + if self._async_client is not None: + self._async_client.headers.update(headers) + return evolve(self, headers={**self._headers, **headers}) + + def with_cookies(self, cookies: dict[str, str]) -> "Client": + """Get a new client matching this one with additional cookies""" + if self._client is not None: + self._client.cookies.update(cookies) + if self._async_client is not None: + self._async_client.cookies.update(cookies) + return evolve(self, cookies={**self._cookies, **cookies}) + + def with_timeout(self, timeout: httpx.Timeout) -> "Client": + """Get a new client matching this one with a new timeout configuration""" + if self._client is not None: + self._client.timeout = timeout + if self._async_client is not None: + self._async_client.timeout = timeout + return evolve(self, timeout=timeout) + + def set_httpx_client(self, client: httpx.Client) -> "Client": + """Manually set the underlying httpx.Client + + **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. + """ + self._client = client + return self + + def get_httpx_client(self) -> httpx.Client: + """Get the underlying httpx.Client, constructing a new one if not previously set""" + if self._client is None: + self._client = httpx.Client( + base_url=self._base_url, + cookies=self._cookies, + headers=self._headers, + timeout=self._timeout, + verify=self._verify_ssl, + follow_redirects=self._follow_redirects, + **self._httpx_args, + ) + return self._client + + def __enter__(self) -> "Client": + """Enter a context manager for self.client—you cannot enter twice (see httpx docs)""" + self.get_httpx_client().__enter__() + return self + + def __exit__(self, *args: Any, **kwargs: Any) -> None: + """Exit a context manager for internal httpx.Client (see httpx docs)""" + self.get_httpx_client().__exit__(*args, **kwargs) + + def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> "Client": + """Manually set the underlying httpx.AsyncClient + + **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. + """ + self._async_client = async_client + return self + + def get_async_httpx_client(self) -> httpx.AsyncClient: + """Get the underlying httpx.AsyncClient, constructing a new one if not previously set""" + if self._async_client is None: + self._async_client = httpx.AsyncClient( + base_url=self._base_url, + cookies=self._cookies, + headers=self._headers, + timeout=self._timeout, + verify=self._verify_ssl, + follow_redirects=self._follow_redirects, + **self._httpx_args, + ) + return self._async_client + + async def __aenter__(self) -> "Client": + """Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)""" + await self.get_async_httpx_client().__aenter__() + return self + + async def __aexit__(self, *args: Any, **kwargs: Any) -> None: + """Exit a context manager for underlying httpx.AsyncClient (see httpx docs)""" + await self.get_async_httpx_client().__aexit__(*args, **kwargs) + + +@define +class AuthenticatedClient: + """A Client which has been authenticated for use on secured endpoints + + The following are accepted as keyword arguments and will be used to construct httpx Clients internally: + + ``base_url``: The base URL for the API, all requests are made to a relative path to this URL + + ``cookies``: A dictionary of cookies to be sent with every request + + ``headers``: A dictionary of headers to be sent with every request + + ``timeout``: The maximum amount of a time a request can take. API functions will raise + httpx.TimeoutException if this is exceeded. + + ``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production, + but can be set to False for testing purposes. + + ``follow_redirects``: Whether or not to follow redirects. Default value is False. + + ``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor. + + + Attributes: + raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a + status code that was not documented in the source OpenAPI document. Can also be provided as a keyword + argument to the constructor. + token: The token to use for authentication + prefix: The prefix to use for the Authorization header + auth_header_name: The name of the Authorization header + """ + + raise_on_unexpected_status: bool = field(default=False, kw_only=True) + _base_url: str = field(alias="base_url") + _cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies") + _headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers") + _timeout: httpx.Timeout | None = field(default=None, kw_only=True, alias="timeout") + _verify_ssl: str | bool | ssl.SSLContext = field( + default=True, kw_only=True, alias="verify_ssl" + ) + _follow_redirects: bool = field( + default=False, kw_only=True, alias="follow_redirects" + ) + _httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias="httpx_args") + _client: httpx.Client | None = field(default=None, init=False) + _async_client: httpx.AsyncClient | None = field(default=None, init=False) + + token: str + prefix: str = "Bearer" + auth_header_name: str = "Authorization" + + def with_headers(self, headers: dict[str, str]) -> "AuthenticatedClient": + """Get a new client matching this one with additional headers""" + if self._client is not None: + self._client.headers.update(headers) + if self._async_client is not None: + self._async_client.headers.update(headers) + return evolve(self, headers={**self._headers, **headers}) + + def with_cookies(self, cookies: dict[str, str]) -> "AuthenticatedClient": + """Get a new client matching this one with additional cookies""" + if self._client is not None: + self._client.cookies.update(cookies) + if self._async_client is not None: + self._async_client.cookies.update(cookies) + return evolve(self, cookies={**self._cookies, **cookies}) + + def with_timeout(self, timeout: httpx.Timeout) -> "AuthenticatedClient": + """Get a new client matching this one with a new timeout configuration""" + if self._client is not None: + self._client.timeout = timeout + if self._async_client is not None: + self._async_client.timeout = timeout + return evolve(self, timeout=timeout) + + def set_httpx_client(self, client: httpx.Client) -> "AuthenticatedClient": + """Manually set the underlying httpx.Client + + **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. + """ + self._client = client + return self + + def get_httpx_client(self) -> httpx.Client: + """Get the underlying httpx.Client, constructing a new one if not previously set""" + if self._client is None: + self._headers[self.auth_header_name] = ( + f"{self.prefix} {self.token}" if self.prefix else self.token + ) + self._client = httpx.Client( + base_url=self._base_url, + cookies=self._cookies, + headers=self._headers, + timeout=self._timeout, + verify=self._verify_ssl, + follow_redirects=self._follow_redirects, + **self._httpx_args, + ) + return self._client + + def __enter__(self) -> "AuthenticatedClient": + """Enter a context manager for self.client—you cannot enter twice (see httpx docs)""" + self.get_httpx_client().__enter__() + return self + + def __exit__(self, *args: Any, **kwargs: Any) -> None: + """Exit a context manager for internal httpx.Client (see httpx docs)""" + self.get_httpx_client().__exit__(*args, **kwargs) + + def set_async_httpx_client( + self, async_client: httpx.AsyncClient + ) -> "AuthenticatedClient": + """Manually set the underlying httpx.AsyncClient + + **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. + """ + self._async_client = async_client + return self + + def get_async_httpx_client(self) -> httpx.AsyncClient: + """Get the underlying httpx.AsyncClient, constructing a new one if not previously set""" + if self._async_client is None: + self._headers[self.auth_header_name] = ( + f"{self.prefix} {self.token}" if self.prefix else self.token + ) + self._async_client = httpx.AsyncClient( + base_url=self._base_url, + cookies=self._cookies, + headers=self._headers, + timeout=self._timeout, + verify=self._verify_ssl, + follow_redirects=self._follow_redirects, + **self._httpx_args, + ) + return self._async_client + + async def __aenter__(self) -> "AuthenticatedClient": + """Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)""" + await self.get_async_httpx_client().__aenter__() + return self + + async def __aexit__(self, *args: Any, **kwargs: Any) -> None: + """Exit a context manager for underlying httpx.AsyncClient (see httpx docs)""" + await self.get_async_httpx_client().__aexit__(*args, **kwargs) diff --git a/fastfuels_sdk/v2/client_library/errors.py b/fastfuels_sdk/v2/client_library/errors.py new file mode 100644 index 0000000..5f92e76 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/errors.py @@ -0,0 +1,16 @@ +"""Contains shared errors types that can be raised from API functions""" + + +class UnexpectedStatus(Exception): + """Raised by api functions when the response status an undocumented status and Client.raise_on_unexpected_status is True""" + + def __init__(self, status_code: int, content: bytes): + self.status_code = status_code + self.content = content + + super().__init__( + f"Unexpected status code: {status_code}\n\nResponse content:\n{content.decode(errors='ignore')}" + ) + + +__all__ = ["UnexpectedStatus"] diff --git a/fastfuels_sdk/v2/client_library/models/__init__.py b/fastfuels_sdk/v2/client_library/models/__init__.py new file mode 100644 index 0000000..518df43 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/__init__.py @@ -0,0 +1,413 @@ +"""Contains all the data models used in inputs/outputs""" + +from .access import Access +from .allometry_biomass_source import AllometryBiomassSource +from .allometry_biomass_source_component_states import ( + AllometryBiomassSourceComponentStates, +) +from .allometry_max_crown_radius_source import AllometryMaxCrownRadiusSource +from .application import Application +from .band import Band +from .band_type import BandType +from .base_model import BaseModel +from .biomass_component import BiomassComponent +from .biomass_component_state import BiomassComponentState +from .biomass_equations import BiomassEquations +from .biomass_unit import BiomassUnit +from .chunks import Chunks +from .chunks_count_by_axis_type_0 import ChunksCountByAxisType0 +from .column import Column +from .column_type import ColumnType +from .create_application_request import CreateApplicationRequest +from .create_chm_inventory_request import CreateChmInventoryRequest +from .create_domain_request_body import CreateDomainRequestBody +from .create_fbfm_40_lookup_request import CreateFbfm40LookupRequest +from .create_geo_tiff_upload_request import CreateGeoTIFFUploadRequest +from .create_inventory_upload_request import CreateInventoryUploadRequest +from .create_key_request import CreateKeyRequest +from .create_key_response import CreateKeyResponse +from .create_landfire_canopy_request import CreateLandfireCanopyRequest +from .create_landfire_fbfm_40_request import CreateLandfireFbfm40Request +from .create_landfire_fccs_request import CreateLandfireFccsRequest +from .create_landfire_topography_request import CreateLandfireTopographyRequest +from .create_layerset_rasterize_request import CreateLayersetRasterizeRequest +from .create_layerset_request_body import CreateLayersetRequestBody +from .create_meta_chm_request import CreateMetaChmRequest +from .create_naip_chm_request import CreateNaipChmRequest +from .create_netcdf_upload_request import CreateNetcdfUploadRequest +from .create_osm_road_feature_request import CreateOsmRoadFeatureRequest +from .create_osm_water_feature_request import CreateOsmWaterFeatureRequest +from .create_pim_inventory_request import CreatePimInventoryRequest +from .create_resample_request import CreateResampleRequest +from .create_resample_request_method_overrides import ( + CreateResampleRequestMethodOverrides, +) +from .create_three_dep_topography_request import CreateThreeDepTopographyRequest +from .create_tree_inventory_request import CreateTreeInventoryRequest +from .create_tree_map_request import CreateTreeMapRequest +from .create_uniform_request import CreateUniformRequest +from .crown_profile_model import CrownProfileModel +from .dense_grid_data import DenseGridData +from .distribution import Distribution +from .domain import Domain +from .domain_lattice import DomainLattice +from .domain_sort_field import DomainSortField +from .domain_sort_order import DomainSortOrder +from .domain_style import DomainStyle +from .duplicate_inventory_request import DuplicateInventoryRequest +from .export import Export +from .export_grid_request import ExportGridRequest +from .export_inventory_request import ExportInventoryRequest +from .export_sort_field import ExportSortField +from .export_source import ExportSource +from .fbfm_40_lookup_band import Fbfm40LookupBand +from .feature import Feature +from .feature_data_metadata import FeatureDataMetadata +from .feature_georeference import FeatureGeoreference +from .feature_partition_info import FeaturePartitionInfo +from .feature_sort_field import FeatureSortField +from .feature_source import FeatureSource +from .feature_type import FeatureType +from .field_source import FieldSource +from .fine_biomass_config import FineBiomassConfig +from .geo_json_crs import GeoJsonCRS +from .geo_json_crs_properties import GeoJsonCRSProperties +from .geo_json_feature import GeoJsonFeature +from .geo_json_feature_properties_type_0 import GeoJsonFeaturePropertiesType0 +from .geometry_collection import GeometryCollection +from .georeference import Georeference +from .georeference_3d import Georeference3D +from .grid import Grid +from .grid_alignment_domain_target import GridAlignmentDomainTarget +from .grid_alignment_grid_target import GridAlignmentGridTarget +from .grid_alignment_native_target import GridAlignmentNativeTarget +from .grid_data_array_format import GridDataArrayFormat +from .grid_data_chunk_metadata import GridDataChunkMetadata +from .grid_data_order import GridDataOrder +from .grid_data_response import GridDataResponse +from .grid_data_response_order import GridDataResponseOrder +from .grid_export_format import GridExportFormat +from .grid_feature_spatial_condition import GridFeatureSpatialCondition +from .grid_geometry_spatial_condition import GridGeometrySpatialCondition +from .grid_geometry_spatial_condition_crs_type_0 import ( + GridGeometrySpatialConditionCrsType0, +) +from .grid_geometry_spatial_condition_geometry import ( + GridGeometrySpatialConditionGeometry, +) +from .grid_modification import GridModification +from .grid_modification_action import GridModificationAction +from .grid_modification_condition import GridModificationCondition +from .grid_sort_field import GridSortField +from .grid_source import GridSource +from .grid_spatial_target import GridSpatialTarget +from .grid_upload_created_response import GridUploadCreatedResponse +from .grid_upload_spec import GridUploadSpec +from .http_validation_error import HTTPValidationError +from .inventory import Inventory +from .inventory_attribute import InventoryAttribute +from .inventory_basal_area_treatment import InventoryBasalAreaTreatment +from .inventory_biomass_column import InventoryBiomassColumn +from .inventory_column_mapping import InventoryColumnMapping +from .inventory_column_max_crown_radius_source import ( + InventoryColumnMaxCrownRadiusSource, +) +from .inventory_columns_biomass_source import InventoryColumnsBiomassSource +from .inventory_columns_biomass_source_columns import ( + InventoryColumnsBiomassSourceColumns, +) +from .inventory_columns_biomass_source_component_states import ( + InventoryColumnsBiomassSourceComponentStates, +) +from .inventory_data_format import InventoryDataFormat +from .inventory_data_metadata import InventoryDataMetadata +from .inventory_data_response import InventoryDataResponse +from .inventory_data_response_data_type_1_item import InventoryDataResponseDataType1Item +from .inventory_diameter_treatment import InventoryDiameterTreatment +from .inventory_diameter_treatment_method import InventoryDiameterTreatmentMethod +from .inventory_export_format import InventoryExportFormat +from .inventory_expression_condition import InventoryExpressionCondition +from .inventory_feature_spatial_condition import InventoryFeatureSpatialCondition +from .inventory_geometry_spatial_condition import InventoryGeometrySpatialCondition +from .inventory_geometry_spatial_condition_crs_type_0 import ( + InventoryGeometrySpatialConditionCrsType0, +) +from .inventory_geometry_spatial_condition_geometry import ( + InventoryGeometrySpatialConditionGeometry, +) +from .inventory_georeference import InventoryGeoreference +from .inventory_json_orientation import InventoryJsonOrientation +from .inventory_modification import InventoryModification +from .inventory_modification_action import InventoryModificationAction +from .inventory_modification_condition import InventoryModificationCondition +from .inventory_partition_info import InventoryPartitionInfo +from .inventory_sort_field import InventorySortField +from .inventory_source import InventorySource +from .inventory_treatment_method import InventoryTreatmentMethod +from .inventory_type import InventoryType +from .inventory_upload_created_response import InventoryUploadCreatedResponse +from .inventory_upload_format import InventoryUploadFormat +from .inventory_upload_spec import InventoryUploadSpec +from .job_error import JobError +from .job_progress import JobProgress +from .job_status import JobStatus +from .key import Key +from .landfire_canopy_fuel_band import LandfireCanopyFuelBand +from .landfire_canopy_version import LandfireCanopyVersion +from .landfire_fbfm_40_version import LandfireFbfm40Version +from .landfire_fccs_version import LandfireFccsVersion +from .landfire_topography_version import LandfireTopographyVersion +from .layerset_crs import LayersetCrs +from .layerset_crs_properties import LayersetCrsProperties +from .layerset_feature import LayersetFeature +from .layerset_properties import LayersetProperties +from .line_string import LineString +from .list_applications_response import ListApplicationsResponse +from .list_domains_response import ListDomainsResponse +from .list_exports_response import ListExportsResponse +from .list_features_response import ListFeaturesResponse +from .list_grids_response import ListGridsResponse +from .list_inventories_response import ListInventoriesResponse +from .list_keys_response import ListKeysResponse +from .max_crown_radius_unit import MaxCrownRadiusUnit +from .meta_chm_version import MetaCHMVersion +from .modifier import Modifier +from .moisture_model import MoistureModel +from .multi_line_string import MultiLineString +from .multi_point import MultiPoint +from .multi_polygon import MultiPolygon +from .non_burnable_fuel_model import NonBurnableFuelModel +from .operator import Operator +from .overlap_method import OverlapMethod +from .point import Point +from .point_process import PointProcess +from .polygon import Polygon +from .quic_fire_export_alignment_domain_target import ( + QUICFireExportAlignmentDomainTarget, +) +from .quic_fire_export_alignment_grid_target import QUICFireExportAlignmentGridTarget +from .quicfire_export_request import QuicfireExportRequest +from .quicfire_export_request_moist_merge import QuicfireExportRequestMoistMerge +from .remove_action import RemoveAction +from .resampling_method import ResamplingMethod +from .resolution_3d import Resolution3D +from .scope import Scope +from .sort_order import SortOrder +from .sparse_grid_data import SparseGridData +from .spatial_operator import SpatialOperator +from .stem_isolation_lmf import StemIsolationLmf +from .stem_isolation_vwf import StemIsolationVwf +from .three_dep_coverage_response import ThreeDepCoverageResponse +from .three_dep_resolution import ThreeDepResolution +from .topography_band import TopographyBand +from .tree_band import TreeBand +from .tree_map_band import TreeMapBand +from .tree_map_version import TreeMapVersion +from .uniform_band import UniformBand +from .uniform_band_input import UniformBandInput +from .uniform_moisture_value import UniformMoistureValue +from .update_application_request import UpdateApplicationRequest +from .update_domain_request_body import UpdateDomainRequestBody +from .update_export_request_body import UpdateExportRequestBody +from .update_feature_request_body import UpdateFeatureRequestBody +from .update_grid_request_body import UpdateGridRequestBody +from .update_inventory_request_body import UpdateInventoryRequestBody +from .upload_band_definition import UploadBandDefinition +from .validation_error import ValidationError + +__all__ = ( + "Access", + "AllometryBiomassSource", + "AllometryBiomassSourceComponentStates", + "AllometryMaxCrownRadiusSource", + "Application", + "Band", + "BandType", + "BaseModel", + "BiomassComponent", + "BiomassComponentState", + "BiomassEquations", + "BiomassUnit", + "Chunks", + "ChunksCountByAxisType0", + "Column", + "ColumnType", + "CreateApplicationRequest", + "CreateChmInventoryRequest", + "CreateDomainRequestBody", + "CreateFbfm40LookupRequest", + "CreateGeoTIFFUploadRequest", + "CreateInventoryUploadRequest", + "CreateKeyRequest", + "CreateKeyResponse", + "CreateLandfireCanopyRequest", + "CreateLandfireFbfm40Request", + "CreateLandfireFccsRequest", + "CreateLandfireTopographyRequest", + "CreateLayersetRasterizeRequest", + "CreateLayersetRequestBody", + "CreateMetaChmRequest", + "CreateNaipChmRequest", + "CreateNetcdfUploadRequest", + "CreateOsmRoadFeatureRequest", + "CreateOsmWaterFeatureRequest", + "CreatePimInventoryRequest", + "CreateResampleRequest", + "CreateResampleRequestMethodOverrides", + "CreateThreeDepTopographyRequest", + "CreateTreeInventoryRequest", + "CreateTreeMapRequest", + "CreateUniformRequest", + "CrownProfileModel", + "DenseGridData", + "Distribution", + "Domain", + "DomainLattice", + "DomainSortField", + "DomainSortOrder", + "DomainStyle", + "DuplicateInventoryRequest", + "Export", + "ExportGridRequest", + "ExportInventoryRequest", + "ExportSortField", + "ExportSource", + "Fbfm40LookupBand", + "Feature", + "FeatureDataMetadata", + "FeatureGeoreference", + "FeaturePartitionInfo", + "FeatureSortField", + "FeatureSource", + "FeatureType", + "FieldSource", + "FineBiomassConfig", + "GeoJsonCRS", + "GeoJsonCRSProperties", + "GeoJsonFeature", + "GeoJsonFeaturePropertiesType0", + "GeometryCollection", + "Georeference", + "Georeference3D", + "Grid", + "GridAlignmentDomainTarget", + "GridAlignmentGridTarget", + "GridAlignmentNativeTarget", + "GridDataArrayFormat", + "GridDataChunkMetadata", + "GridDataOrder", + "GridDataResponse", + "GridDataResponseOrder", + "GridExportFormat", + "GridFeatureSpatialCondition", + "GridGeometrySpatialCondition", + "GridGeometrySpatialConditionCrsType0", + "GridGeometrySpatialConditionGeometry", + "GridModification", + "GridModificationAction", + "GridModificationCondition", + "GridSortField", + "GridSource", + "GridSpatialTarget", + "GridUploadCreatedResponse", + "GridUploadSpec", + "HTTPValidationError", + "Inventory", + "InventoryAttribute", + "InventoryBasalAreaTreatment", + "InventoryBiomassColumn", + "InventoryColumnMapping", + "InventoryColumnMaxCrownRadiusSource", + "InventoryColumnsBiomassSource", + "InventoryColumnsBiomassSourceColumns", + "InventoryColumnsBiomassSourceComponentStates", + "InventoryDataFormat", + "InventoryDataMetadata", + "InventoryDataResponse", + "InventoryDataResponseDataType1Item", + "InventoryDiameterTreatment", + "InventoryDiameterTreatmentMethod", + "InventoryExportFormat", + "InventoryExpressionCondition", + "InventoryFeatureSpatialCondition", + "InventoryGeometrySpatialCondition", + "InventoryGeometrySpatialConditionCrsType0", + "InventoryGeometrySpatialConditionGeometry", + "InventoryGeoreference", + "InventoryJsonOrientation", + "InventoryModification", + "InventoryModificationAction", + "InventoryModificationCondition", + "InventoryPartitionInfo", + "InventorySortField", + "InventorySource", + "InventoryTreatmentMethod", + "InventoryType", + "InventoryUploadCreatedResponse", + "InventoryUploadFormat", + "InventoryUploadSpec", + "JobError", + "JobProgress", + "JobStatus", + "Key", + "LandfireCanopyFuelBand", + "LandfireCanopyVersion", + "LandfireFbfm40Version", + "LandfireFccsVersion", + "LandfireTopographyVersion", + "LayersetCrs", + "LayersetCrsProperties", + "LayersetFeature", + "LayersetProperties", + "LineString", + "ListApplicationsResponse", + "ListDomainsResponse", + "ListExportsResponse", + "ListFeaturesResponse", + "ListGridsResponse", + "ListInventoriesResponse", + "ListKeysResponse", + "MaxCrownRadiusUnit", + "MetaCHMVersion", + "Modifier", + "MoistureModel", + "MultiLineString", + "MultiPoint", + "MultiPolygon", + "NonBurnableFuelModel", + "Operator", + "OverlapMethod", + "Point", + "PointProcess", + "Polygon", + "QUICFireExportAlignmentDomainTarget", + "QUICFireExportAlignmentGridTarget", + "QuicfireExportRequest", + "QuicfireExportRequestMoistMerge", + "RemoveAction", + "ResamplingMethod", + "Resolution3D", + "Scope", + "SortOrder", + "SparseGridData", + "SpatialOperator", + "StemIsolationLmf", + "StemIsolationVwf", + "ThreeDepCoverageResponse", + "ThreeDepResolution", + "TopographyBand", + "TreeBand", + "TreeMapBand", + "TreeMapVersion", + "UniformBand", + "UniformBandInput", + "UniformMoistureValue", + "UpdateApplicationRequest", + "UpdateDomainRequestBody", + "UpdateExportRequestBody", + "UpdateFeatureRequestBody", + "UpdateGridRequestBody", + "UpdateInventoryRequestBody", + "UploadBandDefinition", + "ValidationError", +) diff --git a/fastfuels_sdk/v2/client_library/models/access.py b/fastfuels_sdk/v2/client_library/models/access.py new file mode 100644 index 0000000..7f7052c --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/access.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class Access(str, Enum): + APPLICATION = "application" + PERSONAL = "personal" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/allometry_biomass_source.py b/fastfuels_sdk/v2/client_library/models/allometry_biomass_source.py new file mode 100644 index 0000000..f3ef171 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/allometry_biomass_source.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + TYPE_CHECKING, + Any, + Literal, + TypeVar, + cast, +) + +from attrs import define as _attrs_define + +from ..models.biomass_component import BiomassComponent +from ..models.biomass_equations import BiomassEquations +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.allometry_biomass_source_component_states import ( + AllometryBiomassSourceComponentStates, + ) + from ..models.fine_biomass_config import FineBiomassConfig + + +T = TypeVar("T", bound="AllometryBiomassSource") + + +@_attrs_define +class AllometryBiomassSource: + """Estimate biomass from allometric equations. + + Attributes: + type_ (Literal['allometry'] | Unset): Default: 'allometry'. + equations (BiomassEquations | Unset): Allometric equation families for estimating biomass components. + components (list[BiomassComponent] | Unset): + component_states (AllometryBiomassSourceComponentStates | Unset): Per-component live/dead biomass partition + fractions. + fine (FineBiomassConfig | None | Unset): + """ + + type_: Literal["allometry"] | Unset = "allometry" + equations: BiomassEquations | Unset = UNSET + components: list[BiomassComponent] | Unset = UNSET + component_states: AllometryBiomassSourceComponentStates | Unset = UNSET + fine: FineBiomassConfig | None | Unset = UNSET + + def to_dict(self) -> dict[str, Any]: + from ..models.fine_biomass_config import FineBiomassConfig + + type_ = self.type_ + + equations: str | Unset = UNSET + if not isinstance(self.equations, Unset): + equations = self.equations.value + + components: list[str] | Unset = UNSET + if not isinstance(self.components, Unset): + components = [] + for components_item_data in self.components: + components_item = components_item_data.value + components.append(components_item) + + component_states: dict[str, Any] | Unset = UNSET + if not isinstance(self.component_states, Unset): + component_states = self.component_states.to_dict() + + fine: dict[str, Any] | None | Unset + if isinstance(self.fine, Unset): + fine = UNSET + elif isinstance(self.fine, FineBiomassConfig): + fine = self.fine.to_dict() + else: + fine = self.fine + + field_dict: dict[str, Any] = {} + + field_dict.update({}) + if type_ is not UNSET: + field_dict["type"] = type_ + if equations is not UNSET: + field_dict["equations"] = equations + if components is not UNSET: + field_dict["components"] = components + if component_states is not UNSET: + field_dict["component_states"] = component_states + if fine is not UNSET: + field_dict["fine"] = fine + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.allometry_biomass_source_component_states import ( + AllometryBiomassSourceComponentStates, + ) + from ..models.fine_biomass_config import FineBiomassConfig + + d = dict(src_dict) + type_ = cast(Literal["allometry"] | Unset, d.pop("type", UNSET)) + if type_ != "allometry" and not isinstance(type_, Unset): + raise ValueError(f"type must match const 'allometry', got '{type_}'") + + _equations = d.pop("equations", UNSET) + equations: BiomassEquations | Unset + if isinstance(_equations, Unset): + equations = UNSET + else: + equations = BiomassEquations(_equations) + + _components = d.pop("components", UNSET) + components: list[BiomassComponent] | Unset = UNSET + if _components is not UNSET: + components = [] + for components_item_data in _components: + components_item = BiomassComponent(components_item_data) + + components.append(components_item) + + _component_states = d.pop("component_states", UNSET) + component_states: AllometryBiomassSourceComponentStates | Unset + if isinstance(_component_states, Unset): + component_states = UNSET + else: + component_states = AllometryBiomassSourceComponentStates.from_dict( + _component_states + ) + + def _parse_fine(data: object) -> FineBiomassConfig | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + fine_type_0 = FineBiomassConfig.from_dict(data) + + return fine_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(FineBiomassConfig | None | Unset, data) + + fine = _parse_fine(d.pop("fine", UNSET)) + + allometry_biomass_source = cls( + type_=type_, + equations=equations, + components=components, + component_states=component_states, + fine=fine, + ) + + return allometry_biomass_source diff --git a/fastfuels_sdk/v2/client_library/models/allometry_biomass_source_component_states.py b/fastfuels_sdk/v2/client_library/models/allometry_biomass_source_component_states.py new file mode 100644 index 0000000..379674e --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/allometry_biomass_source_component_states.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.biomass_component_state import BiomassComponentState + + +T = TypeVar("T", bound="AllometryBiomassSourceComponentStates") + + +@_attrs_define +class AllometryBiomassSourceComponentStates: + """Per-component live/dead biomass partition fractions.""" + + additional_properties: dict[str, BiomassComponentState] = _attrs_field( + init=False, factory=dict + ) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + for prop_name, prop in self.additional_properties.items(): + field_dict[prop_name] = prop.to_dict() + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.biomass_component_state import BiomassComponentState + + d = dict(src_dict) + allometry_biomass_source_component_states = cls() + + additional_properties = {} + for prop_name, prop_dict in d.items(): + additional_property = BiomassComponentState.from_dict(prop_dict) + + additional_properties[prop_name] = additional_property + + allometry_biomass_source_component_states.additional_properties = ( + additional_properties + ) + return allometry_biomass_source_component_states + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> BiomassComponentState: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: BiomassComponentState) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/allometry_max_crown_radius_source.py b/fastfuels_sdk/v2/client_library/models/allometry_max_crown_radius_source.py new file mode 100644 index 0000000..cb0c2a1 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/allometry_max_crown_radius_source.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + Any, + Literal, + TypeVar, + cast, +) + +from attrs import define as _attrs_define + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="AllometryMaxCrownRadiusSource") + + +@_attrs_define +class AllometryMaxCrownRadiusSource: + """Use the crown profile model's allometric max crown radius (default). + + Attributes: + type_ (Literal['allometry'] | Unset): Default: 'allometry'. + """ + + type_: Literal["allometry"] | Unset = "allometry" + + def to_dict(self) -> dict[str, Any]: + type_ = self.type_ + + field_dict: dict[str, Any] = {} + + field_dict.update({}) + if type_ is not UNSET: + field_dict["type"] = type_ + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + type_ = cast(Literal["allometry"] | Unset, d.pop("type", UNSET)) + if type_ != "allometry" and not isinstance(type_, Unset): + raise ValueError(f"type must match const 'allometry', got '{type_}'") + + allometry_max_crown_radius_source = cls( + type_=type_, + ) + + return allometry_max_crown_radius_source diff --git a/fastfuels_sdk/v2/client_library/models/application.py b/fastfuels_sdk/v2/client_library/models/application.py new file mode 100644 index 0000000..a5865a4 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/application.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import datetime +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="Application") + + +@_attrs_define +class Application: + """Represents an application that can own API keys. + + Attributes: + id (str): Unique identifier for the application. + owner_id (str): The unique ID of the user who owns the application. + name (str): Name of the application. + description (None | str | Unset): Description of the application. + created_on (datetime.datetime | Unset): When the application was created. + modified_on (datetime.datetime | Unset): When the application was last modified. + """ + + id: str + owner_id: str + name: str + description: None | str | Unset = UNSET + created_on: datetime.datetime | Unset = UNSET + modified_on: datetime.datetime | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + id = self.id + + owner_id = self.owner_id + + name = self.name + + description: None | str | Unset + if isinstance(self.description, Unset): + description = UNSET + else: + description = self.description + + created_on: str | Unset = UNSET + if not isinstance(self.created_on, Unset): + created_on = self.created_on.isoformat() + + modified_on: str | Unset = UNSET + if not isinstance(self.modified_on, Unset): + modified_on = self.modified_on.isoformat() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "id": id, + "owner_id": owner_id, + "name": name, + } + ) + if description is not UNSET: + field_dict["description"] = description + if created_on is not UNSET: + field_dict["created_on"] = created_on + if modified_on is not UNSET: + field_dict["modified_on"] = modified_on + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + id = d.pop("id") + + owner_id = d.pop("owner_id") + + name = d.pop("name") + + def _parse_description(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + description = _parse_description(d.pop("description", UNSET)) + + _created_on = d.pop("created_on", UNSET) + created_on: datetime.datetime | Unset + if isinstance(_created_on, Unset): + created_on = UNSET + else: + created_on = datetime.datetime.fromisoformat(_created_on) + + _modified_on = d.pop("modified_on", UNSET) + modified_on: datetime.datetime | Unset + if isinstance(_modified_on, Unset): + modified_on = UNSET + else: + modified_on = datetime.datetime.fromisoformat(_modified_on) + + application = cls( + id=id, + owner_id=owner_id, + name=name, + description=description, + created_on=created_on, + modified_on=modified_on, + ) + + application.additional_properties = d + return application + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/band.py b/fastfuels_sdk/v2/client_library/models/band.py new file mode 100644 index 0000000..8be09d6 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/band.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.band_type import BandType +from ..types import UNSET, Unset + +T = TypeVar("T", bound="Band") + + +@_attrs_define +class Band: + """A single band in a grid. + + Attributes: + key (str): Dot-notation key (e.g., 'fuel_load.1hr') + type_ (BandType): Type of band data. + index (int): + name (None | str | Unset): Human-readable display name for the band (e.g. 'TreeMap ID', '1-hour Fuel Load'). + description (None | str | Unset): Longer-form description of what the band represents and how to interpret its + values. + unit (None | str | Unset): Physical unit of the band's pixel values, in UDUNITS-2-conformant ASCII form with + `**` for exponents (e.g. `kg/m**3`, `1/m`, `%`). `None` for categorical/identifier bands. See docs/units.md. + nodata (float | int | None | Unset): Value marking missing pixels in this band; pixels equal to it carry no data + and should be excluded from analysis. `null` when the band has no missing pixels, or when they are represented + as floating-point NaN. + """ + + key: str + type_: BandType + index: int + name: None | str | Unset = UNSET + description: None | str | Unset = UNSET + unit: None | str | Unset = UNSET + nodata: float | int | None | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + key = self.key + + type_ = self.type_.value + + index = self.index + + name: None | str | Unset + if isinstance(self.name, Unset): + name = UNSET + else: + name = self.name + + description: None | str | Unset + if isinstance(self.description, Unset): + description = UNSET + else: + description = self.description + + unit: None | str | Unset + if isinstance(self.unit, Unset): + unit = UNSET + else: + unit = self.unit + + nodata: float | int | None | Unset + if isinstance(self.nodata, Unset): + nodata = UNSET + else: + nodata = self.nodata + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "key": key, + "type": type_, + "index": index, + } + ) + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if unit is not UNSET: + field_dict["unit"] = unit + if nodata is not UNSET: + field_dict["nodata"] = nodata + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + key = d.pop("key") + + type_ = BandType(d.pop("type")) + + index = d.pop("index") + + def _parse_name(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + name = _parse_name(d.pop("name", UNSET)) + + def _parse_description(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + description = _parse_description(d.pop("description", UNSET)) + + def _parse_unit(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + unit = _parse_unit(d.pop("unit", UNSET)) + + def _parse_nodata(data: object) -> float | int | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(float | int | None | Unset, data) + + nodata = _parse_nodata(d.pop("nodata", UNSET)) + + band = cls( + key=key, + type_=type_, + index=index, + name=name, + description=description, + unit=unit, + nodata=nodata, + ) + + band.additional_properties = d + return band + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/band_type.py b/fastfuels_sdk/v2/client_library/models/band_type.py new file mode 100644 index 0000000..1282591 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/band_type.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class BandType(str, Enum): + CATEGORICAL = "categorical" + CONTINUOUS = "continuous" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/base_model.py b/fastfuels_sdk/v2/client_library/models/base_model.py new file mode 100644 index 0000000..b915dc1 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/base_model.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="BaseModel") + + +@_attrs_define +class BaseModel: + """ """ + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + base_model = cls() + + base_model.additional_properties = d + return base_model + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/biomass_component.py b/fastfuels_sdk/v2/client_library/models/biomass_component.py new file mode 100644 index 0000000..ce509ef --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/biomass_component.py @@ -0,0 +1,10 @@ +from enum import Enum + + +class BiomassComponent(str, Enum): + BRANCHWOOD = "branchwood" + FINE = "fine" + FOLIAGE = "foliage" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/biomass_component_state.py b/fastfuels_sdk/v2/client_library/models/biomass_component_state.py new file mode 100644 index 0000000..ee4b4ba --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/biomass_component_state.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="BiomassComponentState") + + +@_attrs_define +class BiomassComponentState: + """Live/dead partition for one biomass component. + + Attributes: + live (float | Unset): Default: 1.0. + dead (float | Unset): Default: 0.0. + """ + + live: float | Unset = 1.0 + dead: float | Unset = 0.0 + + def to_dict(self) -> dict[str, Any]: + live = self.live + + dead = self.dead + + field_dict: dict[str, Any] = {} + + field_dict.update({}) + if live is not UNSET: + field_dict["live"] = live + if dead is not UNSET: + field_dict["dead"] = dead + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + live = d.pop("live", UNSET) + + dead = d.pop("dead", UNSET) + + biomass_component_state = cls( + live=live, + dead=dead, + ) + + return biomass_component_state diff --git a/fastfuels_sdk/v2/client_library/models/biomass_equations.py b/fastfuels_sdk/v2/client_library/models/biomass_equations.py new file mode 100644 index 0000000..b42fe29 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/biomass_equations.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class BiomassEquations(str, Enum): + JENKINS = "jenkins" + NSVB = "nsvb" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/biomass_unit.py b/fastfuels_sdk/v2/client_library/models/biomass_unit.py new file mode 100644 index 0000000..f271950 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/biomass_unit.py @@ -0,0 +1,8 @@ +from enum import Enum + + +class BiomassUnit(str, Enum): + KG = "kg" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/chunks.py b/fastfuels_sdk/v2/client_library/models/chunks.py new file mode 100644 index 0000000..fc900d7 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/chunks.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.chunks_count_by_axis_type_0 import ChunksCountByAxisType0 + + +T = TypeVar("T", bound="Chunks") + + +@_attrs_define +class Chunks: + """Chunk layout for a grid. + + Attributes: + shape (list[int]): Size of a single chunk. 2D grids: (y, x). 3D grids: (z, y, x). Edge chunks may be smaller. + count (int | None | Unset): Total number of chunks in the grid. + count_by_axis (ChunksCountByAxisType0 | None | Unset): Number of chunks along each axis. Keys are 'y','x' for 2D + grids and 'z','y','x' for 3D grids. + """ + + shape: list[int] + count: int | None | Unset = UNSET + count_by_axis: ChunksCountByAxisType0 | None | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.chunks_count_by_axis_type_0 import ChunksCountByAxisType0 + + shape: list[int] + if isinstance(self.shape, list): + shape = [] + for shape_type_0_item_data in self.shape: + shape_type_0_item: int + shape_type_0_item = shape_type_0_item_data + shape.append(shape_type_0_item) + + count: int | None | Unset + if isinstance(self.count, Unset): + count = UNSET + else: + count = self.count + + count_by_axis: dict[str, Any] | None | Unset + if isinstance(self.count_by_axis, Unset): + count_by_axis = UNSET + elif isinstance(self.count_by_axis, ChunksCountByAxisType0): + count_by_axis = self.count_by_axis.to_dict() + else: + count_by_axis = self.count_by_axis + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "shape": shape, + } + ) + if count is not UNSET: + field_dict["count"] = count + if count_by_axis is not UNSET: + field_dict["count_by_axis"] = count_by_axis + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.chunks_count_by_axis_type_0 import ChunksCountByAxisType0 + + d = dict(src_dict) + + def _parse_shape(data: object) -> list[int]: + if not isinstance(data, list): + raise TypeError() + shape_type_0 = [] + _shape_type_0 = data + for shape_type_0_item_data in _shape_type_0: + + def _parse_shape_type_0_item(data: object) -> int: + return cast(int, data) + + shape_type_0_item = _parse_shape_type_0_item(shape_type_0_item_data) + + shape_type_0.append(shape_type_0_item) + + return shape_type_0 + + shape = _parse_shape(d.pop("shape")) + + def _parse_count(data: object) -> int | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(int | None | Unset, data) + + count = _parse_count(d.pop("count", UNSET)) + + def _parse_count_by_axis(data: object) -> ChunksCountByAxisType0 | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + count_by_axis_type_0 = ChunksCountByAxisType0.from_dict(data) + + return count_by_axis_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(ChunksCountByAxisType0 | None | Unset, data) + + count_by_axis = _parse_count_by_axis(d.pop("count_by_axis", UNSET)) + + chunks = cls( + shape=shape, + count=count, + count_by_axis=count_by_axis, + ) + + chunks.additional_properties = d + return chunks + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/chunks_count_by_axis_type_0.py b/fastfuels_sdk/v2/client_library/models/chunks_count_by_axis_type_0.py new file mode 100644 index 0000000..ae5746d --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/chunks_count_by_axis_type_0.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="ChunksCountByAxisType0") + + +@_attrs_define +class ChunksCountByAxisType0: + """ """ + + additional_properties: dict[str, int] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + chunks_count_by_axis_type_0 = cls() + + chunks_count_by_axis_type_0.additional_properties = d + return chunks_count_by_axis_type_0 + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> int: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: int) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/column.py b/fastfuels_sdk/v2/client_library/models/column.py new file mode 100644 index 0000000..97cda05 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/column.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.column_type import ColumnType +from ..types import UNSET, Unset + +T = TypeVar("T", bound="Column") + + +@_attrs_define +class Column: + """A single column in an inventory. + + Attributes: + key (str): Column name (e.g., 'dbh', 'fia_species_code') + type_ (ColumnType): Type of column data. + unit (None | str | Unset): + """ + + key: str + type_: ColumnType + unit: None | str | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + key = self.key + + type_ = self.type_.value + + unit: None | str | Unset + if isinstance(self.unit, Unset): + unit = UNSET + else: + unit = self.unit + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "key": key, + "type": type_, + } + ) + if unit is not UNSET: + field_dict["unit"] = unit + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + key = d.pop("key") + + type_ = ColumnType(d.pop("type")) + + def _parse_unit(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + unit = _parse_unit(d.pop("unit", UNSET)) + + column = cls( + key=key, + type_=type_, + unit=unit, + ) + + column.additional_properties = d + return column + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/column_type.py b/fastfuels_sdk/v2/client_library/models/column_type.py new file mode 100644 index 0000000..fc1ea26 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/column_type.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class ColumnType(str, Enum): + CATEGORICAL = "categorical" + CONTINUOUS = "continuous" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/create_application_request.py b/fastfuels_sdk/v2/client_library/models/create_application_request.py new file mode 100644 index 0000000..a675d38 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/create_application_request.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="CreateApplicationRequest") + + +@_attrs_define +class CreateApplicationRequest: + """Request body for creating an application. + + Attributes: + name (str): Name of the application. + description (None | str | Unset): Description of the application. + """ + + name: str + description: None | str | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + name = self.name + + description: None | str | Unset + if isinstance(self.description, Unset): + description = UNSET + else: + description = self.description + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "name": name, + } + ) + if description is not UNSET: + field_dict["description"] = description + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + name = d.pop("name") + + def _parse_description(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + description = _parse_description(d.pop("description", UNSET)) + + create_application_request = cls( + name=name, + description=description, + ) + + create_application_request.additional_properties = d + return create_application_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/create_chm_inventory_request.py b/fastfuels_sdk/v2/client_library/models/create_chm_inventory_request.py new file mode 100644 index 0000000..d417888 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/create_chm_inventory_request.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.inventory_type import InventoryType +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.inventory_basal_area_treatment import InventoryBasalAreaTreatment + from ..models.inventory_diameter_treatment import InventoryDiameterTreatment + from ..models.inventory_modification import InventoryModification + from ..models.stem_isolation_lmf import StemIsolationLmf + from ..models.stem_isolation_vwf import StemIsolationVwf + + +T = TypeVar("T", bound="CreateChmInventoryRequest") + + +@_attrs_define +class CreateChmInventoryRequest: + """Request body for creating an inventory via CHM extraction. + + Attributes: + source_chm_grid_id (str): ID of a completed CHM grid to use as the source. + type_ (InventoryType | Unset): Type of entities in the inventory. + name (str | Unset): Default: ''. + description (str | Unset): Default: ''. + tags (list[str] | Unset): + algorithm (StemIsolationLmf | StemIsolationVwf | Unset): Stem isolation algorithm and its parameters. + modifications (list[InventoryModification] | Unset): Modifications to apply after stem extraction. + treatments (list[InventoryBasalAreaTreatment | InventoryDiameterTreatment] | Unset): Silvicultural treatments + thin against tree diameter, so they require a diameter (`dbh`) column. CHM stem isolation produces only height + and position (`x`, `y`, `height`), so treatments are not supported here and this must be empty. + """ + + source_chm_grid_id: str + type_: InventoryType | Unset = UNSET + name: str | Unset = "" + description: str | Unset = "" + tags: list[str] | Unset = UNSET + algorithm: StemIsolationLmf | StemIsolationVwf | Unset = UNSET + modifications: list[InventoryModification] | Unset = UNSET + treatments: ( + list[InventoryBasalAreaTreatment | InventoryDiameterTreatment] | Unset + ) = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.inventory_diameter_treatment import InventoryDiameterTreatment + from ..models.stem_isolation_lmf import StemIsolationLmf + + source_chm_grid_id = self.source_chm_grid_id + + type_: str | Unset = UNSET + if not isinstance(self.type_, Unset): + type_ = self.type_.value + + name = self.name + + description = self.description + + tags: list[str] | Unset = UNSET + if not isinstance(self.tags, Unset): + tags = self.tags + + algorithm: dict[str, Any] | Unset + if isinstance(self.algorithm, Unset): + algorithm = UNSET + elif isinstance(self.algorithm, StemIsolationLmf): + algorithm = self.algorithm.to_dict() + else: + algorithm = self.algorithm.to_dict() + + modifications: list[dict[str, Any]] | Unset = UNSET + if not isinstance(self.modifications, Unset): + modifications = [] + for modifications_item_data in self.modifications: + modifications_item = modifications_item_data.to_dict() + modifications.append(modifications_item) + + treatments: list[dict[str, Any]] | Unset = UNSET + if not isinstance(self.treatments, Unset): + treatments = [] + for treatments_item_data in self.treatments: + treatments_item: dict[str, Any] + if isinstance(treatments_item_data, InventoryDiameterTreatment): + treatments_item = treatments_item_data.to_dict() + else: + treatments_item = treatments_item_data.to_dict() + + treatments.append(treatments_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "source_chm_grid_id": source_chm_grid_id, + } + ) + if type_ is not UNSET: + field_dict["type"] = type_ + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if tags is not UNSET: + field_dict["tags"] = tags + if algorithm is not UNSET: + field_dict["algorithm"] = algorithm + if modifications is not UNSET: + field_dict["modifications"] = modifications + if treatments is not UNSET: + field_dict["treatments"] = treatments + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.inventory_basal_area_treatment import InventoryBasalAreaTreatment + from ..models.inventory_diameter_treatment import InventoryDiameterTreatment + from ..models.inventory_modification import InventoryModification + from ..models.stem_isolation_lmf import StemIsolationLmf + from ..models.stem_isolation_vwf import StemIsolationVwf + + d = dict(src_dict) + source_chm_grid_id = d.pop("source_chm_grid_id") + + _type_ = d.pop("type", UNSET) + type_: InventoryType | Unset + if isinstance(_type_, Unset): + type_ = UNSET + else: + type_ = InventoryType(_type_) + + name = d.pop("name", UNSET) + + description = d.pop("description", UNSET) + + tags = cast(list[str], d.pop("tags", UNSET)) + + def _parse_algorithm( + data: object, + ) -> StemIsolationLmf | StemIsolationVwf | Unset: + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + algorithm_type_0 = StemIsolationLmf.from_dict(data) + + return algorithm_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + if not isinstance(data, dict): + raise TypeError() + algorithm_type_1 = StemIsolationVwf.from_dict(data) + + return algorithm_type_1 + + algorithm = _parse_algorithm(d.pop("algorithm", UNSET)) + + _modifications = d.pop("modifications", UNSET) + modifications: list[InventoryModification] | Unset = UNSET + if _modifications is not UNSET: + modifications = [] + for modifications_item_data in _modifications: + modifications_item = InventoryModification.from_dict( + modifications_item_data + ) + + modifications.append(modifications_item) + + _treatments = d.pop("treatments", UNSET) + treatments: ( + list[InventoryBasalAreaTreatment | InventoryDiameterTreatment] | Unset + ) = UNSET + if _treatments is not UNSET: + treatments = [] + for treatments_item_data in _treatments: + + def _parse_treatments_item( + data: object, + ) -> InventoryBasalAreaTreatment | InventoryDiameterTreatment: + try: + if not isinstance(data, dict): + raise TypeError() + treatments_item_type_0 = InventoryDiameterTreatment.from_dict( + data + ) + + return treatments_item_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + if not isinstance(data, dict): + raise TypeError() + treatments_item_type_1 = InventoryBasalAreaTreatment.from_dict(data) + + return treatments_item_type_1 + + treatments_item = _parse_treatments_item(treatments_item_data) + + treatments.append(treatments_item) + + create_chm_inventory_request = cls( + source_chm_grid_id=source_chm_grid_id, + type_=type_, + name=name, + description=description, + tags=tags, + algorithm=algorithm, + modifications=modifications, + treatments=treatments, + ) + + create_chm_inventory_request.additional_properties = d + return create_chm_inventory_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/create_domain_request_body.py b/fastfuels_sdk/v2/client_library/models/create_domain_request_body.py new file mode 100644 index 0000000..09dce6a --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/create_domain_request_body.py @@ -0,0 +1,262 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + TYPE_CHECKING, + Any, + Literal, + TypeVar, + cast, +) + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.domain_style import DomainStyle + from ..models.geo_json_crs import GeoJsonCRS + from ..models.geo_json_feature import GeoJsonFeature + + +T = TypeVar("T", bound="CreateDomainRequestBody") + + +@_attrs_define +class CreateDomainRequestBody: + """ + Attributes: + type_ (Literal['FeatureCollection']): + features (list[GeoJsonFeature]): + bbox (list[float] | None | Unset): + name (str | Unset): The name of the domain. Default: ''. + description (str | Unset): A description of the domain. Default: ''. + crs (GeoJsonCRS | Unset): + tags (list[str] | None | Unset): A list of tags associated with the domain. + pad_to_resolution (float | None | Unset): Optional resolution in meters to snap the domain bounding box to. When + set, the bounding box (the 'domain' feature) is snapped outward to the nearest multiple of this value. Grids + whose resolutions divide evenly into this value will produce identical, aligned footprints on this domain. + style (DomainStyle | None | Unset): Optional visual style for rendering the domain on a map. + """ + + type_: Literal["FeatureCollection"] + features: list[GeoJsonFeature] + bbox: list[float] | None | Unset = UNSET + name: str | Unset = "" + description: str | Unset = "" + crs: GeoJsonCRS | Unset = UNSET + tags: list[str] | None | Unset = UNSET + pad_to_resolution: float | None | Unset = UNSET + style: DomainStyle | None | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.domain_style import DomainStyle + + type_ = self.type_ + + features = [] + for features_item_data in self.features: + features_item = features_item_data.to_dict() + features.append(features_item) + + bbox: list[float] | None | Unset + if isinstance(self.bbox, Unset): + bbox = UNSET + elif isinstance(self.bbox, list): + bbox = [] + for bbox_type_0_item_data in self.bbox: + bbox_type_0_item: float + bbox_type_0_item = bbox_type_0_item_data + bbox.append(bbox_type_0_item) + + else: + bbox = self.bbox + + name = self.name + + description = self.description + + crs: dict[str, Any] | Unset = UNSET + if not isinstance(self.crs, Unset): + crs = self.crs.to_dict() + + tags: list[str] | None | Unset + if isinstance(self.tags, Unset): + tags = UNSET + elif isinstance(self.tags, list): + tags = self.tags + + else: + tags = self.tags + + pad_to_resolution: float | None | Unset + if isinstance(self.pad_to_resolution, Unset): + pad_to_resolution = UNSET + else: + pad_to_resolution = self.pad_to_resolution + + style: dict[str, Any] | None | Unset + if isinstance(self.style, Unset): + style = UNSET + elif isinstance(self.style, DomainStyle): + style = self.style.to_dict() + else: + style = self.style + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "type": type_, + "features": features, + } + ) + if bbox is not UNSET: + field_dict["bbox"] = bbox + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if crs is not UNSET: + field_dict["crs"] = crs + if tags is not UNSET: + field_dict["tags"] = tags + if pad_to_resolution is not UNSET: + field_dict["pad_to_resolution"] = pad_to_resolution + if style is not UNSET: + field_dict["style"] = style + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.domain_style import DomainStyle + from ..models.geo_json_crs import GeoJsonCRS + from ..models.geo_json_feature import GeoJsonFeature + + d = dict(src_dict) + type_ = cast(Literal["FeatureCollection"], d.pop("type")) + if type_ != "FeatureCollection": + raise ValueError( + f"type must match const 'FeatureCollection', got '{type_}'" + ) + + features = [] + _features = d.pop("features") + for features_item_data in _features: + features_item = GeoJsonFeature.from_dict(features_item_data) + + features.append(features_item) + + def _parse_bbox(data: object) -> list[float] | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, list): + raise TypeError() + bbox_type_0 = [] + _bbox_type_0 = data + for bbox_type_0_item_data in _bbox_type_0: + + def _parse_bbox_type_0_item(data: object) -> float: + return cast(float, data) + + bbox_type_0_item = _parse_bbox_type_0_item(bbox_type_0_item_data) + + bbox_type_0.append(bbox_type_0_item) + + return bbox_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(list[float] | None | Unset, data) + + bbox = _parse_bbox(d.pop("bbox", UNSET)) + + name = d.pop("name", UNSET) + + description = d.pop("description", UNSET) + + _crs = d.pop("crs", UNSET) + crs: GeoJsonCRS | Unset + if isinstance(_crs, Unset): + crs = UNSET + else: + crs = GeoJsonCRS.from_dict(_crs) + + def _parse_tags(data: object) -> list[str] | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, list): + raise TypeError() + tags_type_0 = cast(list[str], data) + + return tags_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(list[str] | None | Unset, data) + + tags = _parse_tags(d.pop("tags", UNSET)) + + def _parse_pad_to_resolution(data: object) -> float | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(float | None | Unset, data) + + pad_to_resolution = _parse_pad_to_resolution(d.pop("pad_to_resolution", UNSET)) + + def _parse_style(data: object) -> DomainStyle | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + style_type_0 = DomainStyle.from_dict(data) + + return style_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(DomainStyle | None | Unset, data) + + style = _parse_style(d.pop("style", UNSET)) + + create_domain_request_body = cls( + type_=type_, + features=features, + bbox=bbox, + name=name, + description=description, + crs=crs, + tags=tags, + pad_to_resolution=pad_to_resolution, + style=style, + ) + + create_domain_request_body.additional_properties = d + return create_domain_request_body + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/create_fbfm_40_lookup_request.py b/fastfuels_sdk/v2/client_library/models/create_fbfm_40_lookup_request.py new file mode 100644 index 0000000..8383329 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/create_fbfm_40_lookup_request.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.fbfm_40_lookup_band import Fbfm40LookupBand +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.grid_modification import GridModification + + +T = TypeVar("T", bound="CreateFbfm40LookupRequest") + + +@_attrs_define +class CreateFbfm40LookupRequest: + """Request to create a grid by looking up FBFM40 fuel parameters. + + Unlike entry-point grid creation requests, domain_id is not required + because derived grids carry the same domain reference as their source. + + Attributes: + source_grid_id (str): Grid containing FBFM40 codes + bands (list[Fbfm40LookupBand]): + source_band (str | Unset): Band in source grid containing FBFM codes Default: 'fbfm'. + name (str | Unset): Default: ''. + description (str | Unset): Default: ''. + tags (list[str] | Unset): + modifications (list[GridModification] | Unset): + """ + + source_grid_id: str + bands: list[Fbfm40LookupBand] + source_band: str | Unset = "fbfm" + name: str | Unset = "" + description: str | Unset = "" + tags: list[str] | Unset = UNSET + modifications: list[GridModification] | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + source_grid_id = self.source_grid_id + + bands = [] + for bands_item_data in self.bands: + bands_item = bands_item_data.value + bands.append(bands_item) + + source_band = self.source_band + + name = self.name + + description = self.description + + tags: list[str] | Unset = UNSET + if not isinstance(self.tags, Unset): + tags = self.tags + + modifications: list[dict[str, Any]] | Unset = UNSET + if not isinstance(self.modifications, Unset): + modifications = [] + for modifications_item_data in self.modifications: + modifications_item = modifications_item_data.to_dict() + modifications.append(modifications_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "source_grid_id": source_grid_id, + "bands": bands, + } + ) + if source_band is not UNSET: + field_dict["source_band"] = source_band + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if tags is not UNSET: + field_dict["tags"] = tags + if modifications is not UNSET: + field_dict["modifications"] = modifications + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.grid_modification import GridModification + + d = dict(src_dict) + source_grid_id = d.pop("source_grid_id") + + bands = [] + _bands = d.pop("bands") + for bands_item_data in _bands: + bands_item = Fbfm40LookupBand(bands_item_data) + + bands.append(bands_item) + + source_band = d.pop("source_band", UNSET) + + name = d.pop("name", UNSET) + + description = d.pop("description", UNSET) + + tags = cast(list[str], d.pop("tags", UNSET)) + + _modifications = d.pop("modifications", UNSET) + modifications: list[GridModification] | Unset = UNSET + if _modifications is not UNSET: + modifications = [] + for modifications_item_data in _modifications: + modifications_item = GridModification.from_dict(modifications_item_data) + + modifications.append(modifications_item) + + create_fbfm_40_lookup_request = cls( + source_grid_id=source_grid_id, + bands=bands, + source_band=source_band, + name=name, + description=description, + tags=tags, + modifications=modifications, + ) + + create_fbfm_40_lookup_request.additional_properties = d + return create_fbfm_40_lookup_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/create_geo_tiff_upload_request.py b/fastfuels_sdk/v2/client_library/models/create_geo_tiff_upload_request.py new file mode 100644 index 0000000..2f8584f --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/create_geo_tiff_upload_request.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.upload_band_definition import UploadBandDefinition + + +T = TypeVar("T", bound="CreateGeoTIFFUploadRequest") + + +@_attrs_define +class CreateGeoTIFFUploadRequest: + """ + Attributes: + bands (list[UploadBandDefinition]): + name (str | Unset): Default: ''. + description (str | Unset): Default: ''. + tags (list[str] | Unset): + num_buffer_cells (int | Unset): Number of extra native-resolution cells to keep around the domain extent in the + stored grid. The uploaded GeoTIFF must cover the domain bbox expanded by num_buffer_cells * native_pixel_size on + each side; pixels beyond that expanded extent are clipped away. Default: 0. + """ + + bands: list[UploadBandDefinition] + name: str | Unset = "" + description: str | Unset = "" + tags: list[str] | Unset = UNSET + num_buffer_cells: int | Unset = 0 + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + bands = [] + for bands_item_data in self.bands: + bands_item = bands_item_data.to_dict() + bands.append(bands_item) + + name = self.name + + description = self.description + + tags: list[str] | Unset = UNSET + if not isinstance(self.tags, Unset): + tags = self.tags + + num_buffer_cells = self.num_buffer_cells + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "bands": bands, + } + ) + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if tags is not UNSET: + field_dict["tags"] = tags + if num_buffer_cells is not UNSET: + field_dict["num_buffer_cells"] = num_buffer_cells + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.upload_band_definition import UploadBandDefinition + + d = dict(src_dict) + bands = [] + _bands = d.pop("bands") + for bands_item_data in _bands: + bands_item = UploadBandDefinition.from_dict(bands_item_data) + + bands.append(bands_item) + + name = d.pop("name", UNSET) + + description = d.pop("description", UNSET) + + tags = cast(list[str], d.pop("tags", UNSET)) + + num_buffer_cells = d.pop("num_buffer_cells", UNSET) + + create_geo_tiff_upload_request = cls( + bands=bands, + name=name, + description=description, + tags=tags, + num_buffer_cells=num_buffer_cells, + ) + + create_geo_tiff_upload_request.additional_properties = d + return create_geo_tiff_upload_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/create_inventory_upload_request.py b/fastfuels_sdk/v2/client_library/models/create_inventory_upload_request.py new file mode 100644 index 0000000..a940dcd --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/create_inventory_upload_request.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.inventory_upload_format import InventoryUploadFormat +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.inventory_column_mapping import InventoryColumnMapping + + +T = TypeVar("T", bound="CreateInventoryUploadRequest") + + +@_attrs_define +class CreateInventoryUploadRequest: + """ + Attributes: + format_ (InventoryUploadFormat): + columns (InventoryColumnMapping | Unset): Maps v2 column names to the corresponding column names in the uploaded + file. + + Omit any entry whose column already uses the v2 name. For GeoJSON and + GeoPackage formats, x and y are extracted from geometry — their mapping + entries are ignored. + name (str | Unset): Default: ''. + description (str | Unset): Default: ''. + tags (list[str] | Unset): + """ + + format_: InventoryUploadFormat + columns: InventoryColumnMapping | Unset = UNSET + name: str | Unset = "" + description: str | Unset = "" + tags: list[str] | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + format_ = self.format_.value + + columns: dict[str, Any] | Unset = UNSET + if not isinstance(self.columns, Unset): + columns = self.columns.to_dict() + + name = self.name + + description = self.description + + tags: list[str] | Unset = UNSET + if not isinstance(self.tags, Unset): + tags = self.tags + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "format": format_, + } + ) + if columns is not UNSET: + field_dict["columns"] = columns + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if tags is not UNSET: + field_dict["tags"] = tags + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.inventory_column_mapping import InventoryColumnMapping + + d = dict(src_dict) + format_ = InventoryUploadFormat(d.pop("format")) + + _columns = d.pop("columns", UNSET) + columns: InventoryColumnMapping | Unset + if isinstance(_columns, Unset): + columns = UNSET + else: + columns = InventoryColumnMapping.from_dict(_columns) + + name = d.pop("name", UNSET) + + description = d.pop("description", UNSET) + + tags = cast(list[str], d.pop("tags", UNSET)) + + create_inventory_upload_request = cls( + format_=format_, + columns=columns, + name=name, + description=description, + tags=tags, + ) + + create_inventory_upload_request.additional_properties = d + return create_inventory_upload_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/create_key_request.py b/fastfuels_sdk/v2/client_library/models/create_key_request.py new file mode 100644 index 0000000..c0db55c --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/create_key_request.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.access import Access +from ..models.scope import Scope +from ..types import UNSET, Unset + +T = TypeVar("T", bound="CreateKeyRequest") + + +@_attrs_define +class CreateKeyRequest: + """Request body for creating an API key. + + Attributes: + name (str): A name to semantically identify the key. + description (None | str | Unset): An optional description of the key's purpose. + valid_days (int | Unset): Number of days for which this key will be valid. Default: 30. + scopes (list[Scope] | Unset): A list of scopes available to the key. + access (Access | Unset): Access types for an API key. + application_id (None | str | Unset): Application ID accessed by the API key. + """ + + name: str + description: None | str | Unset = UNSET + valid_days: int | Unset = 30 + scopes: list[Scope] | Unset = UNSET + access: Access | Unset = UNSET + application_id: None | str | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + name = self.name + + description: None | str | Unset + if isinstance(self.description, Unset): + description = UNSET + else: + description = self.description + + valid_days = self.valid_days + + scopes: list[str] | Unset = UNSET + if not isinstance(self.scopes, Unset): + scopes = [] + for scopes_item_data in self.scopes: + scopes_item = scopes_item_data.value + scopes.append(scopes_item) + + access: str | Unset = UNSET + if not isinstance(self.access, Unset): + access = self.access.value + + application_id: None | str | Unset + if isinstance(self.application_id, Unset): + application_id = UNSET + else: + application_id = self.application_id + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "name": name, + } + ) + if description is not UNSET: + field_dict["description"] = description + if valid_days is not UNSET: + field_dict["valid_days"] = valid_days + if scopes is not UNSET: + field_dict["scopes"] = scopes + if access is not UNSET: + field_dict["access"] = access + if application_id is not UNSET: + field_dict["application_id"] = application_id + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + name = d.pop("name") + + def _parse_description(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + description = _parse_description(d.pop("description", UNSET)) + + valid_days = d.pop("valid_days", UNSET) + + _scopes = d.pop("scopes", UNSET) + scopes: list[Scope] | Unset = UNSET + if _scopes is not UNSET: + scopes = [] + for scopes_item_data in _scopes: + scopes_item = Scope(scopes_item_data) + + scopes.append(scopes_item) + + _access = d.pop("access", UNSET) + access: Access | Unset + if isinstance(_access, Unset): + access = UNSET + else: + access = Access(_access) + + def _parse_application_id(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + application_id = _parse_application_id(d.pop("application_id", UNSET)) + + create_key_request = cls( + name=name, + description=description, + valid_days=valid_days, + scopes=scopes, + access=access, + application_id=application_id, + ) + + create_key_request.additional_properties = d + return create_key_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/create_key_response.py b/fastfuels_sdk/v2/client_library/models/create_key_response.py new file mode 100644 index 0000000..d7d841b --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/create_key_response.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +import datetime +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.access import Access +from ..models.scope import Scope +from ..types import UNSET, Unset + +T = TypeVar("T", bound="CreateKeyResponse") + + +@_attrs_define +class CreateKeyResponse: + """Response for key creation. Includes the secret, which is shown only once. + + Attributes: + id (str): Unique identifier for the key (SHA-256 hash of the secret). + owner_id (str): The unique ID of the user or application who owns the key. + creator_id (str): The unique ID of the human user who created the key. + name (str): A name to semantically identify the key. + secret (str): The API key secret. Store this securely — it cannot be retrieved again. + description (None | str | Unset): An optional description of the key's purpose. + valid_days (int | Unset): Number of days for which this key will be valid. Default: 30. + scopes (list[Scope] | Unset): A list of scopes available to the key. + access (Access | Unset): Access types for an API key. + application_id (None | str | Unset): Application ID accessed by the API key. + created_on (datetime.datetime | Unset): The date and time the key was created. + expires_on (datetime.datetime | Unset): The date at which this key is no longer valid. + """ + + id: str + owner_id: str + creator_id: str + name: str + secret: str + description: None | str | Unset = UNSET + valid_days: int | Unset = 30 + scopes: list[Scope] | Unset = UNSET + access: Access | Unset = UNSET + application_id: None | str | Unset = UNSET + created_on: datetime.datetime | Unset = UNSET + expires_on: datetime.datetime | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + id = self.id + + owner_id = self.owner_id + + creator_id = self.creator_id + + name = self.name + + secret = self.secret + + description: None | str | Unset + if isinstance(self.description, Unset): + description = UNSET + else: + description = self.description + + valid_days = self.valid_days + + scopes: list[str] | Unset = UNSET + if not isinstance(self.scopes, Unset): + scopes = [] + for scopes_item_data in self.scopes: + scopes_item = scopes_item_data.value + scopes.append(scopes_item) + + access: str | Unset = UNSET + if not isinstance(self.access, Unset): + access = self.access.value + + application_id: None | str | Unset + if isinstance(self.application_id, Unset): + application_id = UNSET + else: + application_id = self.application_id + + created_on: str | Unset = UNSET + if not isinstance(self.created_on, Unset): + created_on = self.created_on.isoformat() + + expires_on: str | Unset = UNSET + if not isinstance(self.expires_on, Unset): + expires_on = self.expires_on.isoformat() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "id": id, + "owner_id": owner_id, + "creator_id": creator_id, + "name": name, + "secret": secret, + } + ) + if description is not UNSET: + field_dict["description"] = description + if valid_days is not UNSET: + field_dict["valid_days"] = valid_days + if scopes is not UNSET: + field_dict["scopes"] = scopes + if access is not UNSET: + field_dict["access"] = access + if application_id is not UNSET: + field_dict["application_id"] = application_id + if created_on is not UNSET: + field_dict["created_on"] = created_on + if expires_on is not UNSET: + field_dict["expires_on"] = expires_on + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + id = d.pop("id") + + owner_id = d.pop("owner_id") + + creator_id = d.pop("creator_id") + + name = d.pop("name") + + secret = d.pop("secret") + + def _parse_description(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + description = _parse_description(d.pop("description", UNSET)) + + valid_days = d.pop("valid_days", UNSET) + + _scopes = d.pop("scopes", UNSET) + scopes: list[Scope] | Unset = UNSET + if _scopes is not UNSET: + scopes = [] + for scopes_item_data in _scopes: + scopes_item = Scope(scopes_item_data) + + scopes.append(scopes_item) + + _access = d.pop("access", UNSET) + access: Access | Unset + if isinstance(_access, Unset): + access = UNSET + else: + access = Access(_access) + + def _parse_application_id(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + application_id = _parse_application_id(d.pop("application_id", UNSET)) + + _created_on = d.pop("created_on", UNSET) + created_on: datetime.datetime | Unset + if isinstance(_created_on, Unset): + created_on = UNSET + else: + created_on = datetime.datetime.fromisoformat(_created_on) + + _expires_on = d.pop("expires_on", UNSET) + expires_on: datetime.datetime | Unset + if isinstance(_expires_on, Unset): + expires_on = UNSET + else: + expires_on = datetime.datetime.fromisoformat(_expires_on) + + create_key_response = cls( + id=id, + owner_id=owner_id, + creator_id=creator_id, + name=name, + secret=secret, + description=description, + valid_days=valid_days, + scopes=scopes, + access=access, + application_id=application_id, + created_on=created_on, + expires_on=expires_on, + ) + + create_key_response.additional_properties = d + return create_key_response + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/create_landfire_canopy_request.py b/fastfuels_sdk/v2/client_library/models/create_landfire_canopy_request.py new file mode 100644 index 0000000..0084766 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/create_landfire_canopy_request.py @@ -0,0 +1,245 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.landfire_canopy_fuel_band import LandfireCanopyFuelBand +from ..models.landfire_canopy_version import LandfireCanopyVersion +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.grid_alignment_domain_target import GridAlignmentDomainTarget + from ..models.grid_alignment_grid_target import GridAlignmentGridTarget + from ..models.grid_alignment_native_target import GridAlignmentNativeTarget + from ..models.grid_modification import GridModification + + +T = TypeVar("T", bound="CreateLandfireCanopyRequest") + + +@_attrs_define +class CreateLandfireCanopyRequest: + """Request to create a grid from LANDFIRE canopy data. + + Returns a grid with one or more continuous canopy bands at 30m + resolution (CONUS): + - chm: Canopy height (m) + - cbd: Canopy bulk density (kg/m**3) + - cbh: Canopy base height (m) + - cc: Canopy cover (%) + + Bands are validated against the canopy band vocabulary and may not be + duplicated. + + Attributes: + name (str | Unset): Default: ''. + description (str | Unset): Default: ''. + tags (list[str] | Unset): + modifications (list[GridModification] | Unset): Rules applied to the grid after it is built from its source. + Each rule has a list of `conditions` (ANDed together) and a list of `actions` (applied where the conditions + match). Conditions can be attribute-based (compare a band value) or spatial (test cell location against a + geometry). Spatial conditions come in two variants discriminated by `source`: `geometry` (inline GeoJSON) or + `feature` (reference a persisted Feature resource — road, water, layerset — in the same domain by `feature_id`). + Both spatial variants accept `buffer_m` (meters, applied in the domain's projected CRS) to widen the geometry, + and `target` (`centroid` or `cell`) to choose which part of the cell is tested. Actions modify band values via + `replace`, `multiply`, `divide`, `add`, or `subtract`. See the `GridModification` schema for the full field + reference and worked examples. + extent_buffer_cells (int | Unset): Number of result-grid cells included as a buffer around the domain extent in + the stored grid. The buffer is measured after the source raster is projected into the domain CRS, so a cell + means one cell in the returned grid rather than one source raster cell. Provides context for later operations + (resample, reproject, focal filters, derivative calculations) that are sensitive to edges. Default 0 adds no + buffer. Maximum: 10 cells. Default: 0. + alignment (GridAlignmentDomainTarget | GridAlignmentGridTarget | GridAlignmentNativeTarget | Unset): Per-fetch + alignment target. Default `target="domain"` anchors output cells to the domain origin so cross-source + composition works by construction. `target="native"` preserves the source pixel anchor. `target="grid"` aligns + to an existing grid by id. + version (LandfireCanopyVersion | Unset): Available LANDFIRE canopy data versions. + bands (list[LandfireCanopyFuelBand] | Unset): + """ + + name: str | Unset = "" + description: str | Unset = "" + tags: list[str] | Unset = UNSET + modifications: list[GridModification] | Unset = UNSET + extent_buffer_cells: int | Unset = 0 + alignment: ( + GridAlignmentDomainTarget + | GridAlignmentGridTarget + | GridAlignmentNativeTarget + | Unset + ) = UNSET + version: LandfireCanopyVersion | Unset = UNSET + bands: list[LandfireCanopyFuelBand] | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.grid_alignment_domain_target import GridAlignmentDomainTarget + from ..models.grid_alignment_native_target import GridAlignmentNativeTarget + + name = self.name + + description = self.description + + tags: list[str] | Unset = UNSET + if not isinstance(self.tags, Unset): + tags = self.tags + + modifications: list[dict[str, Any]] | Unset = UNSET + if not isinstance(self.modifications, Unset): + modifications = [] + for modifications_item_data in self.modifications: + modifications_item = modifications_item_data.to_dict() + modifications.append(modifications_item) + + extent_buffer_cells = self.extent_buffer_cells + + alignment: dict[str, Any] | Unset + if isinstance(self.alignment, Unset): + alignment = UNSET + elif isinstance(self.alignment, GridAlignmentDomainTarget): + alignment = self.alignment.to_dict() + elif isinstance(self.alignment, GridAlignmentNativeTarget): + alignment = self.alignment.to_dict() + else: + alignment = self.alignment.to_dict() + + version: str | Unset = UNSET + if not isinstance(self.version, Unset): + version = self.version.value + + bands: list[str] | Unset = UNSET + if not isinstance(self.bands, Unset): + bands = [] + for bands_item_data in self.bands: + bands_item = bands_item_data.value + bands.append(bands_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if tags is not UNSET: + field_dict["tags"] = tags + if modifications is not UNSET: + field_dict["modifications"] = modifications + if extent_buffer_cells is not UNSET: + field_dict["extent_buffer_cells"] = extent_buffer_cells + if alignment is not UNSET: + field_dict["alignment"] = alignment + if version is not UNSET: + field_dict["version"] = version + if bands is not UNSET: + field_dict["bands"] = bands + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.grid_alignment_domain_target import GridAlignmentDomainTarget + from ..models.grid_alignment_grid_target import GridAlignmentGridTarget + from ..models.grid_alignment_native_target import GridAlignmentNativeTarget + from ..models.grid_modification import GridModification + + d = dict(src_dict) + name = d.pop("name", UNSET) + + description = d.pop("description", UNSET) + + tags = cast(list[str], d.pop("tags", UNSET)) + + _modifications = d.pop("modifications", UNSET) + modifications: list[GridModification] | Unset = UNSET + if _modifications is not UNSET: + modifications = [] + for modifications_item_data in _modifications: + modifications_item = GridModification.from_dict(modifications_item_data) + + modifications.append(modifications_item) + + extent_buffer_cells = d.pop("extent_buffer_cells", UNSET) + + def _parse_alignment( + data: object, + ) -> ( + GridAlignmentDomainTarget + | GridAlignmentGridTarget + | GridAlignmentNativeTarget + | Unset + ): + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + alignment_type_0 = GridAlignmentDomainTarget.from_dict(data) + + return alignment_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + alignment_type_1 = GridAlignmentNativeTarget.from_dict(data) + + return alignment_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + if not isinstance(data, dict): + raise TypeError() + alignment_type_2 = GridAlignmentGridTarget.from_dict(data) + + return alignment_type_2 + + alignment = _parse_alignment(d.pop("alignment", UNSET)) + + _version = d.pop("version", UNSET) + version: LandfireCanopyVersion | Unset + if isinstance(_version, Unset): + version = UNSET + else: + version = LandfireCanopyVersion(_version) + + _bands = d.pop("bands", UNSET) + bands: list[LandfireCanopyFuelBand] | Unset = UNSET + if _bands is not UNSET: + bands = [] + for bands_item_data in _bands: + bands_item = LandfireCanopyFuelBand(bands_item_data) + + bands.append(bands_item) + + create_landfire_canopy_request = cls( + name=name, + description=description, + tags=tags, + modifications=modifications, + extent_buffer_cells=extent_buffer_cells, + alignment=alignment, + version=version, + bands=bands, + ) + + create_landfire_canopy_request.additional_properties = d + return create_landfire_canopy_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/create_landfire_fbfm_40_request.py b/fastfuels_sdk/v2/client_library/models/create_landfire_fbfm_40_request.py new file mode 100644 index 0000000..2a40658 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/create_landfire_fbfm_40_request.py @@ -0,0 +1,264 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.landfire_fbfm_40_version import LandfireFbfm40Version +from ..models.non_burnable_fuel_model import NonBurnableFuelModel +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.grid_alignment_domain_target import GridAlignmentDomainTarget + from ..models.grid_alignment_grid_target import GridAlignmentGridTarget + from ..models.grid_alignment_native_target import GridAlignmentNativeTarget + from ..models.grid_modification import GridModification + + +T = TypeVar("T", bound="CreateLandfireFbfm40Request") + + +@_attrs_define +class CreateLandfireFbfm40Request: + """Request to create a grid from LANDFIRE FBFM40. + + Returns a single-band grid with categorical fuel model codes. + To convert codes to fuel parameters, use /grids/lookup/fbfm40. + + Attributes: + name (str | Unset): Default: ''. + description (str | Unset): Default: ''. + tags (list[str] | Unset): + modifications (list[GridModification] | Unset): Rules applied to the grid after it is built from its source. + Each rule has a list of `conditions` (ANDed together) and a list of `actions` (applied where the conditions + match). Conditions can be attribute-based (compare a band value) or spatial (test cell location against a + geometry). Spatial conditions come in two variants discriminated by `source`: `geometry` (inline GeoJSON) or + `feature` (reference a persisted Feature resource — road, water, layerset — in the same domain by `feature_id`). + Both spatial variants accept `buffer_m` (meters, applied in the domain's projected CRS) to widen the geometry, + and `target` (`centroid` or `cell`) to choose which part of the cell is tested. Actions modify band values via + `replace`, `multiply`, `divide`, `add`, or `subtract`. See the `GridModification` schema for the full field + reference and worked examples. + extent_buffer_cells (int | Unset): Number of result-grid cells included as a buffer around the domain extent in + the stored grid. The buffer is measured after the source raster is projected into the domain CRS, so a cell + means one cell in the returned grid rather than one source raster cell. Provides context for later operations + (resample, reproject, focal filters, derivative calculations) that are sensitive to edges. Default 0 adds no + buffer. Maximum: 10 cells. Default: 0. + alignment (GridAlignmentDomainTarget | GridAlignmentGridTarget | GridAlignmentNativeTarget | Unset): Per-fetch + alignment target. Default `target="domain"` anchors output cells to the domain origin so cross-source + composition works by construction. `target="native"` preserves the source pixel anchor. `target="grid"` aligns + to an existing grid by id. + version (LandfireFbfm40Version | Unset): Available LANDFIRE FBFM40 data versions. + remove_non_burnable (list[NonBurnableFuelModel] | None | Unset): + """ + + name: str | Unset = "" + description: str | Unset = "" + tags: list[str] | Unset = UNSET + modifications: list[GridModification] | Unset = UNSET + extent_buffer_cells: int | Unset = 0 + alignment: ( + GridAlignmentDomainTarget + | GridAlignmentGridTarget + | GridAlignmentNativeTarget + | Unset + ) = UNSET + version: LandfireFbfm40Version | Unset = UNSET + remove_non_burnable: list[NonBurnableFuelModel] | None | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.grid_alignment_domain_target import GridAlignmentDomainTarget + from ..models.grid_alignment_native_target import GridAlignmentNativeTarget + + name = self.name + + description = self.description + + tags: list[str] | Unset = UNSET + if not isinstance(self.tags, Unset): + tags = self.tags + + modifications: list[dict[str, Any]] | Unset = UNSET + if not isinstance(self.modifications, Unset): + modifications = [] + for modifications_item_data in self.modifications: + modifications_item = modifications_item_data.to_dict() + modifications.append(modifications_item) + + extent_buffer_cells = self.extent_buffer_cells + + alignment: dict[str, Any] | Unset + if isinstance(self.alignment, Unset): + alignment = UNSET + elif isinstance(self.alignment, GridAlignmentDomainTarget): + alignment = self.alignment.to_dict() + elif isinstance(self.alignment, GridAlignmentNativeTarget): + alignment = self.alignment.to_dict() + else: + alignment = self.alignment.to_dict() + + version: str | Unset = UNSET + if not isinstance(self.version, Unset): + version = self.version.value + + remove_non_burnable: list[str] | None | Unset + if isinstance(self.remove_non_burnable, Unset): + remove_non_burnable = UNSET + elif isinstance(self.remove_non_burnable, list): + remove_non_burnable = [] + for remove_non_burnable_type_0_item_data in self.remove_non_burnable: + remove_non_burnable_type_0_item = ( + remove_non_burnable_type_0_item_data.value + ) + remove_non_burnable.append(remove_non_burnable_type_0_item) + + else: + remove_non_burnable = self.remove_non_burnable + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if tags is not UNSET: + field_dict["tags"] = tags + if modifications is not UNSET: + field_dict["modifications"] = modifications + if extent_buffer_cells is not UNSET: + field_dict["extent_buffer_cells"] = extent_buffer_cells + if alignment is not UNSET: + field_dict["alignment"] = alignment + if version is not UNSET: + field_dict["version"] = version + if remove_non_burnable is not UNSET: + field_dict["remove_non_burnable"] = remove_non_burnable + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.grid_alignment_domain_target import GridAlignmentDomainTarget + from ..models.grid_alignment_grid_target import GridAlignmentGridTarget + from ..models.grid_alignment_native_target import GridAlignmentNativeTarget + from ..models.grid_modification import GridModification + + d = dict(src_dict) + name = d.pop("name", UNSET) + + description = d.pop("description", UNSET) + + tags = cast(list[str], d.pop("tags", UNSET)) + + _modifications = d.pop("modifications", UNSET) + modifications: list[GridModification] | Unset = UNSET + if _modifications is not UNSET: + modifications = [] + for modifications_item_data in _modifications: + modifications_item = GridModification.from_dict(modifications_item_data) + + modifications.append(modifications_item) + + extent_buffer_cells = d.pop("extent_buffer_cells", UNSET) + + def _parse_alignment( + data: object, + ) -> ( + GridAlignmentDomainTarget + | GridAlignmentGridTarget + | GridAlignmentNativeTarget + | Unset + ): + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + alignment_type_0 = GridAlignmentDomainTarget.from_dict(data) + + return alignment_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + alignment_type_1 = GridAlignmentNativeTarget.from_dict(data) + + return alignment_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + if not isinstance(data, dict): + raise TypeError() + alignment_type_2 = GridAlignmentGridTarget.from_dict(data) + + return alignment_type_2 + + alignment = _parse_alignment(d.pop("alignment", UNSET)) + + _version = d.pop("version", UNSET) + version: LandfireFbfm40Version | Unset + if isinstance(_version, Unset): + version = UNSET + else: + version = LandfireFbfm40Version(_version) + + def _parse_remove_non_burnable( + data: object, + ) -> list[NonBurnableFuelModel] | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, list): + raise TypeError() + remove_non_burnable_type_0 = [] + _remove_non_burnable_type_0 = data + for remove_non_burnable_type_0_item_data in _remove_non_burnable_type_0: + remove_non_burnable_type_0_item = NonBurnableFuelModel( + remove_non_burnable_type_0_item_data + ) + + remove_non_burnable_type_0.append(remove_non_burnable_type_0_item) + + return remove_non_burnable_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(list[NonBurnableFuelModel] | None | Unset, data) + + remove_non_burnable = _parse_remove_non_burnable( + d.pop("remove_non_burnable", UNSET) + ) + + create_landfire_fbfm_40_request = cls( + name=name, + description=description, + tags=tags, + modifications=modifications, + extent_buffer_cells=extent_buffer_cells, + alignment=alignment, + version=version, + remove_non_burnable=remove_non_burnable, + ) + + create_landfire_fbfm_40_request.additional_properties = d + return create_landfire_fbfm_40_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/create_landfire_fccs_request.py b/fastfuels_sdk/v2/client_library/models/create_landfire_fccs_request.py new file mode 100644 index 0000000..bfc5b63 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/create_landfire_fccs_request.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.landfire_fccs_version import LandfireFccsVersion +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.grid_modification import GridModification + + +T = TypeVar("T", bound="CreateLandfireFccsRequest") + + +@_attrs_define +class CreateLandfireFccsRequest: + """Request to create a grid from LANDFIRE FCCS. + + Returns a single-band grid with categorical fuelbed IDs. + To convert IDs to fuel parameters, use /grids/lookup/fccs. + + Attributes: + name (str | Unset): Default: ''. + description (str | Unset): Default: ''. + tags (list[str] | Unset): + modifications (list[GridModification] | Unset): Rules applied to the grid after it is built from its source. + Each rule has a list of `conditions` (ANDed together) and a list of `actions` (applied where the conditions + match). Conditions can be attribute-based (compare a band value) or spatial (test cell location against a + geometry). Spatial conditions come in two variants discriminated by `source`: `geometry` (inline GeoJSON) or + `feature` (reference a persisted Feature resource — road, water, layerset — in the same domain by `feature_id`). + Both spatial variants accept `buffer_m` (meters, applied in the domain's projected CRS) to widen the geometry, + and `target` (`centroid` or `cell`) to choose which part of the cell is tested. Actions modify band values via + `replace`, `multiply`, `divide`, `add`, or `subtract`. See the `GridModification` schema for the full field + reference and worked examples. + version (LandfireFccsVersion | Unset): Available LANDFIRE FCCS data versions. + remove_bare_ground (bool | Unset): Default: False. + """ + + name: str | Unset = "" + description: str | Unset = "" + tags: list[str] | Unset = UNSET + modifications: list[GridModification] | Unset = UNSET + version: LandfireFccsVersion | Unset = UNSET + remove_bare_ground: bool | Unset = False + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + name = self.name + + description = self.description + + tags: list[str] | Unset = UNSET + if not isinstance(self.tags, Unset): + tags = self.tags + + modifications: list[dict[str, Any]] | Unset = UNSET + if not isinstance(self.modifications, Unset): + modifications = [] + for modifications_item_data in self.modifications: + modifications_item = modifications_item_data.to_dict() + modifications.append(modifications_item) + + version: str | Unset = UNSET + if not isinstance(self.version, Unset): + version = self.version.value + + remove_bare_ground = self.remove_bare_ground + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if tags is not UNSET: + field_dict["tags"] = tags + if modifications is not UNSET: + field_dict["modifications"] = modifications + if version is not UNSET: + field_dict["version"] = version + if remove_bare_ground is not UNSET: + field_dict["remove_bare_ground"] = remove_bare_ground + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.grid_modification import GridModification + + d = dict(src_dict) + name = d.pop("name", UNSET) + + description = d.pop("description", UNSET) + + tags = cast(list[str], d.pop("tags", UNSET)) + + _modifications = d.pop("modifications", UNSET) + modifications: list[GridModification] | Unset = UNSET + if _modifications is not UNSET: + modifications = [] + for modifications_item_data in _modifications: + modifications_item = GridModification.from_dict(modifications_item_data) + + modifications.append(modifications_item) + + _version = d.pop("version", UNSET) + version: LandfireFccsVersion | Unset + if isinstance(_version, Unset): + version = UNSET + else: + version = LandfireFccsVersion(_version) + + remove_bare_ground = d.pop("remove_bare_ground", UNSET) + + create_landfire_fccs_request = cls( + name=name, + description=description, + tags=tags, + modifications=modifications, + version=version, + remove_bare_ground=remove_bare_ground, + ) + + create_landfire_fccs_request.additional_properties = d + return create_landfire_fccs_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/create_landfire_topography_request.py b/fastfuels_sdk/v2/client_library/models/create_landfire_topography_request.py new file mode 100644 index 0000000..1c6c9b3 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/create_landfire_topography_request.py @@ -0,0 +1,238 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.landfire_topography_version import LandfireTopographyVersion +from ..models.topography_band import TopographyBand +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.grid_alignment_domain_target import GridAlignmentDomainTarget + from ..models.grid_alignment_grid_target import GridAlignmentGridTarget + from ..models.grid_alignment_native_target import GridAlignmentNativeTarget + from ..models.grid_modification import GridModification + + +T = TypeVar("T", bound="CreateLandfireTopographyRequest") + + +@_attrs_define +class CreateLandfireTopographyRequest: + """Request to create a grid from LANDFIRE topographic data. + + Returns a grid with one or more continuous bands: elevation (m), + slope (degrees), and/or aspect (degrees). + + Attributes: + name (str | Unset): Default: ''. + description (str | Unset): Default: ''. + tags (list[str] | Unset): + modifications (list[GridModification] | Unset): Rules applied to the grid after it is built from its source. + Each rule has a list of `conditions` (ANDed together) and a list of `actions` (applied where the conditions + match). Conditions can be attribute-based (compare a band value) or spatial (test cell location against a + geometry). Spatial conditions come in two variants discriminated by `source`: `geometry` (inline GeoJSON) or + `feature` (reference a persisted Feature resource — road, water, layerset — in the same domain by `feature_id`). + Both spatial variants accept `buffer_m` (meters, applied in the domain's projected CRS) to widen the geometry, + and `target` (`centroid` or `cell`) to choose which part of the cell is tested. Actions modify band values via + `replace`, `multiply`, `divide`, `add`, or `subtract`. See the `GridModification` schema for the full field + reference and worked examples. + extent_buffer_cells (int | Unset): Number of result-grid cells included as a buffer around the domain extent in + the stored grid. The buffer is measured after the source raster is projected into the domain CRS, so a cell + means one cell in the returned grid rather than one source raster cell. Provides context for later operations + (resample, reproject, focal filters, derivative calculations) that are sensitive to edges. Default 0 adds no + buffer. Maximum: 10 cells. Default: 0. + alignment (GridAlignmentDomainTarget | GridAlignmentGridTarget | GridAlignmentNativeTarget | Unset): Per-fetch + alignment target. Default `target="domain"` anchors output cells to the domain origin so cross-source + composition works by construction. `target="native"` preserves the source pixel anchor. `target="grid"` aligns + to an existing grid by id. + version (LandfireTopographyVersion | Unset): Available LANDFIRE topography data versions. + bands (list[TopographyBand] | Unset): + """ + + name: str | Unset = "" + description: str | Unset = "" + tags: list[str] | Unset = UNSET + modifications: list[GridModification] | Unset = UNSET + extent_buffer_cells: int | Unset = 0 + alignment: ( + GridAlignmentDomainTarget + | GridAlignmentGridTarget + | GridAlignmentNativeTarget + | Unset + ) = UNSET + version: LandfireTopographyVersion | Unset = UNSET + bands: list[TopographyBand] | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.grid_alignment_domain_target import GridAlignmentDomainTarget + from ..models.grid_alignment_native_target import GridAlignmentNativeTarget + + name = self.name + + description = self.description + + tags: list[str] | Unset = UNSET + if not isinstance(self.tags, Unset): + tags = self.tags + + modifications: list[dict[str, Any]] | Unset = UNSET + if not isinstance(self.modifications, Unset): + modifications = [] + for modifications_item_data in self.modifications: + modifications_item = modifications_item_data.to_dict() + modifications.append(modifications_item) + + extent_buffer_cells = self.extent_buffer_cells + + alignment: dict[str, Any] | Unset + if isinstance(self.alignment, Unset): + alignment = UNSET + elif isinstance(self.alignment, GridAlignmentDomainTarget): + alignment = self.alignment.to_dict() + elif isinstance(self.alignment, GridAlignmentNativeTarget): + alignment = self.alignment.to_dict() + else: + alignment = self.alignment.to_dict() + + version: str | Unset = UNSET + if not isinstance(self.version, Unset): + version = self.version.value + + bands: list[str] | Unset = UNSET + if not isinstance(self.bands, Unset): + bands = [] + for bands_item_data in self.bands: + bands_item = bands_item_data.value + bands.append(bands_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if tags is not UNSET: + field_dict["tags"] = tags + if modifications is not UNSET: + field_dict["modifications"] = modifications + if extent_buffer_cells is not UNSET: + field_dict["extent_buffer_cells"] = extent_buffer_cells + if alignment is not UNSET: + field_dict["alignment"] = alignment + if version is not UNSET: + field_dict["version"] = version + if bands is not UNSET: + field_dict["bands"] = bands + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.grid_alignment_domain_target import GridAlignmentDomainTarget + from ..models.grid_alignment_grid_target import GridAlignmentGridTarget + from ..models.grid_alignment_native_target import GridAlignmentNativeTarget + from ..models.grid_modification import GridModification + + d = dict(src_dict) + name = d.pop("name", UNSET) + + description = d.pop("description", UNSET) + + tags = cast(list[str], d.pop("tags", UNSET)) + + _modifications = d.pop("modifications", UNSET) + modifications: list[GridModification] | Unset = UNSET + if _modifications is not UNSET: + modifications = [] + for modifications_item_data in _modifications: + modifications_item = GridModification.from_dict(modifications_item_data) + + modifications.append(modifications_item) + + extent_buffer_cells = d.pop("extent_buffer_cells", UNSET) + + def _parse_alignment( + data: object, + ) -> ( + GridAlignmentDomainTarget + | GridAlignmentGridTarget + | GridAlignmentNativeTarget + | Unset + ): + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + alignment_type_0 = GridAlignmentDomainTarget.from_dict(data) + + return alignment_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + alignment_type_1 = GridAlignmentNativeTarget.from_dict(data) + + return alignment_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + if not isinstance(data, dict): + raise TypeError() + alignment_type_2 = GridAlignmentGridTarget.from_dict(data) + + return alignment_type_2 + + alignment = _parse_alignment(d.pop("alignment", UNSET)) + + _version = d.pop("version", UNSET) + version: LandfireTopographyVersion | Unset + if isinstance(_version, Unset): + version = UNSET + else: + version = LandfireTopographyVersion(_version) + + _bands = d.pop("bands", UNSET) + bands: list[TopographyBand] | Unset = UNSET + if _bands is not UNSET: + bands = [] + for bands_item_data in _bands: + bands_item = TopographyBand(bands_item_data) + + bands.append(bands_item) + + create_landfire_topography_request = cls( + name=name, + description=description, + tags=tags, + modifications=modifications, + extent_buffer_cells=extent_buffer_cells, + alignment=alignment, + version=version, + bands=bands, + ) + + create_landfire_topography_request.additional_properties = d + return create_landfire_topography_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/create_layerset_rasterize_request.py b/fastfuels_sdk/v2/client_library/models/create_layerset_rasterize_request.py new file mode 100644 index 0000000..8802237 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/create_layerset_rasterize_request.py @@ -0,0 +1,230 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.overlap_method import OverlapMethod +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.grid_alignment_domain_target import GridAlignmentDomainTarget + from ..models.grid_alignment_grid_target import GridAlignmentGridTarget + from ..models.grid_alignment_native_target import GridAlignmentNativeTarget + from ..models.grid_modification import GridModification + + +T = TypeVar("T", bound="CreateLayersetRasterizeRequest") + + +@_attrs_define +class CreateLayersetRasterizeRequest: + """Request to create a grid by rasterizing a previously-uploaded layerset. + + The referenced layerset must be an existing Feature owned by the caller, + uploaded via ``POST /domains/{id}/features/layerset``. The worker fetches + the GeoJSON from GCS at job time; a fresh upload produces a new + ``feature_id``, so the reference is effectively immutable. + + Attributes: + layerset_id (str): Feature ID of an existing layerset uploaded for this domain. + name (str | Unset): Default: ''. + description (str | Unset): Default: ''. + tags (list[str] | Unset): + modifications (list[GridModification] | Unset): Rules applied to the grid after it is built from its source. + Each rule has a list of `conditions` (ANDed together) and a list of `actions` (applied where the conditions + match). Conditions can be attribute-based (compare a band value) or spatial (test cell location against a + geometry). Spatial conditions come in two variants discriminated by `source`: `geometry` (inline GeoJSON) or + `feature` (reference a persisted Feature resource — road, water, layerset — in the same domain by `feature_id`). + Both spatial variants accept `buffer_m` (meters, applied in the domain's projected CRS) to widen the geometry, + and `target` (`centroid` or `cell`) to choose which part of the cell is tested. Actions modify band values via + `replace`, `multiply`, `divide`, `add`, or `subtract`. See the `GridModification` schema for the full field + reference and worked examples. + extent_buffer_cells (int | Unset): Buffer in result-grid cells around the domain extent. Cells inside the + buffered extent that fall outside polygon coverage are populated with the rasterizer's fill value. Default 0 + adds no buffer. Maximum: 10 cells. Default: 0. + alignment (GridAlignmentDomainTarget | GridAlignmentGridTarget | GridAlignmentNativeTarget | Unset): Per-fetch + alignment target. Default `target="domain"` anchors output cells to the domain origin so cross-source + composition works by construction. `target="native"` preserves the source pixel anchor. `target="grid"` aligns + to an existing grid by id. + overlap_method (OverlapMethod | Unset): Per-cell reduction when multiple polygons of the same ``fuel_type`` + overlap a cell. Applies to ``height`` and the optional bands only — + ``loading`` is always summed by ``fastfuels_core.rasterize_layerset`` + regardless of this setting. + """ + + layerset_id: str + name: str | Unset = "" + description: str | Unset = "" + tags: list[str] | Unset = UNSET + modifications: list[GridModification] | Unset = UNSET + extent_buffer_cells: int | Unset = 0 + alignment: ( + GridAlignmentDomainTarget + | GridAlignmentGridTarget + | GridAlignmentNativeTarget + | Unset + ) = UNSET + overlap_method: OverlapMethod | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.grid_alignment_domain_target import GridAlignmentDomainTarget + from ..models.grid_alignment_native_target import GridAlignmentNativeTarget + + layerset_id = self.layerset_id + + name = self.name + + description = self.description + + tags: list[str] | Unset = UNSET + if not isinstance(self.tags, Unset): + tags = self.tags + + modifications: list[dict[str, Any]] | Unset = UNSET + if not isinstance(self.modifications, Unset): + modifications = [] + for modifications_item_data in self.modifications: + modifications_item = modifications_item_data.to_dict() + modifications.append(modifications_item) + + extent_buffer_cells = self.extent_buffer_cells + + alignment: dict[str, Any] | Unset + if isinstance(self.alignment, Unset): + alignment = UNSET + elif isinstance(self.alignment, GridAlignmentDomainTarget): + alignment = self.alignment.to_dict() + elif isinstance(self.alignment, GridAlignmentNativeTarget): + alignment = self.alignment.to_dict() + else: + alignment = self.alignment.to_dict() + + overlap_method: str | Unset = UNSET + if not isinstance(self.overlap_method, Unset): + overlap_method = self.overlap_method.value + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "layerset_id": layerset_id, + } + ) + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if tags is not UNSET: + field_dict["tags"] = tags + if modifications is not UNSET: + field_dict["modifications"] = modifications + if extent_buffer_cells is not UNSET: + field_dict["extent_buffer_cells"] = extent_buffer_cells + if alignment is not UNSET: + field_dict["alignment"] = alignment + if overlap_method is not UNSET: + field_dict["overlap_method"] = overlap_method + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.grid_alignment_domain_target import GridAlignmentDomainTarget + from ..models.grid_alignment_grid_target import GridAlignmentGridTarget + from ..models.grid_alignment_native_target import GridAlignmentNativeTarget + from ..models.grid_modification import GridModification + + d = dict(src_dict) + layerset_id = d.pop("layerset_id") + + name = d.pop("name", UNSET) + + description = d.pop("description", UNSET) + + tags = cast(list[str], d.pop("tags", UNSET)) + + _modifications = d.pop("modifications", UNSET) + modifications: list[GridModification] | Unset = UNSET + if _modifications is not UNSET: + modifications = [] + for modifications_item_data in _modifications: + modifications_item = GridModification.from_dict(modifications_item_data) + + modifications.append(modifications_item) + + extent_buffer_cells = d.pop("extent_buffer_cells", UNSET) + + def _parse_alignment( + data: object, + ) -> ( + GridAlignmentDomainTarget + | GridAlignmentGridTarget + | GridAlignmentNativeTarget + | Unset + ): + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + alignment_type_0 = GridAlignmentDomainTarget.from_dict(data) + + return alignment_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + alignment_type_1 = GridAlignmentNativeTarget.from_dict(data) + + return alignment_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + if not isinstance(data, dict): + raise TypeError() + alignment_type_2 = GridAlignmentGridTarget.from_dict(data) + + return alignment_type_2 + + alignment = _parse_alignment(d.pop("alignment", UNSET)) + + _overlap_method = d.pop("overlap_method", UNSET) + overlap_method: OverlapMethod | Unset + if isinstance(_overlap_method, Unset): + overlap_method = UNSET + else: + overlap_method = OverlapMethod(_overlap_method) + + create_layerset_rasterize_request = cls( + layerset_id=layerset_id, + name=name, + description=description, + tags=tags, + modifications=modifications, + extent_buffer_cells=extent_buffer_cells, + alignment=alignment, + overlap_method=overlap_method, + ) + + create_layerset_rasterize_request.additional_properties = d + return create_layerset_rasterize_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/create_layerset_request_body.py b/fastfuels_sdk/v2/client_library/models/create_layerset_request_body.py new file mode 100644 index 0000000..d9462ff --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/create_layerset_request_body.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + TYPE_CHECKING, + Any, + Literal, + TypeVar, + cast, +) + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.layerset_crs import LayersetCrs + from ..models.layerset_feature import LayersetFeature + + +T = TypeVar("T", bound="CreateLayersetRequestBody") + + +@_attrs_define +class CreateLayersetRequestBody: + """Request body for uploading a flat GeoJSON layerset. + + The body **is** the GeoJSON FeatureCollection (matching ``POST /domains``, + whose body is a ``FeatureCollection`` directly), extended with the + resource-metadata fields. No ``type`` discriminator: the URL + ``/features/layerset/geojson`` already discriminates layersets from + road/water uploads. + + ``name`` overrides the optional GeoJSON ``name`` member inherited from + ``LayersetFeatureCollection`` — the FeatureCollection's name doubles as the + resource name, exactly as ``CreateDomainRequestBody`` treats it. + + Attributes: + type_ (Literal['FeatureCollection']): + features (list[LayersetFeature]): + bbox (list[float] | None | Unset): + name (str | Unset): Default: ''. + crs (LayersetCrs | None | Unset): + description (str | Unset): Default: ''. + tags (list[str] | Unset): + """ + + type_: Literal["FeatureCollection"] + features: list[LayersetFeature] + bbox: list[float] | None | Unset = UNSET + name: str | Unset = "" + crs: LayersetCrs | None | Unset = UNSET + description: str | Unset = "" + tags: list[str] | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.layerset_crs import LayersetCrs + + type_ = self.type_ + + features = [] + for features_item_data in self.features: + features_item = features_item_data.to_dict() + features.append(features_item) + + bbox: list[float] | None | Unset + if isinstance(self.bbox, Unset): + bbox = UNSET + elif isinstance(self.bbox, list): + bbox = [] + for bbox_type_0_item_data in self.bbox: + bbox_type_0_item: float + bbox_type_0_item = bbox_type_0_item_data + bbox.append(bbox_type_0_item) + + else: + bbox = self.bbox + + name = self.name + + crs: dict[str, Any] | None | Unset + if isinstance(self.crs, Unset): + crs = UNSET + elif isinstance(self.crs, LayersetCrs): + crs = self.crs.to_dict() + else: + crs = self.crs + + description = self.description + + tags: list[str] | Unset = UNSET + if not isinstance(self.tags, Unset): + tags = self.tags + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "type": type_, + "features": features, + } + ) + if bbox is not UNSET: + field_dict["bbox"] = bbox + if name is not UNSET: + field_dict["name"] = name + if crs is not UNSET: + field_dict["crs"] = crs + if description is not UNSET: + field_dict["description"] = description + if tags is not UNSET: + field_dict["tags"] = tags + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.layerset_crs import LayersetCrs + from ..models.layerset_feature import LayersetFeature + + d = dict(src_dict) + type_ = cast(Literal["FeatureCollection"], d.pop("type")) + if type_ != "FeatureCollection": + raise ValueError( + f"type must match const 'FeatureCollection', got '{type_}'" + ) + + features = [] + _features = d.pop("features") + for features_item_data in _features: + features_item = LayersetFeature.from_dict(features_item_data) + + features.append(features_item) + + def _parse_bbox(data: object) -> list[float] | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, list): + raise TypeError() + bbox_type_0 = [] + _bbox_type_0 = data + for bbox_type_0_item_data in _bbox_type_0: + + def _parse_bbox_type_0_item(data: object) -> float: + return cast(float, data) + + bbox_type_0_item = _parse_bbox_type_0_item(bbox_type_0_item_data) + + bbox_type_0.append(bbox_type_0_item) + + return bbox_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(list[float] | None | Unset, data) + + bbox = _parse_bbox(d.pop("bbox", UNSET)) + + name = d.pop("name", UNSET) + + def _parse_crs(data: object) -> LayersetCrs | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + crs_type_0 = LayersetCrs.from_dict(data) + + return crs_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(LayersetCrs | None | Unset, data) + + crs = _parse_crs(d.pop("crs", UNSET)) + + description = d.pop("description", UNSET) + + tags = cast(list[str], d.pop("tags", UNSET)) + + create_layerset_request_body = cls( + type_=type_, + features=features, + bbox=bbox, + name=name, + crs=crs, + description=description, + tags=tags, + ) + + create_layerset_request_body.additional_properties = d + return create_layerset_request_body + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/create_meta_chm_request.py b/fastfuels_sdk/v2/client_library/models/create_meta_chm_request.py new file mode 100644 index 0000000..2e7276d --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/create_meta_chm_request.py @@ -0,0 +1,216 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.meta_chm_version import MetaCHMVersion +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.grid_alignment_domain_target import GridAlignmentDomainTarget + from ..models.grid_alignment_grid_target import GridAlignmentGridTarget + from ..models.grid_alignment_native_target import GridAlignmentNativeTarget + from ..models.grid_modification import GridModification + + +T = TypeVar("T", bound="CreateMetaChmRequest") + + +@_attrs_define +class CreateMetaChmRequest: + """Request to create a grid from Meta CHM. + + Returns a grid with a single continuous band: + - chm: Canopy height in meters + + Attributes: + name (str | Unset): Default: ''. + description (str | Unset): Default: ''. + tags (list[str] | Unset): + modifications (list[GridModification] | Unset): Rules applied to the grid after it is built from its source. + Each rule has a list of `conditions` (ANDed together) and a list of `actions` (applied where the conditions + match). Conditions can be attribute-based (compare a band value) or spatial (test cell location against a + geometry). Spatial conditions come in two variants discriminated by `source`: `geometry` (inline GeoJSON) or + `feature` (reference a persisted Feature resource — road, water, layerset — in the same domain by `feature_id`). + Both spatial variants accept `buffer_m` (meters, applied in the domain's projected CRS) to widen the geometry, + and `target` (`centroid` or `cell`) to choose which part of the cell is tested. Actions modify band values via + `replace`, `multiply`, `divide`, `add`, or `subtract`. See the `GridModification` schema for the full field + reference and worked examples. + extent_buffer_cells (int | Unset): Number of result-grid cells included as a buffer around the domain extent in + the stored grid. The buffer is measured after the source raster is projected into the domain CRS, so a cell + means one cell in the returned grid rather than one source raster cell. Provides context for later operations + (resample, reproject, focal filters, derivative calculations) that are sensitive to edges. Default 0 adds no + buffer. Maximum: 10 cells. Default: 0. + alignment (GridAlignmentDomainTarget | GridAlignmentGridTarget | GridAlignmentNativeTarget | Unset): Per-fetch + alignment target. Default `target="domain"` anchors output cells to the domain origin so cross-source + composition works by construction. `target="native"` preserves the source pixel anchor. `target="grid"` aligns + to an existing grid by id. + version (MetaCHMVersion | Unset): Available Meta CHM data versions. + """ + + name: str | Unset = "" + description: str | Unset = "" + tags: list[str] | Unset = UNSET + modifications: list[GridModification] | Unset = UNSET + extent_buffer_cells: int | Unset = 0 + alignment: ( + GridAlignmentDomainTarget + | GridAlignmentGridTarget + | GridAlignmentNativeTarget + | Unset + ) = UNSET + version: MetaCHMVersion | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.grid_alignment_domain_target import GridAlignmentDomainTarget + from ..models.grid_alignment_native_target import GridAlignmentNativeTarget + + name = self.name + + description = self.description + + tags: list[str] | Unset = UNSET + if not isinstance(self.tags, Unset): + tags = self.tags + + modifications: list[dict[str, Any]] | Unset = UNSET + if not isinstance(self.modifications, Unset): + modifications = [] + for modifications_item_data in self.modifications: + modifications_item = modifications_item_data.to_dict() + modifications.append(modifications_item) + + extent_buffer_cells = self.extent_buffer_cells + + alignment: dict[str, Any] | Unset + if isinstance(self.alignment, Unset): + alignment = UNSET + elif isinstance(self.alignment, GridAlignmentDomainTarget): + alignment = self.alignment.to_dict() + elif isinstance(self.alignment, GridAlignmentNativeTarget): + alignment = self.alignment.to_dict() + else: + alignment = self.alignment.to_dict() + + version: str | Unset = UNSET + if not isinstance(self.version, Unset): + version = self.version.value + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if tags is not UNSET: + field_dict["tags"] = tags + if modifications is not UNSET: + field_dict["modifications"] = modifications + if extent_buffer_cells is not UNSET: + field_dict["extent_buffer_cells"] = extent_buffer_cells + if alignment is not UNSET: + field_dict["alignment"] = alignment + if version is not UNSET: + field_dict["version"] = version + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.grid_alignment_domain_target import GridAlignmentDomainTarget + from ..models.grid_alignment_grid_target import GridAlignmentGridTarget + from ..models.grid_alignment_native_target import GridAlignmentNativeTarget + from ..models.grid_modification import GridModification + + d = dict(src_dict) + name = d.pop("name", UNSET) + + description = d.pop("description", UNSET) + + tags = cast(list[str], d.pop("tags", UNSET)) + + _modifications = d.pop("modifications", UNSET) + modifications: list[GridModification] | Unset = UNSET + if _modifications is not UNSET: + modifications = [] + for modifications_item_data in _modifications: + modifications_item = GridModification.from_dict(modifications_item_data) + + modifications.append(modifications_item) + + extent_buffer_cells = d.pop("extent_buffer_cells", UNSET) + + def _parse_alignment( + data: object, + ) -> ( + GridAlignmentDomainTarget + | GridAlignmentGridTarget + | GridAlignmentNativeTarget + | Unset + ): + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + alignment_type_0 = GridAlignmentDomainTarget.from_dict(data) + + return alignment_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + alignment_type_1 = GridAlignmentNativeTarget.from_dict(data) + + return alignment_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + if not isinstance(data, dict): + raise TypeError() + alignment_type_2 = GridAlignmentGridTarget.from_dict(data) + + return alignment_type_2 + + alignment = _parse_alignment(d.pop("alignment", UNSET)) + + _version = d.pop("version", UNSET) + version: MetaCHMVersion | Unset + if isinstance(_version, Unset): + version = UNSET + else: + version = MetaCHMVersion(_version) + + create_meta_chm_request = cls( + name=name, + description=description, + tags=tags, + modifications=modifications, + extent_buffer_cells=extent_buffer_cells, + alignment=alignment, + version=version, + ) + + create_meta_chm_request.additional_properties = d + return create_meta_chm_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/create_naip_chm_request.py b/fastfuels_sdk/v2/client_library/models/create_naip_chm_request.py new file mode 100644 index 0000000..0ff8168 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/create_naip_chm_request.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.grid_alignment_domain_target import GridAlignmentDomainTarget + from ..models.grid_alignment_grid_target import GridAlignmentGridTarget + from ..models.grid_alignment_native_target import GridAlignmentNativeTarget + from ..models.grid_modification import GridModification + + +T = TypeVar("T", bound="CreateNaipChmRequest") + + +@_attrs_define +class CreateNaipChmRequest: + """Request to create a grid from NAIP CHM. + + Returns a grid with a single continuous band: + - chm: Canopy height in meters + + Attributes: + name (str | Unset): Default: ''. + description (str | Unset): Default: ''. + tags (list[str] | Unset): + modifications (list[GridModification] | Unset): Rules applied to the grid after it is built from its source. + Each rule has a list of `conditions` (ANDed together) and a list of `actions` (applied where the conditions + match). Conditions can be attribute-based (compare a band value) or spatial (test cell location against a + geometry). Spatial conditions come in two variants discriminated by `source`: `geometry` (inline GeoJSON) or + `feature` (reference a persisted Feature resource — road, water, layerset — in the same domain by `feature_id`). + Both spatial variants accept `buffer_m` (meters, applied in the domain's projected CRS) to widen the geometry, + and `target` (`centroid` or `cell`) to choose which part of the cell is tested. Actions modify band values via + `replace`, `multiply`, `divide`, `add`, or `subtract`. See the `GridModification` schema for the full field + reference and worked examples. + extent_buffer_cells (int | Unset): Number of result-grid cells included as a buffer around the domain extent in + the stored grid. The buffer is measured after the source raster is projected into the domain CRS, so a cell + means one cell in the returned grid rather than one source raster cell. Provides context for later operations + (resample, reproject, focal filters, derivative calculations) that are sensitive to edges. Default 0 adds no + buffer. Maximum: 10 cells. Default: 0. + alignment (GridAlignmentDomainTarget | GridAlignmentGridTarget | GridAlignmentNativeTarget | Unset): Per-fetch + alignment target. Default `target="domain"` anchors output cells to the domain origin so cross-source + composition works by construction. `target="native"` preserves the source pixel anchor. `target="grid"` aligns + to an existing grid by id. + """ + + name: str | Unset = "" + description: str | Unset = "" + tags: list[str] | Unset = UNSET + modifications: list[GridModification] | Unset = UNSET + extent_buffer_cells: int | Unset = 0 + alignment: ( + GridAlignmentDomainTarget + | GridAlignmentGridTarget + | GridAlignmentNativeTarget + | Unset + ) = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.grid_alignment_domain_target import GridAlignmentDomainTarget + from ..models.grid_alignment_native_target import GridAlignmentNativeTarget + + name = self.name + + description = self.description + + tags: list[str] | Unset = UNSET + if not isinstance(self.tags, Unset): + tags = self.tags + + modifications: list[dict[str, Any]] | Unset = UNSET + if not isinstance(self.modifications, Unset): + modifications = [] + for modifications_item_data in self.modifications: + modifications_item = modifications_item_data.to_dict() + modifications.append(modifications_item) + + extent_buffer_cells = self.extent_buffer_cells + + alignment: dict[str, Any] | Unset + if isinstance(self.alignment, Unset): + alignment = UNSET + elif isinstance(self.alignment, GridAlignmentDomainTarget): + alignment = self.alignment.to_dict() + elif isinstance(self.alignment, GridAlignmentNativeTarget): + alignment = self.alignment.to_dict() + else: + alignment = self.alignment.to_dict() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if tags is not UNSET: + field_dict["tags"] = tags + if modifications is not UNSET: + field_dict["modifications"] = modifications + if extent_buffer_cells is not UNSET: + field_dict["extent_buffer_cells"] = extent_buffer_cells + if alignment is not UNSET: + field_dict["alignment"] = alignment + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.grid_alignment_domain_target import GridAlignmentDomainTarget + from ..models.grid_alignment_grid_target import GridAlignmentGridTarget + from ..models.grid_alignment_native_target import GridAlignmentNativeTarget + from ..models.grid_modification import GridModification + + d = dict(src_dict) + name = d.pop("name", UNSET) + + description = d.pop("description", UNSET) + + tags = cast(list[str], d.pop("tags", UNSET)) + + _modifications = d.pop("modifications", UNSET) + modifications: list[GridModification] | Unset = UNSET + if _modifications is not UNSET: + modifications = [] + for modifications_item_data in _modifications: + modifications_item = GridModification.from_dict(modifications_item_data) + + modifications.append(modifications_item) + + extent_buffer_cells = d.pop("extent_buffer_cells", UNSET) + + def _parse_alignment( + data: object, + ) -> ( + GridAlignmentDomainTarget + | GridAlignmentGridTarget + | GridAlignmentNativeTarget + | Unset + ): + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + alignment_type_0 = GridAlignmentDomainTarget.from_dict(data) + + return alignment_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + alignment_type_1 = GridAlignmentNativeTarget.from_dict(data) + + return alignment_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + if not isinstance(data, dict): + raise TypeError() + alignment_type_2 = GridAlignmentGridTarget.from_dict(data) + + return alignment_type_2 + + alignment = _parse_alignment(d.pop("alignment", UNSET)) + + create_naip_chm_request = cls( + name=name, + description=description, + tags=tags, + modifications=modifications, + extent_buffer_cells=extent_buffer_cells, + alignment=alignment, + ) + + create_naip_chm_request.additional_properties = d + return create_naip_chm_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/create_netcdf_upload_request.py b/fastfuels_sdk/v2/client_library/models/create_netcdf_upload_request.py new file mode 100644 index 0000000..dab3a91 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/create_netcdf_upload_request.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="CreateNetcdfUploadRequest") + + +@_attrs_define +class CreateNetcdfUploadRequest: + """ + Attributes: + name (str | Unset): Default: ''. + description (str | Unset): Default: ''. + tags (list[str] | Unset): + num_buffer_cells (int | Unset): Number of extra native-resolution cells to keep around the domain extent in the + stored grid. The uploaded netCDF must cover the domain bbox expanded by num_buffer_cells * native_pixel_size on + each side; pixels beyond that expanded extent are clipped away. Default: 0. + """ + + name: str | Unset = "" + description: str | Unset = "" + tags: list[str] | Unset = UNSET + num_buffer_cells: int | Unset = 0 + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + name = self.name + + description = self.description + + tags: list[str] | Unset = UNSET + if not isinstance(self.tags, Unset): + tags = self.tags + + num_buffer_cells = self.num_buffer_cells + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if tags is not UNSET: + field_dict["tags"] = tags + if num_buffer_cells is not UNSET: + field_dict["num_buffer_cells"] = num_buffer_cells + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + name = d.pop("name", UNSET) + + description = d.pop("description", UNSET) + + tags = cast(list[str], d.pop("tags", UNSET)) + + num_buffer_cells = d.pop("num_buffer_cells", UNSET) + + create_netcdf_upload_request = cls( + name=name, + description=description, + tags=tags, + num_buffer_cells=num_buffer_cells, + ) + + create_netcdf_upload_request.additional_properties = d + return create_netcdf_upload_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/create_osm_road_feature_request.py b/fastfuels_sdk/v2/client_library/models/create_osm_road_feature_request.py new file mode 100644 index 0000000..f510ff7 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/create_osm_road_feature_request.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + Any, + Literal, + TypeVar, + cast, +) + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="CreateOsmRoadFeatureRequest") + + +@_attrs_define +class CreateOsmRoadFeatureRequest: + """Request body for creating a road feature via OpenStreetMap. + + Attributes: + type_ (Literal['road'] | Unset): Default: 'road'. + name (str | Unset): Default: ''. + description (str | Unset): Default: ''. + tags (list[str] | Unset): + extent_buffer_m (float | Unset): Distance in meters to expand the domain extent outward before clipping fetched + features. Lets roads that exit the domain at the boundary extend slightly past the edge, providing context for + visualization and downstream operations (fuel breaks, perimeter analysis). Applied in the domain's projected CRS + (reprojected to UTM if the domain CRS is geographic). Default 0 clips exactly to the domain boundary. Maximum: + 100 meters. Default: 0.0. + """ + + type_: Literal["road"] | Unset = "road" + name: str | Unset = "" + description: str | Unset = "" + tags: list[str] | Unset = UNSET + extent_buffer_m: float | Unset = 0.0 + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + type_ = self.type_ + + name = self.name + + description = self.description + + tags: list[str] | Unset = UNSET + if not isinstance(self.tags, Unset): + tags = self.tags + + extent_buffer_m = self.extent_buffer_m + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if type_ is not UNSET: + field_dict["type"] = type_ + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if tags is not UNSET: + field_dict["tags"] = tags + if extent_buffer_m is not UNSET: + field_dict["extent_buffer_m"] = extent_buffer_m + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + type_ = cast(Literal["road"] | Unset, d.pop("type", UNSET)) + if type_ != "road" and not isinstance(type_, Unset): + raise ValueError(f"type must match const 'road', got '{type_}'") + + name = d.pop("name", UNSET) + + description = d.pop("description", UNSET) + + tags = cast(list[str], d.pop("tags", UNSET)) + + extent_buffer_m = d.pop("extent_buffer_m", UNSET) + + create_osm_road_feature_request = cls( + type_=type_, + name=name, + description=description, + tags=tags, + extent_buffer_m=extent_buffer_m, + ) + + create_osm_road_feature_request.additional_properties = d + return create_osm_road_feature_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/create_osm_water_feature_request.py b/fastfuels_sdk/v2/client_library/models/create_osm_water_feature_request.py new file mode 100644 index 0000000..17a76c5 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/create_osm_water_feature_request.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + Any, + Literal, + TypeVar, + cast, +) + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="CreateOsmWaterFeatureRequest") + + +@_attrs_define +class CreateOsmWaterFeatureRequest: + """Request body for creating a water feature via OpenStreetMap. + + Attributes: + type_ (Literal['water'] | Unset): Default: 'water'. + name (str | Unset): Default: ''. + description (str | Unset): Default: ''. + tags (list[str] | Unset): + extent_buffer_m (float | Unset): Distance in meters to expand the domain extent outward before clipping fetched + features. Lets streams and rivers that exit the domain at the boundary extend slightly past the edge, providing + context for visualization and downstream operations (fuel breaks, perimeter analysis). Applied in the domain's + projected CRS (reprojected to UTM if the domain CRS is geographic). Default 0 clips exactly to the domain + boundary. Maximum: 100 meters. Default: 0.0. + """ + + type_: Literal["water"] | Unset = "water" + name: str | Unset = "" + description: str | Unset = "" + tags: list[str] | Unset = UNSET + extent_buffer_m: float | Unset = 0.0 + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + type_ = self.type_ + + name = self.name + + description = self.description + + tags: list[str] | Unset = UNSET + if not isinstance(self.tags, Unset): + tags = self.tags + + extent_buffer_m = self.extent_buffer_m + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if type_ is not UNSET: + field_dict["type"] = type_ + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if tags is not UNSET: + field_dict["tags"] = tags + if extent_buffer_m is not UNSET: + field_dict["extent_buffer_m"] = extent_buffer_m + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + type_ = cast(Literal["water"] | Unset, d.pop("type", UNSET)) + if type_ != "water" and not isinstance(type_, Unset): + raise ValueError(f"type must match const 'water', got '{type_}'") + + name = d.pop("name", UNSET) + + description = d.pop("description", UNSET) + + tags = cast(list[str], d.pop("tags", UNSET)) + + extent_buffer_m = d.pop("extent_buffer_m", UNSET) + + create_osm_water_feature_request = cls( + type_=type_, + name=name, + description=description, + tags=tags, + extent_buffer_m=extent_buffer_m, + ) + + create_osm_water_feature_request.additional_properties = d + return create_osm_water_feature_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/create_pim_inventory_request.py b/fastfuels_sdk/v2/client_library/models/create_pim_inventory_request.py new file mode 100644 index 0000000..261844b --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/create_pim_inventory_request.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.inventory_type import InventoryType +from ..models.point_process import PointProcess +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.inventory_basal_area_treatment import InventoryBasalAreaTreatment + from ..models.inventory_diameter_treatment import InventoryDiameterTreatment + from ..models.inventory_modification import InventoryModification + + +T = TypeVar("T", bound="CreatePimInventoryRequest") + + +@_attrs_define +class CreatePimInventoryRequest: + """Request body for creating an inventory via PIM expansion. + + Attributes: + source_pim_grid_id (str): ID of a completed PIM grid to use as the source. + type_ (InventoryType | Unset): Type of entities in the inventory. + name (str | Unset): Default: ''. + description (str | Unset): Default: ''. + tags (list[str] | Unset): + seed (int | Unset): Random seed for reproducibility. Generated randomly if omitted. + point_process (PointProcess | Unset): Spatial point process for tree coordinate assignment. + modifications (list[InventoryModification] | Unset): Modifications to apply after point process expansion. + treatments (list[InventoryBasalAreaTreatment | InventoryDiameterTreatment] | Unset): Silvicultural treatments to + apply after modifications. + """ + + source_pim_grid_id: str + type_: InventoryType | Unset = UNSET + name: str | Unset = "" + description: str | Unset = "" + tags: list[str] | Unset = UNSET + seed: int | Unset = UNSET + point_process: PointProcess | Unset = UNSET + modifications: list[InventoryModification] | Unset = UNSET + treatments: ( + list[InventoryBasalAreaTreatment | InventoryDiameterTreatment] | Unset + ) = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.inventory_diameter_treatment import InventoryDiameterTreatment + + source_pim_grid_id = self.source_pim_grid_id + + type_: str | Unset = UNSET + if not isinstance(self.type_, Unset): + type_ = self.type_.value + + name = self.name + + description = self.description + + tags: list[str] | Unset = UNSET + if not isinstance(self.tags, Unset): + tags = self.tags + + seed = self.seed + + point_process: str | Unset = UNSET + if not isinstance(self.point_process, Unset): + point_process = self.point_process.value + + modifications: list[dict[str, Any]] | Unset = UNSET + if not isinstance(self.modifications, Unset): + modifications = [] + for modifications_item_data in self.modifications: + modifications_item = modifications_item_data.to_dict() + modifications.append(modifications_item) + + treatments: list[dict[str, Any]] | Unset = UNSET + if not isinstance(self.treatments, Unset): + treatments = [] + for treatments_item_data in self.treatments: + treatments_item: dict[str, Any] + if isinstance(treatments_item_data, InventoryDiameterTreatment): + treatments_item = treatments_item_data.to_dict() + else: + treatments_item = treatments_item_data.to_dict() + + treatments.append(treatments_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "source_pim_grid_id": source_pim_grid_id, + } + ) + if type_ is not UNSET: + field_dict["type"] = type_ + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if tags is not UNSET: + field_dict["tags"] = tags + if seed is not UNSET: + field_dict["seed"] = seed + if point_process is not UNSET: + field_dict["point_process"] = point_process + if modifications is not UNSET: + field_dict["modifications"] = modifications + if treatments is not UNSET: + field_dict["treatments"] = treatments + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.inventory_basal_area_treatment import InventoryBasalAreaTreatment + from ..models.inventory_diameter_treatment import InventoryDiameterTreatment + from ..models.inventory_modification import InventoryModification + + d = dict(src_dict) + source_pim_grid_id = d.pop("source_pim_grid_id") + + _type_ = d.pop("type", UNSET) + type_: InventoryType | Unset + if isinstance(_type_, Unset): + type_ = UNSET + else: + type_ = InventoryType(_type_) + + name = d.pop("name", UNSET) + + description = d.pop("description", UNSET) + + tags = cast(list[str], d.pop("tags", UNSET)) + + seed = d.pop("seed", UNSET) + + _point_process = d.pop("point_process", UNSET) + point_process: PointProcess | Unset + if isinstance(_point_process, Unset): + point_process = UNSET + else: + point_process = PointProcess(_point_process) + + _modifications = d.pop("modifications", UNSET) + modifications: list[InventoryModification] | Unset = UNSET + if _modifications is not UNSET: + modifications = [] + for modifications_item_data in _modifications: + modifications_item = InventoryModification.from_dict( + modifications_item_data + ) + + modifications.append(modifications_item) + + _treatments = d.pop("treatments", UNSET) + treatments: ( + list[InventoryBasalAreaTreatment | InventoryDiameterTreatment] | Unset + ) = UNSET + if _treatments is not UNSET: + treatments = [] + for treatments_item_data in _treatments: + + def _parse_treatments_item( + data: object, + ) -> InventoryBasalAreaTreatment | InventoryDiameterTreatment: + try: + if not isinstance(data, dict): + raise TypeError() + treatments_item_type_0 = InventoryDiameterTreatment.from_dict( + data + ) + + return treatments_item_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + if not isinstance(data, dict): + raise TypeError() + treatments_item_type_1 = InventoryBasalAreaTreatment.from_dict(data) + + return treatments_item_type_1 + + treatments_item = _parse_treatments_item(treatments_item_data) + + treatments.append(treatments_item) + + create_pim_inventory_request = cls( + source_pim_grid_id=source_pim_grid_id, + type_=type_, + name=name, + description=description, + tags=tags, + seed=seed, + point_process=point_process, + modifications=modifications, + treatments=treatments, + ) + + create_pim_inventory_request.additional_properties = d + return create_pim_inventory_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/create_resample_request.py b/fastfuels_sdk/v2/client_library/models/create_resample_request.py new file mode 100644 index 0000000..8b7dc60 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/create_resample_request.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.create_resample_request_method_overrides import ( + CreateResampleRequestMethodOverrides, + ) + from ..models.grid_alignment_domain_target import GridAlignmentDomainTarget + from ..models.grid_alignment_grid_target import GridAlignmentGridTarget + from ..models.grid_alignment_native_target import GridAlignmentNativeTarget + from ..models.grid_modification import GridModification + + +T = TypeVar("T", bound="CreateResampleRequest") + + +@_attrs_define +class CreateResampleRequest: + """Request to create a grid by resampling an existing grid. + + Unlike entry-point grid creation requests, ``domain_id`` is not required + because derived grids carry the same domain reference as their source. + + The ``alignment`` field controls the output lattice. ``alignment.resolution`` + is required for ``target="domain"`` and ``target="native"``; for + ``target="grid"`` it is optional (defaults to the target grid's exact + transform/shape; if supplied, keeps the target's CRS and origin and + recomputes shape at the new cell size). + + Attributes: + source_grid_id (str): Grid to resample + alignment (GridAlignmentDomainTarget | GridAlignmentGridTarget | GridAlignmentNativeTarget | Unset): Output + alignment target. Default `target="domain"` anchors the resampled grid to the domain origin. + method_overrides (CreateResampleRequestMethodOverrides | Unset): Per-band resampling method overrides keyed by + band key. Wins over ``alignment.method`` for the listed bands. Useful for using nearest-neighbor on categorical + bands while using bilinear on continuous bands. + name (str | Unset): Default: ''. + description (str | Unset): Default: ''. + tags (list[str] | Unset): + modifications (list[GridModification] | Unset): + """ + + source_grid_id: str + alignment: ( + GridAlignmentDomainTarget + | GridAlignmentGridTarget + | GridAlignmentNativeTarget + | Unset + ) = UNSET + method_overrides: CreateResampleRequestMethodOverrides | Unset = UNSET + name: str | Unset = "" + description: str | Unset = "" + tags: list[str] | Unset = UNSET + modifications: list[GridModification] | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.grid_alignment_domain_target import GridAlignmentDomainTarget + from ..models.grid_alignment_native_target import GridAlignmentNativeTarget + + source_grid_id = self.source_grid_id + + alignment: dict[str, Any] | Unset + if isinstance(self.alignment, Unset): + alignment = UNSET + elif isinstance(self.alignment, GridAlignmentDomainTarget): + alignment = self.alignment.to_dict() + elif isinstance(self.alignment, GridAlignmentNativeTarget): + alignment = self.alignment.to_dict() + else: + alignment = self.alignment.to_dict() + + method_overrides: dict[str, Any] | Unset = UNSET + if not isinstance(self.method_overrides, Unset): + method_overrides = self.method_overrides.to_dict() + + name = self.name + + description = self.description + + tags: list[str] | Unset = UNSET + if not isinstance(self.tags, Unset): + tags = self.tags + + modifications: list[dict[str, Any]] | Unset = UNSET + if not isinstance(self.modifications, Unset): + modifications = [] + for modifications_item_data in self.modifications: + modifications_item = modifications_item_data.to_dict() + modifications.append(modifications_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "source_grid_id": source_grid_id, + } + ) + if alignment is not UNSET: + field_dict["alignment"] = alignment + if method_overrides is not UNSET: + field_dict["method_overrides"] = method_overrides + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if tags is not UNSET: + field_dict["tags"] = tags + if modifications is not UNSET: + field_dict["modifications"] = modifications + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.create_resample_request_method_overrides import ( + CreateResampleRequestMethodOverrides, + ) + from ..models.grid_alignment_domain_target import GridAlignmentDomainTarget + from ..models.grid_alignment_grid_target import GridAlignmentGridTarget + from ..models.grid_alignment_native_target import GridAlignmentNativeTarget + from ..models.grid_modification import GridModification + + d = dict(src_dict) + source_grid_id = d.pop("source_grid_id") + + def _parse_alignment( + data: object, + ) -> ( + GridAlignmentDomainTarget + | GridAlignmentGridTarget + | GridAlignmentNativeTarget + | Unset + ): + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + alignment_type_0 = GridAlignmentDomainTarget.from_dict(data) + + return alignment_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + alignment_type_1 = GridAlignmentNativeTarget.from_dict(data) + + return alignment_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + if not isinstance(data, dict): + raise TypeError() + alignment_type_2 = GridAlignmentGridTarget.from_dict(data) + + return alignment_type_2 + + alignment = _parse_alignment(d.pop("alignment", UNSET)) + + _method_overrides = d.pop("method_overrides", UNSET) + method_overrides: CreateResampleRequestMethodOverrides | Unset + if isinstance(_method_overrides, Unset): + method_overrides = UNSET + else: + method_overrides = CreateResampleRequestMethodOverrides.from_dict( + _method_overrides + ) + + name = d.pop("name", UNSET) + + description = d.pop("description", UNSET) + + tags = cast(list[str], d.pop("tags", UNSET)) + + _modifications = d.pop("modifications", UNSET) + modifications: list[GridModification] | Unset = UNSET + if _modifications is not UNSET: + modifications = [] + for modifications_item_data in _modifications: + modifications_item = GridModification.from_dict(modifications_item_data) + + modifications.append(modifications_item) + + create_resample_request = cls( + source_grid_id=source_grid_id, + alignment=alignment, + method_overrides=method_overrides, + name=name, + description=description, + tags=tags, + modifications=modifications, + ) + + create_resample_request.additional_properties = d + return create_resample_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/create_resample_request_method_overrides.py b/fastfuels_sdk/v2/client_library/models/create_resample_request_method_overrides.py new file mode 100644 index 0000000..7e77611 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/create_resample_request_method_overrides.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.resampling_method import ResamplingMethod + +T = TypeVar("T", bound="CreateResampleRequestMethodOverrides") + + +@_attrs_define +class CreateResampleRequestMethodOverrides: + """Per-band resampling method overrides keyed by band key. Wins over ``alignment.method`` for the listed bands. Useful + for using nearest-neighbor on categorical bands while using bilinear on continuous bands. + + """ + + additional_properties: dict[str, ResamplingMethod] = _attrs_field( + init=False, factory=dict + ) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + for prop_name, prop in self.additional_properties.items(): + field_dict[prop_name] = prop.value + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + create_resample_request_method_overrides = cls() + + additional_properties = {} + for prop_name, prop_dict in d.items(): + additional_property = ResamplingMethod(prop_dict) + + additional_properties[prop_name] = additional_property + + create_resample_request_method_overrides.additional_properties = ( + additional_properties + ) + return create_resample_request_method_overrides + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> ResamplingMethod: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: ResamplingMethod) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/create_three_dep_topography_request.py b/fastfuels_sdk/v2/client_library/models/create_three_dep_topography_request.py new file mode 100644 index 0000000..b4356a4 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/create_three_dep_topography_request.py @@ -0,0 +1,241 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.three_dep_resolution import ThreeDepResolution +from ..models.topography_band import TopographyBand +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.grid_alignment_domain_target import GridAlignmentDomainTarget + from ..models.grid_alignment_grid_target import GridAlignmentGridTarget + from ..models.grid_alignment_native_target import GridAlignmentNativeTarget + from ..models.grid_modification import GridModification + + +T = TypeVar("T", bound="CreateThreeDepTopographyRequest") + + +@_attrs_define +class CreateThreeDepTopographyRequest: + """Request to create a grid from 3DEP topographic data. + + Returns a grid with one or more continuous bands: elevation (m), + slope (degrees), and/or aspect (degrees). + + `source_resolution` selects the 3DEP product family (1m, 10m, or 30m). + To change the *output* cell size, set ``alignment.resolution`` instead. + + Attributes: + name (str | Unset): Default: ''. + description (str | Unset): Default: ''. + tags (list[str] | Unset): + modifications (list[GridModification] | Unset): Rules applied to the grid after it is built from its source. + Each rule has a list of `conditions` (ANDed together) and a list of `actions` (applied where the conditions + match). Conditions can be attribute-based (compare a band value) or spatial (test cell location against a + geometry). Spatial conditions come in two variants discriminated by `source`: `geometry` (inline GeoJSON) or + `feature` (reference a persisted Feature resource — road, water, layerset — in the same domain by `feature_id`). + Both spatial variants accept `buffer_m` (meters, applied in the domain's projected CRS) to widen the geometry, + and `target` (`centroid` or `cell`) to choose which part of the cell is tested. Actions modify band values via + `replace`, `multiply`, `divide`, `add`, or `subtract`. See the `GridModification` schema for the full field + reference and worked examples. + extent_buffer_cells (int | Unset): Number of result-grid cells included as a buffer around the domain extent in + the stored grid. The buffer is measured after the source raster is projected into the domain CRS, so a cell + means one cell in the returned grid rather than one source raster cell. Provides context for later operations + (resample, reproject, focal filters, derivative calculations) that are sensitive to edges. Default 0 adds no + buffer. Maximum: 10 cells. Default: 0. + alignment (GridAlignmentDomainTarget | GridAlignmentGridTarget | GridAlignmentNativeTarget | Unset): Per-fetch + alignment target. Default `target="domain"` anchors output cells to the domain origin so cross-source + composition works by construction. `target="native"` preserves the source pixel anchor. `target="grid"` aligns + to an existing grid by id. + source_resolution (ThreeDepResolution | Unset): Available resolutions for 3DEP data (meters). + bands (list[TopographyBand] | Unset): + """ + + name: str | Unset = "" + description: str | Unset = "" + tags: list[str] | Unset = UNSET + modifications: list[GridModification] | Unset = UNSET + extent_buffer_cells: int | Unset = 0 + alignment: ( + GridAlignmentDomainTarget + | GridAlignmentGridTarget + | GridAlignmentNativeTarget + | Unset + ) = UNSET + source_resolution: ThreeDepResolution | Unset = UNSET + bands: list[TopographyBand] | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.grid_alignment_domain_target import GridAlignmentDomainTarget + from ..models.grid_alignment_native_target import GridAlignmentNativeTarget + + name = self.name + + description = self.description + + tags: list[str] | Unset = UNSET + if not isinstance(self.tags, Unset): + tags = self.tags + + modifications: list[dict[str, Any]] | Unset = UNSET + if not isinstance(self.modifications, Unset): + modifications = [] + for modifications_item_data in self.modifications: + modifications_item = modifications_item_data.to_dict() + modifications.append(modifications_item) + + extent_buffer_cells = self.extent_buffer_cells + + alignment: dict[str, Any] | Unset + if isinstance(self.alignment, Unset): + alignment = UNSET + elif isinstance(self.alignment, GridAlignmentDomainTarget): + alignment = self.alignment.to_dict() + elif isinstance(self.alignment, GridAlignmentNativeTarget): + alignment = self.alignment.to_dict() + else: + alignment = self.alignment.to_dict() + + source_resolution: int | Unset = UNSET + if not isinstance(self.source_resolution, Unset): + source_resolution = self.source_resolution.value + + bands: list[str] | Unset = UNSET + if not isinstance(self.bands, Unset): + bands = [] + for bands_item_data in self.bands: + bands_item = bands_item_data.value + bands.append(bands_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if tags is not UNSET: + field_dict["tags"] = tags + if modifications is not UNSET: + field_dict["modifications"] = modifications + if extent_buffer_cells is not UNSET: + field_dict["extent_buffer_cells"] = extent_buffer_cells + if alignment is not UNSET: + field_dict["alignment"] = alignment + if source_resolution is not UNSET: + field_dict["source_resolution"] = source_resolution + if bands is not UNSET: + field_dict["bands"] = bands + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.grid_alignment_domain_target import GridAlignmentDomainTarget + from ..models.grid_alignment_grid_target import GridAlignmentGridTarget + from ..models.grid_alignment_native_target import GridAlignmentNativeTarget + from ..models.grid_modification import GridModification + + d = dict(src_dict) + name = d.pop("name", UNSET) + + description = d.pop("description", UNSET) + + tags = cast(list[str], d.pop("tags", UNSET)) + + _modifications = d.pop("modifications", UNSET) + modifications: list[GridModification] | Unset = UNSET + if _modifications is not UNSET: + modifications = [] + for modifications_item_data in _modifications: + modifications_item = GridModification.from_dict(modifications_item_data) + + modifications.append(modifications_item) + + extent_buffer_cells = d.pop("extent_buffer_cells", UNSET) + + def _parse_alignment( + data: object, + ) -> ( + GridAlignmentDomainTarget + | GridAlignmentGridTarget + | GridAlignmentNativeTarget + | Unset + ): + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + alignment_type_0 = GridAlignmentDomainTarget.from_dict(data) + + return alignment_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + alignment_type_1 = GridAlignmentNativeTarget.from_dict(data) + + return alignment_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + if not isinstance(data, dict): + raise TypeError() + alignment_type_2 = GridAlignmentGridTarget.from_dict(data) + + return alignment_type_2 + + alignment = _parse_alignment(d.pop("alignment", UNSET)) + + _source_resolution = d.pop("source_resolution", UNSET) + source_resolution: ThreeDepResolution | Unset + if isinstance(_source_resolution, Unset): + source_resolution = UNSET + else: + source_resolution = ThreeDepResolution(_source_resolution) + + _bands = d.pop("bands", UNSET) + bands: list[TopographyBand] | Unset = UNSET + if _bands is not UNSET: + bands = [] + for bands_item_data in _bands: + bands_item = TopographyBand(bands_item_data) + + bands.append(bands_item) + + create_three_dep_topography_request = cls( + name=name, + description=description, + tags=tags, + modifications=modifications, + extent_buffer_cells=extent_buffer_cells, + alignment=alignment, + source_resolution=source_resolution, + bands=bands, + ) + + create_three_dep_topography_request.additional_properties = d + return create_three_dep_topography_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/create_tree_inventory_request.py b/fastfuels_sdk/v2/client_library/models/create_tree_inventory_request.py new file mode 100644 index 0000000..baf214e --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/create_tree_inventory_request.py @@ -0,0 +1,292 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define + +from ..models.crown_profile_model import CrownProfileModel +from ..models.tree_band import TreeBand +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.allometry_biomass_source import AllometryBiomassSource + from ..models.allometry_max_crown_radius_source import AllometryMaxCrownRadiusSource + from ..models.inventory_column_max_crown_radius_source import ( + InventoryColumnMaxCrownRadiusSource, + ) + from ..models.inventory_columns_biomass_source import InventoryColumnsBiomassSource + from ..models.moisture_model import MoistureModel + from ..models.resolution_3d import Resolution3D + + +T = TypeVar("T", bound="CreateTreeInventoryRequest") + + +@_attrs_define +class CreateTreeInventoryRequest: + """Request body for creating a tree fuel grid from a tree inventory. + + Does not extend CreateGridRequestBase because 3D grids do not support + modifications — modifications must be applied to the inventory before + voxelization, not to the resulting voxel grid. + + Attributes: + source_inventory_id (str): ID of a completed tree inventory to voxelize. + name (str | Unset): Default: ''. + description (str | Unset): Default: ''. + tags (list[str] | Unset): + resolution (Resolution3D | Unset): Voxel resolution for a 3D grid. + + `horizontal` applies to both x and y (fastfuels-core requires isotropic + horizontal resolution). `vertical` is independent. + bands (list[TreeBand] | Unset): Which output bands to produce. Defaults to `bulk_density.foliage.live`. + crown_profile_model (CrownProfileModel | Unset): Crown geometry models — which voxels a tree's crown occupies. + biomass_source (AllometryBiomassSource | InventoryColumnsBiomassSource | Unset): Biomass source and requested + biomass components. + max_crown_radius_source (AllometryMaxCrownRadiusSource | InventoryColumnMaxCrownRadiusSource | Unset): Source of + each tree's maximum crown radius. Defaults to the crown profile model's allometric value. Use `{"type": + "inventory_column", "column": ...}` to read a per-tree maximum crown radius (m) from an inventory column (e.g. + derived from LiDAR); the crown profile model still controls the crown shape — only the peak radius is rescaled. + moisture_model (MoistureModel | None | Unset): Live/dead fuel moisture model. Applied only when matching + fuel_moisture bands are requested. Live defaults to uniform 100.0; dead defaults to uniform 10.0. + seed (int | Unset): Random seed for reproducibility. Controls stochastic tree voxel sampling and biomass + distribution. Generated randomly if omitted; persisted on the grid document either way so re-running a grid + always yields bit-identical output. + """ + + source_inventory_id: str + name: str | Unset = "" + description: str | Unset = "" + tags: list[str] | Unset = UNSET + resolution: Resolution3D | Unset = UNSET + bands: list[TreeBand] | Unset = UNSET + crown_profile_model: CrownProfileModel | Unset = UNSET + biomass_source: AllometryBiomassSource | InventoryColumnsBiomassSource | Unset = ( + UNSET + ) + max_crown_radius_source: ( + AllometryMaxCrownRadiusSource | InventoryColumnMaxCrownRadiusSource | Unset + ) = UNSET + moisture_model: MoistureModel | None | Unset = UNSET + seed: int | Unset = UNSET + + def to_dict(self) -> dict[str, Any]: + from ..models.allometry_biomass_source import AllometryBiomassSource + from ..models.allometry_max_crown_radius_source import ( + AllometryMaxCrownRadiusSource, + ) + from ..models.moisture_model import MoistureModel + + source_inventory_id = self.source_inventory_id + + name = self.name + + description = self.description + + tags: list[str] | Unset = UNSET + if not isinstance(self.tags, Unset): + tags = self.tags + + resolution: dict[str, Any] | Unset = UNSET + if not isinstance(self.resolution, Unset): + resolution = self.resolution.to_dict() + + bands: list[str] | Unset = UNSET + if not isinstance(self.bands, Unset): + bands = [] + for bands_item_data in self.bands: + bands_item = bands_item_data.value + bands.append(bands_item) + + crown_profile_model: str | Unset = UNSET + if not isinstance(self.crown_profile_model, Unset): + crown_profile_model = self.crown_profile_model.value + + biomass_source: dict[str, Any] | Unset + if isinstance(self.biomass_source, Unset): + biomass_source = UNSET + elif isinstance(self.biomass_source, AllometryBiomassSource): + biomass_source = self.biomass_source.to_dict() + else: + biomass_source = self.biomass_source.to_dict() + + max_crown_radius_source: dict[str, Any] | Unset + if isinstance(self.max_crown_radius_source, Unset): + max_crown_radius_source = UNSET + elif isinstance(self.max_crown_radius_source, AllometryMaxCrownRadiusSource): + max_crown_radius_source = self.max_crown_radius_source.to_dict() + else: + max_crown_radius_source = self.max_crown_radius_source.to_dict() + + moisture_model: dict[str, Any] | None | Unset + if isinstance(self.moisture_model, Unset): + moisture_model = UNSET + elif isinstance(self.moisture_model, MoistureModel): + moisture_model = self.moisture_model.to_dict() + else: + moisture_model = self.moisture_model + + seed = self.seed + + field_dict: dict[str, Any] = {} + + field_dict.update( + { + "source_inventory_id": source_inventory_id, + } + ) + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if tags is not UNSET: + field_dict["tags"] = tags + if resolution is not UNSET: + field_dict["resolution"] = resolution + if bands is not UNSET: + field_dict["bands"] = bands + if crown_profile_model is not UNSET: + field_dict["crown_profile_model"] = crown_profile_model + if biomass_source is not UNSET: + field_dict["biomass_source"] = biomass_source + if max_crown_radius_source is not UNSET: + field_dict["max_crown_radius_source"] = max_crown_radius_source + if moisture_model is not UNSET: + field_dict["moisture_model"] = moisture_model + if seed is not UNSET: + field_dict["seed"] = seed + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.allometry_biomass_source import AllometryBiomassSource + from ..models.allometry_max_crown_radius_source import ( + AllometryMaxCrownRadiusSource, + ) + from ..models.inventory_column_max_crown_radius_source import ( + InventoryColumnMaxCrownRadiusSource, + ) + from ..models.inventory_columns_biomass_source import ( + InventoryColumnsBiomassSource, + ) + from ..models.moisture_model import MoistureModel + from ..models.resolution_3d import Resolution3D + + d = dict(src_dict) + source_inventory_id = d.pop("source_inventory_id") + + name = d.pop("name", UNSET) + + description = d.pop("description", UNSET) + + tags = cast(list[str], d.pop("tags", UNSET)) + + _resolution = d.pop("resolution", UNSET) + resolution: Resolution3D | Unset + if isinstance(_resolution, Unset): + resolution = UNSET + else: + resolution = Resolution3D.from_dict(_resolution) + + _bands = d.pop("bands", UNSET) + bands: list[TreeBand] | Unset = UNSET + if _bands is not UNSET: + bands = [] + for bands_item_data in _bands: + bands_item = TreeBand(bands_item_data) + + bands.append(bands_item) + + _crown_profile_model = d.pop("crown_profile_model", UNSET) + crown_profile_model: CrownProfileModel | Unset + if isinstance(_crown_profile_model, Unset): + crown_profile_model = UNSET + else: + crown_profile_model = CrownProfileModel(_crown_profile_model) + + def _parse_biomass_source( + data: object, + ) -> AllometryBiomassSource | InventoryColumnsBiomassSource | Unset: + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + biomass_source_type_0 = AllometryBiomassSource.from_dict(data) + + return biomass_source_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + if not isinstance(data, dict): + raise TypeError() + biomass_source_type_1 = InventoryColumnsBiomassSource.from_dict(data) + + return biomass_source_type_1 + + biomass_source = _parse_biomass_source(d.pop("biomass_source", UNSET)) + + def _parse_max_crown_radius_source( + data: object, + ) -> ( + AllometryMaxCrownRadiusSource | InventoryColumnMaxCrownRadiusSource | Unset + ): + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + max_crown_radius_source_type_0 = ( + AllometryMaxCrownRadiusSource.from_dict(data) + ) + + return max_crown_radius_source_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + if not isinstance(data, dict): + raise TypeError() + max_crown_radius_source_type_1 = ( + InventoryColumnMaxCrownRadiusSource.from_dict(data) + ) + + return max_crown_radius_source_type_1 + + max_crown_radius_source = _parse_max_crown_radius_source( + d.pop("max_crown_radius_source", UNSET) + ) + + def _parse_moisture_model(data: object) -> MoistureModel | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + moisture_model_type_0 = MoistureModel.from_dict(data) + + return moisture_model_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(MoistureModel | None | Unset, data) + + moisture_model = _parse_moisture_model(d.pop("moisture_model", UNSET)) + + seed = d.pop("seed", UNSET) + + create_tree_inventory_request = cls( + source_inventory_id=source_inventory_id, + name=name, + description=description, + tags=tags, + resolution=resolution, + bands=bands, + crown_profile_model=crown_profile_model, + biomass_source=biomass_source, + max_crown_radius_source=max_crown_radius_source, + moisture_model=moisture_model, + seed=seed, + ) + + return create_tree_inventory_request diff --git a/fastfuels_sdk/v2/client_library/models/create_tree_map_request.py b/fastfuels_sdk/v2/client_library/models/create_tree_map_request.py new file mode 100644 index 0000000..3026335 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/create_tree_map_request.py @@ -0,0 +1,239 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.tree_map_band import TreeMapBand +from ..models.tree_map_version import TreeMapVersion +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.grid_alignment_domain_target import GridAlignmentDomainTarget + from ..models.grid_alignment_grid_target import GridAlignmentGridTarget + from ..models.grid_alignment_native_target import GridAlignmentNativeTarget + from ..models.grid_modification import GridModification + + +T = TypeVar("T", bound="CreateTreeMapRequest") + + +@_attrs_define +class CreateTreeMapRequest: + """Request to create a grid from TreeMap. + + Returns a grid with one or two categorical bands: + - tm_id: TreeMap raster pixel values (always available) + - plt_cn: FIA plot condition number (optional, derived from tree table) + + Attributes: + name (str | Unset): Default: ''. + description (str | Unset): Default: ''. + tags (list[str] | Unset): + modifications (list[GridModification] | Unset): Rules applied to the grid after it is built from its source. + Each rule has a list of `conditions` (ANDed together) and a list of `actions` (applied where the conditions + match). Conditions can be attribute-based (compare a band value) or spatial (test cell location against a + geometry). Spatial conditions come in two variants discriminated by `source`: `geometry` (inline GeoJSON) or + `feature` (reference a persisted Feature resource — road, water, layerset — in the same domain by `feature_id`). + Both spatial variants accept `buffer_m` (meters, applied in the domain's projected CRS) to widen the geometry, + and `target` (`centroid` or `cell`) to choose which part of the cell is tested. Actions modify band values via + `replace`, `multiply`, `divide`, `add`, or `subtract`. See the `GridModification` schema for the full field + reference and worked examples. + extent_buffer_cells (int | Unset): Number of result-grid cells included as a buffer around the domain extent in + the stored grid. The buffer is measured after the source raster is projected into the domain CRS, so a cell + means one cell in the returned grid rather than one source raster cell. Provides context for later operations + (resample, reproject, focal filters, derivative calculations) that are sensitive to edges. Default 0 adds no + buffer. Maximum: 10 cells. Default: 0. + alignment (GridAlignmentDomainTarget | GridAlignmentGridTarget | GridAlignmentNativeTarget | Unset): Per-fetch + alignment target. Default `target="domain"` anchors output cells to the domain origin so cross-source + composition works by construction. `target="native"` preserves the source pixel anchor. `target="grid"` aligns + to an existing grid by id. + version (TreeMapVersion | Unset): Available TreeMap data versions. + bands (list[TreeMapBand] | Unset): + """ + + name: str | Unset = "" + description: str | Unset = "" + tags: list[str] | Unset = UNSET + modifications: list[GridModification] | Unset = UNSET + extent_buffer_cells: int | Unset = 0 + alignment: ( + GridAlignmentDomainTarget + | GridAlignmentGridTarget + | GridAlignmentNativeTarget + | Unset + ) = UNSET + version: TreeMapVersion | Unset = UNSET + bands: list[TreeMapBand] | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.grid_alignment_domain_target import GridAlignmentDomainTarget + from ..models.grid_alignment_native_target import GridAlignmentNativeTarget + + name = self.name + + description = self.description + + tags: list[str] | Unset = UNSET + if not isinstance(self.tags, Unset): + tags = self.tags + + modifications: list[dict[str, Any]] | Unset = UNSET + if not isinstance(self.modifications, Unset): + modifications = [] + for modifications_item_data in self.modifications: + modifications_item = modifications_item_data.to_dict() + modifications.append(modifications_item) + + extent_buffer_cells = self.extent_buffer_cells + + alignment: dict[str, Any] | Unset + if isinstance(self.alignment, Unset): + alignment = UNSET + elif isinstance(self.alignment, GridAlignmentDomainTarget): + alignment = self.alignment.to_dict() + elif isinstance(self.alignment, GridAlignmentNativeTarget): + alignment = self.alignment.to_dict() + else: + alignment = self.alignment.to_dict() + + version: str | Unset = UNSET + if not isinstance(self.version, Unset): + version = self.version.value + + bands: list[str] | Unset = UNSET + if not isinstance(self.bands, Unset): + bands = [] + for bands_item_data in self.bands: + bands_item = bands_item_data.value + bands.append(bands_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if tags is not UNSET: + field_dict["tags"] = tags + if modifications is not UNSET: + field_dict["modifications"] = modifications + if extent_buffer_cells is not UNSET: + field_dict["extent_buffer_cells"] = extent_buffer_cells + if alignment is not UNSET: + field_dict["alignment"] = alignment + if version is not UNSET: + field_dict["version"] = version + if bands is not UNSET: + field_dict["bands"] = bands + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.grid_alignment_domain_target import GridAlignmentDomainTarget + from ..models.grid_alignment_grid_target import GridAlignmentGridTarget + from ..models.grid_alignment_native_target import GridAlignmentNativeTarget + from ..models.grid_modification import GridModification + + d = dict(src_dict) + name = d.pop("name", UNSET) + + description = d.pop("description", UNSET) + + tags = cast(list[str], d.pop("tags", UNSET)) + + _modifications = d.pop("modifications", UNSET) + modifications: list[GridModification] | Unset = UNSET + if _modifications is not UNSET: + modifications = [] + for modifications_item_data in _modifications: + modifications_item = GridModification.from_dict(modifications_item_data) + + modifications.append(modifications_item) + + extent_buffer_cells = d.pop("extent_buffer_cells", UNSET) + + def _parse_alignment( + data: object, + ) -> ( + GridAlignmentDomainTarget + | GridAlignmentGridTarget + | GridAlignmentNativeTarget + | Unset + ): + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + alignment_type_0 = GridAlignmentDomainTarget.from_dict(data) + + return alignment_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + alignment_type_1 = GridAlignmentNativeTarget.from_dict(data) + + return alignment_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + if not isinstance(data, dict): + raise TypeError() + alignment_type_2 = GridAlignmentGridTarget.from_dict(data) + + return alignment_type_2 + + alignment = _parse_alignment(d.pop("alignment", UNSET)) + + _version = d.pop("version", UNSET) + version: TreeMapVersion | Unset + if isinstance(_version, Unset): + version = UNSET + else: + version = TreeMapVersion(_version) + + _bands = d.pop("bands", UNSET) + bands: list[TreeMapBand] | Unset = UNSET + if _bands is not UNSET: + bands = [] + for bands_item_data in _bands: + bands_item = TreeMapBand(bands_item_data) + + bands.append(bands_item) + + create_tree_map_request = cls( + name=name, + description=description, + tags=tags, + modifications=modifications, + extent_buffer_cells=extent_buffer_cells, + alignment=alignment, + version=version, + bands=bands, + ) + + create_tree_map_request.additional_properties = d + return create_tree_map_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/create_uniform_request.py b/fastfuels_sdk/v2/client_library/models/create_uniform_request.py new file mode 100644 index 0000000..787d563 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/create_uniform_request.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.grid_modification import GridModification + from ..models.uniform_band_input import UniformBandInput + + +T = TypeVar("T", bound="CreateUniformRequest") + + +@_attrs_define +class CreateUniformRequest: + """Request to create a uniform (constant-value) grid. + + Each band fills the entire domain with a single value at the specified + resolution. No default resolution — it must be explicitly provided since + uniform grids have no "native resolution." + + Attributes: + resolution (float): Grid resolution in meters + bands (list[UniformBandInput]): + name (str | Unset): Default: ''. + description (str | Unset): Default: ''. + tags (list[str] | Unset): + modifications (list[GridModification] | Unset): Rules applied to the grid after it is built from its source. + Each rule has a list of `conditions` (ANDed together) and a list of `actions` (applied where the conditions + match). Conditions can be attribute-based (compare a band value) or spatial (test cell location against a + geometry). Spatial conditions come in two variants discriminated by `source`: `geometry` (inline GeoJSON) or + `feature` (reference a persisted Feature resource — road, water, layerset — in the same domain by `feature_id`). + Both spatial variants accept `buffer_m` (meters, applied in the domain's projected CRS) to widen the geometry, + and `target` (`centroid` or `cell`) to choose which part of the cell is tested. Actions modify band values via + `replace`, `multiply`, `divide`, `add`, or `subtract`. See the `GridModification` schema for the full field + reference and worked examples. + """ + + resolution: float + bands: list[UniformBandInput] + name: str | Unset = "" + description: str | Unset = "" + tags: list[str] | Unset = UNSET + modifications: list[GridModification] | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + resolution = self.resolution + + bands = [] + for bands_item_data in self.bands: + bands_item = bands_item_data.to_dict() + bands.append(bands_item) + + name = self.name + + description = self.description + + tags: list[str] | Unset = UNSET + if not isinstance(self.tags, Unset): + tags = self.tags + + modifications: list[dict[str, Any]] | Unset = UNSET + if not isinstance(self.modifications, Unset): + modifications = [] + for modifications_item_data in self.modifications: + modifications_item = modifications_item_data.to_dict() + modifications.append(modifications_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "resolution": resolution, + "bands": bands, + } + ) + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if tags is not UNSET: + field_dict["tags"] = tags + if modifications is not UNSET: + field_dict["modifications"] = modifications + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.grid_modification import GridModification + from ..models.uniform_band_input import UniformBandInput + + d = dict(src_dict) + resolution = d.pop("resolution") + + bands = [] + _bands = d.pop("bands") + for bands_item_data in _bands: + bands_item = UniformBandInput.from_dict(bands_item_data) + + bands.append(bands_item) + + name = d.pop("name", UNSET) + + description = d.pop("description", UNSET) + + tags = cast(list[str], d.pop("tags", UNSET)) + + _modifications = d.pop("modifications", UNSET) + modifications: list[GridModification] | Unset = UNSET + if _modifications is not UNSET: + modifications = [] + for modifications_item_data in _modifications: + modifications_item = GridModification.from_dict(modifications_item_data) + + modifications.append(modifications_item) + + create_uniform_request = cls( + resolution=resolution, + bands=bands, + name=name, + description=description, + tags=tags, + modifications=modifications, + ) + + create_uniform_request.additional_properties = d + return create_uniform_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/crown_profile_model.py b/fastfuels_sdk/v2/client_library/models/crown_profile_model.py new file mode 100644 index 0000000..172454c --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/crown_profile_model.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class CrownProfileModel(str, Enum): + BETA = "beta" + PURVES = "purves" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/dense_grid_data.py b/fastfuels_sdk/v2/client_library/models/dense_grid_data.py new file mode 100644 index 0000000..13eb053 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/dense_grid_data.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + Any, + Literal, + TypeVar, + cast, +) + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="DenseGridData") + + +@_attrs_define +class DenseGridData: + """ + Attributes: + format_ (Literal['dense']): + values (list[float | int]): + """ + + format_: Literal["dense"] + values: list[float | int] + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + format_ = self.format_ + + values = [] + for values_item_data in self.values: + values_item: float | int + values_item = values_item_data + values.append(values_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "format": format_, + "values": values, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + format_ = cast(Literal["dense"], d.pop("format")) + if format_ != "dense": + raise ValueError(f"format must match const 'dense', got '{format_}'") + + values = [] + _values = d.pop("values") + for values_item_data in _values: + + def _parse_values_item(data: object) -> float | int: + return cast(float | int, data) + + values_item = _parse_values_item(values_item_data) + + values.append(values_item) + + dense_grid_data = cls( + format_=format_, + values=values, + ) + + dense_grid_data.additional_properties = d + return dense_grid_data + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/distribution.py b/fastfuels_sdk/v2/client_library/models/distribution.py new file mode 100644 index 0000000..8d0ad42 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/distribution.py @@ -0,0 +1,10 @@ +from enum import Enum + + +class Distribution(str, Enum): + HOMOGENEOUS = "homogeneous" + RANDOM_CLUSTERS = "random_clusters" + UNIFORM_RANDOM = "uniform_random" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/domain.py b/fastfuels_sdk/v2/client_library/models/domain.py new file mode 100644 index 0000000..6bae497 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/domain.py @@ -0,0 +1,333 @@ +from __future__ import annotations + +import datetime +from collections.abc import Mapping +from typing import ( + TYPE_CHECKING, + Any, + Literal, + TypeVar, + cast, +) + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.domain_style import DomainStyle + from ..models.geo_json_crs import GeoJsonCRS + from ..models.geo_json_feature import GeoJsonFeature + + +T = TypeVar("T", bound="Domain") + + +@_attrs_define +class Domain: + """Represents a domain resource. + + Attributes: + type_ (Literal['FeatureCollection']): + features (list[GeoJsonFeature]): + bbox (list[float] | None | Unset): + name (str | Unset): The name of the domain. Default: ''. + description (str | Unset): A description of the domain. Default: ''. + crs (GeoJsonCRS | Unset): + tags (list[str] | None | Unset): A list of tags associated with the domain. + pad_to_resolution (float | None | Unset): Optional resolution in meters to snap the domain bounding box to. When + set, the bounding box (the 'domain' feature) is snapped outward to the nearest multiple of this value. Grids + whose resolutions divide evenly into this value will produce identical, aligned footprints on this domain. + style (DomainStyle | None | Unset): Optional visual style for rendering the domain on a map. + id (str | Unset): A unique identifier for the domain. + created_on (datetime.datetime | None | Unset): The date and time the domain was created. + modified_on (datetime.datetime | None | Unset): The date and time the domain was last modified. + """ + + type_: Literal["FeatureCollection"] + features: list[GeoJsonFeature] + bbox: list[float] | None | Unset = UNSET + name: str | Unset = "" + description: str | Unset = "" + crs: GeoJsonCRS | Unset = UNSET + tags: list[str] | None | Unset = UNSET + pad_to_resolution: float | None | Unset = UNSET + style: DomainStyle | None | Unset = UNSET + id: str | Unset = UNSET + created_on: datetime.datetime | None | Unset = UNSET + modified_on: datetime.datetime | None | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.domain_style import DomainStyle + + type_ = self.type_ + + features = [] + for features_item_data in self.features: + features_item = features_item_data.to_dict() + features.append(features_item) + + bbox: list[float] | None | Unset + if isinstance(self.bbox, Unset): + bbox = UNSET + elif isinstance(self.bbox, list): + bbox = [] + for bbox_type_0_item_data in self.bbox: + bbox_type_0_item: float + bbox_type_0_item = bbox_type_0_item_data + bbox.append(bbox_type_0_item) + + else: + bbox = self.bbox + + name = self.name + + description = self.description + + crs: dict[str, Any] | Unset = UNSET + if not isinstance(self.crs, Unset): + crs = self.crs.to_dict() + + tags: list[str] | None | Unset + if isinstance(self.tags, Unset): + tags = UNSET + elif isinstance(self.tags, list): + tags = self.tags + + else: + tags = self.tags + + pad_to_resolution: float | None | Unset + if isinstance(self.pad_to_resolution, Unset): + pad_to_resolution = UNSET + else: + pad_to_resolution = self.pad_to_resolution + + style: dict[str, Any] | None | Unset + if isinstance(self.style, Unset): + style = UNSET + elif isinstance(self.style, DomainStyle): + style = self.style.to_dict() + else: + style = self.style + + id = self.id + + created_on: None | str | Unset + if isinstance(self.created_on, Unset): + created_on = UNSET + elif isinstance(self.created_on, datetime.datetime): + created_on = self.created_on.isoformat() + else: + created_on = self.created_on + + modified_on: None | str | Unset + if isinstance(self.modified_on, Unset): + modified_on = UNSET + elif isinstance(self.modified_on, datetime.datetime): + modified_on = self.modified_on.isoformat() + else: + modified_on = self.modified_on + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "type": type_, + "features": features, + } + ) + if bbox is not UNSET: + field_dict["bbox"] = bbox + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if crs is not UNSET: + field_dict["crs"] = crs + if tags is not UNSET: + field_dict["tags"] = tags + if pad_to_resolution is not UNSET: + field_dict["pad_to_resolution"] = pad_to_resolution + if style is not UNSET: + field_dict["style"] = style + if id is not UNSET: + field_dict["id"] = id + if created_on is not UNSET: + field_dict["created_on"] = created_on + if modified_on is not UNSET: + field_dict["modified_on"] = modified_on + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.domain_style import DomainStyle + from ..models.geo_json_crs import GeoJsonCRS + from ..models.geo_json_feature import GeoJsonFeature + + d = dict(src_dict) + type_ = cast(Literal["FeatureCollection"], d.pop("type")) + if type_ != "FeatureCollection": + raise ValueError( + f"type must match const 'FeatureCollection', got '{type_}'" + ) + + features = [] + _features = d.pop("features") + for features_item_data in _features: + features_item = GeoJsonFeature.from_dict(features_item_data) + + features.append(features_item) + + def _parse_bbox(data: object) -> list[float] | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, list): + raise TypeError() + bbox_type_0 = [] + _bbox_type_0 = data + for bbox_type_0_item_data in _bbox_type_0: + + def _parse_bbox_type_0_item(data: object) -> float: + return cast(float, data) + + bbox_type_0_item = _parse_bbox_type_0_item(bbox_type_0_item_data) + + bbox_type_0.append(bbox_type_0_item) + + return bbox_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(list[float] | None | Unset, data) + + bbox = _parse_bbox(d.pop("bbox", UNSET)) + + name = d.pop("name", UNSET) + + description = d.pop("description", UNSET) + + _crs = d.pop("crs", UNSET) + crs: GeoJsonCRS | Unset + if isinstance(_crs, Unset): + crs = UNSET + else: + crs = GeoJsonCRS.from_dict(_crs) + + def _parse_tags(data: object) -> list[str] | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, list): + raise TypeError() + tags_type_0 = cast(list[str], data) + + return tags_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(list[str] | None | Unset, data) + + tags = _parse_tags(d.pop("tags", UNSET)) + + def _parse_pad_to_resolution(data: object) -> float | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(float | None | Unset, data) + + pad_to_resolution = _parse_pad_to_resolution(d.pop("pad_to_resolution", UNSET)) + + def _parse_style(data: object) -> DomainStyle | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + style_type_0 = DomainStyle.from_dict(data) + + return style_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(DomainStyle | None | Unset, data) + + style = _parse_style(d.pop("style", UNSET)) + + id = d.pop("id", UNSET) + + def _parse_created_on(data: object) -> datetime.datetime | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, str): + raise TypeError() + created_on_type_0 = datetime.datetime.fromisoformat(data) + + return created_on_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(datetime.datetime | None | Unset, data) + + created_on = _parse_created_on(d.pop("created_on", UNSET)) + + def _parse_modified_on(data: object) -> datetime.datetime | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, str): + raise TypeError() + modified_on_type_0 = datetime.datetime.fromisoformat(data) + + return modified_on_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(datetime.datetime | None | Unset, data) + + modified_on = _parse_modified_on(d.pop("modified_on", UNSET)) + + domain = cls( + type_=type_, + features=features, + bbox=bbox, + name=name, + description=description, + crs=crs, + tags=tags, + pad_to_resolution=pad_to_resolution, + style=style, + id=id, + created_on=created_on, + modified_on=modified_on, + ) + + domain.additional_properties = d + return domain + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/domain_lattice.py b/fastfuels_sdk/v2/client_library/models/domain_lattice.py new file mode 100644 index 0000000..f423c74 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/domain_lattice.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="DomainLattice") + + +@_attrs_define +class DomainLattice: + """Pixel lattice for a domain at a given resolution. + + Uses rasterio conventions: + - ``transform`` is ``[a, b, c, d, e, f]`` where ``a`` is pixel width, + ``e`` is ``-pixel_height``, and ``(c, f)`` is the upper-left corner. + - ``shape`` is ``(height, width)`` in pixels. + + Attributes: + crs (str): e.g., 'EPSG:32611' + resolution (float): Pixel size in meters. + num_buffer_cells (int): Buffer cells applied on each side. + transform (list[float]): Affine transform [a, b, c, d, e, f] + shape (list[int]): (height, width) in pixels + """ + + crs: str + resolution: float + num_buffer_cells: int + transform: list[float] + shape: list[int] + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + crs = self.crs + + resolution = self.resolution + + num_buffer_cells = self.num_buffer_cells + + transform = [] + for transform_item_data in self.transform: + transform_item: float + transform_item = transform_item_data + transform.append(transform_item) + + shape = [] + for shape_item_data in self.shape: + shape_item: int + shape_item = shape_item_data + shape.append(shape_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "crs": crs, + "resolution": resolution, + "num_buffer_cells": num_buffer_cells, + "transform": transform, + "shape": shape, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + crs = d.pop("crs") + + resolution = d.pop("resolution") + + num_buffer_cells = d.pop("num_buffer_cells") + + transform = [] + _transform = d.pop("transform") + for transform_item_data in _transform: + + def _parse_transform_item(data: object) -> float: + return cast(float, data) + + transform_item = _parse_transform_item(transform_item_data) + + transform.append(transform_item) + + shape = [] + _shape = d.pop("shape") + for shape_item_data in _shape: + + def _parse_shape_item(data: object) -> int: + return cast(int, data) + + shape_item = _parse_shape_item(shape_item_data) + + shape.append(shape_item) + + domain_lattice = cls( + crs=crs, + resolution=resolution, + num_buffer_cells=num_buffer_cells, + transform=transform, + shape=shape, + ) + + domain_lattice.additional_properties = d + return domain_lattice + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/domain_sort_field.py b/fastfuels_sdk/v2/client_library/models/domain_sort_field.py new file mode 100644 index 0000000..a52929b --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/domain_sort_field.py @@ -0,0 +1,10 @@ +from enum import Enum + + +class DomainSortField(str, Enum): + CREATED_ON = "created_on" + MODIFIED_ON = "modified_on" + NAME = "name" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/domain_sort_order.py b/fastfuels_sdk/v2/client_library/models/domain_sort_order.py new file mode 100644 index 0000000..06c3819 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/domain_sort_order.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class DomainSortOrder(str, Enum): + ASCENDING = "ascending" + DESCENDING = "descending" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/domain_style.py b/fastfuels_sdk/v2/client_library/models/domain_style.py new file mode 100644 index 0000000..f1cf66f --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/domain_style.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="DomainStyle") + + +@_attrs_define +class DomainStyle: + """Optional visual style for rendering a domain on a map. + + All fields are optional. On PATCH only the provided fields are merged + into the stored style; unspecified fields preserve their current values. + + Attributes: + stroke_color (None | str | Unset): Stroke color in any renderer-supported format. + stroke_opacity (float | None | Unset): Stroke opacity in [0, 1]. + stroke_width (float | None | Unset): Stroke width in pixels (non-negative). + fill_color (None | str | Unset): Fill color in any renderer-supported format. + fill_opacity (float | None | Unset): Fill opacity in [0, 1]. + """ + + stroke_color: None | str | Unset = UNSET + stroke_opacity: float | None | Unset = UNSET + stroke_width: float | None | Unset = UNSET + fill_color: None | str | Unset = UNSET + fill_opacity: float | None | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + stroke_color: None | str | Unset + if isinstance(self.stroke_color, Unset): + stroke_color = UNSET + else: + stroke_color = self.stroke_color + + stroke_opacity: float | None | Unset + if isinstance(self.stroke_opacity, Unset): + stroke_opacity = UNSET + else: + stroke_opacity = self.stroke_opacity + + stroke_width: float | None | Unset + if isinstance(self.stroke_width, Unset): + stroke_width = UNSET + else: + stroke_width = self.stroke_width + + fill_color: None | str | Unset + if isinstance(self.fill_color, Unset): + fill_color = UNSET + else: + fill_color = self.fill_color + + fill_opacity: float | None | Unset + if isinstance(self.fill_opacity, Unset): + fill_opacity = UNSET + else: + fill_opacity = self.fill_opacity + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if stroke_color is not UNSET: + field_dict["stroke_color"] = stroke_color + if stroke_opacity is not UNSET: + field_dict["stroke_opacity"] = stroke_opacity + if stroke_width is not UNSET: + field_dict["stroke_width"] = stroke_width + if fill_color is not UNSET: + field_dict["fill_color"] = fill_color + if fill_opacity is not UNSET: + field_dict["fill_opacity"] = fill_opacity + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + + def _parse_stroke_color(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + stroke_color = _parse_stroke_color(d.pop("stroke_color", UNSET)) + + def _parse_stroke_opacity(data: object) -> float | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(float | None | Unset, data) + + stroke_opacity = _parse_stroke_opacity(d.pop("stroke_opacity", UNSET)) + + def _parse_stroke_width(data: object) -> float | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(float | None | Unset, data) + + stroke_width = _parse_stroke_width(d.pop("stroke_width", UNSET)) + + def _parse_fill_color(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + fill_color = _parse_fill_color(d.pop("fill_color", UNSET)) + + def _parse_fill_opacity(data: object) -> float | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(float | None | Unset, data) + + fill_opacity = _parse_fill_opacity(d.pop("fill_opacity", UNSET)) + + domain_style = cls( + stroke_color=stroke_color, + stroke_opacity=stroke_opacity, + stroke_width=stroke_width, + fill_color=fill_color, + fill_opacity=fill_opacity, + ) + + domain_style.additional_properties = d + return domain_style + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/duplicate_inventory_request.py b/fastfuels_sdk/v2/client_library/models/duplicate_inventory_request.py new file mode 100644 index 0000000..cf04045 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/duplicate_inventory_request.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="DuplicateInventoryRequest") + + +@_attrs_define +class DuplicateInventoryRequest: + """Optional metadata overrides for a duplicated inventory. + + Every field is optional. Any field omitted is carried over verbatim from + the source inventory. + + Attributes: + name (None | str | Unset): Name for the copy. Omit to reuse the source inventory's name. + description (None | str | Unset): Description for the copy. Omit to reuse the source inventory's description. + tags (list[str] | None | Unset): Tags for the copy. Omit to reuse the source inventory's tags. + """ + + name: None | str | Unset = UNSET + description: None | str | Unset = UNSET + tags: list[str] | None | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + name: None | str | Unset + if isinstance(self.name, Unset): + name = UNSET + else: + name = self.name + + description: None | str | Unset + if isinstance(self.description, Unset): + description = UNSET + else: + description = self.description + + tags: list[str] | None | Unset + if isinstance(self.tags, Unset): + tags = UNSET + elif isinstance(self.tags, list): + tags = self.tags + + else: + tags = self.tags + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if tags is not UNSET: + field_dict["tags"] = tags + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + + def _parse_name(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + name = _parse_name(d.pop("name", UNSET)) + + def _parse_description(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + description = _parse_description(d.pop("description", UNSET)) + + def _parse_tags(data: object) -> list[str] | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, list): + raise TypeError() + tags_type_0 = cast(list[str], data) + + return tags_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(list[str] | None | Unset, data) + + tags = _parse_tags(d.pop("tags", UNSET)) + + duplicate_inventory_request = cls( + name=name, + description=description, + tags=tags, + ) + + duplicate_inventory_request.additional_properties = d + return duplicate_inventory_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/export.py b/fastfuels_sdk/v2/client_library/models/export.py new file mode 100644 index 0000000..d04ec53 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/export.py @@ -0,0 +1,309 @@ +from __future__ import annotations + +import datetime +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.job_status import JobStatus +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.export_source import ExportSource + from ..models.job_error import JobError + from ..models.job_progress import JobProgress + + +T = TypeVar("T", bound="Export") + + +@_attrs_define +class Export: + """The Export resource. + + Exports are standalone artifacts that record provenance (domain_id, grid_id) + but have independent lifecycle — deleting a domain does not delete its exports. + + When status is "completed", signed_url contains a signed URL for + downloading the exported file. + + Attributes: + id (str): + domain_id (str): Domain the source grids belong to (provenance, not lifecycle dependency). + status (JobStatus): Status of an async job. + source (ExportSource): Format-specific export configuration. Contains 'name' (the export format) plus additional + fields depending on the source type. + name (str | Unset): Default: ''. + description (str | Unset): Default: ''. + progress (JobProgress | None | Unset): Progress info when status is 'running'. Null otherwise. + created_on (datetime.datetime | None | Unset): + modified_on (datetime.datetime | None | Unset): + signed_url (None | str | Unset): Signed URL for downloading the exported file. Populated on completion. + expires_on (datetime.datetime | None | Unset): When the signed URL expires. + error (JobError | None | Unset): Error details if status is 'failed'. + tags (list[str] | Unset): + """ + + id: str + domain_id: str + status: JobStatus + source: ExportSource + name: str | Unset = "" + description: str | Unset = "" + progress: JobProgress | None | Unset = UNSET + created_on: datetime.datetime | None | Unset = UNSET + modified_on: datetime.datetime | None | Unset = UNSET + signed_url: None | str | Unset = UNSET + expires_on: datetime.datetime | None | Unset = UNSET + error: JobError | None | Unset = UNSET + tags: list[str] | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.job_error import JobError + from ..models.job_progress import JobProgress + + id = self.id + + domain_id = self.domain_id + + status = self.status.value + + source = self.source.to_dict() + + name = self.name + + description = self.description + + progress: dict[str, Any] | None | Unset + if isinstance(self.progress, Unset): + progress = UNSET + elif isinstance(self.progress, JobProgress): + progress = self.progress.to_dict() + else: + progress = self.progress + + created_on: None | str | Unset + if isinstance(self.created_on, Unset): + created_on = UNSET + elif isinstance(self.created_on, datetime.datetime): + created_on = self.created_on.isoformat() + else: + created_on = self.created_on + + modified_on: None | str | Unset + if isinstance(self.modified_on, Unset): + modified_on = UNSET + elif isinstance(self.modified_on, datetime.datetime): + modified_on = self.modified_on.isoformat() + else: + modified_on = self.modified_on + + signed_url: None | str | Unset + if isinstance(self.signed_url, Unset): + signed_url = UNSET + else: + signed_url = self.signed_url + + expires_on: None | str | Unset + if isinstance(self.expires_on, Unset): + expires_on = UNSET + elif isinstance(self.expires_on, datetime.datetime): + expires_on = self.expires_on.isoformat() + else: + expires_on = self.expires_on + + error: dict[str, Any] | None | Unset + if isinstance(self.error, Unset): + error = UNSET + elif isinstance(self.error, JobError): + error = self.error.to_dict() + else: + error = self.error + + tags: list[str] | Unset = UNSET + if not isinstance(self.tags, Unset): + tags = self.tags + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "id": id, + "domain_id": domain_id, + "status": status, + "source": source, + } + ) + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if progress is not UNSET: + field_dict["progress"] = progress + if created_on is not UNSET: + field_dict["created_on"] = created_on + if modified_on is not UNSET: + field_dict["modified_on"] = modified_on + if signed_url is not UNSET: + field_dict["signed_url"] = signed_url + if expires_on is not UNSET: + field_dict["expires_on"] = expires_on + if error is not UNSET: + field_dict["error"] = error + if tags is not UNSET: + field_dict["tags"] = tags + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.export_source import ExportSource + from ..models.job_error import JobError + from ..models.job_progress import JobProgress + + d = dict(src_dict) + id = d.pop("id") + + domain_id = d.pop("domain_id") + + status = JobStatus(d.pop("status")) + + source = ExportSource.from_dict(d.pop("source")) + + name = d.pop("name", UNSET) + + description = d.pop("description", UNSET) + + def _parse_progress(data: object) -> JobProgress | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + progress_type_0 = JobProgress.from_dict(data) + + return progress_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(JobProgress | None | Unset, data) + + progress = _parse_progress(d.pop("progress", UNSET)) + + def _parse_created_on(data: object) -> datetime.datetime | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, str): + raise TypeError() + created_on_type_0 = datetime.datetime.fromisoformat(data) + + return created_on_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(datetime.datetime | None | Unset, data) + + created_on = _parse_created_on(d.pop("created_on", UNSET)) + + def _parse_modified_on(data: object) -> datetime.datetime | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, str): + raise TypeError() + modified_on_type_0 = datetime.datetime.fromisoformat(data) + + return modified_on_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(datetime.datetime | None | Unset, data) + + modified_on = _parse_modified_on(d.pop("modified_on", UNSET)) + + def _parse_signed_url(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + signed_url = _parse_signed_url(d.pop("signed_url", UNSET)) + + def _parse_expires_on(data: object) -> datetime.datetime | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, str): + raise TypeError() + expires_on_type_0 = datetime.datetime.fromisoformat(data) + + return expires_on_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(datetime.datetime | None | Unset, data) + + expires_on = _parse_expires_on(d.pop("expires_on", UNSET)) + + def _parse_error(data: object) -> JobError | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + error_type_0 = JobError.from_dict(data) + + return error_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(JobError | None | Unset, data) + + error = _parse_error(d.pop("error", UNSET)) + + tags = cast(list[str], d.pop("tags", UNSET)) + + export = cls( + id=id, + domain_id=domain_id, + status=status, + source=source, + name=name, + description=description, + progress=progress, + created_on=created_on, + modified_on=modified_on, + signed_url=signed_url, + expires_on=expires_on, + error=error, + tags=tags, + ) + + export.additional_properties = d + return export + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/export_grid_request.py b/fastfuels_sdk/v2/client_library/models/export_grid_request.py new file mode 100644 index 0000000..4350a94 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/export_grid_request.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="ExportGridRequest") + + +@_attrs_define +class ExportGridRequest: + """Request body for creating a grid export. + + Used at: POST /domains/{domain_id}/grids/{grid_id}/exports/{format} + + Attributes: + bands (list[str] | None | Unset): Band keys to include (e.g. 'fuel_load.1hr', 'fbfm'). Omit to export all bands + from the grid. + expiration_days (int | Unset): Number of days until the signed download URL expires (max 7). Default: 7. + Default: 7. + name (str | Unset): Default: ''. + description (str | Unset): Default: ''. + tags (list[str] | Unset): + """ + + bands: list[str] | None | Unset = UNSET + expiration_days: int | Unset = 7 + name: str | Unset = "" + description: str | Unset = "" + tags: list[str] | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + bands: list[str] | None | Unset + if isinstance(self.bands, Unset): + bands = UNSET + elif isinstance(self.bands, list): + bands = self.bands + + else: + bands = self.bands + + expiration_days = self.expiration_days + + name = self.name + + description = self.description + + tags: list[str] | Unset = UNSET + if not isinstance(self.tags, Unset): + tags = self.tags + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if bands is not UNSET: + field_dict["bands"] = bands + if expiration_days is not UNSET: + field_dict["expiration_days"] = expiration_days + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if tags is not UNSET: + field_dict["tags"] = tags + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + + def _parse_bands(data: object) -> list[str] | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, list): + raise TypeError() + bands_type_0 = cast(list[str], data) + + return bands_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(list[str] | None | Unset, data) + + bands = _parse_bands(d.pop("bands", UNSET)) + + expiration_days = d.pop("expiration_days", UNSET) + + name = d.pop("name", UNSET) + + description = d.pop("description", UNSET) + + tags = cast(list[str], d.pop("tags", UNSET)) + + export_grid_request = cls( + bands=bands, + expiration_days=expiration_days, + name=name, + description=description, + tags=tags, + ) + + export_grid_request.additional_properties = d + return export_grid_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/export_inventory_request.py b/fastfuels_sdk/v2/client_library/models/export_inventory_request.py new file mode 100644 index 0000000..fd0a270 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/export_inventory_request.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="ExportInventoryRequest") + + +@_attrs_define +class ExportInventoryRequest: + """Request body for creating an inventory export. + + Used at: POST /domains/{domain_id}/inventories/{inventory_id}/exports/{format} + + Attributes: + columns (list[str] | None | Unset): Column keys to include in the export (e.g. 'x', 'dbh', 'height'). Omit to + export all columns. Maximum 100 columns. + expiration_days (int | Unset): Number of days until the signed download URL expires (max 7). Default: 7. + Default: 7. + name (str | Unset): Default: ''. + description (str | Unset): Default: ''. + tags (list[str] | Unset): + """ + + columns: list[str] | None | Unset = UNSET + expiration_days: int | Unset = 7 + name: str | Unset = "" + description: str | Unset = "" + tags: list[str] | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + columns: list[str] | None | Unset + if isinstance(self.columns, Unset): + columns = UNSET + elif isinstance(self.columns, list): + columns = self.columns + + else: + columns = self.columns + + expiration_days = self.expiration_days + + name = self.name + + description = self.description + + tags: list[str] | Unset = UNSET + if not isinstance(self.tags, Unset): + tags = self.tags + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if columns is not UNSET: + field_dict["columns"] = columns + if expiration_days is not UNSET: + field_dict["expiration_days"] = expiration_days + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if tags is not UNSET: + field_dict["tags"] = tags + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + + def _parse_columns(data: object) -> list[str] | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, list): + raise TypeError() + columns_type_0 = cast(list[str], data) + + return columns_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(list[str] | None | Unset, data) + + columns = _parse_columns(d.pop("columns", UNSET)) + + expiration_days = d.pop("expiration_days", UNSET) + + name = d.pop("name", UNSET) + + description = d.pop("description", UNSET) + + tags = cast(list[str], d.pop("tags", UNSET)) + + export_inventory_request = cls( + columns=columns, + expiration_days=expiration_days, + name=name, + description=description, + tags=tags, + ) + + export_inventory_request.additional_properties = d + return export_inventory_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/export_sort_field.py b/fastfuels_sdk/v2/client_library/models/export_sort_field.py new file mode 100644 index 0000000..597233e --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/export_sort_field.py @@ -0,0 +1,10 @@ +from enum import Enum + + +class ExportSortField(str, Enum): + CREATED_ON = "created_on" + MODIFIED_ON = "modified_on" + NAME = "name" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/export_source.py b/fastfuels_sdk/v2/client_library/models/export_source.py new file mode 100644 index 0000000..bd0f5d0 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/export_source.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="ExportSource") + + +@_attrs_define +class ExportSource: + """Format-specific export configuration. Contains 'name' (the export format) plus additional fields depending on the + source type. + + """ + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + export_source = cls() + + export_source.additional_properties = d + return export_source + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/fbfm_40_lookup_band.py b/fastfuels_sdk/v2/client_library/models/fbfm_40_lookup_band.py new file mode 100644 index 0000000..8aee452 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/fbfm_40_lookup_band.py @@ -0,0 +1,18 @@ +from enum import Enum + + +class Fbfm40LookupBand(str, Enum): + FUEL_DEPTH = "fuel_depth" + FUEL_LOAD_100HR = "fuel_load.100hr" + FUEL_LOAD_10HR = "fuel_load.10hr" + FUEL_LOAD_1HR = "fuel_load.1hr" + FUEL_LOAD_LIVE_HERB = "fuel_load.live_herb" + FUEL_LOAD_LIVE_WOODY = "fuel_load.live_woody" + SAVR_100HR = "savr.100hr" + SAVR_10HR = "savr.10hr" + SAVR_1HR = "savr.1hr" + SAVR_LIVE_HERB = "savr.live_herb" + SAVR_LIVE_WOODY = "savr.live_woody" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/feature.py b/fastfuels_sdk/v2/client_library/models/feature.py new file mode 100644 index 0000000..727db56 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/feature.py @@ -0,0 +1,298 @@ +from __future__ import annotations + +import datetime +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.feature_type import FeatureType +from ..models.job_status import JobStatus +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.feature_georeference import FeatureGeoreference + from ..models.feature_source import FeatureSource + from ..models.job_error import JobError + from ..models.job_progress import JobProgress + + +T = TypeVar("T", bound="Feature") + + +@_attrs_define +class Feature: + """The Feature resource. + + When status is "pending" or "running", georeference will be null. + The backend worker populates it after successfully generating the GeoJSON + and uploading it to GCS, at which point status transitions to "completed". + + Attributes: + id (str): + domain_id (str): + type_ (FeatureType): Type of geographic feature. + status (JobStatus): Status of an async job. + source (FeatureSource): + name (str | Unset): Default: ''. + description (str | Unset): Default: ''. + progress (JobProgress | None | Unset): Progress info when status is 'running'. Null otherwise. + created_on (datetime.datetime | None | Unset): + modified_on (datetime.datetime | None | Unset): + georeference (FeatureGeoreference | None | Unset): Spatial reference. Null until backend completes processing. + error (JobError | None | Unset): Error details if status is 'failed'. + tags (list[str] | Unset): + """ + + id: str + domain_id: str + type_: FeatureType + status: JobStatus + source: FeatureSource + name: str | Unset = "" + description: str | Unset = "" + progress: JobProgress | None | Unset = UNSET + created_on: datetime.datetime | None | Unset = UNSET + modified_on: datetime.datetime | None | Unset = UNSET + georeference: FeatureGeoreference | None | Unset = UNSET + error: JobError | None | Unset = UNSET + tags: list[str] | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.feature_georeference import FeatureGeoreference + from ..models.job_error import JobError + from ..models.job_progress import JobProgress + + id = self.id + + domain_id = self.domain_id + + type_ = self.type_.value + + status = self.status.value + + source = self.source.to_dict() + + name = self.name + + description = self.description + + progress: dict[str, Any] | None | Unset + if isinstance(self.progress, Unset): + progress = UNSET + elif isinstance(self.progress, JobProgress): + progress = self.progress.to_dict() + else: + progress = self.progress + + created_on: None | str | Unset + if isinstance(self.created_on, Unset): + created_on = UNSET + elif isinstance(self.created_on, datetime.datetime): + created_on = self.created_on.isoformat() + else: + created_on = self.created_on + + modified_on: None | str | Unset + if isinstance(self.modified_on, Unset): + modified_on = UNSET + elif isinstance(self.modified_on, datetime.datetime): + modified_on = self.modified_on.isoformat() + else: + modified_on = self.modified_on + + georeference: dict[str, Any] | None | Unset + if isinstance(self.georeference, Unset): + georeference = UNSET + elif isinstance(self.georeference, FeatureGeoreference): + georeference = self.georeference.to_dict() + else: + georeference = self.georeference + + error: dict[str, Any] | None | Unset + if isinstance(self.error, Unset): + error = UNSET + elif isinstance(self.error, JobError): + error = self.error.to_dict() + else: + error = self.error + + tags: list[str] | Unset = UNSET + if not isinstance(self.tags, Unset): + tags = self.tags + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "id": id, + "domain_id": domain_id, + "type": type_, + "status": status, + "source": source, + } + ) + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if progress is not UNSET: + field_dict["progress"] = progress + if created_on is not UNSET: + field_dict["created_on"] = created_on + if modified_on is not UNSET: + field_dict["modified_on"] = modified_on + if georeference is not UNSET: + field_dict["georeference"] = georeference + if error is not UNSET: + field_dict["error"] = error + if tags is not UNSET: + field_dict["tags"] = tags + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.feature_georeference import FeatureGeoreference + from ..models.feature_source import FeatureSource + from ..models.job_error import JobError + from ..models.job_progress import JobProgress + + d = dict(src_dict) + id = d.pop("id") + + domain_id = d.pop("domain_id") + + type_ = FeatureType(d.pop("type")) + + status = JobStatus(d.pop("status")) + + source = FeatureSource.from_dict(d.pop("source")) + + name = d.pop("name", UNSET) + + description = d.pop("description", UNSET) + + def _parse_progress(data: object) -> JobProgress | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + progress_type_0 = JobProgress.from_dict(data) + + return progress_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(JobProgress | None | Unset, data) + + progress = _parse_progress(d.pop("progress", UNSET)) + + def _parse_created_on(data: object) -> datetime.datetime | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, str): + raise TypeError() + created_on_type_0 = datetime.datetime.fromisoformat(data) + + return created_on_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(datetime.datetime | None | Unset, data) + + created_on = _parse_created_on(d.pop("created_on", UNSET)) + + def _parse_modified_on(data: object) -> datetime.datetime | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, str): + raise TypeError() + modified_on_type_0 = datetime.datetime.fromisoformat(data) + + return modified_on_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(datetime.datetime | None | Unset, data) + + modified_on = _parse_modified_on(d.pop("modified_on", UNSET)) + + def _parse_georeference(data: object) -> FeatureGeoreference | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + georeference_type_0 = FeatureGeoreference.from_dict(data) + + return georeference_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(FeatureGeoreference | None | Unset, data) + + georeference = _parse_georeference(d.pop("georeference", UNSET)) + + def _parse_error(data: object) -> JobError | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + error_type_0 = JobError.from_dict(data) + + return error_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(JobError | None | Unset, data) + + error = _parse_error(d.pop("error", UNSET)) + + tags = cast(list[str], d.pop("tags", UNSET)) + + feature = cls( + id=id, + domain_id=domain_id, + type_=type_, + status=status, + source=source, + name=name, + description=description, + progress=progress, + created_on=created_on, + modified_on=modified_on, + georeference=georeference, + error=error, + tags=tags, + ) + + feature.additional_properties = d + return feature + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/feature_data_metadata.py b/fastfuels_sdk/v2/client_library/models/feature_data_metadata.py new file mode 100644 index 0000000..e0b3392 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/feature_data_metadata.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.feature_partition_info import FeaturePartitionInfo + + +T = TypeVar("T", bound="FeatureDataMetadata") + + +@_attrs_define +class FeatureDataMetadata: + """Partition layout for a feature blob. + + Attributes: + total_features (int): Total number of features across all partitions. + partition_count (int): Number of valid `partition_index` values. Iterate from 0 to `partition_count - 1` to + retrieve every feature exactly once. + partitions (list[FeaturePartitionInfo]): Per-partition row counts read from the GeoParquet footer. Useful for + sizing client-side buffers. + """ + + total_features: int + partition_count: int + partitions: list[FeaturePartitionInfo] + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + total_features = self.total_features + + partition_count = self.partition_count + + partitions = [] + for partitions_item_data in self.partitions: + partitions_item = partitions_item_data.to_dict() + partitions.append(partitions_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "total_features": total_features, + "partition_count": partition_count, + "partitions": partitions, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.feature_partition_info import FeaturePartitionInfo + + d = dict(src_dict) + total_features = d.pop("total_features") + + partition_count = d.pop("partition_count") + + partitions = [] + _partitions = d.pop("partitions") + for partitions_item_data in _partitions: + partitions_item = FeaturePartitionInfo.from_dict(partitions_item_data) + + partitions.append(partitions_item) + + feature_data_metadata = cls( + total_features=total_features, + partition_count=partition_count, + partitions=partitions, + ) + + feature_data_metadata.additional_properties = d + return feature_data_metadata + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/feature_georeference.py b/fastfuels_sdk/v2/client_library/models/feature_georeference.py new file mode 100644 index 0000000..4819a0c --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/feature_georeference.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="FeatureGeoreference") + + +@_attrs_define +class FeatureGeoreference: + """Spatial reference for a feature, computed from the domain geometry. + + Attributes: + crs (str): + bounds (list[float]): + """ + + crs: str + bounds: list[float] + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + crs = self.crs + + bounds = [] + for bounds_item_data in self.bounds: + bounds_item: float + bounds_item = bounds_item_data + bounds.append(bounds_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "crs": crs, + "bounds": bounds, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + crs = d.pop("crs") + + bounds = [] + _bounds = d.pop("bounds") + for bounds_item_data in _bounds: + + def _parse_bounds_item(data: object) -> float: + return cast(float, data) + + bounds_item = _parse_bounds_item(bounds_item_data) + + bounds.append(bounds_item) + + feature_georeference = cls( + crs=crs, + bounds=bounds, + ) + + feature_georeference.additional_properties = d + return feature_georeference + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/feature_partition_info.py b/fastfuels_sdk/v2/client_library/models/feature_partition_info.py new file mode 100644 index 0000000..b82bc43 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/feature_partition_info.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="FeaturePartitionInfo") + + +@_attrs_define +class FeaturePartitionInfo: + """One row group's feature count, surfaced via ``/data/metadata``. + + Attributes: + index (int): Zero-based partition index. + num_features (int): Number of features in this partition. + """ + + index: int + num_features: int + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + index = self.index + + num_features = self.num_features + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "index": index, + "num_features": num_features, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + index = d.pop("index") + + num_features = d.pop("num_features") + + feature_partition_info = cls( + index=index, + num_features=num_features, + ) + + feature_partition_info.additional_properties = d + return feature_partition_info + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/feature_sort_field.py b/fastfuels_sdk/v2/client_library/models/feature_sort_field.py new file mode 100644 index 0000000..8f8b98d --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/feature_sort_field.py @@ -0,0 +1,10 @@ +from enum import Enum + + +class FeatureSortField(str, Enum): + CREATED_ON = "created_on" + MODIFIED_ON = "modified_on" + NAME = "name" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/feature_source.py b/fastfuels_sdk/v2/client_library/models/feature_source.py new file mode 100644 index 0000000..f527b8a --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/feature_source.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="FeatureSource") + + +@_attrs_define +class FeatureSource: + """ """ + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + feature_source = cls() + + feature_source.additional_properties = d + return feature_source + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/feature_type.py b/fastfuels_sdk/v2/client_library/models/feature_type.py new file mode 100644 index 0000000..551898b --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/feature_type.py @@ -0,0 +1,10 @@ +from enum import Enum + + +class FeatureType(str, Enum): + LAYERSET = "layerset" + ROAD = "road" + WATER = "water" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/field_source.py b/fastfuels_sdk/v2/client_library/models/field_source.py new file mode 100644 index 0000000..9be4e53 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/field_source.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="FieldSource") + + +@_attrs_define +class FieldSource: + """A single physical quantity drawn from one band on one grid. + + Every per-role input to the QUIC-Fire export uses this shape so the schema + is uniform across roles. The forward path for `nfuel>1` (when QUIC-Fire's + multi-fuel-type capability becomes relevant) is to allow each per-fuel-type + role to accept `FieldSource | dict[FuelType, FieldSource]`; today's scalar + requests keep working unchanged when that lands. + + Attributes: + grid_id (str): Grid containing the source band. + band (str): Band key on that grid (e.g. 'fuel_load.1hr'). + """ + + grid_id: str + band: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + grid_id = self.grid_id + + band = self.band + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "grid_id": grid_id, + "band": band, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + grid_id = d.pop("grid_id") + + band = d.pop("band") + + field_source = cls( + grid_id=grid_id, + band=band, + ) + + field_source.additional_properties = d + return field_source + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/fine_biomass_config.py b/fastfuels_sdk/v2/client_library/models/fine_biomass_config.py new file mode 100644 index 0000000..8f77a62 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/fine_biomass_config.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + Any, + Literal, + TypeVar, + cast, +) + +from attrs import define as _attrs_define + +T = TypeVar("T", bound="FineBiomassConfig") + + +@_attrs_define +class FineBiomassConfig: + """Configuration for derived fine biomass. + + Attributes: + recipe (Literal['foliage_plus_branchwood_fraction']): + branchwood_fraction (float): + """ + + recipe: Literal["foliage_plus_branchwood_fraction"] + branchwood_fraction: float + + def to_dict(self) -> dict[str, Any]: + recipe = self.recipe + + branchwood_fraction = self.branchwood_fraction + + field_dict: dict[str, Any] = {} + + field_dict.update( + { + "recipe": recipe, + "branchwood_fraction": branchwood_fraction, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + recipe = cast(Literal["foliage_plus_branchwood_fraction"], d.pop("recipe")) + if recipe != "foliage_plus_branchwood_fraction": + raise ValueError( + f"recipe must match const 'foliage_plus_branchwood_fraction', got '{recipe}'" + ) + + branchwood_fraction = d.pop("branchwood_fraction") + + fine_biomass_config = cls( + recipe=recipe, + branchwood_fraction=branchwood_fraction, + ) + + return fine_biomass_config diff --git a/fastfuels_sdk/v2/client_library/models/geo_json_crs.py b/fastfuels_sdk/v2/client_library/models/geo_json_crs.py new file mode 100644 index 0000000..48187f3 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/geo_json_crs.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + TYPE_CHECKING, + Any, + Literal, + TypeVar, + cast, +) + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.geo_json_crs_properties import GeoJsonCRSProperties + + +T = TypeVar("T", bound="GeoJsonCRS") + + +@_attrs_define +class GeoJsonCRS: + """ + Attributes: + properties (GeoJsonCRSProperties): + type_ (Literal['name'] | Unset): Default: 'name'. + """ + + properties: GeoJsonCRSProperties + type_: Literal["name"] | Unset = "name" + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + properties = self.properties.to_dict() + + type_ = self.type_ + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "properties": properties, + } + ) + if type_ is not UNSET: + field_dict["type"] = type_ + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.geo_json_crs_properties import GeoJsonCRSProperties + + d = dict(src_dict) + properties = GeoJsonCRSProperties.from_dict(d.pop("properties")) + + type_ = cast(Literal["name"] | Unset, d.pop("type", UNSET)) + if type_ != "name" and not isinstance(type_, Unset): + raise ValueError(f"type must match const 'name', got '{type_}'") + + geo_json_crs = cls( + properties=properties, + type_=type_, + ) + + geo_json_crs.additional_properties = d + return geo_json_crs + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/geo_json_crs_properties.py b/fastfuels_sdk/v2/client_library/models/geo_json_crs_properties.py new file mode 100644 index 0000000..8630eb4 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/geo_json_crs_properties.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="GeoJsonCRSProperties") + + +@_attrs_define +class GeoJsonCRSProperties: + """ + Attributes: + name (str | Unset): Default: 'EPSG:4326'. + """ + + name: str | Unset = "EPSG:4326" + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + name = self.name + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if name is not UNSET: + field_dict["name"] = name + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + name = d.pop("name", UNSET) + + geo_json_crs_properties = cls( + name=name, + ) + + geo_json_crs_properties.additional_properties = d + return geo_json_crs_properties + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/geo_json_feature.py b/fastfuels_sdk/v2/client_library/models/geo_json_feature.py new file mode 100644 index 0000000..98949c9 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/geo_json_feature.py @@ -0,0 +1,327 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + TYPE_CHECKING, + Any, + Literal, + TypeVar, + cast, +) + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.base_model import BaseModel + from ..models.geo_json_feature_properties_type_0 import ( + GeoJsonFeaturePropertiesType0, + ) + from ..models.geometry_collection import GeometryCollection + from ..models.line_string import LineString + from ..models.multi_line_string import MultiLineString + from ..models.multi_point import MultiPoint + from ..models.multi_polygon import MultiPolygon + from ..models.point import Point + from ..models.polygon import Polygon + + +T = TypeVar("T", bound="GeoJsonFeature") + + +@_attrs_define +class GeoJsonFeature: + """Feature Model + + Attributes: + type_ (Literal['Feature']): + geometry (GeometryCollection | LineString | MultiLineString | MultiPoint | MultiPolygon | None | Point | + Polygon): + properties (BaseModel | GeoJsonFeaturePropertiesType0 | None): + bbox (list[float] | None | Unset): + id (int | None | str | Unset): + """ + + type_: Literal["Feature"] + geometry: ( + GeometryCollection + | LineString + | MultiLineString + | MultiPoint + | MultiPolygon + | None + | Point + | Polygon + ) + properties: BaseModel | GeoJsonFeaturePropertiesType0 | None + bbox: list[float] | None | Unset = UNSET + id: int | None | str | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.base_model import BaseModel + from ..models.geo_json_feature_properties_type_0 import ( + GeoJsonFeaturePropertiesType0, + ) + from ..models.geometry_collection import GeometryCollection + from ..models.line_string import LineString + from ..models.multi_line_string import MultiLineString + from ..models.multi_point import MultiPoint + from ..models.multi_polygon import MultiPolygon + from ..models.point import Point + from ..models.polygon import Polygon + + type_ = self.type_ + + geometry: dict[str, Any] | None + if isinstance(self.geometry, Point): + geometry = self.geometry.to_dict() + elif isinstance(self.geometry, MultiPoint): + geometry = self.geometry.to_dict() + elif isinstance(self.geometry, LineString): + geometry = self.geometry.to_dict() + elif isinstance(self.geometry, MultiLineString): + geometry = self.geometry.to_dict() + elif isinstance(self.geometry, Polygon): + geometry = self.geometry.to_dict() + elif isinstance(self.geometry, MultiPolygon): + geometry = self.geometry.to_dict() + elif isinstance(self.geometry, GeometryCollection): + geometry = self.geometry.to_dict() + else: + geometry = self.geometry + + properties: dict[str, Any] | None + if isinstance(self.properties, GeoJsonFeaturePropertiesType0): + properties = self.properties.to_dict() + elif isinstance(self.properties, BaseModel): + properties = self.properties.to_dict() + else: + properties = self.properties + + bbox: list[float] | None | Unset + if isinstance(self.bbox, Unset): + bbox = UNSET + elif isinstance(self.bbox, list): + bbox = [] + for bbox_type_0_item_data in self.bbox: + bbox_type_0_item: float + bbox_type_0_item = bbox_type_0_item_data + bbox.append(bbox_type_0_item) + + else: + bbox = self.bbox + + id: int | None | str | Unset + if isinstance(self.id, Unset): + id = UNSET + else: + id = self.id + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "type": type_, + "geometry": geometry, + "properties": properties, + } + ) + if bbox is not UNSET: + field_dict["bbox"] = bbox + if id is not UNSET: + field_dict["id"] = id + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.base_model import BaseModel + from ..models.geo_json_feature_properties_type_0 import ( + GeoJsonFeaturePropertiesType0, + ) + from ..models.geometry_collection import GeometryCollection + from ..models.line_string import LineString + from ..models.multi_line_string import MultiLineString + from ..models.multi_point import MultiPoint + from ..models.multi_polygon import MultiPolygon + from ..models.point import Point + from ..models.polygon import Polygon + + d = dict(src_dict) + type_ = cast(Literal["Feature"], d.pop("type")) + if type_ != "Feature": + raise ValueError(f"type must match const 'Feature', got '{type_}'") + + def _parse_geometry( + data: object, + ) -> ( + GeometryCollection + | LineString + | MultiLineString + | MultiPoint + | MultiPolygon + | None + | Point + | Polygon + ): + if data is None: + return data + try: + if not isinstance(data, dict): + raise TypeError() + geometry_type_0_type_0 = Point.from_dict(data) + + return geometry_type_0_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + geometry_type_0_type_1 = MultiPoint.from_dict(data) + + return geometry_type_0_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + geometry_type_0_type_2 = LineString.from_dict(data) + + return geometry_type_0_type_2 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + geometry_type_0_type_3 = MultiLineString.from_dict(data) + + return geometry_type_0_type_3 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + geometry_type_0_type_4 = Polygon.from_dict(data) + + return geometry_type_0_type_4 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + geometry_type_0_type_5 = MultiPolygon.from_dict(data) + + return geometry_type_0_type_5 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + geometry_type_0_type_6 = GeometryCollection.from_dict(data) + + return geometry_type_0_type_6 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast( + GeometryCollection + | LineString + | MultiLineString + | MultiPoint + | MultiPolygon + | None + | Point + | Polygon, + data, + ) + + geometry = _parse_geometry(d.pop("geometry")) + + def _parse_properties( + data: object, + ) -> BaseModel | GeoJsonFeaturePropertiesType0 | None: + if data is None: + return data + try: + if not isinstance(data, dict): + raise TypeError() + properties_type_0 = GeoJsonFeaturePropertiesType0.from_dict(data) + + return properties_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + properties_type_1 = BaseModel.from_dict(data) + + return properties_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(BaseModel | GeoJsonFeaturePropertiesType0 | None, data) + + properties = _parse_properties(d.pop("properties")) + + def _parse_bbox(data: object) -> list[float] | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, list): + raise TypeError() + bbox_type_0 = [] + _bbox_type_0 = data + for bbox_type_0_item_data in _bbox_type_0: + + def _parse_bbox_type_0_item(data: object) -> float: + return cast(float, data) + + bbox_type_0_item = _parse_bbox_type_0_item(bbox_type_0_item_data) + + bbox_type_0.append(bbox_type_0_item) + + return bbox_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(list[float] | None | Unset, data) + + bbox = _parse_bbox(d.pop("bbox", UNSET)) + + def _parse_id(data: object) -> int | None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(int | None | str | Unset, data) + + id = _parse_id(d.pop("id", UNSET)) + + geo_json_feature = cls( + type_=type_, + geometry=geometry, + properties=properties, + bbox=bbox, + id=id, + ) + + geo_json_feature.additional_properties = d + return geo_json_feature + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/geo_json_feature_properties_type_0.py b/fastfuels_sdk/v2/client_library/models/geo_json_feature_properties_type_0.py new file mode 100644 index 0000000..5b957e8 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/geo_json_feature_properties_type_0.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="GeoJsonFeaturePropertiesType0") + + +@_attrs_define +class GeoJsonFeaturePropertiesType0: + """ """ + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + geo_json_feature_properties_type_0 = cls() + + geo_json_feature_properties_type_0.additional_properties = d + return geo_json_feature_properties_type_0 + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/geometry_collection.py b/fastfuels_sdk/v2/client_library/models/geometry_collection.py new file mode 100644 index 0000000..6031dd5 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/geometry_collection.py @@ -0,0 +1,247 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + TYPE_CHECKING, + Any, + Literal, + TypeVar, + cast, +) + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.line_string import LineString + from ..models.multi_line_string import MultiLineString + from ..models.multi_point import MultiPoint + from ..models.multi_polygon import MultiPolygon + from ..models.point import Point + from ..models.polygon import Polygon + + +T = TypeVar("T", bound="GeometryCollection") + + +@_attrs_define +class GeometryCollection: + """GeometryCollection Model + + Attributes: + type_ (Literal['GeometryCollection']): + geometries (list[GeometryCollection | LineString | MultiLineString | MultiPoint | MultiPolygon | Point | + Polygon]): + bbox (list[float] | None | Unset): + """ + + type_: Literal["GeometryCollection"] + geometries: list[ + GeometryCollection + | LineString + | MultiLineString + | MultiPoint + | MultiPolygon + | Point + | Polygon + ] + bbox: list[float] | None | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.line_string import LineString + from ..models.multi_line_string import MultiLineString + from ..models.multi_point import MultiPoint + from ..models.multi_polygon import MultiPolygon + from ..models.point import Point + from ..models.polygon import Polygon + + type_ = self.type_ + + geometries = [] + for geometries_item_data in self.geometries: + geometries_item: dict[str, Any] + if isinstance(geometries_item_data, Point): + geometries_item = geometries_item_data.to_dict() + elif isinstance(geometries_item_data, MultiPoint): + geometries_item = geometries_item_data.to_dict() + elif isinstance(geometries_item_data, LineString): + geometries_item = geometries_item_data.to_dict() + elif isinstance(geometries_item_data, MultiLineString): + geometries_item = geometries_item_data.to_dict() + elif isinstance(geometries_item_data, Polygon): + geometries_item = geometries_item_data.to_dict() + elif isinstance(geometries_item_data, MultiPolygon): + geometries_item = geometries_item_data.to_dict() + else: + geometries_item = geometries_item_data.to_dict() + + geometries.append(geometries_item) + + bbox: list[float] | None | Unset + if isinstance(self.bbox, Unset): + bbox = UNSET + elif isinstance(self.bbox, list): + bbox = [] + for bbox_type_0_item_data in self.bbox: + bbox_type_0_item: float + bbox_type_0_item = bbox_type_0_item_data + bbox.append(bbox_type_0_item) + + else: + bbox = self.bbox + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "type": type_, + "geometries": geometries, + } + ) + if bbox is not UNSET: + field_dict["bbox"] = bbox + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.line_string import LineString + from ..models.multi_line_string import MultiLineString + from ..models.multi_point import MultiPoint + from ..models.multi_polygon import MultiPolygon + from ..models.point import Point + from ..models.polygon import Polygon + + d = dict(src_dict) + type_ = cast(Literal["GeometryCollection"], d.pop("type")) + if type_ != "GeometryCollection": + raise ValueError( + f"type must match const 'GeometryCollection', got '{type_}'" + ) + + geometries = [] + _geometries = d.pop("geometries") + for geometries_item_data in _geometries: + + def _parse_geometries_item( + data: object, + ) -> ( + GeometryCollection + | LineString + | MultiLineString + | MultiPoint + | MultiPolygon + | Point + | Polygon + ): + try: + if not isinstance(data, dict): + raise TypeError() + geometries_item_type_0 = Point.from_dict(data) + + return geometries_item_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + geometries_item_type_1 = MultiPoint.from_dict(data) + + return geometries_item_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + geometries_item_type_2 = LineString.from_dict(data) + + return geometries_item_type_2 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + geometries_item_type_3 = MultiLineString.from_dict(data) + + return geometries_item_type_3 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + geometries_item_type_4 = Polygon.from_dict(data) + + return geometries_item_type_4 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + geometries_item_type_5 = MultiPolygon.from_dict(data) + + return geometries_item_type_5 + except (TypeError, ValueError, AttributeError, KeyError): + pass + if not isinstance(data, dict): + raise TypeError() + geometries_item_type_6 = GeometryCollection.from_dict(data) + + return geometries_item_type_6 + + geometries_item = _parse_geometries_item(geometries_item_data) + + geometries.append(geometries_item) + + def _parse_bbox(data: object) -> list[float] | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, list): + raise TypeError() + bbox_type_0 = [] + _bbox_type_0 = data + for bbox_type_0_item_data in _bbox_type_0: + + def _parse_bbox_type_0_item(data: object) -> float: + return cast(float, data) + + bbox_type_0_item = _parse_bbox_type_0_item(bbox_type_0_item_data) + + bbox_type_0.append(bbox_type_0_item) + + return bbox_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(list[float] | None | Unset, data) + + bbox = _parse_bbox(d.pop("bbox", UNSET)) + + geometry_collection = cls( + type_=type_, + geometries=geometries, + bbox=bbox, + ) + + geometry_collection.additional_properties = d + return geometry_collection + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/georeference.py b/fastfuels_sdk/v2/client_library/models/georeference.py new file mode 100644 index 0000000..eb017be --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/georeference.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="Georeference") + + +@_attrs_define +class Georeference: + """Spatial reference for a 2D grid. + + Uses rasterio/GDAL conventions: + - transform: Affine coefficients [a, b, c, d, e, f] where: + x = a * col + b * row + c + y = d * col + e * row + f + For north-up images: a = pixel_width, e = -pixel_height, b = d = 0 + - shape: (height, width) in pixels + + Attributes: + crs (str): e.g., 'EPSG:32610' + transform (list[float]): Affine transform [a, b, c, d, e, f] + shape (list[int]): (height, width) + """ + + crs: str + transform: list[float] + shape: list[int] + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + crs = self.crs + + transform = [] + for transform_item_data in self.transform: + transform_item: float + transform_item = transform_item_data + transform.append(transform_item) + + shape = [] + for shape_item_data in self.shape: + shape_item: int + shape_item = shape_item_data + shape.append(shape_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "crs": crs, + "transform": transform, + "shape": shape, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + crs = d.pop("crs") + + transform = [] + _transform = d.pop("transform") + for transform_item_data in _transform: + + def _parse_transform_item(data: object) -> float: + return cast(float, data) + + transform_item = _parse_transform_item(transform_item_data) + + transform.append(transform_item) + + shape = [] + _shape = d.pop("shape") + for shape_item_data in _shape: + + def _parse_shape_item(data: object) -> int: + return cast(int, data) + + shape_item = _parse_shape_item(shape_item_data) + + shape.append(shape_item) + + georeference = cls( + crs=crs, + transform=transform, + shape=shape, + ) + + georeference.additional_properties = d + return georeference + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/georeference_3d.py b/fastfuels_sdk/v2/client_library/models/georeference_3d.py new file mode 100644 index 0000000..f36c362 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/georeference_3d.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="Georeference3D") + + +@_attrs_define +class Georeference3D: + """Spatial reference for a 3D grid. + + Attributes: + crs (str): e.g., 'EPSG:32610' + transform (list[float]): Affine transform [a, b, c, d, e, f] + shape (list[int]): (z, height, width) + z_resolution (float): + z_origin (float): + """ + + crs: str + transform: list[float] + shape: list[int] + z_resolution: float + z_origin: float + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + crs = self.crs + + transform = [] + for transform_item_data in self.transform: + transform_item: float + transform_item = transform_item_data + transform.append(transform_item) + + shape = [] + for shape_item_data in self.shape: + shape_item: int + shape_item = shape_item_data + shape.append(shape_item) + + z_resolution = self.z_resolution + + z_origin = self.z_origin + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "crs": crs, + "transform": transform, + "shape": shape, + "z_resolution": z_resolution, + "z_origin": z_origin, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + crs = d.pop("crs") + + transform = [] + _transform = d.pop("transform") + for transform_item_data in _transform: + + def _parse_transform_item(data: object) -> float: + return cast(float, data) + + transform_item = _parse_transform_item(transform_item_data) + + transform.append(transform_item) + + shape = [] + _shape = d.pop("shape") + for shape_item_data in _shape: + + def _parse_shape_item(data: object) -> int: + return cast(int, data) + + shape_item = _parse_shape_item(shape_item_data) + + shape.append(shape_item) + + z_resolution = d.pop("z_resolution") + + z_origin = d.pop("z_origin") + + georeference_3d = cls( + crs=crs, + transform=transform, + shape=shape, + z_resolution=z_resolution, + z_origin=z_origin, + ) + + georeference_3d.additional_properties = d + return georeference_3d + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/grid.py b/fastfuels_sdk/v2/client_library/models/grid.py new file mode 100644 index 0000000..c674d5e --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/grid.py @@ -0,0 +1,407 @@ +from __future__ import annotations + +import datetime +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.job_status import JobStatus +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.band import Band + from ..models.chunks import Chunks + from ..models.georeference import Georeference + from ..models.georeference_3d import Georeference3D + from ..models.grid_modification import GridModification + from ..models.grid_source import GridSource + from ..models.job_error import JobError + from ..models.job_progress import JobProgress + + +T = TypeVar("T", bound="Grid") + + +@_attrs_define +class Grid: + """The Grid resource. + + When status is "pending" or "running", georeference will be null. + The backend populates georeference after successfully fetching data, + at which point status transitions to "completed". + + When status is "failed", the error field contains details about what + went wrong and suggestions for the user. The full traceback is stored + in Firestore but not exposed in API responses. + + Attributes: + id (str): + domain_id (str): + status (JobStatus): Status of an async job. + source (GridSource): + bands (list[Band]): + name (str | Unset): Default: ''. + description (str | Unset): Default: ''. + progress (JobProgress | None | Unset): Progress info when status is 'running'. Null otherwise. + created_on (datetime.datetime | None | Unset): + modified_on (datetime.datetime | None | Unset): + checksum (None | str | Unset): Version marker for this grid's content. It changes each time the grid is rebuilt + and is unaffected by metadata-only edits (name, description, tags). A resource derived from this grid stores the + checksum it was built from; comparing that stored value against this field reveals whether this grid has changed + since. May be null for grids created before checksums were introduced. + modifications (list[GridModification] | Unset): + georeference (Georeference | Georeference3D | None | Unset): Spatial reference. Null until backend completes + data fetch. + error (JobError | None | Unset): Error details if status is 'failed'. Traceback stored but not exposed. + chunks (Chunks | None | Unset): Chunk layout. Null until the grid finishes processing. Use chunks.count to know + how many chunks are available to fetch. + tags (list[str] | Unset): + """ + + id: str + domain_id: str + status: JobStatus + source: GridSource + bands: list[Band] + name: str | Unset = "" + description: str | Unset = "" + progress: JobProgress | None | Unset = UNSET + created_on: datetime.datetime | None | Unset = UNSET + modified_on: datetime.datetime | None | Unset = UNSET + checksum: None | str | Unset = UNSET + modifications: list[GridModification] | Unset = UNSET + georeference: Georeference | Georeference3D | None | Unset = UNSET + error: JobError | None | Unset = UNSET + chunks: Chunks | None | Unset = UNSET + tags: list[str] | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.chunks import Chunks + from ..models.georeference import Georeference + from ..models.georeference_3d import Georeference3D + from ..models.job_error import JobError + from ..models.job_progress import JobProgress + + id = self.id + + domain_id = self.domain_id + + status = self.status.value + + source = self.source.to_dict() + + bands = [] + for bands_item_data in self.bands: + bands_item = bands_item_data.to_dict() + bands.append(bands_item) + + name = self.name + + description = self.description + + progress: dict[str, Any] | None | Unset + if isinstance(self.progress, Unset): + progress = UNSET + elif isinstance(self.progress, JobProgress): + progress = self.progress.to_dict() + else: + progress = self.progress + + created_on: None | str | Unset + if isinstance(self.created_on, Unset): + created_on = UNSET + elif isinstance(self.created_on, datetime.datetime): + created_on = self.created_on.isoformat() + else: + created_on = self.created_on + + modified_on: None | str | Unset + if isinstance(self.modified_on, Unset): + modified_on = UNSET + elif isinstance(self.modified_on, datetime.datetime): + modified_on = self.modified_on.isoformat() + else: + modified_on = self.modified_on + + checksum: None | str | Unset + if isinstance(self.checksum, Unset): + checksum = UNSET + else: + checksum = self.checksum + + modifications: list[dict[str, Any]] | Unset = UNSET + if not isinstance(self.modifications, Unset): + modifications = [] + for modifications_item_data in self.modifications: + modifications_item = modifications_item_data.to_dict() + modifications.append(modifications_item) + + georeference: dict[str, Any] | None | Unset + if isinstance(self.georeference, Unset): + georeference = UNSET + elif isinstance(self.georeference, Georeference): + georeference = self.georeference.to_dict() + elif isinstance(self.georeference, Georeference3D): + georeference = self.georeference.to_dict() + else: + georeference = self.georeference + + error: dict[str, Any] | None | Unset + if isinstance(self.error, Unset): + error = UNSET + elif isinstance(self.error, JobError): + error = self.error.to_dict() + else: + error = self.error + + chunks: dict[str, Any] | None | Unset + if isinstance(self.chunks, Unset): + chunks = UNSET + elif isinstance(self.chunks, Chunks): + chunks = self.chunks.to_dict() + else: + chunks = self.chunks + + tags: list[str] | Unset = UNSET + if not isinstance(self.tags, Unset): + tags = self.tags + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "id": id, + "domain_id": domain_id, + "status": status, + "source": source, + "bands": bands, + } + ) + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if progress is not UNSET: + field_dict["progress"] = progress + if created_on is not UNSET: + field_dict["created_on"] = created_on + if modified_on is not UNSET: + field_dict["modified_on"] = modified_on + if checksum is not UNSET: + field_dict["checksum"] = checksum + if modifications is not UNSET: + field_dict["modifications"] = modifications + if georeference is not UNSET: + field_dict["georeference"] = georeference + if error is not UNSET: + field_dict["error"] = error + if chunks is not UNSET: + field_dict["chunks"] = chunks + if tags is not UNSET: + field_dict["tags"] = tags + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.band import Band + from ..models.chunks import Chunks + from ..models.georeference import Georeference + from ..models.georeference_3d import Georeference3D + from ..models.grid_modification import GridModification + from ..models.grid_source import GridSource + from ..models.job_error import JobError + from ..models.job_progress import JobProgress + + d = dict(src_dict) + id = d.pop("id") + + domain_id = d.pop("domain_id") + + status = JobStatus(d.pop("status")) + + source = GridSource.from_dict(d.pop("source")) + + bands = [] + _bands = d.pop("bands") + for bands_item_data in _bands: + bands_item = Band.from_dict(bands_item_data) + + bands.append(bands_item) + + name = d.pop("name", UNSET) + + description = d.pop("description", UNSET) + + def _parse_progress(data: object) -> JobProgress | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + progress_type_0 = JobProgress.from_dict(data) + + return progress_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(JobProgress | None | Unset, data) + + progress = _parse_progress(d.pop("progress", UNSET)) + + def _parse_created_on(data: object) -> datetime.datetime | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, str): + raise TypeError() + created_on_type_0 = datetime.datetime.fromisoformat(data) + + return created_on_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(datetime.datetime | None | Unset, data) + + created_on = _parse_created_on(d.pop("created_on", UNSET)) + + def _parse_modified_on(data: object) -> datetime.datetime | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, str): + raise TypeError() + modified_on_type_0 = datetime.datetime.fromisoformat(data) + + return modified_on_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(datetime.datetime | None | Unset, data) + + modified_on = _parse_modified_on(d.pop("modified_on", UNSET)) + + def _parse_checksum(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + checksum = _parse_checksum(d.pop("checksum", UNSET)) + + _modifications = d.pop("modifications", UNSET) + modifications: list[GridModification] | Unset = UNSET + if _modifications is not UNSET: + modifications = [] + for modifications_item_data in _modifications: + modifications_item = GridModification.from_dict(modifications_item_data) + + modifications.append(modifications_item) + + def _parse_georeference( + data: object, + ) -> Georeference | Georeference3D | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + georeference_type_0 = Georeference.from_dict(data) + + return georeference_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + georeference_type_1 = Georeference3D.from_dict(data) + + return georeference_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(Georeference | Georeference3D | None | Unset, data) + + georeference = _parse_georeference(d.pop("georeference", UNSET)) + + def _parse_error(data: object) -> JobError | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + error_type_0 = JobError.from_dict(data) + + return error_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(JobError | None | Unset, data) + + error = _parse_error(d.pop("error", UNSET)) + + def _parse_chunks(data: object) -> Chunks | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + chunks_type_0 = Chunks.from_dict(data) + + return chunks_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(Chunks | None | Unset, data) + + chunks = _parse_chunks(d.pop("chunks", UNSET)) + + tags = cast(list[str], d.pop("tags", UNSET)) + + grid = cls( + id=id, + domain_id=domain_id, + status=status, + source=source, + bands=bands, + name=name, + description=description, + progress=progress, + created_on=created_on, + modified_on=modified_on, + checksum=checksum, + modifications=modifications, + georeference=georeference, + error=error, + chunks=chunks, + tags=tags, + ) + + grid.additional_properties = d + return grid + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/grid_alignment_domain_target.py b/fastfuels_sdk/v2/client_library/models/grid_alignment_domain_target.py new file mode 100644 index 0000000..adb633b --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/grid_alignment_domain_target.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + Any, + Literal, + TypeVar, + cast, +) + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.resampling_method import ResamplingMethod +from ..types import UNSET, Unset + +T = TypeVar("T", bound="GridAlignmentDomainTarget") + + +@_attrs_define +class GridAlignmentDomainTarget: + """Anchor output to the domain origin. + + `resolution=None` uses the source's native cell size. Output cells tile + the domain bounding box (already snapped at domain creation if + `pad_to_resolution` was set). + + Attributes: + target (Literal['domain'] | Unset): Default: 'domain'. + resolution (float | None | Unset): + method (None | ResamplingMethod | Unset): + """ + + target: Literal["domain"] | Unset = "domain" + resolution: float | None | Unset = UNSET + method: None | ResamplingMethod | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + target = self.target + + resolution: float | None | Unset + if isinstance(self.resolution, Unset): + resolution = UNSET + else: + resolution = self.resolution + + method: None | str | Unset + if isinstance(self.method, Unset): + method = UNSET + elif isinstance(self.method, ResamplingMethod): + method = self.method.value + else: + method = self.method + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if target is not UNSET: + field_dict["target"] = target + if resolution is not UNSET: + field_dict["resolution"] = resolution + if method is not UNSET: + field_dict["method"] = method + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + target = cast(Literal["domain"] | Unset, d.pop("target", UNSET)) + if target != "domain" and not isinstance(target, Unset): + raise ValueError(f"target must match const 'domain', got '{target}'") + + def _parse_resolution(data: object) -> float | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(float | None | Unset, data) + + resolution = _parse_resolution(d.pop("resolution", UNSET)) + + def _parse_method(data: object) -> None | ResamplingMethod | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, str): + raise TypeError() + method_type_0 = ResamplingMethod(data) + + return method_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(None | ResamplingMethod | Unset, data) + + method = _parse_method(d.pop("method", UNSET)) + + grid_alignment_domain_target = cls( + target=target, + resolution=resolution, + method=method, + ) + + grid_alignment_domain_target.additional_properties = d + return grid_alignment_domain_target + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/grid_alignment_grid_target.py b/fastfuels_sdk/v2/client_library/models/grid_alignment_grid_target.py new file mode 100644 index 0000000..939f4c3 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/grid_alignment_grid_target.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + Any, + Literal, + TypeVar, + cast, +) + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.resampling_method import ResamplingMethod +from ..types import UNSET, Unset + +T = TypeVar("T", bound="GridAlignmentGridTarget") + + +@_attrs_define +class GridAlignmentGridTarget: + """Align to an existing grid by id. + + `resolution=None` produces an exact lattice match (CRS, transform, and + shape from the target grid). With an explicit `resolution`, the output + keeps the target's CRS and origin but uses the new cell size; shape is + recomputed from the target grid's bounds. + + Attributes: + target (Literal['grid']): + grid_id (str): + resolution (float | None | Unset): + method (None | ResamplingMethod | Unset): + """ + + target: Literal["grid"] + grid_id: str + resolution: float | None | Unset = UNSET + method: None | ResamplingMethod | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + target = self.target + + grid_id = self.grid_id + + resolution: float | None | Unset + if isinstance(self.resolution, Unset): + resolution = UNSET + else: + resolution = self.resolution + + method: None | str | Unset + if isinstance(self.method, Unset): + method = UNSET + elif isinstance(self.method, ResamplingMethod): + method = self.method.value + else: + method = self.method + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "target": target, + "grid_id": grid_id, + } + ) + if resolution is not UNSET: + field_dict["resolution"] = resolution + if method is not UNSET: + field_dict["method"] = method + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + target = cast(Literal["grid"], d.pop("target")) + if target != "grid": + raise ValueError(f"target must match const 'grid', got '{target}'") + + grid_id = d.pop("grid_id") + + def _parse_resolution(data: object) -> float | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(float | None | Unset, data) + + resolution = _parse_resolution(d.pop("resolution", UNSET)) + + def _parse_method(data: object) -> None | ResamplingMethod | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, str): + raise TypeError() + method_type_0 = ResamplingMethod(data) + + return method_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(None | ResamplingMethod | Unset, data) + + method = _parse_method(d.pop("method", UNSET)) + + grid_alignment_grid_target = cls( + target=target, + grid_id=grid_id, + resolution=resolution, + method=method, + ) + + grid_alignment_grid_target.additional_properties = d + return grid_alignment_grid_target + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/grid_alignment_native_target.py b/fastfuels_sdk/v2/client_library/models/grid_alignment_native_target.py new file mode 100644 index 0000000..d9f820c --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/grid_alignment_native_target.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + Any, + Literal, + TypeVar, + cast, +) + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.resampling_method import ResamplingMethod +from ..types import UNSET, Unset + +T = TypeVar("T", bound="GridAlignmentNativeTarget") + + +@_attrs_define +class GridAlignmentNativeTarget: + """Preserve the source raster's pixel anchor. + + `resolution=None` is exactly today's behavior — no anchor or resolution + change beyond the standard ROI-CRS reprojection. + + Attributes: + target (Literal['native']): + resolution (float | None | Unset): + method (None | ResamplingMethod | Unset): + """ + + target: Literal["native"] + resolution: float | None | Unset = UNSET + method: None | ResamplingMethod | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + target = self.target + + resolution: float | None | Unset + if isinstance(self.resolution, Unset): + resolution = UNSET + else: + resolution = self.resolution + + method: None | str | Unset + if isinstance(self.method, Unset): + method = UNSET + elif isinstance(self.method, ResamplingMethod): + method = self.method.value + else: + method = self.method + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "target": target, + } + ) + if resolution is not UNSET: + field_dict["resolution"] = resolution + if method is not UNSET: + field_dict["method"] = method + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + target = cast(Literal["native"], d.pop("target")) + if target != "native": + raise ValueError(f"target must match const 'native', got '{target}'") + + def _parse_resolution(data: object) -> float | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(float | None | Unset, data) + + resolution = _parse_resolution(d.pop("resolution", UNSET)) + + def _parse_method(data: object) -> None | ResamplingMethod | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, str): + raise TypeError() + method_type_0 = ResamplingMethod(data) + + return method_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(None | ResamplingMethod | Unset, data) + + method = _parse_method(d.pop("method", UNSET)) + + grid_alignment_native_target = cls( + target=target, + resolution=resolution, + method=method, + ) + + grid_alignment_native_target.additional_properties = d + return grid_alignment_native_target + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/grid_data_array_format.py b/fastfuels_sdk/v2/client_library/models/grid_data_array_format.py new file mode 100644 index 0000000..517d421 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/grid_data_array_format.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class GridDataArrayFormat(str, Enum): + DENSE = "dense" + SPARSE = "sparse" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/grid_data_chunk_metadata.py b/fastfuels_sdk/v2/client_library/models/grid_data_chunk_metadata.py new file mode 100644 index 0000000..54b9663 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/grid_data_chunk_metadata.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="GridDataChunkMetadata") + + +@_attrs_define +class GridDataChunkMetadata: + """ + Attributes: + index (int): + shape (list[int]): + offset (list[int]): + transform (list[float]): + z_origin (float | None | Unset): + z_resolution (float | None | Unset): + """ + + index: int + shape: list[int] + offset: list[int] + transform: list[float] + z_origin: float | None | Unset = UNSET + z_resolution: float | None | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + index = self.index + + shape: list[int] + if isinstance(self.shape, list): + shape = [] + for shape_type_0_item_data in self.shape: + shape_type_0_item: int + shape_type_0_item = shape_type_0_item_data + shape.append(shape_type_0_item) + + offset: list[int] + if isinstance(self.offset, list): + offset = [] + for offset_type_0_item_data in self.offset: + offset_type_0_item: int + offset_type_0_item = offset_type_0_item_data + offset.append(offset_type_0_item) + + transform = [] + for transform_item_data in self.transform: + transform_item: float + transform_item = transform_item_data + transform.append(transform_item) + + z_origin: float | None | Unset + if isinstance(self.z_origin, Unset): + z_origin = UNSET + else: + z_origin = self.z_origin + + z_resolution: float | None | Unset + if isinstance(self.z_resolution, Unset): + z_resolution = UNSET + else: + z_resolution = self.z_resolution + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "index": index, + "shape": shape, + "offset": offset, + "transform": transform, + } + ) + if z_origin is not UNSET: + field_dict["z_origin"] = z_origin + if z_resolution is not UNSET: + field_dict["z_resolution"] = z_resolution + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + index = d.pop("index") + + def _parse_shape(data: object) -> list[int]: + if not isinstance(data, list): + raise TypeError() + shape_type_0 = [] + _shape_type_0 = data + for shape_type_0_item_data in _shape_type_0: + + def _parse_shape_type_0_item(data: object) -> int: + return cast(int, data) + + shape_type_0_item = _parse_shape_type_0_item(shape_type_0_item_data) + + shape_type_0.append(shape_type_0_item) + + return shape_type_0 + + shape = _parse_shape(d.pop("shape")) + + def _parse_offset(data: object) -> list[int]: + if not isinstance(data, list): + raise TypeError() + offset_type_0 = [] + _offset_type_0 = data + for offset_type_0_item_data in _offset_type_0: + + def _parse_offset_type_0_item(data: object) -> int: + return cast(int, data) + + offset_type_0_item = _parse_offset_type_0_item(offset_type_0_item_data) + + offset_type_0.append(offset_type_0_item) + + return offset_type_0 + + offset = _parse_offset(d.pop("offset")) + + transform = [] + _transform = d.pop("transform") + for transform_item_data in _transform: + + def _parse_transform_item(data: object) -> float: + return cast(float, data) + + transform_item = _parse_transform_item(transform_item_data) + + transform.append(transform_item) + + def _parse_z_origin(data: object) -> float | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(float | None | Unset, data) + + z_origin = _parse_z_origin(d.pop("z_origin", UNSET)) + + def _parse_z_resolution(data: object) -> float | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(float | None | Unset, data) + + z_resolution = _parse_z_resolution(d.pop("z_resolution", UNSET)) + + grid_data_chunk_metadata = cls( + index=index, + shape=shape, + offset=offset, + transform=transform, + z_origin=z_origin, + z_resolution=z_resolution, + ) + + grid_data_chunk_metadata.additional_properties = d + return grid_data_chunk_metadata + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/grid_data_order.py b/fastfuels_sdk/v2/client_library/models/grid_data_order.py new file mode 100644 index 0000000..15eaee3 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/grid_data_order.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class GridDataOrder(str, Enum): + C = "C" + F = "F" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/grid_data_response.py b/fastfuels_sdk/v2/client_library/models/grid_data_response.py new file mode 100644 index 0000000..9c2d56a --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/grid_data_response.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.grid_data_response_order import GridDataResponseOrder + +if TYPE_CHECKING: + from ..models.dense_grid_data import DenseGridData + from ..models.grid_data_chunk_metadata import GridDataChunkMetadata + from ..models.sparse_grid_data import SparseGridData + + +T = TypeVar("T", bound="GridDataResponse") + + +@_attrs_define +class GridDataResponse: + """ + Attributes: + shape (list[int]): + order (GridDataResponseOrder): + metadata (GridDataChunkMetadata): + data (DenseGridData | SparseGridData): + """ + + shape: list[int] + order: GridDataResponseOrder + metadata: GridDataChunkMetadata + data: DenseGridData | SparseGridData + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.dense_grid_data import DenseGridData + + shape = self.shape + + order = self.order.value + + metadata = self.metadata.to_dict() + + data: dict[str, Any] + if isinstance(self.data, DenseGridData): + data = self.data.to_dict() + else: + data = self.data.to_dict() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "shape": shape, + "order": order, + "metadata": metadata, + "data": data, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.dense_grid_data import DenseGridData + from ..models.grid_data_chunk_metadata import GridDataChunkMetadata + from ..models.sparse_grid_data import SparseGridData + + d = dict(src_dict) + shape = cast(list[int], d.pop("shape")) + + order = GridDataResponseOrder(d.pop("order")) + + metadata = GridDataChunkMetadata.from_dict(d.pop("metadata")) + + def _parse_data(data: object) -> DenseGridData | SparseGridData: + try: + if not isinstance(data, dict): + raise TypeError() + data_type_0 = DenseGridData.from_dict(data) + + return data_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + if not isinstance(data, dict): + raise TypeError() + data_type_1 = SparseGridData.from_dict(data) + + return data_type_1 + + data = _parse_data(d.pop("data")) + + grid_data_response = cls( + shape=shape, + order=order, + metadata=metadata, + data=data, + ) + + grid_data_response.additional_properties = d + return grid_data_response + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/grid_data_response_order.py b/fastfuels_sdk/v2/client_library/models/grid_data_response_order.py new file mode 100644 index 0000000..1c9e0c3 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/grid_data_response_order.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class GridDataResponseOrder(str, Enum): + C = "C" + F = "F" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/grid_export_format.py b/fastfuels_sdk/v2/client_library/models/grid_export_format.py new file mode 100644 index 0000000..065b15d --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/grid_export_format.py @@ -0,0 +1,10 @@ +from enum import Enum + + +class GridExportFormat(str, Enum): + GEOTIFF = "geotiff" + NETCDF = "netcdf" + ZARR = "zarr" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/grid_feature_spatial_condition.py b/fastfuels_sdk/v2/client_library/models/grid_feature_spatial_condition.py new file mode 100644 index 0000000..c30a96a --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/grid_feature_spatial_condition.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + Any, + Literal, + TypeVar, + cast, +) + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.grid_spatial_target import GridSpatialTarget +from ..models.spatial_operator import SpatialOperator +from ..types import UNSET, Unset + +T = TypeVar("T", bound="GridFeatureSpatialCondition") + + +@_attrs_define +class GridFeatureSpatialCondition: + """Spatial condition that tests cells against a persisted Feature resource. + + The referenced Feature must belong to the same domain as the grid being + modified. The processing service loads the feature's geometry, reprojects + it into the domain CRS, optionally buffers it, and then evaluates the + spatial operator against grid cells. + + Attributes: + source (Literal['feature']): Discriminator selecting this variant. Must be the literal string `"feature"`. Use + `"geometry"` instead to supply inline GeoJSON. + operator (SpatialOperator): Spatial relationship operators for geometry-based conditions. + + - within: Select items whose target (centroid or cell) is inside the geometry + - outside: Select items whose target is outside the geometry (inverse of within) + - intersects: Select items whose target overlaps with the geometry + feature_id (str): ID of a Feature resource (road, water, or layerset) hosted in the same domain as the grid. + Cross-domain references are rejected; the feature must be in `completed` status. + buffer_m (float | None | Unset): Optional buffer distance in meters applied to the feature geometry (in the + domain's projected CRS) before testing. With `target="centroid"` (the default), a buffer is typically needed for + linestring features such as roads since a bare linestring rarely passes through a cell centroid; `target="cell"` + catches every cell the line crosses without a buffer. + target (GridSpatialTarget | Unset): Specifies which part of a grid cell is tested against the geometry. + + - centroid: Test only the cell's centroid point against the geometry + - cell: Test the entire cell bounds against the geometry + """ + + source: Literal["feature"] + operator: SpatialOperator + feature_id: str + buffer_m: float | None | Unset = UNSET + target: GridSpatialTarget | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + source = self.source + + operator = self.operator.value + + feature_id = self.feature_id + + buffer_m: float | None | Unset + if isinstance(self.buffer_m, Unset): + buffer_m = UNSET + else: + buffer_m = self.buffer_m + + target: str | Unset = UNSET + if not isinstance(self.target, Unset): + target = self.target.value + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "source": source, + "operator": operator, + "feature_id": feature_id, + } + ) + if buffer_m is not UNSET: + field_dict["buffer_m"] = buffer_m + if target is not UNSET: + field_dict["target"] = target + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + source = cast(Literal["feature"], d.pop("source")) + if source != "feature": + raise ValueError(f"source must match const 'feature', got '{source}'") + + operator = SpatialOperator(d.pop("operator")) + + feature_id = d.pop("feature_id") + + def _parse_buffer_m(data: object) -> float | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(float | None | Unset, data) + + buffer_m = _parse_buffer_m(d.pop("buffer_m", UNSET)) + + _target = d.pop("target", UNSET) + target: GridSpatialTarget | Unset + if isinstance(_target, Unset): + target = UNSET + else: + target = GridSpatialTarget(_target) + + grid_feature_spatial_condition = cls( + source=source, + operator=operator, + feature_id=feature_id, + buffer_m=buffer_m, + target=target, + ) + + grid_feature_spatial_condition.additional_properties = d + return grid_feature_spatial_condition + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/grid_geometry_spatial_condition.py b/fastfuels_sdk/v2/client_library/models/grid_geometry_spatial_condition.py new file mode 100644 index 0000000..e628c8f --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/grid_geometry_spatial_condition.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + TYPE_CHECKING, + Any, + Literal, + TypeVar, + cast, +) + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.grid_spatial_target import GridSpatialTarget +from ..models.spatial_operator import SpatialOperator +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.grid_geometry_spatial_condition_crs_type_0 import ( + GridGeometrySpatialConditionCrsType0, + ) + from ..models.grid_geometry_spatial_condition_geometry import ( + GridGeometrySpatialConditionGeometry, + ) + + +T = TypeVar("T", bound="GridGeometrySpatialCondition") + + +@_attrs_define +class GridGeometrySpatialCondition: + """Spatial condition that tests cells against an inline GeoJSON geometry. + + Use this variant when the geometry is supplied directly in the request + (e.g. a hand-drawn polygon). For a persisted geometry hosted as a + Feature resource, use ``GridFeatureSpatialCondition`` instead. + + Attributes: + source (Literal['geometry']): Discriminator selecting this variant. Must be the literal string `"geometry"`. Use + `"feature"` instead to reference a persisted Feature resource by id. + operator (SpatialOperator): Spatial relationship operators for geometry-based conditions. + + - within: Select items whose target (centroid or cell) is inside the geometry + - outside: Select items whose target is outside the geometry (inverse of within) + - intersects: Select items whose target overlaps with the geometry + geometry (GridGeometrySpatialConditionGeometry): Inline GeoJSON geometry. Polygon and MultiPolygon are the + common shapes; LineString works in combination with `target="cell"` since centroid-mode rarely matches a bare + line. + crs (GridGeometrySpatialConditionCrsType0 | None | Unset): CRS of `geometry`, expressed as a GeoJSON CRS object + (`{"type": "name", "properties": {"name": "EPSG:..."}}`). Defaults to the domain CRS when null. + buffer_m (float | None | Unset): Optional buffer distance in meters applied to the geometry (in the domain's + projected CRS) before testing. Use a non-zero buffer to widen the masked region beyond the literal geometry + (e.g. shoreline tolerance around a water polygon). + target (GridSpatialTarget | Unset): Specifies which part of a grid cell is tested against the geometry. + + - centroid: Test only the cell's centroid point against the geometry + - cell: Test the entire cell bounds against the geometry + """ + + source: Literal["geometry"] + operator: SpatialOperator + geometry: GridGeometrySpatialConditionGeometry + crs: GridGeometrySpatialConditionCrsType0 | None | Unset = UNSET + buffer_m: float | None | Unset = UNSET + target: GridSpatialTarget | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.grid_geometry_spatial_condition_crs_type_0 import ( + GridGeometrySpatialConditionCrsType0, + ) + + source = self.source + + operator = self.operator.value + + geometry = self.geometry.to_dict() + + crs: dict[str, Any] | None | Unset + if isinstance(self.crs, Unset): + crs = UNSET + elif isinstance(self.crs, GridGeometrySpatialConditionCrsType0): + crs = self.crs.to_dict() + else: + crs = self.crs + + buffer_m: float | None | Unset + if isinstance(self.buffer_m, Unset): + buffer_m = UNSET + else: + buffer_m = self.buffer_m + + target: str | Unset = UNSET + if not isinstance(self.target, Unset): + target = self.target.value + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "source": source, + "operator": operator, + "geometry": geometry, + } + ) + if crs is not UNSET: + field_dict["crs"] = crs + if buffer_m is not UNSET: + field_dict["buffer_m"] = buffer_m + if target is not UNSET: + field_dict["target"] = target + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.grid_geometry_spatial_condition_crs_type_0 import ( + GridGeometrySpatialConditionCrsType0, + ) + from ..models.grid_geometry_spatial_condition_geometry import ( + GridGeometrySpatialConditionGeometry, + ) + + d = dict(src_dict) + source = cast(Literal["geometry"], d.pop("source")) + if source != "geometry": + raise ValueError(f"source must match const 'geometry', got '{source}'") + + operator = SpatialOperator(d.pop("operator")) + + geometry = GridGeometrySpatialConditionGeometry.from_dict(d.pop("geometry")) + + def _parse_crs( + data: object, + ) -> GridGeometrySpatialConditionCrsType0 | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + crs_type_0 = GridGeometrySpatialConditionCrsType0.from_dict(data) + + return crs_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(GridGeometrySpatialConditionCrsType0 | None | Unset, data) + + crs = _parse_crs(d.pop("crs", UNSET)) + + def _parse_buffer_m(data: object) -> float | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(float | None | Unset, data) + + buffer_m = _parse_buffer_m(d.pop("buffer_m", UNSET)) + + _target = d.pop("target", UNSET) + target: GridSpatialTarget | Unset + if isinstance(_target, Unset): + target = UNSET + else: + target = GridSpatialTarget(_target) + + grid_geometry_spatial_condition = cls( + source=source, + operator=operator, + geometry=geometry, + crs=crs, + buffer_m=buffer_m, + target=target, + ) + + grid_geometry_spatial_condition.additional_properties = d + return grid_geometry_spatial_condition + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/grid_geometry_spatial_condition_crs_type_0.py b/fastfuels_sdk/v2/client_library/models/grid_geometry_spatial_condition_crs_type_0.py new file mode 100644 index 0000000..1ccda3d --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/grid_geometry_spatial_condition_crs_type_0.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="GridGeometrySpatialConditionCrsType0") + + +@_attrs_define +class GridGeometrySpatialConditionCrsType0: + """ """ + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + grid_geometry_spatial_condition_crs_type_0 = cls() + + grid_geometry_spatial_condition_crs_type_0.additional_properties = d + return grid_geometry_spatial_condition_crs_type_0 + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/grid_geometry_spatial_condition_geometry.py b/fastfuels_sdk/v2/client_library/models/grid_geometry_spatial_condition_geometry.py new file mode 100644 index 0000000..52fa73d --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/grid_geometry_spatial_condition_geometry.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="GridGeometrySpatialConditionGeometry") + + +@_attrs_define +class GridGeometrySpatialConditionGeometry: + """Inline GeoJSON geometry. Polygon and MultiPolygon are the common shapes; LineString works in combination with + `target="cell"` since centroid-mode rarely matches a bare line. + + """ + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + grid_geometry_spatial_condition_geometry = cls() + + grid_geometry_spatial_condition_geometry.additional_properties = d + return grid_geometry_spatial_condition_geometry + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/grid_modification.py b/fastfuels_sdk/v2/client_library/models/grid_modification.py new file mode 100644 index 0000000..451fe13 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/grid_modification.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.grid_feature_spatial_condition import GridFeatureSpatialCondition + from ..models.grid_geometry_spatial_condition import GridGeometrySpatialCondition + from ..models.grid_modification_action import GridModificationAction + from ..models.grid_modification_condition import GridModificationCondition + + +T = TypeVar("T", bound="GridModification") + + +@_attrs_define +class GridModification: + """A grid modification rule with conditions and actions. + + All conditions are ANDed together. When all match, actions are applied. + Conditions can be band-based (checking values) or spatial (checking location). + + Attributes: + conditions (list[GridFeatureSpatialCondition | GridGeometrySpatialCondition | GridModificationCondition]): + Conditions that must all be true + actions (list[GridModificationAction]): Actions to apply when conditions match + """ + + conditions: list[ + GridFeatureSpatialCondition + | GridGeometrySpatialCondition + | GridModificationCondition + ] + actions: list[GridModificationAction] + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.grid_geometry_spatial_condition import ( + GridGeometrySpatialCondition, + ) + from ..models.grid_modification_condition import GridModificationCondition + + conditions = [] + for conditions_item_data in self.conditions: + conditions_item: dict[str, Any] + if isinstance(conditions_item_data, GridModificationCondition): + conditions_item = conditions_item_data.to_dict() + elif isinstance(conditions_item_data, GridGeometrySpatialCondition): + conditions_item = conditions_item_data.to_dict() + else: + conditions_item = conditions_item_data.to_dict() + + conditions.append(conditions_item) + + actions = [] + for actions_item_data in self.actions: + actions_item = actions_item_data.to_dict() + actions.append(actions_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "conditions": conditions, + "actions": actions, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.grid_feature_spatial_condition import GridFeatureSpatialCondition + from ..models.grid_geometry_spatial_condition import ( + GridGeometrySpatialCondition, + ) + from ..models.grid_modification_action import GridModificationAction + from ..models.grid_modification_condition import GridModificationCondition + + d = dict(src_dict) + conditions = [] + _conditions = d.pop("conditions") + for conditions_item_data in _conditions: + + def _parse_conditions_item( + data: object, + ) -> ( + GridFeatureSpatialCondition + | GridGeometrySpatialCondition + | GridModificationCondition + ): + try: + if not isinstance(data, dict): + raise TypeError() + conditions_item_type_0 = GridModificationCondition.from_dict(data) + + return conditions_item_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + conditions_item_type_1_type_0 = ( + GridGeometrySpatialCondition.from_dict(data) + ) + + return conditions_item_type_1_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + if not isinstance(data, dict): + raise TypeError() + conditions_item_type_1_type_1 = GridFeatureSpatialCondition.from_dict( + data + ) + + return conditions_item_type_1_type_1 + + conditions_item = _parse_conditions_item(conditions_item_data) + + conditions.append(conditions_item) + + actions = [] + _actions = d.pop("actions") + for actions_item_data in _actions: + actions_item = GridModificationAction.from_dict(actions_item_data) + + actions.append(actions_item) + + grid_modification = cls( + conditions=conditions, + actions=actions, + ) + + grid_modification.additional_properties = d + return grid_modification + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/grid_modification_action.py b/fastfuels_sdk/v2/client_library/models/grid_modification_action.py new file mode 100644 index 0000000..da566f2 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/grid_modification_action.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.modifier import Modifier + +T = TypeVar("T", bound="GridModificationAction") + + +@_attrs_define +class GridModificationAction: + """Action to perform when grid modification conditions are met. + + Uses dot-notation band keys (e.g., "fuel_load.1hr", "fbfm"). + + Attributes: + band (str): The band to modify (dot-notation key) + modifier (Modifier): Modifiers for modification actions. + value (float | int | str): The value to use with the modifier + """ + + band: str + modifier: Modifier + value: float | int | str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + band = self.band + + modifier = self.modifier.value + + value: float | int | str + value = self.value + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "band": band, + "modifier": modifier, + "value": value, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + band = d.pop("band") + + modifier = Modifier(d.pop("modifier")) + + def _parse_value(data: object) -> float | int | str: + return cast(float | int | str, data) + + value = _parse_value(d.pop("value")) + + grid_modification_action = cls( + band=band, + modifier=modifier, + value=value, + ) + + grid_modification_action.additional_properties = d + return grid_modification_action + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/grid_modification_condition.py b/fastfuels_sdk/v2/client_library/models/grid_modification_condition.py new file mode 100644 index 0000000..f1658d1 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/grid_modification_condition.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.operator import Operator + +T = TypeVar("T", bound="GridModificationCondition") + + +@_attrs_define +class GridModificationCondition: + """Attribute-based condition for grid modifications. + + Uses dot-notation band keys (e.g., "fuel_load.1hr", "fbfm"). + + Attributes: + band (str): The band to check (dot-notation key) + operator (Operator): Comparison operators for attribute-based conditions. + value (float | int | list[float | int | str] | str): The value(s) to compare against + """ + + band: str + operator: Operator + value: float | int | list[float | int | str] | str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + band = self.band + + operator = self.operator.value + + value: float | int | list[float | int | str] | str + if isinstance(self.value, list): + value = [] + for value_type_3_item_data in self.value: + value_type_3_item: float | int | str + value_type_3_item = value_type_3_item_data + value.append(value_type_3_item) + + else: + value = self.value + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "band": band, + "operator": operator, + "value": value, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + band = d.pop("band") + + operator = Operator(d.pop("operator")) + + def _parse_value(data: object) -> float | int | list[float | int | str] | str: + try: + if not isinstance(data, list): + raise TypeError() + value_type_3 = [] + _value_type_3 = data + for value_type_3_item_data in _value_type_3: + + def _parse_value_type_3_item(data: object) -> float | int | str: + return cast(float | int | str, data) + + value_type_3_item = _parse_value_type_3_item(value_type_3_item_data) + + value_type_3.append(value_type_3_item) + + return value_type_3 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(float | int | list[float | int | str] | str, data) + + value = _parse_value(d.pop("value")) + + grid_modification_condition = cls( + band=band, + operator=operator, + value=value, + ) + + grid_modification_condition.additional_properties = d + return grid_modification_condition + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/grid_sort_field.py b/fastfuels_sdk/v2/client_library/models/grid_sort_field.py new file mode 100644 index 0000000..7a0f04e --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/grid_sort_field.py @@ -0,0 +1,10 @@ +from enum import Enum + + +class GridSortField(str, Enum): + CREATED_ON = "created_on" + MODIFIED_ON = "modified_on" + NAME = "name" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/grid_source.py b/fastfuels_sdk/v2/client_library/models/grid_source.py new file mode 100644 index 0000000..f3ddee6 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/grid_source.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="GridSource") + + +@_attrs_define +class GridSource: + """ """ + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + grid_source = cls() + + grid_source.additional_properties = d + return grid_source + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/grid_spatial_target.py b/fastfuels_sdk/v2/client_library/models/grid_spatial_target.py new file mode 100644 index 0000000..469d2e7 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/grid_spatial_target.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class GridSpatialTarget(str, Enum): + CELL = "cell" + CENTROID = "centroid" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/grid_upload_created_response.py b/fastfuels_sdk/v2/client_library/models/grid_upload_created_response.py new file mode 100644 index 0000000..584d634 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/grid_upload_created_response.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.grid import Grid + from ..models.grid_upload_spec import GridUploadSpec + + +T = TypeVar("T", bound="GridUploadCreatedResponse") + + +@_attrs_define +class GridUploadCreatedResponse: + """ + Attributes: + grid (Grid): The Grid resource. + + When status is "pending" or "running", georeference will be null. + The backend populates georeference after successfully fetching data, + at which point status transitions to "completed". + + When status is "failed", the error field contains details about what + went wrong and suggestions for the user. The full traceback is stored + in Firestore but not exposed in API responses. + upload (GridUploadSpec): + """ + + grid: Grid + upload: GridUploadSpec + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + grid = self.grid.to_dict() + + upload = self.upload.to_dict() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "grid": grid, + "upload": upload, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.grid import Grid + from ..models.grid_upload_spec import GridUploadSpec + + d = dict(src_dict) + grid = Grid.from_dict(d.pop("grid")) + + upload = GridUploadSpec.from_dict(d.pop("upload")) + + grid_upload_created_response = cls( + grid=grid, + upload=upload, + ) + + grid_upload_created_response.additional_properties = d + return grid_upload_created_response + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/grid_upload_spec.py b/fastfuels_sdk/v2/client_library/models/grid_upload_spec.py new file mode 100644 index 0000000..0a748dd --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/grid_upload_spec.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import datetime +from collections.abc import Mapping +from typing import ( + Any, + Literal, + TypeVar, + cast, +) + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="GridUploadSpec") + + +@_attrs_define +class GridUploadSpec: + """ + Attributes: + url (str): + content_type (str): + expires_at (datetime.datetime): + max_size_bytes (int): + method (Literal['PUT'] | Unset): Default: 'PUT'. + """ + + url: str + content_type: str + expires_at: datetime.datetime + max_size_bytes: int + method: Literal["PUT"] | Unset = "PUT" + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + url = self.url + + content_type = self.content_type + + expires_at = self.expires_at.isoformat() + + max_size_bytes = self.max_size_bytes + + method = self.method + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "url": url, + "content_type": content_type, + "expires_at": expires_at, + "max_size_bytes": max_size_bytes, + } + ) + if method is not UNSET: + field_dict["method"] = method + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + url = d.pop("url") + + content_type = d.pop("content_type") + + expires_at = datetime.datetime.fromisoformat(d.pop("expires_at")) + + max_size_bytes = d.pop("max_size_bytes") + + method = cast(Literal["PUT"] | Unset, d.pop("method", UNSET)) + if method != "PUT" and not isinstance(method, Unset): + raise ValueError(f"method must match const 'PUT', got '{method}'") + + grid_upload_spec = cls( + url=url, + content_type=content_type, + expires_at=expires_at, + max_size_bytes=max_size_bytes, + method=method, + ) + + grid_upload_spec.additional_properties = d + return grid_upload_spec + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/http_validation_error.py b/fastfuels_sdk/v2/client_library/models/http_validation_error.py new file mode 100644 index 0000000..195e5a7 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/http_validation_error.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.validation_error import ValidationError + + +T = TypeVar("T", bound="HTTPValidationError") + + +@_attrs_define +class HTTPValidationError: + """ + Attributes: + detail (list[ValidationError] | Unset): + """ + + detail: list[ValidationError] | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + detail: list[dict[str, Any]] | Unset = UNSET + if not isinstance(self.detail, Unset): + detail = [] + for detail_item_data in self.detail: + detail_item = detail_item_data.to_dict() + detail.append(detail_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if detail is not UNSET: + field_dict["detail"] = detail + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.validation_error import ValidationError + + d = dict(src_dict) + _detail = d.pop("detail", UNSET) + detail: list[ValidationError] | Unset = UNSET + if _detail is not UNSET: + detail = [] + for detail_item_data in _detail: + detail_item = ValidationError.from_dict(detail_item_data) + + detail.append(detail_item) + + http_validation_error = cls( + detail=detail, + ) + + http_validation_error.additional_properties = d + return http_validation_error + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/inventory.py b/fastfuels_sdk/v2/client_library/models/inventory.py new file mode 100644 index 0000000..399356a --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/inventory.py @@ -0,0 +1,424 @@ +from __future__ import annotations + +import datetime +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.inventory_type import InventoryType +from ..models.job_status import JobStatus +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.column import Column + from ..models.inventory_basal_area_treatment import InventoryBasalAreaTreatment + from ..models.inventory_diameter_treatment import InventoryDiameterTreatment + from ..models.inventory_georeference import InventoryGeoreference + from ..models.inventory_modification import InventoryModification + from ..models.inventory_source import InventorySource + from ..models.job_error import JobError + from ..models.job_progress import JobProgress + + +T = TypeVar("T", bound="Inventory") + + +@_attrs_define +class Inventory: + """The Inventory resource. + + When status is "pending" or "running", georeference will be null. + The backend populates it after successfully processing data, + at which point status transitions to "completed". + + Attributes: + id (str): + domain_id (str): + type_ (InventoryType): Type of entities in the inventory. + status (JobStatus): Status of an async job. + source (InventorySource): + name (str | Unset): Default: ''. + description (str | Unset): Default: ''. + progress (JobProgress | None | Unset): Progress info when status is 'running'. Null otherwise. + created_on (datetime.datetime | None | Unset): + modified_on (datetime.datetime | None | Unset): + checksum (None | str | Unset): Version marker for this inventory's content. It changes each time the inventory + is rebuilt and is unaffected by metadata-only edits (name, description, tags). A resource derived from this + inventory stores the checksum it was built from; comparing that stored value against this field reveals whether + this inventory has changed since. May be null for inventories created before checksums were introduced. + modifications (list[InventoryModification] | Unset): + treatments (list[InventoryBasalAreaTreatment | InventoryDiameterTreatment] | Unset): + columns (list[Column] | Unset): + georeference (InventoryGeoreference | None | Unset): Spatial reference. Null until backend completes processing. + error (JobError | None | Unset): Error details if status is 'failed'. + tags (list[str] | Unset): + """ + + id: str + domain_id: str + type_: InventoryType + status: JobStatus + source: InventorySource + name: str | Unset = "" + description: str | Unset = "" + progress: JobProgress | None | Unset = UNSET + created_on: datetime.datetime | None | Unset = UNSET + modified_on: datetime.datetime | None | Unset = UNSET + checksum: None | str | Unset = UNSET + modifications: list[InventoryModification] | Unset = UNSET + treatments: ( + list[InventoryBasalAreaTreatment | InventoryDiameterTreatment] | Unset + ) = UNSET + columns: list[Column] | Unset = UNSET + georeference: InventoryGeoreference | None | Unset = UNSET + error: JobError | None | Unset = UNSET + tags: list[str] | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.inventory_diameter_treatment import InventoryDiameterTreatment + from ..models.inventory_georeference import InventoryGeoreference + from ..models.job_error import JobError + from ..models.job_progress import JobProgress + + id = self.id + + domain_id = self.domain_id + + type_ = self.type_.value + + status = self.status.value + + source = self.source.to_dict() + + name = self.name + + description = self.description + + progress: dict[str, Any] | None | Unset + if isinstance(self.progress, Unset): + progress = UNSET + elif isinstance(self.progress, JobProgress): + progress = self.progress.to_dict() + else: + progress = self.progress + + created_on: None | str | Unset + if isinstance(self.created_on, Unset): + created_on = UNSET + elif isinstance(self.created_on, datetime.datetime): + created_on = self.created_on.isoformat() + else: + created_on = self.created_on + + modified_on: None | str | Unset + if isinstance(self.modified_on, Unset): + modified_on = UNSET + elif isinstance(self.modified_on, datetime.datetime): + modified_on = self.modified_on.isoformat() + else: + modified_on = self.modified_on + + checksum: None | str | Unset + if isinstance(self.checksum, Unset): + checksum = UNSET + else: + checksum = self.checksum + + modifications: list[dict[str, Any]] | Unset = UNSET + if not isinstance(self.modifications, Unset): + modifications = [] + for modifications_item_data in self.modifications: + modifications_item = modifications_item_data.to_dict() + modifications.append(modifications_item) + + treatments: list[dict[str, Any]] | Unset = UNSET + if not isinstance(self.treatments, Unset): + treatments = [] + for treatments_item_data in self.treatments: + treatments_item: dict[str, Any] + if isinstance(treatments_item_data, InventoryDiameterTreatment): + treatments_item = treatments_item_data.to_dict() + else: + treatments_item = treatments_item_data.to_dict() + + treatments.append(treatments_item) + + columns: list[dict[str, Any]] | Unset = UNSET + if not isinstance(self.columns, Unset): + columns = [] + for columns_item_data in self.columns: + columns_item = columns_item_data.to_dict() + columns.append(columns_item) + + georeference: dict[str, Any] | None | Unset + if isinstance(self.georeference, Unset): + georeference = UNSET + elif isinstance(self.georeference, InventoryGeoreference): + georeference = self.georeference.to_dict() + else: + georeference = self.georeference + + error: dict[str, Any] | None | Unset + if isinstance(self.error, Unset): + error = UNSET + elif isinstance(self.error, JobError): + error = self.error.to_dict() + else: + error = self.error + + tags: list[str] | Unset = UNSET + if not isinstance(self.tags, Unset): + tags = self.tags + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "id": id, + "domain_id": domain_id, + "type": type_, + "status": status, + "source": source, + } + ) + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if progress is not UNSET: + field_dict["progress"] = progress + if created_on is not UNSET: + field_dict["created_on"] = created_on + if modified_on is not UNSET: + field_dict["modified_on"] = modified_on + if checksum is not UNSET: + field_dict["checksum"] = checksum + if modifications is not UNSET: + field_dict["modifications"] = modifications + if treatments is not UNSET: + field_dict["treatments"] = treatments + if columns is not UNSET: + field_dict["columns"] = columns + if georeference is not UNSET: + field_dict["georeference"] = georeference + if error is not UNSET: + field_dict["error"] = error + if tags is not UNSET: + field_dict["tags"] = tags + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.column import Column + from ..models.inventory_basal_area_treatment import InventoryBasalAreaTreatment + from ..models.inventory_diameter_treatment import InventoryDiameterTreatment + from ..models.inventory_georeference import InventoryGeoreference + from ..models.inventory_modification import InventoryModification + from ..models.inventory_source import InventorySource + from ..models.job_error import JobError + from ..models.job_progress import JobProgress + + d = dict(src_dict) + id = d.pop("id") + + domain_id = d.pop("domain_id") + + type_ = InventoryType(d.pop("type")) + + status = JobStatus(d.pop("status")) + + source = InventorySource.from_dict(d.pop("source")) + + name = d.pop("name", UNSET) + + description = d.pop("description", UNSET) + + def _parse_progress(data: object) -> JobProgress | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + progress_type_0 = JobProgress.from_dict(data) + + return progress_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(JobProgress | None | Unset, data) + + progress = _parse_progress(d.pop("progress", UNSET)) + + def _parse_created_on(data: object) -> datetime.datetime | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, str): + raise TypeError() + created_on_type_0 = datetime.datetime.fromisoformat(data) + + return created_on_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(datetime.datetime | None | Unset, data) + + created_on = _parse_created_on(d.pop("created_on", UNSET)) + + def _parse_modified_on(data: object) -> datetime.datetime | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, str): + raise TypeError() + modified_on_type_0 = datetime.datetime.fromisoformat(data) + + return modified_on_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(datetime.datetime | None | Unset, data) + + modified_on = _parse_modified_on(d.pop("modified_on", UNSET)) + + def _parse_checksum(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + checksum = _parse_checksum(d.pop("checksum", UNSET)) + + _modifications = d.pop("modifications", UNSET) + modifications: list[InventoryModification] | Unset = UNSET + if _modifications is not UNSET: + modifications = [] + for modifications_item_data in _modifications: + modifications_item = InventoryModification.from_dict( + modifications_item_data + ) + + modifications.append(modifications_item) + + _treatments = d.pop("treatments", UNSET) + treatments: ( + list[InventoryBasalAreaTreatment | InventoryDiameterTreatment] | Unset + ) = UNSET + if _treatments is not UNSET: + treatments = [] + for treatments_item_data in _treatments: + + def _parse_treatments_item( + data: object, + ) -> InventoryBasalAreaTreatment | InventoryDiameterTreatment: + try: + if not isinstance(data, dict): + raise TypeError() + treatments_item_type_0 = InventoryDiameterTreatment.from_dict( + data + ) + + return treatments_item_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + if not isinstance(data, dict): + raise TypeError() + treatments_item_type_1 = InventoryBasalAreaTreatment.from_dict(data) + + return treatments_item_type_1 + + treatments_item = _parse_treatments_item(treatments_item_data) + + treatments.append(treatments_item) + + _columns = d.pop("columns", UNSET) + columns: list[Column] | Unset = UNSET + if _columns is not UNSET: + columns = [] + for columns_item_data in _columns: + columns_item = Column.from_dict(columns_item_data) + + columns.append(columns_item) + + def _parse_georeference(data: object) -> InventoryGeoreference | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + georeference_type_0 = InventoryGeoreference.from_dict(data) + + return georeference_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(InventoryGeoreference | None | Unset, data) + + georeference = _parse_georeference(d.pop("georeference", UNSET)) + + def _parse_error(data: object) -> JobError | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + error_type_0 = JobError.from_dict(data) + + return error_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(JobError | None | Unset, data) + + error = _parse_error(d.pop("error", UNSET)) + + tags = cast(list[str], d.pop("tags", UNSET)) + + inventory = cls( + id=id, + domain_id=domain_id, + type_=type_, + status=status, + source=source, + name=name, + description=description, + progress=progress, + created_on=created_on, + modified_on=modified_on, + checksum=checksum, + modifications=modifications, + treatments=treatments, + columns=columns, + georeference=georeference, + error=error, + tags=tags, + ) + + inventory.additional_properties = d + return inventory + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/inventory_attribute.py b/fastfuels_sdk/v2/client_library/models/inventory_attribute.py new file mode 100644 index 0000000..1ca571d --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/inventory_attribute.py @@ -0,0 +1,11 @@ +from enum import Enum + + +class InventoryAttribute(str, Enum): + CROWN_RATIO = "crown_ratio" + DBH = "dbh" + FIA_SPECIES_CODE = "fia_species_code" + HEIGHT = "height" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/inventory_basal_area_treatment.py b/fastfuels_sdk/v2/client_library/models/inventory_basal_area_treatment.py new file mode 100644 index 0000000..17e7d09 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/inventory_basal_area_treatment.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + TYPE_CHECKING, + Any, + Literal, + TypeVar, + cast, +) + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.inventory_treatment_method import InventoryTreatmentMethod +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.inventory_feature_spatial_condition import ( + InventoryFeatureSpatialCondition, + ) + from ..models.inventory_geometry_spatial_condition import ( + InventoryGeometrySpatialCondition, + ) + + +T = TypeVar("T", bound="InventoryBasalAreaTreatment") + + +@_attrs_define +class InventoryBasalAreaTreatment: + """Thin to a residual basal area. + + ``from_below``/``from_above`` remove the smallest/largest trees first until + the target is reached; ``proportional`` removes across all diameter classes. + + Attributes: + method (InventoryTreatmentMethod): Tree-selection strategy for a silvicultural treatment. + + - from_below: low thinning — remove smaller/suppressed trees first + - from_above: crown thinning — remove larger/dominant trees first + - proportional: remove across all diameter classes proportionally + value (float): Target residual basal area, in m**2/ha unless `unit` is set. + unit (None | str | Unset): Optional unit for `value`. Must be canonical and dimensionally compatible with the + metric's native unit; converted before the treatment is applied. + conditions (list[InventoryFeatureSpatialCondition | InventoryGeometrySpatialCondition] | Unset): Spatial + conditions restricting the treatment to a region (within/outside/intersects a geometry or Feature). An empty + list applies the treatment to the entire inventory. + metric (Literal['basal_area'] | Unset): Default: 'basal_area'. + """ + + method: InventoryTreatmentMethod + value: float + unit: None | str | Unset = UNSET + conditions: ( + list[InventoryFeatureSpatialCondition | InventoryGeometrySpatialCondition] + | Unset + ) = UNSET + metric: Literal["basal_area"] | Unset = "basal_area" + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.inventory_geometry_spatial_condition import ( + InventoryGeometrySpatialCondition, + ) + + method = self.method.value + + value = self.value + + unit: None | str | Unset + if isinstance(self.unit, Unset): + unit = UNSET + else: + unit = self.unit + + conditions: list[dict[str, Any]] | Unset = UNSET + if not isinstance(self.conditions, Unset): + conditions = [] + for conditions_item_data in self.conditions: + conditions_item: dict[str, Any] + if isinstance(conditions_item_data, InventoryGeometrySpatialCondition): + conditions_item = conditions_item_data.to_dict() + else: + conditions_item = conditions_item_data.to_dict() + + conditions.append(conditions_item) + + metric = self.metric + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "method": method, + "value": value, + } + ) + if unit is not UNSET: + field_dict["unit"] = unit + if conditions is not UNSET: + field_dict["conditions"] = conditions + if metric is not UNSET: + field_dict["metric"] = metric + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.inventory_feature_spatial_condition import ( + InventoryFeatureSpatialCondition, + ) + from ..models.inventory_geometry_spatial_condition import ( + InventoryGeometrySpatialCondition, + ) + + d = dict(src_dict) + method = InventoryTreatmentMethod(d.pop("method")) + + value = d.pop("value") + + def _parse_unit(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + unit = _parse_unit(d.pop("unit", UNSET)) + + _conditions = d.pop("conditions", UNSET) + conditions: ( + list[InventoryFeatureSpatialCondition | InventoryGeometrySpatialCondition] + | Unset + ) = UNSET + if _conditions is not UNSET: + conditions = [] + for conditions_item_data in _conditions: + + def _parse_conditions_item( + data: object, + ) -> ( + InventoryFeatureSpatialCondition | InventoryGeometrySpatialCondition + ): + try: + if not isinstance(data, dict): + raise TypeError() + conditions_item_type_0 = ( + InventoryGeometrySpatialCondition.from_dict(data) + ) + + return conditions_item_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + if not isinstance(data, dict): + raise TypeError() + conditions_item_type_1 = InventoryFeatureSpatialCondition.from_dict( + data + ) + + return conditions_item_type_1 + + conditions_item = _parse_conditions_item(conditions_item_data) + + conditions.append(conditions_item) + + metric = cast(Literal["basal_area"] | Unset, d.pop("metric", UNSET)) + if metric != "basal_area" and not isinstance(metric, Unset): + raise ValueError(f"metric must match const 'basal_area', got '{metric}'") + + inventory_basal_area_treatment = cls( + method=method, + value=value, + unit=unit, + conditions=conditions, + metric=metric, + ) + + inventory_basal_area_treatment.additional_properties = d + return inventory_basal_area_treatment + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/inventory_biomass_column.py b/fastfuels_sdk/v2/client_library/models/inventory_biomass_column.py new file mode 100644 index 0000000..24e9833 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/inventory_biomass_column.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define + +from ..models.biomass_unit import BiomassUnit +from ..types import UNSET, Unset + +T = TypeVar("T", bound="InventoryBiomassColumn") + + +@_attrs_define +class InventoryBiomassColumn: + """Inventory column containing per-tree biomass for one component. + + Attributes: + column (str): + unit (BiomassUnit | Unset): Accepted inventory biomass units. + """ + + column: str + unit: BiomassUnit | Unset = UNSET + + def to_dict(self) -> dict[str, Any]: + column = self.column + + unit: str | Unset = UNSET + if not isinstance(self.unit, Unset): + unit = self.unit.value + + field_dict: dict[str, Any] = {} + + field_dict.update( + { + "column": column, + } + ) + if unit is not UNSET: + field_dict["unit"] = unit + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + column = d.pop("column") + + _unit = d.pop("unit", UNSET) + unit: BiomassUnit | Unset + if isinstance(_unit, Unset): + unit = UNSET + else: + unit = BiomassUnit(_unit) + + inventory_biomass_column = cls( + column=column, + unit=unit, + ) + + return inventory_biomass_column diff --git a/fastfuels_sdk/v2/client_library/models/inventory_column_mapping.py b/fastfuels_sdk/v2/client_library/models/inventory_column_mapping.py new file mode 100644 index 0000000..6f8d247 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/inventory_column_mapping.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="InventoryColumnMapping") + + +@_attrs_define +class InventoryColumnMapping: + """Maps v2 column names to the corresponding column names in the uploaded file. + + Omit any entry whose column already uses the v2 name. For GeoJSON and + GeoPackage formats, x and y are extracted from geometry — their mapping + entries are ignored. + + Attributes: + x (None | str | Unset): + y (None | str | Unset): + height (None | str | Unset): + fia_species_code (None | str | Unset): + fia_status_code (None | str | Unset): + dbh (None | str | Unset): + crown_ratio (None | str | Unset): + """ + + x: None | str | Unset = UNSET + y: None | str | Unset = UNSET + height: None | str | Unset = UNSET + fia_species_code: None | str | Unset = UNSET + fia_status_code: None | str | Unset = UNSET + dbh: None | str | Unset = UNSET + crown_ratio: None | str | Unset = UNSET + + def to_dict(self) -> dict[str, Any]: + x: None | str | Unset + if isinstance(self.x, Unset): + x = UNSET + else: + x = self.x + + y: None | str | Unset + if isinstance(self.y, Unset): + y = UNSET + else: + y = self.y + + height: None | str | Unset + if isinstance(self.height, Unset): + height = UNSET + else: + height = self.height + + fia_species_code: None | str | Unset + if isinstance(self.fia_species_code, Unset): + fia_species_code = UNSET + else: + fia_species_code = self.fia_species_code + + fia_status_code: None | str | Unset + if isinstance(self.fia_status_code, Unset): + fia_status_code = UNSET + else: + fia_status_code = self.fia_status_code + + dbh: None | str | Unset + if isinstance(self.dbh, Unset): + dbh = UNSET + else: + dbh = self.dbh + + crown_ratio: None | str | Unset + if isinstance(self.crown_ratio, Unset): + crown_ratio = UNSET + else: + crown_ratio = self.crown_ratio + + field_dict: dict[str, Any] = {} + + field_dict.update({}) + if x is not UNSET: + field_dict["x"] = x + if y is not UNSET: + field_dict["y"] = y + if height is not UNSET: + field_dict["height"] = height + if fia_species_code is not UNSET: + field_dict["fia_species_code"] = fia_species_code + if fia_status_code is not UNSET: + field_dict["fia_status_code"] = fia_status_code + if dbh is not UNSET: + field_dict["dbh"] = dbh + if crown_ratio is not UNSET: + field_dict["crown_ratio"] = crown_ratio + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + + def _parse_x(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + x = _parse_x(d.pop("x", UNSET)) + + def _parse_y(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + y = _parse_y(d.pop("y", UNSET)) + + def _parse_height(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + height = _parse_height(d.pop("height", UNSET)) + + def _parse_fia_species_code(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + fia_species_code = _parse_fia_species_code(d.pop("fia_species_code", UNSET)) + + def _parse_fia_status_code(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + fia_status_code = _parse_fia_status_code(d.pop("fia_status_code", UNSET)) + + def _parse_dbh(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + dbh = _parse_dbh(d.pop("dbh", UNSET)) + + def _parse_crown_ratio(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + crown_ratio = _parse_crown_ratio(d.pop("crown_ratio", UNSET)) + + inventory_column_mapping = cls( + x=x, + y=y, + height=height, + fia_species_code=fia_species_code, + fia_status_code=fia_status_code, + dbh=dbh, + crown_ratio=crown_ratio, + ) + + return inventory_column_mapping diff --git a/fastfuels_sdk/v2/client_library/models/inventory_column_max_crown_radius_source.py b/fastfuels_sdk/v2/client_library/models/inventory_column_max_crown_radius_source.py new file mode 100644 index 0000000..20d1d11 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/inventory_column_max_crown_radius_source.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + Any, + Literal, + TypeVar, + cast, +) + +from attrs import define as _attrs_define + +from ..models.max_crown_radius_unit import MaxCrownRadiusUnit +from ..types import UNSET, Unset + +T = TypeVar("T", bound="InventoryColumnMaxCrownRadiusSource") + + +@_attrs_define +class InventoryColumnMaxCrownRadiusSource: + """Read per-tree max crown radius from an inventory column. + + The crown profile model still drives the crown shape — the supplied + radius rescales it so the maximum radius matches the per-tree value. + + Attributes: + column (str): + type_ (Literal['inventory_column'] | Unset): Default: 'inventory_column'. + unit (MaxCrownRadiusUnit | Unset): Accepted inventory max crown radius units. + """ + + column: str + type_: Literal["inventory_column"] | Unset = "inventory_column" + unit: MaxCrownRadiusUnit | Unset = UNSET + + def to_dict(self) -> dict[str, Any]: + column = self.column + + type_ = self.type_ + + unit: str | Unset = UNSET + if not isinstance(self.unit, Unset): + unit = self.unit.value + + field_dict: dict[str, Any] = {} + + field_dict.update( + { + "column": column, + } + ) + if type_ is not UNSET: + field_dict["type"] = type_ + if unit is not UNSET: + field_dict["unit"] = unit + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + column = d.pop("column") + + type_ = cast(Literal["inventory_column"] | Unset, d.pop("type", UNSET)) + if type_ != "inventory_column" and not isinstance(type_, Unset): + raise ValueError(f"type must match const 'inventory_column', got '{type_}'") + + _unit = d.pop("unit", UNSET) + unit: MaxCrownRadiusUnit | Unset + if isinstance(_unit, Unset): + unit = UNSET + else: + unit = MaxCrownRadiusUnit(_unit) + + inventory_column_max_crown_radius_source = cls( + column=column, + type_=type_, + unit=unit, + ) + + return inventory_column_max_crown_radius_source diff --git a/fastfuels_sdk/v2/client_library/models/inventory_columns_biomass_source.py b/fastfuels_sdk/v2/client_library/models/inventory_columns_biomass_source.py new file mode 100644 index 0000000..3ab9860 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/inventory_columns_biomass_source.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + TYPE_CHECKING, + Any, + Literal, + TypeVar, + cast, +) + +from attrs import define as _attrs_define + +from ..models.biomass_component import BiomassComponent +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.fine_biomass_config import FineBiomassConfig + from ..models.inventory_columns_biomass_source_columns import ( + InventoryColumnsBiomassSourceColumns, + ) + from ..models.inventory_columns_biomass_source_component_states import ( + InventoryColumnsBiomassSourceComponentStates, + ) + + +T = TypeVar("T", bound="InventoryColumnsBiomassSource") + + +@_attrs_define +class InventoryColumnsBiomassSource: + """Read per-tree component biomass from inventory columns. + + Attributes: + columns (InventoryColumnsBiomassSourceColumns): Per-component inventory columns. Values must be per-tree kg. + type_ (Literal['inventory_columns'] | Unset): Default: 'inventory_columns'. + components (list[BiomassComponent] | Unset): + component_states (InventoryColumnsBiomassSourceComponentStates | Unset): Per-component live/dead biomass + partition fractions. + fine (FineBiomassConfig | None | Unset): + """ + + columns: InventoryColumnsBiomassSourceColumns + type_: Literal["inventory_columns"] | Unset = "inventory_columns" + components: list[BiomassComponent] | Unset = UNSET + component_states: InventoryColumnsBiomassSourceComponentStates | Unset = UNSET + fine: FineBiomassConfig | None | Unset = UNSET + + def to_dict(self) -> dict[str, Any]: + from ..models.fine_biomass_config import FineBiomassConfig + + columns = self.columns.to_dict() + + type_ = self.type_ + + components: list[str] | Unset = UNSET + if not isinstance(self.components, Unset): + components = [] + for components_item_data in self.components: + components_item = components_item_data.value + components.append(components_item) + + component_states: dict[str, Any] | Unset = UNSET + if not isinstance(self.component_states, Unset): + component_states = self.component_states.to_dict() + + fine: dict[str, Any] | None | Unset + if isinstance(self.fine, Unset): + fine = UNSET + elif isinstance(self.fine, FineBiomassConfig): + fine = self.fine.to_dict() + else: + fine = self.fine + + field_dict: dict[str, Any] = {} + + field_dict.update( + { + "columns": columns, + } + ) + if type_ is not UNSET: + field_dict["type"] = type_ + if components is not UNSET: + field_dict["components"] = components + if component_states is not UNSET: + field_dict["component_states"] = component_states + if fine is not UNSET: + field_dict["fine"] = fine + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.fine_biomass_config import FineBiomassConfig + from ..models.inventory_columns_biomass_source_columns import ( + InventoryColumnsBiomassSourceColumns, + ) + from ..models.inventory_columns_biomass_source_component_states import ( + InventoryColumnsBiomassSourceComponentStates, + ) + + d = dict(src_dict) + columns = InventoryColumnsBiomassSourceColumns.from_dict(d.pop("columns")) + + type_ = cast(Literal["inventory_columns"] | Unset, d.pop("type", UNSET)) + if type_ != "inventory_columns" and not isinstance(type_, Unset): + raise ValueError( + f"type must match const 'inventory_columns', got '{type_}'" + ) + + _components = d.pop("components", UNSET) + components: list[BiomassComponent] | Unset = UNSET + if _components is not UNSET: + components = [] + for components_item_data in _components: + components_item = BiomassComponent(components_item_data) + + components.append(components_item) + + _component_states = d.pop("component_states", UNSET) + component_states: InventoryColumnsBiomassSourceComponentStates | Unset + if isinstance(_component_states, Unset): + component_states = UNSET + else: + component_states = InventoryColumnsBiomassSourceComponentStates.from_dict( + _component_states + ) + + def _parse_fine(data: object) -> FineBiomassConfig | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + fine_type_0 = FineBiomassConfig.from_dict(data) + + return fine_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(FineBiomassConfig | None | Unset, data) + + fine = _parse_fine(d.pop("fine", UNSET)) + + inventory_columns_biomass_source = cls( + columns=columns, + type_=type_, + components=components, + component_states=component_states, + fine=fine, + ) + + return inventory_columns_biomass_source diff --git a/fastfuels_sdk/v2/client_library/models/inventory_columns_biomass_source_columns.py b/fastfuels_sdk/v2/client_library/models/inventory_columns_biomass_source_columns.py new file mode 100644 index 0000000..53b3075 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/inventory_columns_biomass_source_columns.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.inventory_biomass_column import InventoryBiomassColumn + + +T = TypeVar("T", bound="InventoryColumnsBiomassSourceColumns") + + +@_attrs_define +class InventoryColumnsBiomassSourceColumns: + """Per-component inventory columns. Values must be per-tree kg.""" + + additional_properties: dict[str, InventoryBiomassColumn] = _attrs_field( + init=False, factory=dict + ) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + for prop_name, prop in self.additional_properties.items(): + field_dict[prop_name] = prop.to_dict() + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.inventory_biomass_column import InventoryBiomassColumn + + d = dict(src_dict) + inventory_columns_biomass_source_columns = cls() + + additional_properties = {} + for prop_name, prop_dict in d.items(): + additional_property = InventoryBiomassColumn.from_dict(prop_dict) + + additional_properties[prop_name] = additional_property + + inventory_columns_biomass_source_columns.additional_properties = ( + additional_properties + ) + return inventory_columns_biomass_source_columns + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> InventoryBiomassColumn: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: InventoryBiomassColumn) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/inventory_columns_biomass_source_component_states.py b/fastfuels_sdk/v2/client_library/models/inventory_columns_biomass_source_component_states.py new file mode 100644 index 0000000..c41c2f0 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/inventory_columns_biomass_source_component_states.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.biomass_component_state import BiomassComponentState + + +T = TypeVar("T", bound="InventoryColumnsBiomassSourceComponentStates") + + +@_attrs_define +class InventoryColumnsBiomassSourceComponentStates: + """Per-component live/dead biomass partition fractions.""" + + additional_properties: dict[str, BiomassComponentState] = _attrs_field( + init=False, factory=dict + ) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + for prop_name, prop in self.additional_properties.items(): + field_dict[prop_name] = prop.to_dict() + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.biomass_component_state import BiomassComponentState + + d = dict(src_dict) + inventory_columns_biomass_source_component_states = cls() + + additional_properties = {} + for prop_name, prop_dict in d.items(): + additional_property = BiomassComponentState.from_dict(prop_dict) + + additional_properties[prop_name] = additional_property + + inventory_columns_biomass_source_component_states.additional_properties = ( + additional_properties + ) + return inventory_columns_biomass_source_component_states + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> BiomassComponentState: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: BiomassComponentState) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/inventory_data_format.py b/fastfuels_sdk/v2/client_library/models/inventory_data_format.py new file mode 100644 index 0000000..824cd1e --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/inventory_data_format.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class InventoryDataFormat(str, Enum): + CSV = "csv" + JSON = "json" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/inventory_data_metadata.py b/fastfuels_sdk/v2/client_library/models/inventory_data_metadata.py new file mode 100644 index 0000000..b0bec7d --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/inventory_data_metadata.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.inventory_partition_info import InventoryPartitionInfo + + +T = TypeVar("T", bound="InventoryDataMetadata") + + +@_attrs_define +class InventoryDataMetadata: + """ + Attributes: + inventory_id (str): + num_partitions (int): + total_rows (int): + columns (list[str]): + partitions (list[InventoryPartitionInfo]): + """ + + inventory_id: str + num_partitions: int + total_rows: int + columns: list[str] + partitions: list[InventoryPartitionInfo] + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + inventory_id = self.inventory_id + + num_partitions = self.num_partitions + + total_rows = self.total_rows + + columns = self.columns + + partitions = [] + for partitions_item_data in self.partitions: + partitions_item = partitions_item_data.to_dict() + partitions.append(partitions_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "inventory_id": inventory_id, + "num_partitions": num_partitions, + "total_rows": total_rows, + "columns": columns, + "partitions": partitions, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.inventory_partition_info import InventoryPartitionInfo + + d = dict(src_dict) + inventory_id = d.pop("inventory_id") + + num_partitions = d.pop("num_partitions") + + total_rows = d.pop("total_rows") + + columns = cast(list[str], d.pop("columns")) + + partitions = [] + _partitions = d.pop("partitions") + for partitions_item_data in _partitions: + partitions_item = InventoryPartitionInfo.from_dict(partitions_item_data) + + partitions.append(partitions_item) + + inventory_data_metadata = cls( + inventory_id=inventory_id, + num_partitions=num_partitions, + total_rows=total_rows, + columns=columns, + partitions=partitions, + ) + + inventory_data_metadata.additional_properties = d + return inventory_data_metadata + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/inventory_data_response.py b/fastfuels_sdk/v2/client_library/models/inventory_data_response.py new file mode 100644 index 0000000..5d75137 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/inventory_data_response.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.inventory_data_response_data_type_1_item import ( + InventoryDataResponseDataType1Item, + ) + + +T = TypeVar("T", bound="InventoryDataResponse") + + +@_attrs_define +class InventoryDataResponse: + """ + Attributes: + partition (int): + num_rows (int): + columns (list[str]): + data (list[InventoryDataResponseDataType1Item] | list[list[Any]]): + """ + + partition: int + num_rows: int + columns: list[str] + data: list[InventoryDataResponseDataType1Item] | list[list[Any]] + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + partition = self.partition + + num_rows = self.num_rows + + columns = self.columns + + data: list[dict[str, Any]] | list[list[Any]] + if isinstance(self.data, list): + data = [] + for data_type_0_item_data in self.data: + data_type_0_item = data_type_0_item_data + + data.append(data_type_0_item) + + else: + data = [] + for data_type_1_item_data in self.data: + data_type_1_item = data_type_1_item_data.to_dict() + data.append(data_type_1_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "partition": partition, + "num_rows": num_rows, + "columns": columns, + "data": data, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.inventory_data_response_data_type_1_item import ( + InventoryDataResponseDataType1Item, + ) + + d = dict(src_dict) + partition = d.pop("partition") + + num_rows = d.pop("num_rows") + + columns = cast(list[str], d.pop("columns")) + + def _parse_data( + data: object, + ) -> list[InventoryDataResponseDataType1Item] | list[list[Any]]: + try: + if not isinstance(data, list): + raise TypeError() + data_type_0 = [] + _data_type_0 = data + for data_type_0_item_data in _data_type_0: + data_type_0_item = cast(list[Any], data_type_0_item_data) + + data_type_0.append(data_type_0_item) + + return data_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + if not isinstance(data, list): + raise TypeError() + data_type_1 = [] + _data_type_1 = data + for data_type_1_item_data in _data_type_1: + data_type_1_item = InventoryDataResponseDataType1Item.from_dict( + data_type_1_item_data + ) + + data_type_1.append(data_type_1_item) + + return data_type_1 + + data = _parse_data(d.pop("data")) + + inventory_data_response = cls( + partition=partition, + num_rows=num_rows, + columns=columns, + data=data, + ) + + inventory_data_response.additional_properties = d + return inventory_data_response + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/inventory_data_response_data_type_1_item.py b/fastfuels_sdk/v2/client_library/models/inventory_data_response_data_type_1_item.py new file mode 100644 index 0000000..6f10a02 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/inventory_data_response_data_type_1_item.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="InventoryDataResponseDataType1Item") + + +@_attrs_define +class InventoryDataResponseDataType1Item: + """ """ + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + inventory_data_response_data_type_1_item = cls() + + inventory_data_response_data_type_1_item.additional_properties = d + return inventory_data_response_data_type_1_item + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/inventory_diameter_treatment.py b/fastfuels_sdk/v2/client_library/models/inventory_diameter_treatment.py new file mode 100644 index 0000000..102e54f --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/inventory_diameter_treatment.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + TYPE_CHECKING, + Any, + Literal, + TypeVar, + cast, +) + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.inventory_diameter_treatment_method import ( + InventoryDiameterTreatmentMethod, +) +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.inventory_feature_spatial_condition import ( + InventoryFeatureSpatialCondition, + ) + from ..models.inventory_geometry_spatial_condition import ( + InventoryGeometrySpatialCondition, + ) + + +T = TypeVar("T", bound="InventoryDiameterTreatment") + + +@_attrs_define +class InventoryDiameterTreatment: + """Thin to a diameter-at-breast-height limit. + + A hard cutoff: ``from_below`` removes trees smaller than ``value``, + ``from_above`` removes trees larger than ``value``. ``proportional`` does not + apply to a diameter limit and is not an option here. + + Attributes: + method (InventoryDiameterTreatmentMethod): `from_below` removes trees below the limit; `from_above` removes + trees above the limit. + value (float): Diameter-at-breast-height limit, in cm unless `unit` is set. + unit (None | str | Unset): Optional unit for `value`. Must be canonical and dimensionally compatible with the + metric's native unit; converted before the treatment is applied. + conditions (list[InventoryFeatureSpatialCondition | InventoryGeometrySpatialCondition] | Unset): Spatial + conditions restricting the treatment to a region (within/outside/intersects a geometry or Feature). An empty + list applies the treatment to the entire inventory. + metric (Literal['diameter'] | Unset): Default: 'diameter'. + """ + + method: InventoryDiameterTreatmentMethod + value: float + unit: None | str | Unset = UNSET + conditions: ( + list[InventoryFeatureSpatialCondition | InventoryGeometrySpatialCondition] + | Unset + ) = UNSET + metric: Literal["diameter"] | Unset = "diameter" + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.inventory_geometry_spatial_condition import ( + InventoryGeometrySpatialCondition, + ) + + method = self.method.value + + value = self.value + + unit: None | str | Unset + if isinstance(self.unit, Unset): + unit = UNSET + else: + unit = self.unit + + conditions: list[dict[str, Any]] | Unset = UNSET + if not isinstance(self.conditions, Unset): + conditions = [] + for conditions_item_data in self.conditions: + conditions_item: dict[str, Any] + if isinstance(conditions_item_data, InventoryGeometrySpatialCondition): + conditions_item = conditions_item_data.to_dict() + else: + conditions_item = conditions_item_data.to_dict() + + conditions.append(conditions_item) + + metric = self.metric + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "method": method, + "value": value, + } + ) + if unit is not UNSET: + field_dict["unit"] = unit + if conditions is not UNSET: + field_dict["conditions"] = conditions + if metric is not UNSET: + field_dict["metric"] = metric + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.inventory_feature_spatial_condition import ( + InventoryFeatureSpatialCondition, + ) + from ..models.inventory_geometry_spatial_condition import ( + InventoryGeometrySpatialCondition, + ) + + d = dict(src_dict) + method = InventoryDiameterTreatmentMethod(d.pop("method")) + + value = d.pop("value") + + def _parse_unit(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + unit = _parse_unit(d.pop("unit", UNSET)) + + _conditions = d.pop("conditions", UNSET) + conditions: ( + list[InventoryFeatureSpatialCondition | InventoryGeometrySpatialCondition] + | Unset + ) = UNSET + if _conditions is not UNSET: + conditions = [] + for conditions_item_data in _conditions: + + def _parse_conditions_item( + data: object, + ) -> ( + InventoryFeatureSpatialCondition | InventoryGeometrySpatialCondition + ): + try: + if not isinstance(data, dict): + raise TypeError() + conditions_item_type_0 = ( + InventoryGeometrySpatialCondition.from_dict(data) + ) + + return conditions_item_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + if not isinstance(data, dict): + raise TypeError() + conditions_item_type_1 = InventoryFeatureSpatialCondition.from_dict( + data + ) + + return conditions_item_type_1 + + conditions_item = _parse_conditions_item(conditions_item_data) + + conditions.append(conditions_item) + + metric = cast(Literal["diameter"] | Unset, d.pop("metric", UNSET)) + if metric != "diameter" and not isinstance(metric, Unset): + raise ValueError(f"metric must match const 'diameter', got '{metric}'") + + inventory_diameter_treatment = cls( + method=method, + value=value, + unit=unit, + conditions=conditions, + metric=metric, + ) + + inventory_diameter_treatment.additional_properties = d + return inventory_diameter_treatment + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/inventory_diameter_treatment_method.py b/fastfuels_sdk/v2/client_library/models/inventory_diameter_treatment_method.py new file mode 100644 index 0000000..5641b1c --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/inventory_diameter_treatment_method.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class InventoryDiameterTreatmentMethod(str, Enum): + FROM_ABOVE = "from_above" + FROM_BELOW = "from_below" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/inventory_export_format.py b/fastfuels_sdk/v2/client_library/models/inventory_export_format.py new file mode 100644 index 0000000..f1258c7 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/inventory_export_format.py @@ -0,0 +1,11 @@ +from enum import Enum + + +class InventoryExportFormat(str, Enum): + CSV = "csv" + GEOJSON = "geojson" + GEOPACKAGE = "geopackage" + PARQUET = "parquet" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/inventory_expression_condition.py b/fastfuels_sdk/v2/client_library/models/inventory_expression_condition.py new file mode 100644 index 0000000..67aefba --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/inventory_expression_condition.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="InventoryExpressionCondition") + + +@_attrs_define +class InventoryExpressionCondition: + """Boolean expression condition evaluated against tree attributes. + + Expressions use native units (cm, m, 0-1 fraction). No unit field + is provided — convert values in the expression yourself. + + Example: "dbh < 5 and height < 2" + + Attributes: + expression (str): Boolean expression using dbh, height, crown_ratio + """ + + expression: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + expression = self.expression + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "expression": expression, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + expression = d.pop("expression") + + inventory_expression_condition = cls( + expression=expression, + ) + + inventory_expression_condition.additional_properties = d + return inventory_expression_condition + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/inventory_feature_spatial_condition.py b/fastfuels_sdk/v2/client_library/models/inventory_feature_spatial_condition.py new file mode 100644 index 0000000..2b650ee --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/inventory_feature_spatial_condition.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + Any, + Literal, + TypeVar, + cast, +) + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.spatial_operator import SpatialOperator +from ..types import UNSET, Unset + +T = TypeVar("T", bound="InventoryFeatureSpatialCondition") + + +@_attrs_define +class InventoryFeatureSpatialCondition: + """Spatial condition that tests tree locations against a persisted Feature + resource. + + The referenced Feature must belong to the same domain as the inventory. + The processing service loads the feature's geometry, reprojects it into + the domain CRS, optionally buffers it, and then evaluates the spatial + operator against each tree's point coordinate. + + A non-zero ``buffer_m`` is typically required for linestring features + (e.g. roads), since tree points almost never intersect a bare linestring. + + Attributes: + source (Literal['feature']): Discriminator selecting this variant. Must be the literal string `"feature"`. Use + `"geometry"` instead to supply inline GeoJSON. + operator (SpatialOperator): Spatial relationship operators for geometry-based conditions. + + - within: Select items whose target (centroid or cell) is inside the geometry + - outside: Select items whose target is outside the geometry (inverse of within) + - intersects: Select items whose target overlaps with the geometry + feature_id (str): ID of a Feature resource (road, water, or layerset) hosted in the same domain as the + inventory. Cross-domain references are rejected; the feature must be in `completed` status. + buffer_m (float | None | Unset): Optional buffer distance in meters applied to the feature geometry (in the + domain's projected CRS) before testing. Effectively required for linestring features such as roads since a tree + point almost never intersects a bare linestring. + """ + + source: Literal["feature"] + operator: SpatialOperator + feature_id: str + buffer_m: float | None | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + source = self.source + + operator = self.operator.value + + feature_id = self.feature_id + + buffer_m: float | None | Unset + if isinstance(self.buffer_m, Unset): + buffer_m = UNSET + else: + buffer_m = self.buffer_m + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "source": source, + "operator": operator, + "feature_id": feature_id, + } + ) + if buffer_m is not UNSET: + field_dict["buffer_m"] = buffer_m + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + source = cast(Literal["feature"], d.pop("source")) + if source != "feature": + raise ValueError(f"source must match const 'feature', got '{source}'") + + operator = SpatialOperator(d.pop("operator")) + + feature_id = d.pop("feature_id") + + def _parse_buffer_m(data: object) -> float | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(float | None | Unset, data) + + buffer_m = _parse_buffer_m(d.pop("buffer_m", UNSET)) + + inventory_feature_spatial_condition = cls( + source=source, + operator=operator, + feature_id=feature_id, + buffer_m=buffer_m, + ) + + inventory_feature_spatial_condition.additional_properties = d + return inventory_feature_spatial_condition + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/inventory_geometry_spatial_condition.py b/fastfuels_sdk/v2/client_library/models/inventory_geometry_spatial_condition.py new file mode 100644 index 0000000..ef5b9fb --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/inventory_geometry_spatial_condition.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + TYPE_CHECKING, + Any, + Literal, + TypeVar, + cast, +) + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.spatial_operator import SpatialOperator +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.inventory_geometry_spatial_condition_crs_type_0 import ( + InventoryGeometrySpatialConditionCrsType0, + ) + from ..models.inventory_geometry_spatial_condition_geometry import ( + InventoryGeometrySpatialConditionGeometry, + ) + + +T = TypeVar("T", bound="InventoryGeometrySpatialCondition") + + +@_attrs_define +class InventoryGeometrySpatialCondition: + """Spatial condition that tests tree locations against an inline GeoJSON + geometry. + + Trees are points, so the test is always point-in-(optionally-buffered)-geometry. + Use this variant when the geometry is supplied directly in the request; for a + persisted geometry hosted as a Feature resource, use + ``InventoryFeatureSpatialCondition``. + + Attributes: + source (Literal['geometry']): Discriminator selecting this variant. Must be the literal string `"geometry"`. Use + `"feature"` instead to reference a persisted Feature resource by id. + operator (SpatialOperator): Spatial relationship operators for geometry-based conditions. + + - within: Select items whose target (centroid or cell) is inside the geometry + - outside: Select items whose target is outside the geometry (inverse of within) + - intersects: Select items whose target overlaps with the geometry + geometry (InventoryGeometrySpatialConditionGeometry): Inline GeoJSON geometry. Polygon and MultiPolygon are the + common shapes; LineString geometries should typically be paired with a non-zero `buffer_m` since a tree point + almost never lies exactly on a line. + crs (InventoryGeometrySpatialConditionCrsType0 | None | Unset): CRS of `geometry`, expressed as a GeoJSON CRS + object (`{"type": "name", "properties": {"name": "EPSG:..."}}`). Defaults to the domain CRS when null. + buffer_m (float | None | Unset): Optional buffer distance in meters applied to the geometry (in the domain's + projected CRS) before testing. Use a non-zero buffer to widen the masked region beyond the literal geometry. + """ + + source: Literal["geometry"] + operator: SpatialOperator + geometry: InventoryGeometrySpatialConditionGeometry + crs: InventoryGeometrySpatialConditionCrsType0 | None | Unset = UNSET + buffer_m: float | None | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.inventory_geometry_spatial_condition_crs_type_0 import ( + InventoryGeometrySpatialConditionCrsType0, + ) + + source = self.source + + operator = self.operator.value + + geometry = self.geometry.to_dict() + + crs: dict[str, Any] | None | Unset + if isinstance(self.crs, Unset): + crs = UNSET + elif isinstance(self.crs, InventoryGeometrySpatialConditionCrsType0): + crs = self.crs.to_dict() + else: + crs = self.crs + + buffer_m: float | None | Unset + if isinstance(self.buffer_m, Unset): + buffer_m = UNSET + else: + buffer_m = self.buffer_m + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "source": source, + "operator": operator, + "geometry": geometry, + } + ) + if crs is not UNSET: + field_dict["crs"] = crs + if buffer_m is not UNSET: + field_dict["buffer_m"] = buffer_m + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.inventory_geometry_spatial_condition_crs_type_0 import ( + InventoryGeometrySpatialConditionCrsType0, + ) + from ..models.inventory_geometry_spatial_condition_geometry import ( + InventoryGeometrySpatialConditionGeometry, + ) + + d = dict(src_dict) + source = cast(Literal["geometry"], d.pop("source")) + if source != "geometry": + raise ValueError(f"source must match const 'geometry', got '{source}'") + + operator = SpatialOperator(d.pop("operator")) + + geometry = InventoryGeometrySpatialConditionGeometry.from_dict( + d.pop("geometry") + ) + + def _parse_crs( + data: object, + ) -> InventoryGeometrySpatialConditionCrsType0 | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + crs_type_0 = InventoryGeometrySpatialConditionCrsType0.from_dict(data) + + return crs_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(InventoryGeometrySpatialConditionCrsType0 | None | Unset, data) + + crs = _parse_crs(d.pop("crs", UNSET)) + + def _parse_buffer_m(data: object) -> float | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(float | None | Unset, data) + + buffer_m = _parse_buffer_m(d.pop("buffer_m", UNSET)) + + inventory_geometry_spatial_condition = cls( + source=source, + operator=operator, + geometry=geometry, + crs=crs, + buffer_m=buffer_m, + ) + + inventory_geometry_spatial_condition.additional_properties = d + return inventory_geometry_spatial_condition + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/inventory_geometry_spatial_condition_crs_type_0.py b/fastfuels_sdk/v2/client_library/models/inventory_geometry_spatial_condition_crs_type_0.py new file mode 100644 index 0000000..3317577 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/inventory_geometry_spatial_condition_crs_type_0.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="InventoryGeometrySpatialConditionCrsType0") + + +@_attrs_define +class InventoryGeometrySpatialConditionCrsType0: + """ """ + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + inventory_geometry_spatial_condition_crs_type_0 = cls() + + inventory_geometry_spatial_condition_crs_type_0.additional_properties = d + return inventory_geometry_spatial_condition_crs_type_0 + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/inventory_geometry_spatial_condition_geometry.py b/fastfuels_sdk/v2/client_library/models/inventory_geometry_spatial_condition_geometry.py new file mode 100644 index 0000000..caf368f --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/inventory_geometry_spatial_condition_geometry.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="InventoryGeometrySpatialConditionGeometry") + + +@_attrs_define +class InventoryGeometrySpatialConditionGeometry: + """Inline GeoJSON geometry. Polygon and MultiPolygon are the common shapes; LineString geometries should typically be + paired with a non-zero `buffer_m` since a tree point almost never lies exactly on a line. + + """ + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + inventory_geometry_spatial_condition_geometry = cls() + + inventory_geometry_spatial_condition_geometry.additional_properties = d + return inventory_geometry_spatial_condition_geometry + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/inventory_georeference.py b/fastfuels_sdk/v2/client_library/models/inventory_georeference.py new file mode 100644 index 0000000..81d9895 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/inventory_georeference.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="InventoryGeoreference") + + +@_attrs_define +class InventoryGeoreference: + """Spatial reference for an inventory, computed from the domain geometry. + + Attributes: + crs (str): + bounds (list[float]): + """ + + crs: str + bounds: list[float] + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + crs = self.crs + + bounds = [] + for bounds_item_data in self.bounds: + bounds_item: float + bounds_item = bounds_item_data + bounds.append(bounds_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "crs": crs, + "bounds": bounds, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + crs = d.pop("crs") + + bounds = [] + _bounds = d.pop("bounds") + for bounds_item_data in _bounds: + + def _parse_bounds_item(data: object) -> float: + return cast(float, data) + + bounds_item = _parse_bounds_item(bounds_item_data) + + bounds.append(bounds_item) + + inventory_georeference = cls( + crs=crs, + bounds=bounds, + ) + + inventory_georeference.additional_properties = d + return inventory_georeference + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/inventory_json_orientation.py b/fastfuels_sdk/v2/client_library/models/inventory_json_orientation.py new file mode 100644 index 0000000..a7d208d --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/inventory_json_orientation.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class InventoryJsonOrientation(str, Enum): + RECORDS = "records" + SPLIT = "split" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/inventory_modification.py b/fastfuels_sdk/v2/client_library/models/inventory_modification.py new file mode 100644 index 0000000..f11c7f8 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/inventory_modification.py @@ -0,0 +1,208 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.inventory_expression_condition import InventoryExpressionCondition + from ..models.inventory_feature_spatial_condition import ( + InventoryFeatureSpatialCondition, + ) + from ..models.inventory_geometry_spatial_condition import ( + InventoryGeometrySpatialCondition, + ) + from ..models.inventory_modification_action import InventoryModificationAction + from ..models.inventory_modification_condition import InventoryModificationCondition + from ..models.remove_action import RemoveAction + + +T = TypeVar("T", bound="InventoryModification") + + +@_attrs_define +class InventoryModification: + """A modification rule: when all conditions match, apply actions. + + If a RemoveAction is present, it must be the only action. + + Attributes: + conditions (list[InventoryExpressionCondition | InventoryFeatureSpatialCondition | + InventoryGeometrySpatialCondition | InventoryModificationCondition]): + actions (list[InventoryModificationAction | RemoveAction]): + """ + + conditions: list[ + InventoryExpressionCondition + | InventoryFeatureSpatialCondition + | InventoryGeometrySpatialCondition + | InventoryModificationCondition + ] + actions: list[InventoryModificationAction | RemoveAction] + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.inventory_expression_condition import InventoryExpressionCondition + from ..models.inventory_geometry_spatial_condition import ( + InventoryGeometrySpatialCondition, + ) + from ..models.inventory_modification_action import InventoryModificationAction + from ..models.inventory_modification_condition import ( + InventoryModificationCondition, + ) + + conditions = [] + for conditions_item_data in self.conditions: + conditions_item: dict[str, Any] + if isinstance(conditions_item_data, InventoryModificationCondition): + conditions_item = conditions_item_data.to_dict() + elif isinstance(conditions_item_data, InventoryExpressionCondition): + conditions_item = conditions_item_data.to_dict() + elif isinstance(conditions_item_data, InventoryGeometrySpatialCondition): + conditions_item = conditions_item_data.to_dict() + else: + conditions_item = conditions_item_data.to_dict() + + conditions.append(conditions_item) + + actions = [] + for actions_item_data in self.actions: + actions_item: dict[str, Any] + if isinstance(actions_item_data, InventoryModificationAction): + actions_item = actions_item_data.to_dict() + else: + actions_item = actions_item_data.to_dict() + + actions.append(actions_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "conditions": conditions, + "actions": actions, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.inventory_expression_condition import InventoryExpressionCondition + from ..models.inventory_feature_spatial_condition import ( + InventoryFeatureSpatialCondition, + ) + from ..models.inventory_geometry_spatial_condition import ( + InventoryGeometrySpatialCondition, + ) + from ..models.inventory_modification_action import InventoryModificationAction + from ..models.inventory_modification_condition import ( + InventoryModificationCondition, + ) + from ..models.remove_action import RemoveAction + + d = dict(src_dict) + conditions = [] + _conditions = d.pop("conditions") + for conditions_item_data in _conditions: + + def _parse_conditions_item( + data: object, + ) -> ( + InventoryExpressionCondition + | InventoryFeatureSpatialCondition + | InventoryGeometrySpatialCondition + | InventoryModificationCondition + ): + try: + if not isinstance(data, dict): + raise TypeError() + conditions_item_type_0 = InventoryModificationCondition.from_dict( + data + ) + + return conditions_item_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + conditions_item_type_1 = InventoryExpressionCondition.from_dict( + data + ) + + return conditions_item_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + conditions_item_type_2_type_0 = ( + InventoryGeometrySpatialCondition.from_dict(data) + ) + + return conditions_item_type_2_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + if not isinstance(data, dict): + raise TypeError() + conditions_item_type_2_type_1 = ( + InventoryFeatureSpatialCondition.from_dict(data) + ) + + return conditions_item_type_2_type_1 + + conditions_item = _parse_conditions_item(conditions_item_data) + + conditions.append(conditions_item) + + actions = [] + _actions = d.pop("actions") + for actions_item_data in _actions: + + def _parse_actions_item( + data: object, + ) -> InventoryModificationAction | RemoveAction: + try: + if not isinstance(data, dict): + raise TypeError() + actions_item_type_0 = InventoryModificationAction.from_dict(data) + + return actions_item_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + if not isinstance(data, dict): + raise TypeError() + actions_item_type_1 = RemoveAction.from_dict(data) + + return actions_item_type_1 + + actions_item = _parse_actions_item(actions_item_data) + + actions.append(actions_item) + + inventory_modification = cls( + conditions=conditions, + actions=actions, + ) + + inventory_modification.additional_properties = d + return inventory_modification + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/inventory_modification_action.py b/fastfuels_sdk/v2/client_library/models/inventory_modification_action.py new file mode 100644 index 0000000..d4a0d84 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/inventory_modification_action.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.inventory_attribute import InventoryAttribute +from ..models.modifier import Modifier +from ..types import UNSET, Unset + +T = TypeVar("T", bound="InventoryModificationAction") + + +@_attrs_define +class InventoryModificationAction: + """Action that modifies a tree attribute value. + + Optionally specify a unit to convert the value to the attribute's + native unit before applying the modifier. + + Attributes: + attribute (InventoryAttribute): Attributes available for inventory modifications. + modifier (Modifier): Modifiers for modification actions. + value (float | int | str): The value to use with the modifier + unit (None | str | Unset): Optional pint-compatible unit for the value. + """ + + attribute: InventoryAttribute + modifier: Modifier + value: float | int | str + unit: None | str | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + attribute = self.attribute.value + + modifier = self.modifier.value + + value: float | int | str + value = self.value + + unit: None | str | Unset + if isinstance(self.unit, Unset): + unit = UNSET + else: + unit = self.unit + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "attribute": attribute, + "modifier": modifier, + "value": value, + } + ) + if unit is not UNSET: + field_dict["unit"] = unit + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + attribute = InventoryAttribute(d.pop("attribute")) + + modifier = Modifier(d.pop("modifier")) + + def _parse_value(data: object) -> float | int | str: + return cast(float | int | str, data) + + value = _parse_value(d.pop("value")) + + def _parse_unit(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + unit = _parse_unit(d.pop("unit", UNSET)) + + inventory_modification_action = cls( + attribute=attribute, + modifier=modifier, + value=value, + unit=unit, + ) + + inventory_modification_action.additional_properties = d + return inventory_modification_action + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/inventory_modification_condition.py b/fastfuels_sdk/v2/client_library/models/inventory_modification_condition.py new file mode 100644 index 0000000..8e21fc8 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/inventory_modification_condition.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.inventory_attribute import InventoryAttribute +from ..models.operator import Operator +from ..types import UNSET, Unset + +T = TypeVar("T", bound="InventoryModificationCondition") + + +@_attrs_define +class InventoryModificationCondition: + """Condition that checks a tree attribute against a value. + + Optionally specify a unit (e.g., "in", "ft") to convert the value + to the attribute's native unit before comparison. + + Attributes: + attribute (InventoryAttribute): Attributes available for inventory modifications. + operator (Operator): Comparison operators for attribute-based conditions. + value (float | int | list[float | int | str] | str): The value(s) to compare against + unit (None | str | Unset): Optional pint-compatible unit for the value (e.g., 'in', 'ft', 'mm'). Converted to + the attribute's native unit before comparison. + """ + + attribute: InventoryAttribute + operator: Operator + value: float | int | list[float | int | str] | str + unit: None | str | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + attribute = self.attribute.value + + operator = self.operator.value + + value: float | int | list[float | int | str] | str + if isinstance(self.value, list): + value = [] + for value_type_3_item_data in self.value: + value_type_3_item: float | int | str + value_type_3_item = value_type_3_item_data + value.append(value_type_3_item) + + else: + value = self.value + + unit: None | str | Unset + if isinstance(self.unit, Unset): + unit = UNSET + else: + unit = self.unit + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "attribute": attribute, + "operator": operator, + "value": value, + } + ) + if unit is not UNSET: + field_dict["unit"] = unit + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + attribute = InventoryAttribute(d.pop("attribute")) + + operator = Operator(d.pop("operator")) + + def _parse_value(data: object) -> float | int | list[float | int | str] | str: + try: + if not isinstance(data, list): + raise TypeError() + value_type_3 = [] + _value_type_3 = data + for value_type_3_item_data in _value_type_3: + + def _parse_value_type_3_item(data: object) -> float | int | str: + return cast(float | int | str, data) + + value_type_3_item = _parse_value_type_3_item(value_type_3_item_data) + + value_type_3.append(value_type_3_item) + + return value_type_3 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(float | int | list[float | int | str] | str, data) + + value = _parse_value(d.pop("value")) + + def _parse_unit(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + unit = _parse_unit(d.pop("unit", UNSET)) + + inventory_modification_condition = cls( + attribute=attribute, + operator=operator, + value=value, + unit=unit, + ) + + inventory_modification_condition.additional_properties = d + return inventory_modification_condition + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/inventory_partition_info.py b/fastfuels_sdk/v2/client_library/models/inventory_partition_info.py new file mode 100644 index 0000000..9d0875b --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/inventory_partition_info.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="InventoryPartitionInfo") + + +@_attrs_define +class InventoryPartitionInfo: + """ + Attributes: + index (int): + num_rows (int): + """ + + index: int + num_rows: int + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + index = self.index + + num_rows = self.num_rows + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "index": index, + "num_rows": num_rows, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + index = d.pop("index") + + num_rows = d.pop("num_rows") + + inventory_partition_info = cls( + index=index, + num_rows=num_rows, + ) + + inventory_partition_info.additional_properties = d + return inventory_partition_info + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/inventory_sort_field.py b/fastfuels_sdk/v2/client_library/models/inventory_sort_field.py new file mode 100644 index 0000000..d1e3323 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/inventory_sort_field.py @@ -0,0 +1,10 @@ +from enum import Enum + + +class InventorySortField(str, Enum): + CREATED_ON = "created_on" + MODIFIED_ON = "modified_on" + NAME = "name" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/inventory_source.py b/fastfuels_sdk/v2/client_library/models/inventory_source.py new file mode 100644 index 0000000..ca88567 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/inventory_source.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="InventorySource") + + +@_attrs_define +class InventorySource: + """ """ + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + inventory_source = cls() + + inventory_source.additional_properties = d + return inventory_source + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/inventory_treatment_method.py b/fastfuels_sdk/v2/client_library/models/inventory_treatment_method.py new file mode 100644 index 0000000..5eb416a --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/inventory_treatment_method.py @@ -0,0 +1,10 @@ +from enum import Enum + + +class InventoryTreatmentMethod(str, Enum): + FROM_ABOVE = "from_above" + FROM_BELOW = "from_below" + PROPORTIONAL = "proportional" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/inventory_type.py b/fastfuels_sdk/v2/client_library/models/inventory_type.py new file mode 100644 index 0000000..b158ca4 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/inventory_type.py @@ -0,0 +1,8 @@ +from enum import Enum + + +class InventoryType(str, Enum): + TREE = "tree" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/inventory_upload_created_response.py b/fastfuels_sdk/v2/client_library/models/inventory_upload_created_response.py new file mode 100644 index 0000000..650450f --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/inventory_upload_created_response.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.inventory import Inventory + from ..models.inventory_upload_spec import InventoryUploadSpec + + +T = TypeVar("T", bound="InventoryUploadCreatedResponse") + + +@_attrs_define +class InventoryUploadCreatedResponse: + """ + Attributes: + inventory (Inventory): The Inventory resource. + + When status is "pending" or "running", georeference will be null. + The backend populates it after successfully processing data, + at which point status transitions to "completed". + upload (InventoryUploadSpec): + """ + + inventory: Inventory + upload: InventoryUploadSpec + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + inventory = self.inventory.to_dict() + + upload = self.upload.to_dict() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "inventory": inventory, + "upload": upload, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.inventory import Inventory + from ..models.inventory_upload_spec import InventoryUploadSpec + + d = dict(src_dict) + inventory = Inventory.from_dict(d.pop("inventory")) + + upload = InventoryUploadSpec.from_dict(d.pop("upload")) + + inventory_upload_created_response = cls( + inventory=inventory, + upload=upload, + ) + + inventory_upload_created_response.additional_properties = d + return inventory_upload_created_response + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/inventory_upload_format.py b/fastfuels_sdk/v2/client_library/models/inventory_upload_format.py new file mode 100644 index 0000000..3a02930 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/inventory_upload_format.py @@ -0,0 +1,10 @@ +from enum import Enum + + +class InventoryUploadFormat(str, Enum): + CSV = "csv" + GEOJSON = "geojson" + GEOPACKAGE = "geopackage" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/inventory_upload_spec.py b/fastfuels_sdk/v2/client_library/models/inventory_upload_spec.py new file mode 100644 index 0000000..a642700 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/inventory_upload_spec.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import datetime +from collections.abc import Mapping +from typing import ( + Any, + Literal, + TypeVar, + cast, +) + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="InventoryUploadSpec") + + +@_attrs_define +class InventoryUploadSpec: + """ + Attributes: + url (str): + content_type (str): + expires_at (datetime.datetime): + max_size_bytes (int): + method (Literal['PUT'] | Unset): Default: 'PUT'. + """ + + url: str + content_type: str + expires_at: datetime.datetime + max_size_bytes: int + method: Literal["PUT"] | Unset = "PUT" + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + url = self.url + + content_type = self.content_type + + expires_at = self.expires_at.isoformat() + + max_size_bytes = self.max_size_bytes + + method = self.method + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "url": url, + "content_type": content_type, + "expires_at": expires_at, + "max_size_bytes": max_size_bytes, + } + ) + if method is not UNSET: + field_dict["method"] = method + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + url = d.pop("url") + + content_type = d.pop("content_type") + + expires_at = datetime.datetime.fromisoformat(d.pop("expires_at")) + + max_size_bytes = d.pop("max_size_bytes") + + method = cast(Literal["PUT"] | Unset, d.pop("method", UNSET)) + if method != "PUT" and not isinstance(method, Unset): + raise ValueError(f"method must match const 'PUT', got '{method}'") + + inventory_upload_spec = cls( + url=url, + content_type=content_type, + expires_at=expires_at, + max_size_bytes=max_size_bytes, + method=method, + ) + + inventory_upload_spec.additional_properties = d + return inventory_upload_spec + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/job_error.py b/fastfuels_sdk/v2/client_library/models/job_error.py new file mode 100644 index 0000000..289843d --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/job_error.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="JobError") + + +@_attrs_define +class JobError: + """Error information for failed async jobs. + + Provides structured error information with user-facing messages and + developer debugging information. The traceback is stored in Firestore + but excluded from API responses. + + Attributes: + code: Machine-readable error code for programmatic handling. + Examples: "LANDFIRE_COVERAGE_ERROR", "SOURCE_GRID_NOT_FOUND" + message: User-friendly explanation of what went wrong. + suggestion: Optional actionable advice for resolving the error. + traceback: Full Python stack trace for debugging. Stored in Firestore + but not included in API responses. + + Attributes: + code (str): Machine-readable error code + message (str): User-friendly error message + suggestion (None | str | Unset): Actionable suggestion for the user + traceback (None | str | Unset): Full stack trace (stored but not exposed in API responses) + """ + + code: str + message: str + suggestion: None | str | Unset = UNSET + traceback: None | str | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + code = self.code + + message = self.message + + suggestion: None | str | Unset + if isinstance(self.suggestion, Unset): + suggestion = UNSET + else: + suggestion = self.suggestion + + traceback: None | str | Unset + if isinstance(self.traceback, Unset): + traceback = UNSET + else: + traceback = self.traceback + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "code": code, + "message": message, + } + ) + if suggestion is not UNSET: + field_dict["suggestion"] = suggestion + if traceback is not UNSET: + field_dict["traceback"] = traceback + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + code = d.pop("code") + + message = d.pop("message") + + def _parse_suggestion(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + suggestion = _parse_suggestion(d.pop("suggestion", UNSET)) + + def _parse_traceback(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + traceback = _parse_traceback(d.pop("traceback", UNSET)) + + job_error = cls( + code=code, + message=message, + suggestion=suggestion, + traceback=traceback, + ) + + job_error.additional_properties = d + return job_error + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/job_progress.py b/fastfuels_sdk/v2/client_library/models/job_progress.py new file mode 100644 index 0000000..6d8914e --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/job_progress.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="JobProgress") + + +@_attrs_define +class JobProgress: + """Progress information for running async jobs. + + Provides real-time feedback during long-running operations. + + Attributes: + percent: Completion percentage (0-100). Null for indeterminate operations + like "Connecting to LANDFIRE..." where progress can't be quantified. + message: Human-readable status message describing what's happening. + + Attributes: + message (str): Human-readable status message, e.g. 'Fetching LANDFIRE data...' + percent (int | None | Unset): Completion percentage (0-100), null if indeterminate + """ + + message: str + percent: int | None | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + message = self.message + + percent: int | None | Unset + if isinstance(self.percent, Unset): + percent = UNSET + else: + percent = self.percent + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "message": message, + } + ) + if percent is not UNSET: + field_dict["percent"] = percent + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + message = d.pop("message") + + def _parse_percent(data: object) -> int | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(int | None | Unset, data) + + percent = _parse_percent(d.pop("percent", UNSET)) + + job_progress = cls( + message=message, + percent=percent, + ) + + job_progress.additional_properties = d + return job_progress + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/job_status.py b/fastfuels_sdk/v2/client_library/models/job_status.py new file mode 100644 index 0000000..f4045d2 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/job_status.py @@ -0,0 +1,11 @@ +from enum import Enum + + +class JobStatus(str, Enum): + COMPLETED = "completed" + FAILED = "failed" + PENDING = "pending" + RUNNING = "running" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/key.py b/fastfuels_sdk/v2/client_library/models/key.py new file mode 100644 index 0000000..0d4ab5f --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/key.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +import datetime +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.access import Access +from ..models.scope import Scope +from ..types import UNSET, Unset + +T = TypeVar("T", bound="Key") + + +@_attrs_define +class Key: + """Represents an API key for authenticating programmatic API access. + + Attributes: + id (str): Unique identifier for the key (SHA-256 hash of the secret). + owner_id (str): The unique ID of the user or application who owns the key. + creator_id (str): The unique ID of the human user who created the key. + name (str): A name to semantically identify the key. + description (None | str | Unset): An optional description of the key's purpose. + valid_days (int | Unset): Number of days for which this key will be valid. Default: 30. + scopes (list[Scope] | Unset): A list of scopes available to the key. + access (Access | Unset): Access types for an API key. + application_id (None | str | Unset): Application ID accessed by the API key. + created_on (datetime.datetime | Unset): The date and time the key was created. + expires_on (datetime.datetime | Unset): The date at which this key is no longer valid. + """ + + id: str + owner_id: str + creator_id: str + name: str + description: None | str | Unset = UNSET + valid_days: int | Unset = 30 + scopes: list[Scope] | Unset = UNSET + access: Access | Unset = UNSET + application_id: None | str | Unset = UNSET + created_on: datetime.datetime | Unset = UNSET + expires_on: datetime.datetime | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + id = self.id + + owner_id = self.owner_id + + creator_id = self.creator_id + + name = self.name + + description: None | str | Unset + if isinstance(self.description, Unset): + description = UNSET + else: + description = self.description + + valid_days = self.valid_days + + scopes: list[str] | Unset = UNSET + if not isinstance(self.scopes, Unset): + scopes = [] + for scopes_item_data in self.scopes: + scopes_item = scopes_item_data.value + scopes.append(scopes_item) + + access: str | Unset = UNSET + if not isinstance(self.access, Unset): + access = self.access.value + + application_id: None | str | Unset + if isinstance(self.application_id, Unset): + application_id = UNSET + else: + application_id = self.application_id + + created_on: str | Unset = UNSET + if not isinstance(self.created_on, Unset): + created_on = self.created_on.isoformat() + + expires_on: str | Unset = UNSET + if not isinstance(self.expires_on, Unset): + expires_on = self.expires_on.isoformat() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "id": id, + "owner_id": owner_id, + "creator_id": creator_id, + "name": name, + } + ) + if description is not UNSET: + field_dict["description"] = description + if valid_days is not UNSET: + field_dict["valid_days"] = valid_days + if scopes is not UNSET: + field_dict["scopes"] = scopes + if access is not UNSET: + field_dict["access"] = access + if application_id is not UNSET: + field_dict["application_id"] = application_id + if created_on is not UNSET: + field_dict["created_on"] = created_on + if expires_on is not UNSET: + field_dict["expires_on"] = expires_on + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + id = d.pop("id") + + owner_id = d.pop("owner_id") + + creator_id = d.pop("creator_id") + + name = d.pop("name") + + def _parse_description(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + description = _parse_description(d.pop("description", UNSET)) + + valid_days = d.pop("valid_days", UNSET) + + _scopes = d.pop("scopes", UNSET) + scopes: list[Scope] | Unset = UNSET + if _scopes is not UNSET: + scopes = [] + for scopes_item_data in _scopes: + scopes_item = Scope(scopes_item_data) + + scopes.append(scopes_item) + + _access = d.pop("access", UNSET) + access: Access | Unset + if isinstance(_access, Unset): + access = UNSET + else: + access = Access(_access) + + def _parse_application_id(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + application_id = _parse_application_id(d.pop("application_id", UNSET)) + + _created_on = d.pop("created_on", UNSET) + created_on: datetime.datetime | Unset + if isinstance(_created_on, Unset): + created_on = UNSET + else: + created_on = datetime.datetime.fromisoformat(_created_on) + + _expires_on = d.pop("expires_on", UNSET) + expires_on: datetime.datetime | Unset + if isinstance(_expires_on, Unset): + expires_on = UNSET + else: + expires_on = datetime.datetime.fromisoformat(_expires_on) + + key = cls( + id=id, + owner_id=owner_id, + creator_id=creator_id, + name=name, + description=description, + valid_days=valid_days, + scopes=scopes, + access=access, + application_id=application_id, + created_on=created_on, + expires_on=expires_on, + ) + + key.additional_properties = d + return key + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/landfire_canopy_fuel_band.py b/fastfuels_sdk/v2/client_library/models/landfire_canopy_fuel_band.py new file mode 100644 index 0000000..3769c03 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/landfire_canopy_fuel_band.py @@ -0,0 +1,11 @@ +from enum import Enum + + +class LandfireCanopyFuelBand(str, Enum): + CBD = "cbd" + CBH = "cbh" + CC = "cc" + CHM = "chm" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/landfire_canopy_version.py b/fastfuels_sdk/v2/client_library/models/landfire_canopy_version.py new file mode 100644 index 0000000..d4e4f60 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/landfire_canopy_version.py @@ -0,0 +1,8 @@ +from enum import Enum + + +class LandfireCanopyVersion(str, Enum): + VALUE_0 = "2024" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/landfire_fbfm_40_version.py b/fastfuels_sdk/v2/client_library/models/landfire_fbfm_40_version.py new file mode 100644 index 0000000..30d20d4 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/landfire_fbfm_40_version.py @@ -0,0 +1,12 @@ +from enum import Enum + + +class LandfireFbfm40Version(str, Enum): + VALUE_0 = "2019" + VALUE_1 = "2020" + VALUE_2 = "2022" + VALUE_3 = "2023" + VALUE_4 = "2024" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/landfire_fccs_version.py b/fastfuels_sdk/v2/client_library/models/landfire_fccs_version.py new file mode 100644 index 0000000..2324894 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/landfire_fccs_version.py @@ -0,0 +1,8 @@ +from enum import Enum + + +class LandfireFccsVersion(str, Enum): + VALUE_0 = "2023" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/landfire_topography_version.py b/fastfuels_sdk/v2/client_library/models/landfire_topography_version.py new file mode 100644 index 0000000..fa9ffff --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/landfire_topography_version.py @@ -0,0 +1,8 @@ +from enum import Enum + + +class LandfireTopographyVersion(str, Enum): + VALUE_0 = "2020" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/layerset_crs.py b/fastfuels_sdk/v2/client_library/models/layerset_crs.py new file mode 100644 index 0000000..c603528 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/layerset_crs.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.layerset_crs_properties import LayersetCrsProperties + + +T = TypeVar("T", bound="LayersetCrs") + + +@_attrs_define +class LayersetCrs: + """Optional GeoJSON crs block. + + Per RFC 7946, ``crs`` is deprecated at the GeoJSON level (and + ``geojson_pydantic`` therefore omits it), but the team's pipeline emits + it and downstream consumers (geopandas, this server) read it to anchor + bounds and the projected-CRS check in the upload router. + + Attributes: + type_ (str | Unset): Default: 'name'. + properties (LayersetCrsProperties | Unset): + """ + + type_: str | Unset = "name" + properties: LayersetCrsProperties | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + type_ = self.type_ + + properties: dict[str, Any] | Unset = UNSET + if not isinstance(self.properties, Unset): + properties = self.properties.to_dict() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if type_ is not UNSET: + field_dict["type"] = type_ + if properties is not UNSET: + field_dict["properties"] = properties + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.layerset_crs_properties import LayersetCrsProperties + + d = dict(src_dict) + type_ = d.pop("type", UNSET) + + _properties = d.pop("properties", UNSET) + properties: LayersetCrsProperties | Unset + if isinstance(_properties, Unset): + properties = UNSET + else: + properties = LayersetCrsProperties.from_dict(_properties) + + layerset_crs = cls( + type_=type_, + properties=properties, + ) + + layerset_crs.additional_properties = d + return layerset_crs + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/layerset_crs_properties.py b/fastfuels_sdk/v2/client_library/models/layerset_crs_properties.py new file mode 100644 index 0000000..011abb3 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/layerset_crs_properties.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="LayersetCrsProperties") + + +@_attrs_define +class LayersetCrsProperties: + """ """ + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + layerset_crs_properties = cls() + + layerset_crs_properties.additional_properties = d + return layerset_crs_properties + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/layerset_feature.py b/fastfuels_sdk/v2/client_library/models/layerset_feature.py new file mode 100644 index 0000000..17e2b2c --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/layerset_feature.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + TYPE_CHECKING, + Any, + Literal, + TypeVar, + cast, +) + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.layerset_properties import LayersetProperties + from ..models.multi_polygon import MultiPolygon + from ..models.polygon import Polygon + + +T = TypeVar("T", bound="LayersetFeature") + + +@_attrs_define +class LayersetFeature: + """One Feature in the layerset FeatureCollection. + + Inherits coordinate validation from ``geojson_pydantic``. Both + ``Polygon`` and ``MultiPolygon`` are accepted because standard tooling + (QGIS, GDAL, geopandas) emits ``Polygon`` for single-ring features and + ``MultiPolygon`` for multi-ring ones. ``properties`` is narrowed to + non-Optional because every fuelbed row must carry the rasterizer's + required columns. + + Attributes: + type_ (Literal['Feature']): + geometry (MultiPolygon | None | Polygon): + properties (LayersetProperties): Per-feature properties — one row of input to ``rasterize_layerset``. + + Required fields match the rasterizer's required input columns. Optional + fields map to the rasterizer's optional bands; omitting them leaves the + corresponding output band as NaN. + bbox (list[float] | None | Unset): + id (int | None | str | Unset): + """ + + type_: Literal["Feature"] + geometry: MultiPolygon | None | Polygon + properties: LayersetProperties + bbox: list[float] | None | Unset = UNSET + id: int | None | str | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.multi_polygon import MultiPolygon + from ..models.polygon import Polygon + + type_ = self.type_ + + geometry: dict[str, Any] | None + if isinstance(self.geometry, Polygon): + geometry = self.geometry.to_dict() + elif isinstance(self.geometry, MultiPolygon): + geometry = self.geometry.to_dict() + else: + geometry = self.geometry + + properties = self.properties.to_dict() + + bbox: list[float] | None | Unset + if isinstance(self.bbox, Unset): + bbox = UNSET + elif isinstance(self.bbox, list): + bbox = [] + for bbox_type_0_item_data in self.bbox: + bbox_type_0_item: float + bbox_type_0_item = bbox_type_0_item_data + bbox.append(bbox_type_0_item) + + else: + bbox = self.bbox + + id: int | None | str | Unset + if isinstance(self.id, Unset): + id = UNSET + else: + id = self.id + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "type": type_, + "geometry": geometry, + "properties": properties, + } + ) + if bbox is not UNSET: + field_dict["bbox"] = bbox + if id is not UNSET: + field_dict["id"] = id + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.layerset_properties import LayersetProperties + from ..models.multi_polygon import MultiPolygon + from ..models.polygon import Polygon + + d = dict(src_dict) + type_ = cast(Literal["Feature"], d.pop("type")) + if type_ != "Feature": + raise ValueError(f"type must match const 'Feature', got '{type_}'") + + def _parse_geometry(data: object) -> MultiPolygon | None | Polygon: + if data is None: + return data + try: + if not isinstance(data, dict): + raise TypeError() + geometry_type_0 = Polygon.from_dict(data) + + return geometry_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + geometry_type_1 = MultiPolygon.from_dict(data) + + return geometry_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(MultiPolygon | None | Polygon, data) + + geometry = _parse_geometry(d.pop("geometry")) + + properties = LayersetProperties.from_dict(d.pop("properties")) + + def _parse_bbox(data: object) -> list[float] | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, list): + raise TypeError() + bbox_type_0 = [] + _bbox_type_0 = data + for bbox_type_0_item_data in _bbox_type_0: + + def _parse_bbox_type_0_item(data: object) -> float: + return cast(float, data) + + bbox_type_0_item = _parse_bbox_type_0_item(bbox_type_0_item_data) + + bbox_type_0.append(bbox_type_0_item) + + return bbox_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(list[float] | None | Unset, data) + + bbox = _parse_bbox(d.pop("bbox", UNSET)) + + def _parse_id(data: object) -> int | None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(int | None | str | Unset, data) + + id = _parse_id(d.pop("id", UNSET)) + + layerset_feature = cls( + type_=type_, + geometry=geometry, + properties=properties, + bbox=bbox, + id=id, + ) + + layerset_feature.additional_properties = d + return layerset_feature + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/layerset_properties.py b/fastfuels_sdk/v2/client_library/models/layerset_properties.py new file mode 100644 index 0000000..35ea5be --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/layerset_properties.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.distribution import Distribution +from ..types import UNSET, Unset + +T = TypeVar("T", bound="LayersetProperties") + + +@_attrs_define +class LayersetProperties: + """Per-feature properties — one row of input to ``rasterize_layerset``. + + Required fields match the rasterizer's required input columns. Optional + fields map to the rasterizer's optional bands; omitting them leaves the + corresponding output band as NaN. + + Attributes: + fuel_type (str): + fuel_loading (float): + fuel_height (float): + percent_cover (float): + distribution (Distribution): Per-cell spatial-distribution mode for a fuelbed. + + Mirrors ``fastfuels_core.rasterize_layerset``'s ``distribution`` column. + strata_fb (None | str | Unset): + patch_size (float | None | Unset): + live_fuel_moisture (float | None | Unset): + dead_fuel_moisture (float | None | Unset): + heat_of_combustion (float | None | Unset): + patch_std_dev (float | None | Unset): + """ + + fuel_type: str + fuel_loading: float + fuel_height: float + percent_cover: float + distribution: Distribution + strata_fb: None | str | Unset = UNSET + patch_size: float | None | Unset = UNSET + live_fuel_moisture: float | None | Unset = UNSET + dead_fuel_moisture: float | None | Unset = UNSET + heat_of_combustion: float | None | Unset = UNSET + patch_std_dev: float | None | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + fuel_type = self.fuel_type + + fuel_loading = self.fuel_loading + + fuel_height = self.fuel_height + + percent_cover = self.percent_cover + + distribution = self.distribution.value + + strata_fb: None | str | Unset + if isinstance(self.strata_fb, Unset): + strata_fb = UNSET + else: + strata_fb = self.strata_fb + + patch_size: float | None | Unset + if isinstance(self.patch_size, Unset): + patch_size = UNSET + else: + patch_size = self.patch_size + + live_fuel_moisture: float | None | Unset + if isinstance(self.live_fuel_moisture, Unset): + live_fuel_moisture = UNSET + else: + live_fuel_moisture = self.live_fuel_moisture + + dead_fuel_moisture: float | None | Unset + if isinstance(self.dead_fuel_moisture, Unset): + dead_fuel_moisture = UNSET + else: + dead_fuel_moisture = self.dead_fuel_moisture + + heat_of_combustion: float | None | Unset + if isinstance(self.heat_of_combustion, Unset): + heat_of_combustion = UNSET + else: + heat_of_combustion = self.heat_of_combustion + + patch_std_dev: float | None | Unset + if isinstance(self.patch_std_dev, Unset): + patch_std_dev = UNSET + else: + patch_std_dev = self.patch_std_dev + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "fuel_type": fuel_type, + "fuel_loading": fuel_loading, + "fuel_height": fuel_height, + "percent_cover": percent_cover, + "distribution": distribution, + } + ) + if strata_fb is not UNSET: + field_dict["strata_fb"] = strata_fb + if patch_size is not UNSET: + field_dict["patch_size"] = patch_size + if live_fuel_moisture is not UNSET: + field_dict["live_fuel_moisture"] = live_fuel_moisture + if dead_fuel_moisture is not UNSET: + field_dict["dead_fuel_moisture"] = dead_fuel_moisture + if heat_of_combustion is not UNSET: + field_dict["heat_of_combustion"] = heat_of_combustion + if patch_std_dev is not UNSET: + field_dict["patch_std_dev"] = patch_std_dev + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + fuel_type = d.pop("fuel_type") + + fuel_loading = d.pop("fuel_loading") + + fuel_height = d.pop("fuel_height") + + percent_cover = d.pop("percent_cover") + + distribution = Distribution(d.pop("distribution")) + + def _parse_strata_fb(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + strata_fb = _parse_strata_fb(d.pop("strata_fb", UNSET)) + + def _parse_patch_size(data: object) -> float | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(float | None | Unset, data) + + patch_size = _parse_patch_size(d.pop("patch_size", UNSET)) + + def _parse_live_fuel_moisture(data: object) -> float | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(float | None | Unset, data) + + live_fuel_moisture = _parse_live_fuel_moisture( + d.pop("live_fuel_moisture", UNSET) + ) + + def _parse_dead_fuel_moisture(data: object) -> float | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(float | None | Unset, data) + + dead_fuel_moisture = _parse_dead_fuel_moisture( + d.pop("dead_fuel_moisture", UNSET) + ) + + def _parse_heat_of_combustion(data: object) -> float | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(float | None | Unset, data) + + heat_of_combustion = _parse_heat_of_combustion( + d.pop("heat_of_combustion", UNSET) + ) + + def _parse_patch_std_dev(data: object) -> float | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(float | None | Unset, data) + + patch_std_dev = _parse_patch_std_dev(d.pop("patch_std_dev", UNSET)) + + layerset_properties = cls( + fuel_type=fuel_type, + fuel_loading=fuel_loading, + fuel_height=fuel_height, + percent_cover=percent_cover, + distribution=distribution, + strata_fb=strata_fb, + patch_size=patch_size, + live_fuel_moisture=live_fuel_moisture, + dead_fuel_moisture=dead_fuel_moisture, + heat_of_combustion=heat_of_combustion, + patch_std_dev=patch_std_dev, + ) + + layerset_properties.additional_properties = d + return layerset_properties + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/line_string.py b/fastfuels_sdk/v2/client_library/models/line_string.py new file mode 100644 index 0000000..5e5f89e --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/line_string.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + Any, + Literal, + TypeVar, + cast, +) + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="LineString") + + +@_attrs_define +class LineString: + """LineString Model + + Attributes: + type_ (Literal['LineString']): + coordinates (list[list[float]]): + bbox (list[float] | None | Unset): + """ + + type_: Literal["LineString"] + coordinates: list[list[float]] + bbox: list[float] | None | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + type_ = self.type_ + + coordinates = [] + for coordinates_item_data in self.coordinates: + coordinates_item: list[float] + if isinstance(coordinates_item_data, list): + coordinates_item = [] + for componentsschemas_position_2d_item_data in coordinates_item_data: + componentsschemas_position_2d_item: float + componentsschemas_position_2d_item = ( + componentsschemas_position_2d_item_data + ) + coordinates_item.append(componentsschemas_position_2d_item) + + coordinates.append(coordinates_item) + + bbox: list[float] | None | Unset + if isinstance(self.bbox, Unset): + bbox = UNSET + elif isinstance(self.bbox, list): + bbox = [] + for bbox_type_0_item_data in self.bbox: + bbox_type_0_item: float + bbox_type_0_item = bbox_type_0_item_data + bbox.append(bbox_type_0_item) + + else: + bbox = self.bbox + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "type": type_, + "coordinates": coordinates, + } + ) + if bbox is not UNSET: + field_dict["bbox"] = bbox + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + type_ = cast(Literal["LineString"], d.pop("type")) + if type_ != "LineString": + raise ValueError(f"type must match const 'LineString', got '{type_}'") + + coordinates = [] + _coordinates = d.pop("coordinates") + for coordinates_item_data in _coordinates: + + def _parse_coordinates_item(data: object) -> list[float]: + if not isinstance(data, list): + raise TypeError() + coordinates_item_type_0 = [] + _coordinates_item_type_0 = data + for componentsschemas_position_2d_item_data in _coordinates_item_type_0: + + def _parse_componentsschemas_position_2d_item( + data: object, + ) -> float: + return cast(float, data) + + componentsschemas_position_2d_item = ( + _parse_componentsschemas_position_2d_item( + componentsschemas_position_2d_item_data + ) + ) + + coordinates_item_type_0.append(componentsschemas_position_2d_item) + + return coordinates_item_type_0 + + coordinates_item = _parse_coordinates_item(coordinates_item_data) + + coordinates.append(coordinates_item) + + def _parse_bbox(data: object) -> list[float] | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, list): + raise TypeError() + bbox_type_0 = [] + _bbox_type_0 = data + for bbox_type_0_item_data in _bbox_type_0: + + def _parse_bbox_type_0_item(data: object) -> float: + return cast(float, data) + + bbox_type_0_item = _parse_bbox_type_0_item(bbox_type_0_item_data) + + bbox_type_0.append(bbox_type_0_item) + + return bbox_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(list[float] | None | Unset, data) + + bbox = _parse_bbox(d.pop("bbox", UNSET)) + + line_string = cls( + type_=type_, + coordinates=coordinates, + bbox=bbox, + ) + + line_string.additional_properties = d + return line_string + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/list_applications_response.py b/fastfuels_sdk/v2/client_library/models/list_applications_response.py new file mode 100644 index 0000000..3b59df8 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/list_applications_response.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.application import Application + + +T = TypeVar("T", bound="ListApplicationsResponse") + + +@_attrs_define +class ListApplicationsResponse: + """Paginated response for listing applications. + + Attributes: + current_page (int): The current page number (zero-indexed). + page_size (int): The number of items per page. + total_items (int): The total number of items across all pages. + applications (list[Application]): A list of applications. + """ + + current_page: int + page_size: int + total_items: int + applications: list[Application] + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + current_page = self.current_page + + page_size = self.page_size + + total_items = self.total_items + + applications = [] + for applications_item_data in self.applications: + applications_item = applications_item_data.to_dict() + applications.append(applications_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "current_page": current_page, + "page_size": page_size, + "total_items": total_items, + "applications": applications, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.application import Application + + d = dict(src_dict) + current_page = d.pop("current_page") + + page_size = d.pop("page_size") + + total_items = d.pop("total_items") + + applications = [] + _applications = d.pop("applications") + for applications_item_data in _applications: + applications_item = Application.from_dict(applications_item_data) + + applications.append(applications_item) + + list_applications_response = cls( + current_page=current_page, + page_size=page_size, + total_items=total_items, + applications=applications, + ) + + list_applications_response.additional_properties = d + return list_applications_response + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/list_domains_response.py b/fastfuels_sdk/v2/client_library/models/list_domains_response.py new file mode 100644 index 0000000..608c0fb --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/list_domains_response.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.domain import Domain + + +T = TypeVar("T", bound="ListDomainsResponse") + + +@_attrs_define +class ListDomainsResponse: + """Paginated response for listing domain resources. + + Attributes: + current_page (int): The current page number (zero-indexed). + page_size (int): The number of items per page. + total_items (int): The total number of items across all pages. + domains (list[Domain]): The list of domain resources for the current page. + """ + + current_page: int + page_size: int + total_items: int + domains: list[Domain] + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + current_page = self.current_page + + page_size = self.page_size + + total_items = self.total_items + + domains = [] + for domains_item_data in self.domains: + domains_item = domains_item_data.to_dict() + domains.append(domains_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "current_page": current_page, + "page_size": page_size, + "total_items": total_items, + "domains": domains, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.domain import Domain + + d = dict(src_dict) + current_page = d.pop("current_page") + + page_size = d.pop("page_size") + + total_items = d.pop("total_items") + + domains = [] + _domains = d.pop("domains") + for domains_item_data in _domains: + domains_item = Domain.from_dict(domains_item_data) + + domains.append(domains_item) + + list_domains_response = cls( + current_page=current_page, + page_size=page_size, + total_items=total_items, + domains=domains, + ) + + list_domains_response.additional_properties = d + return list_domains_response + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/list_exports_response.py b/fastfuels_sdk/v2/client_library/models/list_exports_response.py new file mode 100644 index 0000000..87b8eba --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/list_exports_response.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.export import Export + + +T = TypeVar("T", bound="ListExportsResponse") + + +@_attrs_define +class ListExportsResponse: + """Paginated response for listing exports. + + Attributes: + current_page (int): The current page number (zero-indexed). + page_size (int): The number of items per page. + total_items (int): The total number of items across all pages. + exports (list[Export]): + """ + + current_page: int + page_size: int + total_items: int + exports: list[Export] + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + current_page = self.current_page + + page_size = self.page_size + + total_items = self.total_items + + exports = [] + for exports_item_data in self.exports: + exports_item = exports_item_data.to_dict() + exports.append(exports_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "current_page": current_page, + "page_size": page_size, + "total_items": total_items, + "exports": exports, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.export import Export + + d = dict(src_dict) + current_page = d.pop("current_page") + + page_size = d.pop("page_size") + + total_items = d.pop("total_items") + + exports = [] + _exports = d.pop("exports") + for exports_item_data in _exports: + exports_item = Export.from_dict(exports_item_data) + + exports.append(exports_item) + + list_exports_response = cls( + current_page=current_page, + page_size=page_size, + total_items=total_items, + exports=exports, + ) + + list_exports_response.additional_properties = d + return list_exports_response + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/list_features_response.py b/fastfuels_sdk/v2/client_library/models/list_features_response.py new file mode 100644 index 0000000..4453c35 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/list_features_response.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.feature import Feature + + +T = TypeVar("T", bound="ListFeaturesResponse") + + +@_attrs_define +class ListFeaturesResponse: + """Paginated response for listing features. + + Attributes: + current_page (int): The current page number (zero-indexed). + page_size (int): The number of items per page. + total_items (int): The total number of items across all pages. + features (list[Feature]): + """ + + current_page: int + page_size: int + total_items: int + features: list[Feature] + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + current_page = self.current_page + + page_size = self.page_size + + total_items = self.total_items + + features = [] + for features_item_data in self.features: + features_item = features_item_data.to_dict() + features.append(features_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "current_page": current_page, + "page_size": page_size, + "total_items": total_items, + "features": features, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.feature import Feature + + d = dict(src_dict) + current_page = d.pop("current_page") + + page_size = d.pop("page_size") + + total_items = d.pop("total_items") + + features = [] + _features = d.pop("features") + for features_item_data in _features: + features_item = Feature.from_dict(features_item_data) + + features.append(features_item) + + list_features_response = cls( + current_page=current_page, + page_size=page_size, + total_items=total_items, + features=features, + ) + + list_features_response.additional_properties = d + return list_features_response + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/list_grids_response.py b/fastfuels_sdk/v2/client_library/models/list_grids_response.py new file mode 100644 index 0000000..6b9cfa0 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/list_grids_response.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.grid import Grid + + +T = TypeVar("T", bound="ListGridsResponse") + + +@_attrs_define +class ListGridsResponse: + """Paginated response for listing grids. + + Attributes: + current_page (int): The current page number (zero-indexed). + page_size (int): The number of items per page. + total_items (int): The total number of items across all pages. + grids (list[Grid]): + """ + + current_page: int + page_size: int + total_items: int + grids: list[Grid] + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + current_page = self.current_page + + page_size = self.page_size + + total_items = self.total_items + + grids = [] + for grids_item_data in self.grids: + grids_item = grids_item_data.to_dict() + grids.append(grids_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "current_page": current_page, + "page_size": page_size, + "total_items": total_items, + "grids": grids, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.grid import Grid + + d = dict(src_dict) + current_page = d.pop("current_page") + + page_size = d.pop("page_size") + + total_items = d.pop("total_items") + + grids = [] + _grids = d.pop("grids") + for grids_item_data in _grids: + grids_item = Grid.from_dict(grids_item_data) + + grids.append(grids_item) + + list_grids_response = cls( + current_page=current_page, + page_size=page_size, + total_items=total_items, + grids=grids, + ) + + list_grids_response.additional_properties = d + return list_grids_response + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/list_inventories_response.py b/fastfuels_sdk/v2/client_library/models/list_inventories_response.py new file mode 100644 index 0000000..afe4a8a --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/list_inventories_response.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.inventory import Inventory + + +T = TypeVar("T", bound="ListInventoriesResponse") + + +@_attrs_define +class ListInventoriesResponse: + """Paginated response for listing inventories. + + Attributes: + current_page (int): The current page number (zero-indexed). + page_size (int): The number of items per page. + total_items (int): The total number of items across all pages. + inventories (list[Inventory]): + """ + + current_page: int + page_size: int + total_items: int + inventories: list[Inventory] + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + current_page = self.current_page + + page_size = self.page_size + + total_items = self.total_items + + inventories = [] + for inventories_item_data in self.inventories: + inventories_item = inventories_item_data.to_dict() + inventories.append(inventories_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "current_page": current_page, + "page_size": page_size, + "total_items": total_items, + "inventories": inventories, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.inventory import Inventory + + d = dict(src_dict) + current_page = d.pop("current_page") + + page_size = d.pop("page_size") + + total_items = d.pop("total_items") + + inventories = [] + _inventories = d.pop("inventories") + for inventories_item_data in _inventories: + inventories_item = Inventory.from_dict(inventories_item_data) + + inventories.append(inventories_item) + + list_inventories_response = cls( + current_page=current_page, + page_size=page_size, + total_items=total_items, + inventories=inventories, + ) + + list_inventories_response.additional_properties = d + return list_inventories_response + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/list_keys_response.py b/fastfuels_sdk/v2/client_library/models/list_keys_response.py new file mode 100644 index 0000000..03013ec --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/list_keys_response.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.key import Key + + +T = TypeVar("T", bound="ListKeysResponse") + + +@_attrs_define +class ListKeysResponse: + """Paginated response for listing API keys. + + Attributes: + current_page (int): The current page number (zero-indexed). + page_size (int): The number of items per page. + total_items (int): The total number of items across all pages. + keys (list[Key]): A list of API keys. + """ + + current_page: int + page_size: int + total_items: int + keys: list[Key] + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + current_page = self.current_page + + page_size = self.page_size + + total_items = self.total_items + + keys = [] + for keys_item_data in self.keys: + keys_item = keys_item_data.to_dict() + keys.append(keys_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "current_page": current_page, + "page_size": page_size, + "total_items": total_items, + "keys": keys, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.key import Key + + d = dict(src_dict) + current_page = d.pop("current_page") + + page_size = d.pop("page_size") + + total_items = d.pop("total_items") + + keys = [] + _keys = d.pop("keys") + for keys_item_data in _keys: + keys_item = Key.from_dict(keys_item_data) + + keys.append(keys_item) + + list_keys_response = cls( + current_page=current_page, + page_size=page_size, + total_items=total_items, + keys=keys, + ) + + list_keys_response.additional_properties = d + return list_keys_response + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/max_crown_radius_unit.py b/fastfuels_sdk/v2/client_library/models/max_crown_radius_unit.py new file mode 100644 index 0000000..4d1dca9 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/max_crown_radius_unit.py @@ -0,0 +1,8 @@ +from enum import Enum + + +class MaxCrownRadiusUnit(str, Enum): + M = "m" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/meta_chm_version.py b/fastfuels_sdk/v2/client_library/models/meta_chm_version.py new file mode 100644 index 0000000..fc114b7 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/meta_chm_version.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class MetaCHMVersion(str, Enum): + VALUE_0 = "1" + VALUE_1 = "2" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/modifier.py b/fastfuels_sdk/v2/client_library/models/modifier.py new file mode 100644 index 0000000..e75f638 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/modifier.py @@ -0,0 +1,12 @@ +from enum import Enum + + +class Modifier(str, Enum): + ADD = "add" + DIVIDE = "divide" + MULTIPLY = "multiply" + REPLACE = "replace" + SUBTRACT = "subtract" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/moisture_model.py b/fastfuels_sdk/v2/client_library/models/moisture_model.py new file mode 100644 index 0000000..989e385 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/moisture_model.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.uniform_moisture_value import UniformMoistureValue + + +T = TypeVar("T", bound="MoistureModel") + + +@_attrs_define +class MoistureModel: + """Live/dead fuel moisture settings. + + Attributes: + live (None | UniformMoistureValue | Unset): + dead (None | UniformMoistureValue | Unset): + """ + + live: None | UniformMoistureValue | Unset = UNSET + dead: None | UniformMoistureValue | Unset = UNSET + + def to_dict(self) -> dict[str, Any]: + from ..models.uniform_moisture_value import UniformMoistureValue + + live: dict[str, Any] | None | Unset + if isinstance(self.live, Unset): + live = UNSET + elif isinstance(self.live, UniformMoistureValue): + live = self.live.to_dict() + else: + live = self.live + + dead: dict[str, Any] | None | Unset + if isinstance(self.dead, Unset): + dead = UNSET + elif isinstance(self.dead, UniformMoistureValue): + dead = self.dead.to_dict() + else: + dead = self.dead + + field_dict: dict[str, Any] = {} + + field_dict.update({}) + if live is not UNSET: + field_dict["live"] = live + if dead is not UNSET: + field_dict["dead"] = dead + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.uniform_moisture_value import UniformMoistureValue + + d = dict(src_dict) + + def _parse_live(data: object) -> None | UniformMoistureValue | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + live_type_0 = UniformMoistureValue.from_dict(data) + + return live_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(None | UniformMoistureValue | Unset, data) + + live = _parse_live(d.pop("live", UNSET)) + + def _parse_dead(data: object) -> None | UniformMoistureValue | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + dead_type_0 = UniformMoistureValue.from_dict(data) + + return dead_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(None | UniformMoistureValue | Unset, data) + + dead = _parse_dead(d.pop("dead", UNSET)) + + moisture_model = cls( + live=live, + dead=dead, + ) + + return moisture_model diff --git a/fastfuels_sdk/v2/client_library/models/multi_line_string.py b/fastfuels_sdk/v2/client_library/models/multi_line_string.py new file mode 100644 index 0000000..3be320d --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/multi_line_string.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + Any, + Literal, + TypeVar, + cast, +) + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="MultiLineString") + + +@_attrs_define +class MultiLineString: + """MultiLineString Model + + Attributes: + type_ (Literal['MultiLineString']): + coordinates (list[list[list[float]]]): + bbox (list[float] | None | Unset): + """ + + type_: Literal["MultiLineString"] + coordinates: list[list[list[float]]] + bbox: list[float] | None | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + type_ = self.type_ + + coordinates = [] + for coordinates_item_data in self.coordinates: + coordinates_item = [] + for coordinates_item_item_data in coordinates_item_data: + coordinates_item_item: list[float] + if isinstance(coordinates_item_item_data, list): + coordinates_item_item = [] + for ( + componentsschemas_position_2d_item_data + ) in coordinates_item_item_data: + componentsschemas_position_2d_item: float + componentsschemas_position_2d_item = ( + componentsschemas_position_2d_item_data + ) + coordinates_item_item.append(componentsschemas_position_2d_item) + + coordinates_item.append(coordinates_item_item) + + coordinates.append(coordinates_item) + + bbox: list[float] | None | Unset + if isinstance(self.bbox, Unset): + bbox = UNSET + elif isinstance(self.bbox, list): + bbox = [] + for bbox_type_0_item_data in self.bbox: + bbox_type_0_item: float + bbox_type_0_item = bbox_type_0_item_data + bbox.append(bbox_type_0_item) + + else: + bbox = self.bbox + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "type": type_, + "coordinates": coordinates, + } + ) + if bbox is not UNSET: + field_dict["bbox"] = bbox + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + type_ = cast(Literal["MultiLineString"], d.pop("type")) + if type_ != "MultiLineString": + raise ValueError(f"type must match const 'MultiLineString', got '{type_}'") + + coordinates = [] + _coordinates = d.pop("coordinates") + for coordinates_item_data in _coordinates: + coordinates_item = [] + _coordinates_item = coordinates_item_data + for coordinates_item_item_data in _coordinates_item: + + def _parse_coordinates_item_item(data: object) -> list[float]: + if not isinstance(data, list): + raise TypeError() + coordinates_item_item_type_0 = [] + _coordinates_item_item_type_0 = data + for ( + componentsschemas_position_2d_item_data + ) in _coordinates_item_item_type_0: + + def _parse_componentsschemas_position_2d_item( + data: object, + ) -> float: + return cast(float, data) + + componentsschemas_position_2d_item = ( + _parse_componentsschemas_position_2d_item( + componentsschemas_position_2d_item_data + ) + ) + + coordinates_item_item_type_0.append( + componentsschemas_position_2d_item + ) + + return coordinates_item_item_type_0 + + coordinates_item_item = _parse_coordinates_item_item( + coordinates_item_item_data + ) + + coordinates_item.append(coordinates_item_item) + + coordinates.append(coordinates_item) + + def _parse_bbox(data: object) -> list[float] | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, list): + raise TypeError() + bbox_type_0 = [] + _bbox_type_0 = data + for bbox_type_0_item_data in _bbox_type_0: + + def _parse_bbox_type_0_item(data: object) -> float: + return cast(float, data) + + bbox_type_0_item = _parse_bbox_type_0_item(bbox_type_0_item_data) + + bbox_type_0.append(bbox_type_0_item) + + return bbox_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(list[float] | None | Unset, data) + + bbox = _parse_bbox(d.pop("bbox", UNSET)) + + multi_line_string = cls( + type_=type_, + coordinates=coordinates, + bbox=bbox, + ) + + multi_line_string.additional_properties = d + return multi_line_string + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/multi_point.py b/fastfuels_sdk/v2/client_library/models/multi_point.py new file mode 100644 index 0000000..13a9956 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/multi_point.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + Any, + Literal, + TypeVar, + cast, +) + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="MultiPoint") + + +@_attrs_define +class MultiPoint: + """MultiPoint Model + + Attributes: + type_ (Literal['MultiPoint']): + coordinates (list[list[float]]): + bbox (list[float] | None | Unset): + """ + + type_: Literal["MultiPoint"] + coordinates: list[list[float]] + bbox: list[float] | None | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + type_ = self.type_ + + coordinates = [] + for coordinates_item_data in self.coordinates: + coordinates_item: list[float] + if isinstance(coordinates_item_data, list): + coordinates_item = [] + for componentsschemas_position_2d_item_data in coordinates_item_data: + componentsschemas_position_2d_item: float + componentsschemas_position_2d_item = ( + componentsschemas_position_2d_item_data + ) + coordinates_item.append(componentsschemas_position_2d_item) + + coordinates.append(coordinates_item) + + bbox: list[float] | None | Unset + if isinstance(self.bbox, Unset): + bbox = UNSET + elif isinstance(self.bbox, list): + bbox = [] + for bbox_type_0_item_data in self.bbox: + bbox_type_0_item: float + bbox_type_0_item = bbox_type_0_item_data + bbox.append(bbox_type_0_item) + + else: + bbox = self.bbox + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "type": type_, + "coordinates": coordinates, + } + ) + if bbox is not UNSET: + field_dict["bbox"] = bbox + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + type_ = cast(Literal["MultiPoint"], d.pop("type")) + if type_ != "MultiPoint": + raise ValueError(f"type must match const 'MultiPoint', got '{type_}'") + + coordinates = [] + _coordinates = d.pop("coordinates") + for coordinates_item_data in _coordinates: + + def _parse_coordinates_item(data: object) -> list[float]: + if not isinstance(data, list): + raise TypeError() + coordinates_item_type_0 = [] + _coordinates_item_type_0 = data + for componentsschemas_position_2d_item_data in _coordinates_item_type_0: + + def _parse_componentsschemas_position_2d_item( + data: object, + ) -> float: + return cast(float, data) + + componentsschemas_position_2d_item = ( + _parse_componentsschemas_position_2d_item( + componentsschemas_position_2d_item_data + ) + ) + + coordinates_item_type_0.append(componentsschemas_position_2d_item) + + return coordinates_item_type_0 + + coordinates_item = _parse_coordinates_item(coordinates_item_data) + + coordinates.append(coordinates_item) + + def _parse_bbox(data: object) -> list[float] | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, list): + raise TypeError() + bbox_type_0 = [] + _bbox_type_0 = data + for bbox_type_0_item_data in _bbox_type_0: + + def _parse_bbox_type_0_item(data: object) -> float: + return cast(float, data) + + bbox_type_0_item = _parse_bbox_type_0_item(bbox_type_0_item_data) + + bbox_type_0.append(bbox_type_0_item) + + return bbox_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(list[float] | None | Unset, data) + + bbox = _parse_bbox(d.pop("bbox", UNSET)) + + multi_point = cls( + type_=type_, + coordinates=coordinates, + bbox=bbox, + ) + + multi_point.additional_properties = d + return multi_point + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/multi_polygon.py b/fastfuels_sdk/v2/client_library/models/multi_polygon.py new file mode 100644 index 0000000..09c6355 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/multi_polygon.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + Any, + Literal, + TypeVar, + cast, +) + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="MultiPolygon") + + +@_attrs_define +class MultiPolygon: + """MultiPolygon Model + + Attributes: + type_ (Literal['MultiPolygon']): + coordinates (list[list[list[list[float]]]]): + bbox (list[float] | None | Unset): + """ + + type_: Literal["MultiPolygon"] + coordinates: list[list[list[list[float]]]] + bbox: list[float] | None | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + type_ = self.type_ + + coordinates = [] + for coordinates_item_data in self.coordinates: + coordinates_item = [] + for coordinates_item_item_data in coordinates_item_data: + coordinates_item_item = [] + for coordinates_item_item_item_data in coordinates_item_item_data: + coordinates_item_item_item: list[float] + if isinstance(coordinates_item_item_item_data, list): + coordinates_item_item_item = [] + for ( + componentsschemas_position_2d_item_data + ) in coordinates_item_item_item_data: + componentsschemas_position_2d_item: float + componentsschemas_position_2d_item = ( + componentsschemas_position_2d_item_data + ) + coordinates_item_item_item.append( + componentsschemas_position_2d_item + ) + + coordinates_item_item.append(coordinates_item_item_item) + + coordinates_item.append(coordinates_item_item) + + coordinates.append(coordinates_item) + + bbox: list[float] | None | Unset + if isinstance(self.bbox, Unset): + bbox = UNSET + elif isinstance(self.bbox, list): + bbox = [] + for bbox_type_0_item_data in self.bbox: + bbox_type_0_item: float + bbox_type_0_item = bbox_type_0_item_data + bbox.append(bbox_type_0_item) + + else: + bbox = self.bbox + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "type": type_, + "coordinates": coordinates, + } + ) + if bbox is not UNSET: + field_dict["bbox"] = bbox + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + type_ = cast(Literal["MultiPolygon"], d.pop("type")) + if type_ != "MultiPolygon": + raise ValueError(f"type must match const 'MultiPolygon', got '{type_}'") + + coordinates = [] + _coordinates = d.pop("coordinates") + for coordinates_item_data in _coordinates: + coordinates_item = [] + _coordinates_item = coordinates_item_data + for coordinates_item_item_data in _coordinates_item: + coordinates_item_item = [] + _coordinates_item_item = coordinates_item_item_data + for coordinates_item_item_item_data in _coordinates_item_item: + + def _parse_coordinates_item_item_item(data: object) -> list[float]: + if not isinstance(data, list): + raise TypeError() + coordinates_item_item_item_type_0 = [] + _coordinates_item_item_item_type_0 = data + for ( + componentsschemas_position_2d_item_data + ) in _coordinates_item_item_item_type_0: + + def _parse_componentsschemas_position_2d_item( + data: object, + ) -> float: + return cast(float, data) + + componentsschemas_position_2d_item = ( + _parse_componentsschemas_position_2d_item( + componentsschemas_position_2d_item_data + ) + ) + + coordinates_item_item_item_type_0.append( + componentsschemas_position_2d_item + ) + + return coordinates_item_item_item_type_0 + + coordinates_item_item_item = _parse_coordinates_item_item_item( + coordinates_item_item_item_data + ) + + coordinates_item_item.append(coordinates_item_item_item) + + coordinates_item.append(coordinates_item_item) + + coordinates.append(coordinates_item) + + def _parse_bbox(data: object) -> list[float] | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, list): + raise TypeError() + bbox_type_0 = [] + _bbox_type_0 = data + for bbox_type_0_item_data in _bbox_type_0: + + def _parse_bbox_type_0_item(data: object) -> float: + return cast(float, data) + + bbox_type_0_item = _parse_bbox_type_0_item(bbox_type_0_item_data) + + bbox_type_0.append(bbox_type_0_item) + + return bbox_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(list[float] | None | Unset, data) + + bbox = _parse_bbox(d.pop("bbox", UNSET)) + + multi_polygon = cls( + type_=type_, + coordinates=coordinates, + bbox=bbox, + ) + + multi_polygon.additional_properties = d + return multi_polygon + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/non_burnable_fuel_model.py b/fastfuels_sdk/v2/client_library/models/non_burnable_fuel_model.py new file mode 100644 index 0000000..f256e4f --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/non_burnable_fuel_model.py @@ -0,0 +1,12 @@ +from enum import Enum + + +class NonBurnableFuelModel(str, Enum): + NB1 = "NB1" + NB2 = "NB2" + NB3 = "NB3" + NB8 = "NB8" + NB9 = "NB9" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/operator.py b/fastfuels_sdk/v2/client_library/models/operator.py new file mode 100644 index 0000000..bfab306 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/operator.py @@ -0,0 +1,13 @@ +from enum import Enum + + +class Operator(str, Enum): + EQ = "eq" + GE = "ge" + GT = "gt" + LE = "le" + LT = "lt" + NE = "ne" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/overlap_method.py b/fastfuels_sdk/v2/client_library/models/overlap_method.py new file mode 100644 index 0000000..31d85fd --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/overlap_method.py @@ -0,0 +1,10 @@ +from enum import Enum + + +class OverlapMethod(str, Enum): + MAX = "max" + MEAN = "mean" + MIN = "min" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/point.py b/fastfuels_sdk/v2/client_library/models/point.py new file mode 100644 index 0000000..135f977 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/point.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + Any, + Literal, + TypeVar, + cast, +) + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="Point") + + +@_attrs_define +class Point: + """Point Model + + Attributes: + type_ (Literal['Point']): + coordinates (list[float]): + bbox (list[float] | None | Unset): + """ + + type_: Literal["Point"] + coordinates: list[float] + bbox: list[float] | None | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + type_ = self.type_ + + coordinates: list[float] + if isinstance(self.coordinates, list): + coordinates = [] + for componentsschemas_position_2d_item_data in self.coordinates: + componentsschemas_position_2d_item: float + componentsschemas_position_2d_item = ( + componentsschemas_position_2d_item_data + ) + coordinates.append(componentsschemas_position_2d_item) + + bbox: list[float] | None | Unset + if isinstance(self.bbox, Unset): + bbox = UNSET + elif isinstance(self.bbox, list): + bbox = [] + for bbox_type_0_item_data in self.bbox: + bbox_type_0_item: float + bbox_type_0_item = bbox_type_0_item_data + bbox.append(bbox_type_0_item) + + else: + bbox = self.bbox + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "type": type_, + "coordinates": coordinates, + } + ) + if bbox is not UNSET: + field_dict["bbox"] = bbox + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + type_ = cast(Literal["Point"], d.pop("type")) + if type_ != "Point": + raise ValueError(f"type must match const 'Point', got '{type_}'") + + def _parse_coordinates(data: object) -> list[float]: + if not isinstance(data, list): + raise TypeError() + coordinates_type_0 = [] + _coordinates_type_0 = data + for componentsschemas_position_2d_item_data in _coordinates_type_0: + + def _parse_componentsschemas_position_2d_item(data: object) -> float: + return cast(float, data) + + componentsschemas_position_2d_item = ( + _parse_componentsschemas_position_2d_item( + componentsschemas_position_2d_item_data + ) + ) + + coordinates_type_0.append(componentsschemas_position_2d_item) + + return coordinates_type_0 + + coordinates = _parse_coordinates(d.pop("coordinates")) + + def _parse_bbox(data: object) -> list[float] | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, list): + raise TypeError() + bbox_type_0 = [] + _bbox_type_0 = data + for bbox_type_0_item_data in _bbox_type_0: + + def _parse_bbox_type_0_item(data: object) -> float: + return cast(float, data) + + bbox_type_0_item = _parse_bbox_type_0_item(bbox_type_0_item_data) + + bbox_type_0.append(bbox_type_0_item) + + return bbox_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(list[float] | None | Unset, data) + + bbox = _parse_bbox(d.pop("bbox", UNSET)) + + point = cls( + type_=type_, + coordinates=coordinates, + bbox=bbox, + ) + + point.additional_properties = d + return point + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/point_process.py b/fastfuels_sdk/v2/client_library/models/point_process.py new file mode 100644 index 0000000..4fe1a76 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/point_process.py @@ -0,0 +1,8 @@ +from enum import Enum + + +class PointProcess(str, Enum): + INHOMOGENEOUS_POISSON = "inhomogeneous_poisson" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/polygon.py b/fastfuels_sdk/v2/client_library/models/polygon.py new file mode 100644 index 0000000..73589ef --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/polygon.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + Any, + Literal, + TypeVar, + cast, +) + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="Polygon") + + +@_attrs_define +class Polygon: + """Polygon Model + + Attributes: + type_ (Literal['Polygon']): + coordinates (list[list[list[float]]]): + bbox (list[float] | None | Unset): + """ + + type_: Literal["Polygon"] + coordinates: list[list[list[float]]] + bbox: list[float] | None | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + type_ = self.type_ + + coordinates = [] + for coordinates_item_data in self.coordinates: + coordinates_item = [] + for coordinates_item_item_data in coordinates_item_data: + coordinates_item_item: list[float] + if isinstance(coordinates_item_item_data, list): + coordinates_item_item = [] + for ( + componentsschemas_position_2d_item_data + ) in coordinates_item_item_data: + componentsschemas_position_2d_item: float + componentsschemas_position_2d_item = ( + componentsschemas_position_2d_item_data + ) + coordinates_item_item.append(componentsschemas_position_2d_item) + + coordinates_item.append(coordinates_item_item) + + coordinates.append(coordinates_item) + + bbox: list[float] | None | Unset + if isinstance(self.bbox, Unset): + bbox = UNSET + elif isinstance(self.bbox, list): + bbox = [] + for bbox_type_0_item_data in self.bbox: + bbox_type_0_item: float + bbox_type_0_item = bbox_type_0_item_data + bbox.append(bbox_type_0_item) + + else: + bbox = self.bbox + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "type": type_, + "coordinates": coordinates, + } + ) + if bbox is not UNSET: + field_dict["bbox"] = bbox + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + type_ = cast(Literal["Polygon"], d.pop("type")) + if type_ != "Polygon": + raise ValueError(f"type must match const 'Polygon', got '{type_}'") + + coordinates = [] + _coordinates = d.pop("coordinates") + for coordinates_item_data in _coordinates: + coordinates_item = [] + _coordinates_item = coordinates_item_data + for coordinates_item_item_data in _coordinates_item: + + def _parse_coordinates_item_item(data: object) -> list[float]: + if not isinstance(data, list): + raise TypeError() + coordinates_item_item_type_0 = [] + _coordinates_item_item_type_0 = data + for ( + componentsschemas_position_2d_item_data + ) in _coordinates_item_item_type_0: + + def _parse_componentsschemas_position_2d_item( + data: object, + ) -> float: + return cast(float, data) + + componentsschemas_position_2d_item = ( + _parse_componentsschemas_position_2d_item( + componentsschemas_position_2d_item_data + ) + ) + + coordinates_item_item_type_0.append( + componentsschemas_position_2d_item + ) + + return coordinates_item_item_type_0 + + coordinates_item_item = _parse_coordinates_item_item( + coordinates_item_item_data + ) + + coordinates_item.append(coordinates_item_item) + + coordinates.append(coordinates_item) + + def _parse_bbox(data: object) -> list[float] | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, list): + raise TypeError() + bbox_type_0 = [] + _bbox_type_0 = data + for bbox_type_0_item_data in _bbox_type_0: + + def _parse_bbox_type_0_item(data: object) -> float: + return cast(float, data) + + bbox_type_0_item = _parse_bbox_type_0_item(bbox_type_0_item_data) + + bbox_type_0.append(bbox_type_0_item) + + return bbox_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(list[float] | None | Unset, data) + + bbox = _parse_bbox(d.pop("bbox", UNSET)) + + polygon = cls( + type_=type_, + coordinates=coordinates, + bbox=bbox, + ) + + polygon.additional_properties = d + return polygon + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/quic_fire_export_alignment_domain_target.py b/fastfuels_sdk/v2/client_library/models/quic_fire_export_alignment_domain_target.py new file mode 100644 index 0000000..76a5521 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/quic_fire_export_alignment_domain_target.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + Any, + Literal, + TypeVar, + cast, +) + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="QUICFireExportAlignmentDomainTarget") + + +@_attrs_define +class QUICFireExportAlignmentDomainTarget: + """Anchor the fire grid to the Domain bounding box. + + Output cells tile the Domain bbox at the given `dx` / `dy`, padded + outward if the bbox isn't already a whole multiple. `dz` sets the + uniform vertical cell size; the exporter always writes `aa1=1` so + fuel layers map 1:1 to QUIC-Fire cells. Defaults are QUIC-Fire's + recommended values (2 m horizontal, 1 m vertical). + + Attributes: + target (Literal['domain'] | Unset): Default: 'domain'. + dx (float | Unset): Horizontal fire-grid cell size in x (UTM east-west), in meters. QUIC-Fire recommends 2 m. + Default: 2.0. + dy (float | Unset): Horizontal fire-grid cell size in y (UTM north-south), in meters. Must equal `dx` — non- + square fire-grid cells are not supported. Default: 2.0. + dz (float | Unset): Vertical fire-grid cell size, in meters. QUIC-Fire recommends 1 m. Default: 1.0. + """ + + target: Literal["domain"] | Unset = "domain" + dx: float | Unset = 2.0 + dy: float | Unset = 2.0 + dz: float | Unset = 1.0 + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + target = self.target + + dx = self.dx + + dy = self.dy + + dz = self.dz + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if target is not UNSET: + field_dict["target"] = target + if dx is not UNSET: + field_dict["dx"] = dx + if dy is not UNSET: + field_dict["dy"] = dy + if dz is not UNSET: + field_dict["dz"] = dz + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + target = cast(Literal["domain"] | Unset, d.pop("target", UNSET)) + if target != "domain" and not isinstance(target, Unset): + raise ValueError(f"target must match const 'domain', got '{target}'") + + dx = d.pop("dx", UNSET) + + dy = d.pop("dy", UNSET) + + dz = d.pop("dz", UNSET) + + quic_fire_export_alignment_domain_target = cls( + target=target, + dx=dx, + dy=dy, + dz=dz, + ) + + quic_fire_export_alignment_domain_target.additional_properties = d + return quic_fire_export_alignment_domain_target + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/quic_fire_export_alignment_grid_target.py b/fastfuels_sdk/v2/client_library/models/quic_fire_export_alignment_grid_target.py new file mode 100644 index 0000000..ce99705 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/quic_fire_export_alignment_grid_target.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + Any, + Literal, + TypeVar, + cast, +) + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="QUICFireExportAlignmentGridTarget") + + +@_attrs_define +class QUICFireExportAlignmentGridTarget: + """Anchor the fire grid to an existing grid's lattice. + + Useful when role grids share a non-Domain-anchored lattice (e.g. all + chained off a `target="native"` master grid). The referenced grid's + CRS, transform, and shape become the fire grid's horizontal lattice; + vertical cell size and layer count are taken from the canopy grid. + + Attributes: + target (Literal['grid']): + grid_id (str): Existing grid whose horizontal lattice (CRS, transform, shape) the fire grid should match + exactly. + """ + + target: Literal["grid"] + grid_id: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + target = self.target + + grid_id = self.grid_id + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "target": target, + "grid_id": grid_id, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + target = cast(Literal["grid"], d.pop("target")) + if target != "grid": + raise ValueError(f"target must match const 'grid', got '{target}'") + + grid_id = d.pop("grid_id") + + quic_fire_export_alignment_grid_target = cls( + target=target, + grid_id=grid_id, + ) + + quic_fire_export_alignment_grid_target.additional_properties = d + return quic_fire_export_alignment_grid_target + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/quicfire_export_request.py b/fastfuels_sdk/v2/client_library/models/quicfire_export_request.py new file mode 100644 index 0000000..11ab90f --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/quicfire_export_request.py @@ -0,0 +1,387 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + TYPE_CHECKING, + Any, + Literal, + TypeVar, + cast, +) + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.quicfire_export_request_moist_merge import QuicfireExportRequestMoistMerge +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.field_source import FieldSource + from ..models.quic_fire_export_alignment_domain_target import ( + QUICFireExportAlignmentDomainTarget, + ) + from ..models.quic_fire_export_alignment_grid_target import ( + QUICFireExportAlignmentGridTarget, + ) + + +T = TypeVar("T", bound="QuicfireExportRequest") + + +@_attrs_define +class QuicfireExportRequest: + """Request body for creating a QUIC-Fire combined export. + + Five required roles produce `treesrhof.dat`, `treesmoist.dat`, and + `treesfueldepth.dat`. `topography` (optional) produces `topo.dat`. The + SAVR pair (optional, both-or-neither) produces `treesss.dat`. + + The fire grid is defined by the `alignment` field — either the Domain + bounding box padded to `(dx, dy)` (with `dz` vertical), or the lattice + of an existing grid. Every role grid must be lattice-aligned to this + fire grid and cover its full extent; otherwise the request is rejected. + The exporter only crops oversized roles by integer slicing — it never + resamples or reprojects. + + Attributes: + canopy_bulk_density (FieldSource): A single physical quantity drawn from one band on one grid. + + Every per-role input to the QUIC-Fire export uses this shape so the schema + is uniform across roles. The forward path for `nfuel>1` (when QUIC-Fire's + multi-fuel-type capability becomes relevant) is to allow each per-fuel-type + role to accept `FieldSource | dict[FuelType, FieldSource]`; today's scalar + requests keep working unchanged when that lands. + canopy_moisture (FieldSource): A single physical quantity drawn from one band on one grid. + + Every per-role input to the QUIC-Fire export uses this shape so the schema + is uniform across roles. The forward path for `nfuel>1` (when QUIC-Fire's + multi-fuel-type capability becomes relevant) is to allow each per-fuel-type + role to accept `FieldSource | dict[FuelType, FieldSource]`; today's scalar + requests keep working unchanged when that lands. + surface_fuel_load (FieldSource): A single physical quantity drawn from one band on one grid. + + Every per-role input to the QUIC-Fire export uses this shape so the schema + is uniform across roles. The forward path for `nfuel>1` (when QUIC-Fire's + multi-fuel-type capability becomes relevant) is to allow each per-fuel-type + role to accept `FieldSource | dict[FuelType, FieldSource]`; today's scalar + requests keep working unchanged when that lands. + surface_fuel_depth (FieldSource): A single physical quantity drawn from one band on one grid. + + Every per-role input to the QUIC-Fire export uses this shape so the schema + is uniform across roles. The forward path for `nfuel>1` (when QUIC-Fire's + multi-fuel-type capability becomes relevant) is to allow each per-fuel-type + role to accept `FieldSource | dict[FuelType, FieldSource]`; today's scalar + requests keep working unchanged when that lands. + surface_moisture (FieldSource): A single physical quantity drawn from one band on one grid. + + Every per-role input to the QUIC-Fire export uses this shape so the schema + is uniform across roles. The forward path for `nfuel>1` (when QUIC-Fire's + multi-fuel-type capability becomes relevant) is to allow each per-fuel-type + role to accept `FieldSource | dict[FuelType, FieldSource]`; today's scalar + requests keep working unchanged when that lands. + alignment (QUICFireExportAlignmentDomainTarget | QUICFireExportAlignmentGridTarget | Unset): How the fire grid + lattice is defined. Discriminated by `target`: `'domain'` (default) pads the Domain bbox to `(dx, dy)`; `'grid'` + matches an existing grid's lattice exactly. Omit for the default Domain-anchored 2 m / 1 m fire grid. + canopy_savr (FieldSource | None | Unset): 3D canopy SAVR (1/m), optional. When provided, must be paired with + `surface_savr`; together they produce `treesss.dat` in the output zip. + surface_savr (FieldSource | None | Unset): 2D surface SAVR (1/m), optional. Pairs with `canopy_savr`. + topography (FieldSource | None | Unset): 2D elevation (m), optional. When provided, produces `topo.dat`. + rhof_merge (Literal['sum'] | Unset): How to combine canopy and surface bulk density at the bottom slab (k=0). + Currently only 'sum' is supported: `merged[0] = canopy[0] + surface_load / dz`. Mass-additive. Default: 'sum'. + moist_merge (QuicfireExportRequestMoistMerge | Unset): How to combine canopy and surface fuel moisture at the + bottom slab (k=0). `'max'` (default, v1-parity): `merged[0] = max(canopy_moist[0], surface_moist)`. + `'weighted_avg'`: `merged[0] = (canopy_rhof[0] * canopy_moist[0] + surface_rhof_layer * surface_moist) / + (canopy_rhof[0] + surface_rhof_layer)` (both moistures already converted to fraction). Default: + QuicfireExportRequestMoistMerge.MAX. + savr_merge (Literal['weighted_avg'] | Unset): How to combine canopy and surface SAVR at the bottom slab. + Currently only 'weighted_avg' is supported (mass-weighted SAVR, then converted to particle size scale `2/SAVR` + before write). Only applies when both `canopy_savr` and `surface_savr` are set. Default: 'weighted_avg'. + expiration_days (int | Unset): Days until the signed download URL expires (max 7). Default: 7. + name (str | Unset): Default: ''. + description (str | Unset): Default: ''. + tags (list[str] | Unset): + """ + + canopy_bulk_density: FieldSource + canopy_moisture: FieldSource + surface_fuel_load: FieldSource + surface_fuel_depth: FieldSource + surface_moisture: FieldSource + alignment: ( + QUICFireExportAlignmentDomainTarget | QUICFireExportAlignmentGridTarget | Unset + ) = UNSET + canopy_savr: FieldSource | None | Unset = UNSET + surface_savr: FieldSource | None | Unset = UNSET + topography: FieldSource | None | Unset = UNSET + rhof_merge: Literal["sum"] | Unset = "sum" + moist_merge: QuicfireExportRequestMoistMerge | Unset = ( + QuicfireExportRequestMoistMerge.MAX + ) + savr_merge: Literal["weighted_avg"] | Unset = "weighted_avg" + expiration_days: int | Unset = 7 + name: str | Unset = "" + description: str | Unset = "" + tags: list[str] | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.field_source import FieldSource + from ..models.quic_fire_export_alignment_domain_target import ( + QUICFireExportAlignmentDomainTarget, + ) + + canopy_bulk_density = self.canopy_bulk_density.to_dict() + + canopy_moisture = self.canopy_moisture.to_dict() + + surface_fuel_load = self.surface_fuel_load.to_dict() + + surface_fuel_depth = self.surface_fuel_depth.to_dict() + + surface_moisture = self.surface_moisture.to_dict() + + alignment: dict[str, Any] | Unset + if isinstance(self.alignment, Unset): + alignment = UNSET + elif isinstance(self.alignment, QUICFireExportAlignmentDomainTarget): + alignment = self.alignment.to_dict() + else: + alignment = self.alignment.to_dict() + + canopy_savr: dict[str, Any] | None | Unset + if isinstance(self.canopy_savr, Unset): + canopy_savr = UNSET + elif isinstance(self.canopy_savr, FieldSource): + canopy_savr = self.canopy_savr.to_dict() + else: + canopy_savr = self.canopy_savr + + surface_savr: dict[str, Any] | None | Unset + if isinstance(self.surface_savr, Unset): + surface_savr = UNSET + elif isinstance(self.surface_savr, FieldSource): + surface_savr = self.surface_savr.to_dict() + else: + surface_savr = self.surface_savr + + topography: dict[str, Any] | None | Unset + if isinstance(self.topography, Unset): + topography = UNSET + elif isinstance(self.topography, FieldSource): + topography = self.topography.to_dict() + else: + topography = self.topography + + rhof_merge = self.rhof_merge + + moist_merge: str | Unset = UNSET + if not isinstance(self.moist_merge, Unset): + moist_merge = self.moist_merge.value + + savr_merge = self.savr_merge + + expiration_days = self.expiration_days + + name = self.name + + description = self.description + + tags: list[str] | Unset = UNSET + if not isinstance(self.tags, Unset): + tags = self.tags + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "canopy_bulk_density": canopy_bulk_density, + "canopy_moisture": canopy_moisture, + "surface_fuel_load": surface_fuel_load, + "surface_fuel_depth": surface_fuel_depth, + "surface_moisture": surface_moisture, + } + ) + if alignment is not UNSET: + field_dict["alignment"] = alignment + if canopy_savr is not UNSET: + field_dict["canopy_savr"] = canopy_savr + if surface_savr is not UNSET: + field_dict["surface_savr"] = surface_savr + if topography is not UNSET: + field_dict["topography"] = topography + if rhof_merge is not UNSET: + field_dict["rhof_merge"] = rhof_merge + if moist_merge is not UNSET: + field_dict["moist_merge"] = moist_merge + if savr_merge is not UNSET: + field_dict["savr_merge"] = savr_merge + if expiration_days is not UNSET: + field_dict["expiration_days"] = expiration_days + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if tags is not UNSET: + field_dict["tags"] = tags + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.field_source import FieldSource + from ..models.quic_fire_export_alignment_domain_target import ( + QUICFireExportAlignmentDomainTarget, + ) + from ..models.quic_fire_export_alignment_grid_target import ( + QUICFireExportAlignmentGridTarget, + ) + + d = dict(src_dict) + canopy_bulk_density = FieldSource.from_dict(d.pop("canopy_bulk_density")) + + canopy_moisture = FieldSource.from_dict(d.pop("canopy_moisture")) + + surface_fuel_load = FieldSource.from_dict(d.pop("surface_fuel_load")) + + surface_fuel_depth = FieldSource.from_dict(d.pop("surface_fuel_depth")) + + surface_moisture = FieldSource.from_dict(d.pop("surface_moisture")) + + def _parse_alignment( + data: object, + ) -> ( + QUICFireExportAlignmentDomainTarget + | QUICFireExportAlignmentGridTarget + | Unset + ): + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + alignment_type_0 = QUICFireExportAlignmentDomainTarget.from_dict(data) + + return alignment_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + if not isinstance(data, dict): + raise TypeError() + alignment_type_1 = QUICFireExportAlignmentGridTarget.from_dict(data) + + return alignment_type_1 + + alignment = _parse_alignment(d.pop("alignment", UNSET)) + + def _parse_canopy_savr(data: object) -> FieldSource | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + canopy_savr_type_0 = FieldSource.from_dict(data) + + return canopy_savr_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(FieldSource | None | Unset, data) + + canopy_savr = _parse_canopy_savr(d.pop("canopy_savr", UNSET)) + + def _parse_surface_savr(data: object) -> FieldSource | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + surface_savr_type_0 = FieldSource.from_dict(data) + + return surface_savr_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(FieldSource | None | Unset, data) + + surface_savr = _parse_surface_savr(d.pop("surface_savr", UNSET)) + + def _parse_topography(data: object) -> FieldSource | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + topography_type_0 = FieldSource.from_dict(data) + + return topography_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(FieldSource | None | Unset, data) + + topography = _parse_topography(d.pop("topography", UNSET)) + + rhof_merge = cast(Literal["sum"] | Unset, d.pop("rhof_merge", UNSET)) + if rhof_merge != "sum" and not isinstance(rhof_merge, Unset): + raise ValueError(f"rhof_merge must match const 'sum', got '{rhof_merge}'") + + _moist_merge = d.pop("moist_merge", UNSET) + moist_merge: QuicfireExportRequestMoistMerge | Unset + if isinstance(_moist_merge, Unset): + moist_merge = UNSET + else: + moist_merge = QuicfireExportRequestMoistMerge(_moist_merge) + + savr_merge = cast(Literal["weighted_avg"] | Unset, d.pop("savr_merge", UNSET)) + if savr_merge != "weighted_avg" and not isinstance(savr_merge, Unset): + raise ValueError( + f"savr_merge must match const 'weighted_avg', got '{savr_merge}'" + ) + + expiration_days = d.pop("expiration_days", UNSET) + + name = d.pop("name", UNSET) + + description = d.pop("description", UNSET) + + tags = cast(list[str], d.pop("tags", UNSET)) + + quicfire_export_request = cls( + canopy_bulk_density=canopy_bulk_density, + canopy_moisture=canopy_moisture, + surface_fuel_load=surface_fuel_load, + surface_fuel_depth=surface_fuel_depth, + surface_moisture=surface_moisture, + alignment=alignment, + canopy_savr=canopy_savr, + surface_savr=surface_savr, + topography=topography, + rhof_merge=rhof_merge, + moist_merge=moist_merge, + savr_merge=savr_merge, + expiration_days=expiration_days, + name=name, + description=description, + tags=tags, + ) + + quicfire_export_request.additional_properties = d + return quicfire_export_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/quicfire_export_request_moist_merge.py b/fastfuels_sdk/v2/client_library/models/quicfire_export_request_moist_merge.py new file mode 100644 index 0000000..b8dafc9 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/quicfire_export_request_moist_merge.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class QuicfireExportRequestMoistMerge(str, Enum): + MAX = "max" + WEIGHTED_AVG = "weighted_avg" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/remove_action.py b/fastfuels_sdk/v2/client_library/models/remove_action.py new file mode 100644 index 0000000..3b13b43 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/remove_action.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + Any, + Literal, + TypeVar, + cast, +) + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="RemoveAction") + + +@_attrs_define +class RemoveAction: + """Action that removes matching trees from the inventory. + + Attributes: + modifier (Literal['remove'] | Unset): Default: 'remove'. + """ + + modifier: Literal["remove"] | Unset = "remove" + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + modifier = self.modifier + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if modifier is not UNSET: + field_dict["modifier"] = modifier + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + modifier = cast(Literal["remove"] | Unset, d.pop("modifier", UNSET)) + if modifier != "remove" and not isinstance(modifier, Unset): + raise ValueError(f"modifier must match const 'remove', got '{modifier}'") + + remove_action = cls( + modifier=modifier, + ) + + remove_action.additional_properties = d + return remove_action + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/resampling_method.py b/fastfuels_sdk/v2/client_library/models/resampling_method.py new file mode 100644 index 0000000..e287911 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/resampling_method.py @@ -0,0 +1,21 @@ +from enum import Enum + + +class ResamplingMethod(str, Enum): + AVERAGE = "average" + BILINEAR = "bilinear" + CUBIC = "cubic" + CUBIC_SPLINE = "cubic_spline" + FIRST_QUARTILE = "first_quartile" + LANCZOS = "lanczos" + MAX = "max" + MEDIAN = "median" + MIN = "min" + MODE = "mode" + NEAREST = "nearest" + ROOT_MEAN_SQUARE = "root_mean_square" + SUM = "sum" + THIRD_QUARTILE = "third_quartile" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/resolution_3d.py b/fastfuels_sdk/v2/client_library/models/resolution_3d.py new file mode 100644 index 0000000..4bef871 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/resolution_3d.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define + +T = TypeVar("T", bound="Resolution3D") + + +@_attrs_define +class Resolution3D: + """Voxel resolution for a 3D grid. + + `horizontal` applies to both x and y (fastfuels-core requires isotropic + horizontal resolution). `vertical` is independent. + + Attributes: + horizontal (float): Cell size in x and y, meters. + vertical (float): Cell size in z, meters. + """ + + horizontal: float + vertical: float + + def to_dict(self) -> dict[str, Any]: + horizontal = self.horizontal + + vertical = self.vertical + + field_dict: dict[str, Any] = {} + + field_dict.update( + { + "horizontal": horizontal, + "vertical": vertical, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + horizontal = d.pop("horizontal") + + vertical = d.pop("vertical") + + resolution_3d = cls( + horizontal=horizontal, + vertical=vertical, + ) + + return resolution_3d diff --git a/fastfuels_sdk/v2/client_library/models/scope.py b/fastfuels_sdk/v2/client_library/models/scope.py new file mode 100644 index 0000000..06c3e11 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/scope.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class Scope(str, Enum): + READ = "read" + WRITE = "write" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/sort_order.py b/fastfuels_sdk/v2/client_library/models/sort_order.py new file mode 100644 index 0000000..87e7de1 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/sort_order.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class SortOrder(str, Enum): + ASCENDING = "ascending" + DESCENDING = "descending" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/sparse_grid_data.py b/fastfuels_sdk/v2/client_library/models/sparse_grid_data.py new file mode 100644 index 0000000..d3bc816 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/sparse_grid_data.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + Any, + Literal, + TypeVar, + cast, +) + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="SparseGridData") + + +@_attrs_define +class SparseGridData: + """ + Attributes: + format_ (Literal['sparse']): + fill_value (float | int | None): + indices (list[int]): + values (list[float | int]): + """ + + format_: Literal["sparse"] + fill_value: float | int | None + indices: list[int] + values: list[float | int] + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + format_ = self.format_ + + fill_value: float | int | None + fill_value = self.fill_value + + indices = self.indices + + values = [] + for values_item_data in self.values: + values_item: float | int + values_item = values_item_data + values.append(values_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "format": format_, + "fill_value": fill_value, + "indices": indices, + "values": values, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + format_ = cast(Literal["sparse"], d.pop("format")) + if format_ != "sparse": + raise ValueError(f"format must match const 'sparse', got '{format_}'") + + def _parse_fill_value(data: object) -> float | int | None: + if data is None: + return data + return cast(float | int | None, data) + + fill_value = _parse_fill_value(d.pop("fill_value")) + + indices = cast(list[int], d.pop("indices")) + + values = [] + _values = d.pop("values") + for values_item_data in _values: + + def _parse_values_item(data: object) -> float | int: + return cast(float | int, data) + + values_item = _parse_values_item(values_item_data) + + values.append(values_item) + + sparse_grid_data = cls( + format_=format_, + fill_value=fill_value, + indices=indices, + values=values, + ) + + sparse_grid_data.additional_properties = d + return sparse_grid_data + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/spatial_operator.py b/fastfuels_sdk/v2/client_library/models/spatial_operator.py new file mode 100644 index 0000000..67ba0be --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/spatial_operator.py @@ -0,0 +1,10 @@ +from enum import Enum + + +class SpatialOperator(str, Enum): + INTERSECTS = "intersects" + OUTSIDE = "outside" + WITHIN = "within" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/stem_isolation_lmf.py b/fastfuels_sdk/v2/client_library/models/stem_isolation_lmf.py new file mode 100644 index 0000000..3fb865e --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/stem_isolation_lmf.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + Any, + Literal, + TypeVar, + cast, +) + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="StemIsolationLmf") + + +@_attrs_define +class StemIsolationLmf: + """Parameters for Local Maximum Filter (LMF) stem isolation. + + Attributes: + name (Literal['lmf'] | Unset): Default: 'lmf'. + min_height (float | Unset): Minimum height threshold (in CHM units) for a treetop. Default: 2.0. + footprint_size (int | Unset): Diameter of the circular footprint in pixels. Must be an odd integer. Default: 3. + """ + + name: Literal["lmf"] | Unset = "lmf" + min_height: float | Unset = 2.0 + footprint_size: int | Unset = 3 + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + name = self.name + + min_height = self.min_height + + footprint_size = self.footprint_size + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if name is not UNSET: + field_dict["name"] = name + if min_height is not UNSET: + field_dict["min_height"] = min_height + if footprint_size is not UNSET: + field_dict["footprint_size"] = footprint_size + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + name = cast(Literal["lmf"] | Unset, d.pop("name", UNSET)) + if name != "lmf" and not isinstance(name, Unset): + raise ValueError(f"name must match const 'lmf', got '{name}'") + + min_height = d.pop("min_height", UNSET) + + footprint_size = d.pop("footprint_size", UNSET) + + stem_isolation_lmf = cls( + name=name, + min_height=min_height, + footprint_size=footprint_size, + ) + + stem_isolation_lmf.additional_properties = d + return stem_isolation_lmf + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/stem_isolation_vwf.py b/fastfuels_sdk/v2/client_library/models/stem_isolation_vwf.py new file mode 100644 index 0000000..b2112c0 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/stem_isolation_vwf.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + Any, + Literal, + TypeVar, + cast, +) + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="StemIsolationVwf") + + +@_attrs_define +class StemIsolationVwf: + """Parameters for Variable Window Filter (VWF) stem isolation. + + Attributes: + name (Literal['vwf'] | Unset): Default: 'vwf'. + min_height (float | Unset): Minimum height threshold (in CHM units) for a treetop. Default: 2.0. + spatial_resolution (float | None | Unset): Spatial resolution of the CHM. If omitted, it will be automatically + inferred from the source grid metadata. + crown_ratio (float | Unset): Multiplier used to dynamically scale the search window based on pixel height. + Default: 0.1. + crown_offset (float | Unset): Constant offset (in meters) added to the dynamic search window. Default: 1.0. + """ + + name: Literal["vwf"] | Unset = "vwf" + min_height: float | Unset = 2.0 + spatial_resolution: float | None | Unset = UNSET + crown_ratio: float | Unset = 0.1 + crown_offset: float | Unset = 1.0 + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + name = self.name + + min_height = self.min_height + + spatial_resolution: float | None | Unset + if isinstance(self.spatial_resolution, Unset): + spatial_resolution = UNSET + else: + spatial_resolution = self.spatial_resolution + + crown_ratio = self.crown_ratio + + crown_offset = self.crown_offset + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if name is not UNSET: + field_dict["name"] = name + if min_height is not UNSET: + field_dict["min_height"] = min_height + if spatial_resolution is not UNSET: + field_dict["spatial_resolution"] = spatial_resolution + if crown_ratio is not UNSET: + field_dict["crown_ratio"] = crown_ratio + if crown_offset is not UNSET: + field_dict["crown_offset"] = crown_offset + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + name = cast(Literal["vwf"] | Unset, d.pop("name", UNSET)) + if name != "vwf" and not isinstance(name, Unset): + raise ValueError(f"name must match const 'vwf', got '{name}'") + + min_height = d.pop("min_height", UNSET) + + def _parse_spatial_resolution(data: object) -> float | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(float | None | Unset, data) + + spatial_resolution = _parse_spatial_resolution( + d.pop("spatial_resolution", UNSET) + ) + + crown_ratio = d.pop("crown_ratio", UNSET) + + crown_offset = d.pop("crown_offset", UNSET) + + stem_isolation_vwf = cls( + name=name, + min_height=min_height, + spatial_resolution=spatial_resolution, + crown_ratio=crown_ratio, + crown_offset=crown_offset, + ) + + stem_isolation_vwf.additional_properties = d + return stem_isolation_vwf + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/three_dep_coverage_response.py b/fastfuels_sdk/v2/client_library/models/three_dep_coverage_response.py new file mode 100644 index 0000000..b282e25 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/three_dep_coverage_response.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.three_dep_resolution import ThreeDepResolution +from ..types import UNSET, Unset + +T = TypeVar("T", bound="ThreeDepCoverageResponse") + + +@_attrs_define +class ThreeDepCoverageResponse: + """Response model for 3DEP tile coverage pre-flight check. + + Attributes: + resolution (ThreeDepResolution): Available resolutions for 3DEP data (meters). + available (bool): + tile_count (int): + tiles (list[str]): + acquisition_dates (list[str] | None | Unset): + """ + + resolution: ThreeDepResolution + available: bool + tile_count: int + tiles: list[str] + acquisition_dates: list[str] | None | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + resolution = self.resolution.value + + available = self.available + + tile_count = self.tile_count + + tiles = self.tiles + + acquisition_dates: list[str] | None | Unset + if isinstance(self.acquisition_dates, Unset): + acquisition_dates = UNSET + elif isinstance(self.acquisition_dates, list): + acquisition_dates = self.acquisition_dates + + else: + acquisition_dates = self.acquisition_dates + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "resolution": resolution, + "available": available, + "tile_count": tile_count, + "tiles": tiles, + } + ) + if acquisition_dates is not UNSET: + field_dict["acquisition_dates"] = acquisition_dates + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + resolution = ThreeDepResolution(d.pop("resolution")) + + available = d.pop("available") + + tile_count = d.pop("tile_count") + + tiles = cast(list[str], d.pop("tiles")) + + def _parse_acquisition_dates(data: object) -> list[str] | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, list): + raise TypeError() + acquisition_dates_type_0 = cast(list[str], data) + + return acquisition_dates_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(list[str] | None | Unset, data) + + acquisition_dates = _parse_acquisition_dates(d.pop("acquisition_dates", UNSET)) + + three_dep_coverage_response = cls( + resolution=resolution, + available=available, + tile_count=tile_count, + tiles=tiles, + acquisition_dates=acquisition_dates, + ) + + three_dep_coverage_response.additional_properties = d + return three_dep_coverage_response + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/three_dep_resolution.py b/fastfuels_sdk/v2/client_library/models/three_dep_resolution.py new file mode 100644 index 0000000..adfcd21 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/three_dep_resolution.py @@ -0,0 +1,10 @@ +from enum import IntEnum + + +class ThreeDepResolution(IntEnum): + VALUE_1 = 1 + VALUE_10 = 10 + VALUE_30 = 30 + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/topography_band.py b/fastfuels_sdk/v2/client_library/models/topography_band.py new file mode 100644 index 0000000..359226c --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/topography_band.py @@ -0,0 +1,10 @@ +from enum import Enum + + +class TopographyBand(str, Enum): + ASPECT = "aspect" + ELEVATION = "elevation" + SLOPE = "slope" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/tree_band.py b/fastfuels_sdk/v2/client_library/models/tree_band.py new file mode 100644 index 0000000..0626250 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/tree_band.py @@ -0,0 +1,19 @@ +from enum import Enum + + +class TreeBand(str, Enum): + BULK_DENSITY_BRANCHWOOD_DEAD = "bulk_density.branchwood.dead" + BULK_DENSITY_BRANCHWOOD_LIVE = "bulk_density.branchwood.live" + BULK_DENSITY_FINE_DEAD = "bulk_density.fine.dead" + BULK_DENSITY_FINE_LIVE = "bulk_density.fine.live" + BULK_DENSITY_FOLIAGE_DEAD = "bulk_density.foliage.dead" + BULK_DENSITY_FOLIAGE_LIVE = "bulk_density.foliage.live" + FUEL_MOISTURE_DEAD = "fuel_moisture.dead" + FUEL_MOISTURE_LIVE = "fuel_moisture.live" + SAVR_FOLIAGE = "savr.foliage" + SPCD = "spcd" + TREE_ID = "tree_id" + VOLUME_FRACTION = "volume_fraction" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/tree_map_band.py b/fastfuels_sdk/v2/client_library/models/tree_map_band.py new file mode 100644 index 0000000..656b64c --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/tree_map_band.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class TreeMapBand(str, Enum): + PLT_CN = "plt_cn" + TM_ID = "tm_id" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/tree_map_version.py b/fastfuels_sdk/v2/client_library/models/tree_map_version.py new file mode 100644 index 0000000..295708a --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/tree_map_version.py @@ -0,0 +1,11 @@ +from enum import Enum + + +class TreeMapVersion(str, Enum): + VALUE_0 = "2014" + VALUE_1 = "2016" + VALUE_2 = "2020" + VALUE_3 = "2022" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/uniform_band.py b/fastfuels_sdk/v2/client_library/models/uniform_band.py new file mode 100644 index 0000000..d6e5894 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/uniform_band.py @@ -0,0 +1,19 @@ +from enum import Enum + + +class UniformBand(str, Enum): + CURING = "curing" + FUEL_DEPTH = "fuel_depth" + FUEL_LOAD_100HR = "fuel_load.100hr" + FUEL_LOAD_10HR = "fuel_load.10hr" + FUEL_LOAD_1HR = "fuel_load.1hr" + FUEL_LOAD_LIVE_HERB = "fuel_load.live_herb" + FUEL_LOAD_LIVE_WOODY = "fuel_load.live_woody" + FUEL_MOISTURE_100HR = "fuel_moisture.100hr" + FUEL_MOISTURE_10HR = "fuel_moisture.10hr" + FUEL_MOISTURE_1HR = "fuel_moisture.1hr" + FUEL_MOISTURE_LIVE_HERB = "fuel_moisture.live_herb" + FUEL_MOISTURE_LIVE_WOODY = "fuel_moisture.live_woody" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/uniform_band_input.py b/fastfuels_sdk/v2/client_library/models/uniform_band_input.py new file mode 100644 index 0000000..0dae3da --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/uniform_band_input.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.uniform_band import UniformBand + +T = TypeVar("T", bound="UniformBandInput") + + +@_attrs_define +class UniformBandInput: + """A single band specification for a uniform grid. + + Users provide a band key (from the predefined list) and a constant value. + The API resolves the key to unit and type. + + Attributes: + key (UniformBand): Predefined bands available for uniform grids. + value (float | int): + """ + + key: UniformBand + value: float | int + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + key = self.key.value + + value: float | int + value = self.value + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "key": key, + "value": value, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + key = UniformBand(d.pop("key")) + + def _parse_value(data: object) -> float | int: + return cast(float | int, data) + + value = _parse_value(d.pop("value")) + + uniform_band_input = cls( + key=key, + value=value, + ) + + uniform_band_input.additional_properties = d + return uniform_band_input + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/uniform_moisture_value.py b/fastfuels_sdk/v2/client_library/models/uniform_moisture_value.py new file mode 100644 index 0000000..c90a684 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/uniform_moisture_value.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + Any, + Literal, + TypeVar, + cast, +) + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="UniformMoistureValue") + + +@_attrs_define +class UniformMoistureValue: + """Uniform fuel moisture for one fuel state. + + Attributes: + method (Literal['uniform'] | Unset): Default: 'uniform'. + value (float | Unset): Fuel moisture content (%), applied uniformly. Default: 100.0. + """ + + method: Literal["uniform"] | Unset = "uniform" + value: float | Unset = 100.0 + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + method = self.method + + value = self.value + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if method is not UNSET: + field_dict["method"] = method + if value is not UNSET: + field_dict["value"] = value + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + method = cast(Literal["uniform"] | Unset, d.pop("method", UNSET)) + if method != "uniform" and not isinstance(method, Unset): + raise ValueError(f"method must match const 'uniform', got '{method}'") + + value = d.pop("value", UNSET) + + uniform_moisture_value = cls( + method=method, + value=value, + ) + + uniform_moisture_value.additional_properties = d + return uniform_moisture_value + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/update_application_request.py b/fastfuels_sdk/v2/client_library/models/update_application_request.py new file mode 100644 index 0000000..d0f9905 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/update_application_request.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="UpdateApplicationRequest") + + +@_attrs_define +class UpdateApplicationRequest: + """Request body for updating an application. + + Attributes: + name (None | str | Unset): New name for the application. + description (None | str | Unset): New description for the application. + """ + + name: None | str | Unset = UNSET + description: None | str | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + name: None | str | Unset + if isinstance(self.name, Unset): + name = UNSET + else: + name = self.name + + description: None | str | Unset + if isinstance(self.description, Unset): + description = UNSET + else: + description = self.description + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + + def _parse_name(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + name = _parse_name(d.pop("name", UNSET)) + + def _parse_description(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + description = _parse_description(d.pop("description", UNSET)) + + update_application_request = cls( + name=name, + description=description, + ) + + update_application_request.additional_properties = d + return update_application_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/update_domain_request_body.py b/fastfuels_sdk/v2/client_library/models/update_domain_request_body.py new file mode 100644 index 0000000..18f8f8a --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/update_domain_request_body.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.domain_style import DomainStyle + + +T = TypeVar("T", bound="UpdateDomainRequestBody") + + +@_attrs_define +class UpdateDomainRequestBody: + """Request body for updating a domain's metadata. + + All fields are optional. Only provided fields will be updated. + Geometry (features) and CRS cannot be modified after creation. + + Attributes: + name (None | str | Unset): The name of the domain. + description (None | str | Unset): A description of the domain. + tags (list[str] | None | Unset): A list of tags associated with the domain. + style (DomainStyle | None | Unset): Update visual style fields. Only provided sub-fields are merged into the + existing style; unspecified sub-fields preserve their current values. + """ + + name: None | str | Unset = UNSET + description: None | str | Unset = UNSET + tags: list[str] | None | Unset = UNSET + style: DomainStyle | None | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.domain_style import DomainStyle + + name: None | str | Unset + if isinstance(self.name, Unset): + name = UNSET + else: + name = self.name + + description: None | str | Unset + if isinstance(self.description, Unset): + description = UNSET + else: + description = self.description + + tags: list[str] | None | Unset + if isinstance(self.tags, Unset): + tags = UNSET + elif isinstance(self.tags, list): + tags = self.tags + + else: + tags = self.tags + + style: dict[str, Any] | None | Unset + if isinstance(self.style, Unset): + style = UNSET + elif isinstance(self.style, DomainStyle): + style = self.style.to_dict() + else: + style = self.style + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if tags is not UNSET: + field_dict["tags"] = tags + if style is not UNSET: + field_dict["style"] = style + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.domain_style import DomainStyle + + d = dict(src_dict) + + def _parse_name(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + name = _parse_name(d.pop("name", UNSET)) + + def _parse_description(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + description = _parse_description(d.pop("description", UNSET)) + + def _parse_tags(data: object) -> list[str] | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, list): + raise TypeError() + tags_type_0 = cast(list[str], data) + + return tags_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(list[str] | None | Unset, data) + + tags = _parse_tags(d.pop("tags", UNSET)) + + def _parse_style(data: object) -> DomainStyle | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + style_type_0 = DomainStyle.from_dict(data) + + return style_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(DomainStyle | None | Unset, data) + + style = _parse_style(d.pop("style", UNSET)) + + update_domain_request_body = cls( + name=name, + description=description, + tags=tags, + style=style, + ) + + update_domain_request_body.additional_properties = d + return update_domain_request_body + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/update_export_request_body.py b/fastfuels_sdk/v2/client_library/models/update_export_request_body.py new file mode 100644 index 0000000..2725755 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/update_export_request_body.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="UpdateExportRequestBody") + + +@_attrs_define +class UpdateExportRequestBody: + """Request body for updating export metadata. + + Attributes: + name (None | str | Unset): + description (None | str | Unset): + tags (list[str] | None | Unset): + """ + + name: None | str | Unset = UNSET + description: None | str | Unset = UNSET + tags: list[str] | None | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + name: None | str | Unset + if isinstance(self.name, Unset): + name = UNSET + else: + name = self.name + + description: None | str | Unset + if isinstance(self.description, Unset): + description = UNSET + else: + description = self.description + + tags: list[str] | None | Unset + if isinstance(self.tags, Unset): + tags = UNSET + elif isinstance(self.tags, list): + tags = self.tags + + else: + tags = self.tags + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if tags is not UNSET: + field_dict["tags"] = tags + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + + def _parse_name(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + name = _parse_name(d.pop("name", UNSET)) + + def _parse_description(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + description = _parse_description(d.pop("description", UNSET)) + + def _parse_tags(data: object) -> list[str] | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, list): + raise TypeError() + tags_type_0 = cast(list[str], data) + + return tags_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(list[str] | None | Unset, data) + + tags = _parse_tags(d.pop("tags", UNSET)) + + update_export_request_body = cls( + name=name, + description=description, + tags=tags, + ) + + update_export_request_body.additional_properties = d + return update_export_request_body + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/update_feature_request_body.py b/fastfuels_sdk/v2/client_library/models/update_feature_request_body.py new file mode 100644 index 0000000..aa044ed --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/update_feature_request_body.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="UpdateFeatureRequestBody") + + +@_attrs_define +class UpdateFeatureRequestBody: + """Request body for updating feature metadata. + + Attributes: + name (None | str | Unset): + description (None | str | Unset): + tags (list[str] | None | Unset): + """ + + name: None | str | Unset = UNSET + description: None | str | Unset = UNSET + tags: list[str] | None | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + name: None | str | Unset + if isinstance(self.name, Unset): + name = UNSET + else: + name = self.name + + description: None | str | Unset + if isinstance(self.description, Unset): + description = UNSET + else: + description = self.description + + tags: list[str] | None | Unset + if isinstance(self.tags, Unset): + tags = UNSET + elif isinstance(self.tags, list): + tags = self.tags + + else: + tags = self.tags + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if tags is not UNSET: + field_dict["tags"] = tags + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + + def _parse_name(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + name = _parse_name(d.pop("name", UNSET)) + + def _parse_description(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + description = _parse_description(d.pop("description", UNSET)) + + def _parse_tags(data: object) -> list[str] | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, list): + raise TypeError() + tags_type_0 = cast(list[str], data) + + return tags_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(list[str] | None | Unset, data) + + tags = _parse_tags(d.pop("tags", UNSET)) + + update_feature_request_body = cls( + name=name, + description=description, + tags=tags, + ) + + update_feature_request_body.additional_properties = d + return update_feature_request_body + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/update_grid_request_body.py b/fastfuels_sdk/v2/client_library/models/update_grid_request_body.py new file mode 100644 index 0000000..7e26569 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/update_grid_request_body.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="UpdateGridRequestBody") + + +@_attrs_define +class UpdateGridRequestBody: + """Request body for updating grid metadata. + + Attributes: + name (None | str | Unset): + description (None | str | Unset): + tags (list[str] | None | Unset): + """ + + name: None | str | Unset = UNSET + description: None | str | Unset = UNSET + tags: list[str] | None | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + name: None | str | Unset + if isinstance(self.name, Unset): + name = UNSET + else: + name = self.name + + description: None | str | Unset + if isinstance(self.description, Unset): + description = UNSET + else: + description = self.description + + tags: list[str] | None | Unset + if isinstance(self.tags, Unset): + tags = UNSET + elif isinstance(self.tags, list): + tags = self.tags + + else: + tags = self.tags + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if tags is not UNSET: + field_dict["tags"] = tags + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + + def _parse_name(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + name = _parse_name(d.pop("name", UNSET)) + + def _parse_description(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + description = _parse_description(d.pop("description", UNSET)) + + def _parse_tags(data: object) -> list[str] | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, list): + raise TypeError() + tags_type_0 = cast(list[str], data) + + return tags_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(list[str] | None | Unset, data) + + tags = _parse_tags(d.pop("tags", UNSET)) + + update_grid_request_body = cls( + name=name, + description=description, + tags=tags, + ) + + update_grid_request_body.additional_properties = d + return update_grid_request_body + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/update_inventory_request_body.py b/fastfuels_sdk/v2/client_library/models/update_inventory_request_body.py new file mode 100644 index 0000000..f8ec94d --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/update_inventory_request_body.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="UpdateInventoryRequestBody") + + +@_attrs_define +class UpdateInventoryRequestBody: + """Request body for updating inventory metadata. + + Attributes: + name (None | str | Unset): + description (None | str | Unset): + tags (list[str] | None | Unset): + """ + + name: None | str | Unset = UNSET + description: None | str | Unset = UNSET + tags: list[str] | None | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + name: None | str | Unset + if isinstance(self.name, Unset): + name = UNSET + else: + name = self.name + + description: None | str | Unset + if isinstance(self.description, Unset): + description = UNSET + else: + description = self.description + + tags: list[str] | None | Unset + if isinstance(self.tags, Unset): + tags = UNSET + elif isinstance(self.tags, list): + tags = self.tags + + else: + tags = self.tags + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if tags is not UNSET: + field_dict["tags"] = tags + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + + def _parse_name(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + name = _parse_name(d.pop("name", UNSET)) + + def _parse_description(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + description = _parse_description(d.pop("description", UNSET)) + + def _parse_tags(data: object) -> list[str] | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, list): + raise TypeError() + tags_type_0 = cast(list[str], data) + + return tags_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(list[str] | None | Unset, data) + + tags = _parse_tags(d.pop("tags", UNSET)) + + update_inventory_request_body = cls( + name=name, + description=description, + tags=tags, + ) + + update_inventory_request_body.additional_properties = d + return update_inventory_request_body + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/upload_band_definition.py b/fastfuels_sdk/v2/client_library/models/upload_band_definition.py new file mode 100644 index 0000000..92508a7 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/upload_band_definition.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.band_type import BandType +from ..types import UNSET, Unset + +T = TypeVar("T", bound="UploadBandDefinition") + + +@_attrs_define +class UploadBandDefinition: + """ + Attributes: + key (str): Dot-notation variable name, e.g. 'bulk_density.foliage' + type_ (BandType): Type of band data. + unit (None | str | Unset): Physical unit of the band's pixel values, in UDUNITS-2-conformant ASCII form with + `**` for exponents (e.g. `kg/m**3`, `1/m`, `%`). Optional for categorical/identifier bands. Non-canonical forms + (`kg/m³`, `kg/m^3`, `kg/m3`) are rejected. See docs/units.md. + """ + + key: str + type_: BandType + unit: None | str | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + key = self.key + + type_ = self.type_.value + + unit: None | str | Unset + if isinstance(self.unit, Unset): + unit = UNSET + else: + unit = self.unit + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "key": key, + "type": type_, + } + ) + if unit is not UNSET: + field_dict["unit"] = unit + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + key = d.pop("key") + + type_ = BandType(d.pop("type")) + + def _parse_unit(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + unit = _parse_unit(d.pop("unit", UNSET)) + + upload_band_definition = cls( + key=key, + type_=type_, + unit=unit, + ) + + upload_band_definition.additional_properties = d + return upload_band_definition + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/validation_error.py b/fastfuels_sdk/v2/client_library/models/validation_error.py new file mode 100644 index 0000000..cb0708f --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/validation_error.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="ValidationError") + + +@_attrs_define +class ValidationError: + """ + Attributes: + loc (list[int | str]): + msg (str): + type_ (str): + """ + + loc: list[int | str] + msg: str + type_: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + loc = [] + for loc_item_data in self.loc: + loc_item: int | str + loc_item = loc_item_data + loc.append(loc_item) + + msg = self.msg + + type_ = self.type_ + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "loc": loc, + "msg": msg, + "type": type_, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + loc = [] + _loc = d.pop("loc") + for loc_item_data in _loc: + + def _parse_loc_item(data: object) -> int | str: + return cast(int | str, data) + + loc_item = _parse_loc_item(loc_item_data) + + loc.append(loc_item) + + msg = d.pop("msg") + + type_ = d.pop("type") + + validation_error = cls( + loc=loc, + msg=msg, + type_=type_, + ) + + validation_error.additional_properties = d + return validation_error + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/types.py b/fastfuels_sdk/v2/client_library/types.py new file mode 100644 index 0000000..b64af09 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/types.py @@ -0,0 +1,54 @@ +"""Contains some shared types for properties""" + +from collections.abc import Mapping, MutableMapping +from http import HTTPStatus +from typing import IO, BinaryIO, Generic, Literal, TypeVar + +from attrs import define + + +class Unset: + def __bool__(self) -> Literal[False]: + return False + + +UNSET: Unset = Unset() + +# The types that `httpx.Client(files=)` can accept, copied from that library. +FileContent = IO[bytes] | bytes | str +FileTypes = ( + # (filename, file (or bytes), content_type) + tuple[str | None, FileContent, str | None] + # (filename, file (or bytes), content_type, headers) + | tuple[str | None, FileContent, str | None, Mapping[str, str]] +) +RequestFiles = list[tuple[str, FileTypes]] + + +@define +class File: + """Contains information for file uploads""" + + payload: BinaryIO + file_name: str | None = None + mime_type: str | None = None + + def to_tuple(self) -> FileTypes: + """Return a tuple representation that httpx will accept for multipart/form-data""" + return self.file_name, self.payload, self.mime_type + + +T = TypeVar("T") + + +@define +class Response(Generic[T]): + """A response from an endpoint""" + + status_code: HTTPStatus + content: bytes + headers: MutableMapping[str, str] + parsed: T | None + + +__all__ = ["UNSET", "File", "FileTypes", "RequestFiles", "Response", "Unset"] diff --git a/fastfuels_sdk/v2/generate_clients.sh b/fastfuels_sdk/v2/generate_clients.sh new file mode 100644 index 0000000..a250228 --- /dev/null +++ b/fastfuels_sdk/v2/generate_clients.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Regenerate the v2 client library (openapi-python-client). +# Run from this directory: bash generate_clients.sh +# +# Requirements: uv (openapi-python-client is run via uvx, no install needed) +set -euo pipefail + +URL="https://api-v2-prod-782971006568.us-west1.run.app" +SPEC=$(mktemp /tmp/v2_openapi.XXXXXX.json) +PATCHED=$(mktemp /tmp/v2_openapi_patched.XXXXXX.json) + +curl -s "$URL/openapi.json" > "$SPEC" + +# The spec contains two schemas titled "Feature": the FastFuels feature +# resource and geojson_pydantic's GeoJSON Feature (auto-namespaced by +# FastAPI as geojson_pydantic__features__Feature, but with title "Feature"). +# openapi-python-client names classes from titles and refuses duplicates, +# so re-title the GeoJSON one. Long-term fix: re-title the model in the +# FastFuels-API-v2 repo so the spec has no collision. +python3 - "$SPEC" "$PATCHED" <<'EOF' +import json, sys +spec = json.load(open(sys.argv[1])) +spec["components"]["schemas"]["geojson_pydantic__features__Feature"]["title"] = "GeoJsonFeature" +json.dump(spec, open(sys.argv[2], "w")) +EOF + +uvx openapi-python-client generate \ + --path "$PATCHED" \ + --meta none \ + --output-path client_library \ + --overwrite + +rm -f "$SPEC" "$PATCHED" +echo "Done." From 457d308658688be658be4f6ca34d81ea36669d6c Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Fri, 5 Jun 2026 12:25:54 -0600 Subject: [PATCH 02/71] Add v2 wrapper drafts and generator comparison record The draft modules are the seeds for the real v2 wrappers (#177-#180): Domain at full v1 parity (from_id/from_geojson/from_geodataframe/ from_file/get/update/delete/list, to_geodataframe/to_json), and job-based Grid (LANDFIRE FBFM40, TreeMap/PIM, 3DEP topography), Feature (OSM road/water), and Inventory (PIM grid -> tree inventory) with a shared get/wait_until_completed/delete/list lifecycle that should be factored into a common base when promoted (#176). COMPARISON.md records the openapi-generator vs openapi-python-client evaluation that selected the latter: both generators were run against the same spec and the same wrapper surface was written twice across all four resources. Key findings: the spec is anyOf-saturated (38% of schemas, structural to FastAPI/pydantic), openapi-generator 7.22.0 emits broken code for the GeoJSON coordinate schemas (cannot deserialize any Domain) and wrapper-object ergonomics elsewhere, while ~68% of the wrapper code is generator-independent. Part of #176 --- fastfuels_sdk/v2/COMPARISON.md | 159 ++++++++++ fastfuels_sdk/v2/draft_domains_opc.py | 336 ++++++++++++++++++++++ fastfuels_sdk/v2/draft_features_opc.py | 203 +++++++++++++ fastfuels_sdk/v2/draft_grids_opc.py | 214 ++++++++++++++ fastfuels_sdk/v2/draft_inventories_opc.py | 159 ++++++++++ 5 files changed, 1071 insertions(+) create mode 100644 fastfuels_sdk/v2/COMPARISON.md create mode 100644 fastfuels_sdk/v2/draft_domains_opc.py create mode 100644 fastfuels_sdk/v2/draft_features_opc.py create mode 100644 fastfuels_sdk/v2/draft_grids_opc.py create mode 100644 fastfuels_sdk/v2/draft_inventories_opc.py diff --git a/fastfuels_sdk/v2/COMPARISON.md b/fastfuels_sdk/v2/COMPARISON.md new file mode 100644 index 0000000..2218b52 --- /dev/null +++ b/fastfuels_sdk/v2/COMPARISON.md @@ -0,0 +1,159 @@ +# v2 Client Library Generator Comparison + +> **Status: decision executed (2026-06-05).** openapi-python-client won; +> its output now lives at `fastfuels_sdk/v2/client_library/` and the +> `draft_*_opc.py` modules import from there. The openapi-generator tree +> and the `draft_*_oag.py` modules have been deleted (regenerable via the +> recipe below if the comparison ever needs to be revisited). This +> document is the record of the evaluation. + +Two complete generated clients for the FastFuels v2 API (OpenAPI **3.1**, +FastAPI, pydantic/geojson-pydantic), each exercised through **four pairs of +draft wrapper modules** at v1-parity ergonomics, structured identically so +each `diff draft_X_oag.py draft_X_opc.py` reads as generator differences +only (`NOTE (comparison)` comments mark each forced divergence): + +| Resource | Drafts | Endpoints covered | +|---|---|---| +| Domains | `draft_domains_{oag,opc}.py` | create (from_geojson / from_geodataframe / from_file), get, update, delete, list, to_geodataframe/to_json | +| Grids | `draft_grids_{oag,opc}.py` | LANDFIRE FBFM40, TreeMap (PIM), 3DEP topography/elevation; get, wait_until_completed, delete, list | +| Features | `draft_features_{oag,opc}.py` | OSM road, OSM water; get, wait_until_completed, update, delete, list | +| Inventories | `draft_inventories_{oag,opc}.py` | **PIM grid → tree inventory** (`create_from_pim_grid`); get, wait_until_completed, delete, list | + +| | openapi-generator 7.22.0 | openapi-python-client 0.29.0 | +|---|---|---| +| Stack | pydantic v2 + urllib3 (same as v1) | attrs + httpx | +| Imports | absolute (fixed via dotted `--package-name`) | relative (relocatable as-is) | +| OpenAPI 3.1 support | "still in beta" (generator warning) | native | +| Generated size | 209 model files, **29 \*Api classes** (multi-tag operations duplicated into one class per tag) | 195 model files, 11 api/ packages (one per resource) | +| Functional against the live API | domains: **no** (broken geometry codegen); grids/features/inventories: yes (geometry-free payloads) | all resources: yes | + +## Verdict: openapi-python-client + +The deciding criterion is `anyOf` handling (next section): the v2 spec is +saturated with `anyOf`, openapi-generator's treatment of it ranges from +awkward (wrapper objects) to broken (geometry parsing crashes), and this +is structural to how FastAPI emits specs — not fixable by waiting. +openapi-generator's genuine advantages — typed exceptions and the +v1-familiar per-resource Api classes — are thin layers that the real v2 +wrappers can recreate over opc (see Design notes). + +## Why `anyOf` is the deciding criterion + +The v2 spec contains **202 `anyOf` occurrences across 68 of 177 schemas +(38%)**. This is structural: FastAPI + pydantic v2 emit OpenAPI 3.1 where +every `Optional[X]` field becomes `anyOf: [X, null]`, and geojson-pydantic +contributes geometry unions and `Position2D | Position3D` coordinate +arrays. Four resource pairs gave a precise taxonomy of how each generator +copes: + +| `anyOf` shape | Example | openapi-generator | openapi-python-client | +|---|---|---|---| +| `[X, null]` (nullable scalar/model) | `Feature.georeference`, `Grid.progress` | OK — `Optional[X]` | OK — `X \| None \| Unset` | +| `[X, Y, null]` (multi-model union) | `Grid.georeference` (2D/3D) | **wrapper dispatcher object** (`Georeference1(actual_instance=...)`) | native union — plain `Georeference` | +| union with plain values | `Domain.bbox` (4- or 6-tuple), `Band.nodata` | **wrapper models** (`Bbox(actual_instance=[...])`, `Nodata`) that reject plain values | plain `list[float]` / `int` | +| nested array unions (GeoJSON coordinates) | every geometry | **broken generated code** — `List[CoordinatesInner].from_dict(...)` raises `AttributeError`; the anyOf dispatcher only catches `ValidationError`, so parsing ANY domain crashes | parses to the right class (`Polygon`) | + +Reproduce the domains blocker: + +```python +from fastfuels_sdk.v2.draft_domains_oag import Domain +Domain.from_file("tests/data/blue_mtn.geojson") +# AttributeError: type object 'list' has no attribute 'from_dict' +# (crashes in the generated client before any API call is made) +``` + +The grids/features/inventories drafts sharpened the picture: their +payloads are geometry-free, so the oag client *parses them* — the hard +blocker is specifically geojson-pydantic's coordinate schemas, i.e. +exactly the schemas every Domain response contains. + +## What writing every wrapper twice revealed + +**546 of ~800 non-blank lines (≈ 68%) are identical across the four +pairs** (domains 187/287, grids 141/194, features 122/180, inventories +96/138). Everything that makes the SDK an SDK — geopandas conversions, +CRS forwarding, job polling, v1 ergonomics, docstrings — is +generator-independent. The differing third is plumbing: + +| Aspect | openapi-generator (oag) | openapi-python-client (opc) | +|---|---|---| +| Client bootstrap | `Configuration` → `ApiClient` → one `*Api` object **per resource** (each draft repeats the bootstrap; a real impl needs a v1-style `api.py`) | one `AuthenticatedClient(base_url, token, prefix="", auth_header_name="api-key")` imported by every draft | +| Endpoint call | `get_grids_api().get_grid(domain_id, grid_id)` | `get_grid.sync(domain_id, grid_id, client=...)` — module-per-endpoint functions | +| Error handling | **typed exceptions raised natively** (`NotFoundException`, `UnprocessableEntityException`, `ServiceException`) — same as v1 | returns documented error models (a 422 is a returned `HTTPValidationError` object, even with `raise_on_unexpected_status=True`); drafts share a `_checked()` unwrapper | +| Model ↔ wrapper conversion | `cls.from_dict(model.to_dict())` — the v1 `cls(**model.model_dump())` pattern **breaks** on anyOf wrapper fields | `cls.from_dict(model.to_dict())` — `from_dict` constructs `cls`, so subclassing just works | +| Request body construction | `from_dict` only (kwargs construction rejects plain values for anyOf wrapper fields) | `from_dict` or kwargs both work | +| `to_dict()` output | **leaks enum objects** (`LandfireFbfm40Version.ENUM_2024`, `PointProcess.INHOMOGENEOUS_POISSON`) — not JSON-safe without `to_json()` | plain values (`"2024"`, `"inhomogeneous_poisson"`) | +| "absent field" sentinel | `None` — conflates *omit* with *set to null*; PATCH bodies rely on `to_dict()` dropping `None`s | explicit `UNSET` sentinel — omitted fields stay out of the PATCH body by construction | +| Field-copy idiom (in-place refresh) | `for f in Model.model_fields: setattr(...)` | `for f in attrs.fields(Model): setattr(...)` + `additional_properties` | +| Reserved-ish names | `type` | `type_` | +| Digit-leading op names | `create3dep_topography` | `create_3dep_topography` | +| API surface organization | 29 `*Api` classes for 11 resources (operations duplicated per tag: `create_osm_road_feature` lives in both `FeaturesApi` and `FeaturesRoadApi`) | 11 `api//` packages, one module per operation | +| Async | none | free `asyncio`/`asyncio_detailed` variants of every endpoint | + +Shared findings (identical code in both drafts): + +- **v2 collapses v1's per-type resources** (surface/tree/topography grids; + road/water features) into unified job-based resources distinguished by + `source`/`type`. Grid, Feature, and Inventory all share the same + `status`/`progress`/`error` lifecycle — `wait_until_completed` is + line-identical across all six job-resource drafts, in both stacks + (`JobStatus` is exported under the same name by both clients). +- The **PIM workflow** chains cleanly through the wrappers: + `Grid.create_treemap(...)` → `wait_until_completed()` → + `Inventory.create_from_pim_grid(domain_id, grid.id)` → `wait_until_completed()`. +- Spec details encoded in both drafts: creation `version` fields are + *string* enums (`"2024"`); 3DEP `source_resolution` is an int enum + (1/10/30); `Band` objects in responses vs band-name strings in requests; + `FeatureGeoreference.bounds` is required. + +## What openapi-python-client needed + +1. **A spec patch**: the spec has two schemas titled `Feature` + (the FastFuels feature resource and geojson-pydantic's GeoJSON + Feature). opc names classes from titles and refuses duplicates — + without the patch it silently drops every domain endpoint. + `generate_clients.sh` re-titles the GeoJSON one to `GeoJsonFeature`. + The proper fix is in FastFuels-API-v2 (re-title the model) so the spec + has no collision. +2. **Dependencies**: `httpx`, `attrs`, `python-dateutil` (currently in the + dev group; move to runtime deps when v2 ships). + +## Design notes for the real v2 wrappers (carried out of the drafts) + +- **Shared job-resource base.** Grid/Feature/Inventory repeat identical + `get`/`wait_until_completed`/`delete`/`_copy_fields_from` code — the + real SDK should factor a `_JobResource` mixin (status polling, error + raising, in-place refresh) used by all three. +- **`fastfuels_sdk/v2/exceptions.py`** translating opc's returned error + models / `UnexpectedStatus` into typed SDK exceptions + (`NotFoundException`, ...) — recreates v1 (and oag) ergonomics. The + drafts' shared `_checked()` is the seed. +- **One cached `AuthenticatedClient`** (the drafts' `get_client()`) + replaces v1's per-resource API singletons in `api.py`. +- The `Resource(GeneratedModel)` subclass pattern carries over from v1: + opc's generated `from_dict` constructs `cls`, so classmethod + constructors and in-place refresh work unchanged. +- `from_geodataframe` now forwards the GeoDataFrame CRS (v1 silently + dropped it and relied on the EPSG:4326 default — latent bug for + projected inputs). +- v1's `Domain.export()` has no v2 endpoint; v2-only endpoints + (preview/reproject domain, domain lattice, feature/inventory data + partitions, grid data/exports) are unwrapped so far. + +## Regeneration + +```bash +cd fastfuels_sdk/v2 && bash generate_clients.sh # regenerates client_library/ +``` + +To reproduce the deleted openapi-generator client for re-evaluation: + +```bash +openapi-generator generate -i -g python \ + --package-name fastfuels_sdk.v2.client_library_openapi_generator -o +``` + +Remaining cleanup: the scratch `fastfuels_sdk/client_library_v2/` tree +(pre-dating this comparison) is fully superseded; the `draft_*_opc.py` +modules become the seeds of the real v2 wrappers. diff --git a/fastfuels_sdk/v2/draft_domains_opc.py b/fastfuels_sdk/v2/draft_domains_opc.py new file mode 100644 index 0000000..2e2e938 --- /dev/null +++ b/fastfuels_sdk/v2/draft_domains_opc.py @@ -0,0 +1,336 @@ +""" +fastfuels_sdk/v2/draft_domains_opc.py + +DRAFT — Domain wrapper built on the **openapi-python-client** client +(`client_library`: attrs models + httpx). + +Part of the v2 client-library generator comparison; see COMPARISON.md. +Mirrors the v1 wrapper ergonomics: a Domain class extending the generated +model with classmethod constructors and instance methods. + +v1 parity notes: +- `from_geojson`/`from_geodataframe` lose `horizontal_resolution`/ + `vertical_resolution` (resolution moved to grids in v2) and gain + `tags`/`pad_to_resolution`. +- `from_file` is new convenience (v1 users went through geopandas manually). +- v1's `export()` has no v2 endpoint equivalent (no + `/domains/{id}/export` in the v2 spec) and is omitted. +- v2-only endpoints (preview_domain, reproject_domain, get_domain_lattice) + are not wrapped yet — out of scope for the generator comparison. +""" + +import json +import os +from pathlib import Path +from typing import Any, List, Optional, Type, TypeVar, Union + +import attrs +import geopandas as gpd + +from fastfuels_sdk.v2.client_library.client import ( + AuthenticatedClient, +) +from fastfuels_sdk.v2.client_library.api.domains import ( + create_domain, + delete_domain, + get_domain, + list_domains as list_domains_endpoint, + update_domain, +) +from fastfuels_sdk.v2.client_library.models import ( + Domain as DomainModel, + CreateDomainRequestBody, + HTTPValidationError, + ListDomainsResponse, + UpdateDomainRequestBody, + DomainSortField, + DomainSortOrder, +) +from fastfuels_sdk.v2.client_library.types import UNSET, Unset + +V2_BASE_URL = "https://api-v2-prod-782971006568.us-west1.run.app" + +T = TypeVar("T") + +_client: Optional[AuthenticatedClient] = None + + +def get_client() -> AuthenticatedClient: + """Get the cached AuthenticatedClient, creating it if necessary.""" + global _client + client = _client + if client is None: + api_key = os.getenv("FASTFUELS_API_KEY") + if not api_key: + raise RuntimeError( + "FastFuels API key not configured. " + "Set the FASTFUELS_API_KEY environment variable." + ) + client = AuthenticatedClient( + base_url=V2_BASE_URL, + token=api_key, + prefix="", # raw key, not "Bearer " + auth_header_name="api-key", + raise_on_unexpected_status=True, + ) + _client = client + return client + + +def _checked(response: Any, expected: Type[T]) -> T: + """Unwrap a generated sync() response, raising on error results. + + The generated client returns documented error models instead of raising: + a 422 comes back as an HTTPValidationError instance (only *undocumented* + statuses raise errors.UnexpectedStatus, even with + raise_on_unexpected_status=True). A real v2 SDK should translate these + into typed SDK exceptions in a shared exceptions layer; the draft raises + RuntimeError. + """ + if isinstance(response, expected): + return response + if isinstance(response, HTTPValidationError): + raise RuntimeError(f"API validation error (422): {response.detail}") + raise RuntimeError(f"Unexpected API response: {response!r}") + + +class Domain(DomainModel): + """Domain resource for the FastFuels v2 API (openapi-python-client draft). + + Inherits all attributes from the generated attrs model: id, name, + description, type_, features, bbox, crs, tags, pad_to_resolution, style, + created_on, modified_on. + """ + + @classmethod + def _from_model(cls, model: DomainModel) -> "Domain": + # The idiomatic opc conversion: round-trip through the generated + # to_dict/from_dict (from_dict constructs `cls`, i.e. this subclass). + return cls.from_dict(model.to_dict()) + + def _copy_fields_from(self, model: DomainModel) -> "Domain": + """Copy all generated-model fields from `model` onto self (in-place).""" + for field in attrs.fields(DomainModel): + if field.init: + setattr(self, field.name, getattr(model, field.name)) + self.additional_properties = dict(model.additional_properties) + return self + + def _require_id(self) -> str: + """Return the domain id, raising if this instance has none.""" + if isinstance(self.id, Unset): + raise ValueError( + "This Domain has no id (it was not created through the API)." + ) + return self.id + + @classmethod + def from_id(cls, domain_id: str) -> "Domain": + """Retrieve an existing Domain by its ID. + + Raises errors.UnexpectedStatus for any non-2xx response (the + generated client has no per-status exception types). + """ + response = get_domain.sync(client=get_client(), domain_id=domain_id) + return cls._from_model(_checked(response, DomainModel)) + + @classmethod + def from_geojson( + cls, + geojson: dict, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, + pad_to_resolution: Optional[float] = None, + ) -> "Domain": + """Create a new Domain from a GeoJSON FeatureCollection. + + Note: unlike v1, the v2 API takes no horizontal/vertical resolution + on the domain (resolution moved to grids); `pad_to_resolution` + optionally snaps the bounding box outward for grid alignment. + Also unlike v1, the v2 API accepts FeatureCollection input only — + wrap a single Feature in a FeatureCollection. + """ + request_body = CreateDomainRequestBody.from_dict( + { + **geojson, + "name": name, + "description": description, + "tags": tags, + "pad_to_resolution": pad_to_resolution, + } + ) + response = create_domain.sync(client=get_client(), body=request_body) + return cls._from_model(_checked(response, DomainModel)) + + @classmethod + def from_geodataframe( + cls, + geodataframe: gpd.GeoDataFrame, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, + pad_to_resolution: Optional[float] = None, + ) -> "Domain": + """Create a new Domain from a GeoPandas GeoDataFrame. + + The GeoDataFrame's CRS (when set) is forwarded to the API as the + FeatureCollection's `crs` member, so projected inputs (e.g. + EPSG:5070) are interpreted correctly. (The v1 SDK dropped the + GeoDataFrame CRS and relied on the API's EPSG:4326 default.) + """ + geojson = json.loads(geodataframe.to_json()) + if geodataframe.crs is not None: + authority = geodataframe.crs.to_authority() + if authority is None: + raise ValueError( + "GeoDataFrame CRS has no authority code (e.g. a custom " + "CRS). Reproject to a known CRS such as EPSG:4326 with " + "geodataframe.to_crs(...) before creating a domain." + ) + geojson["crs"] = { + "type": "name", + "properties": {"name": ":".join(authority)}, + } + return cls.from_geojson( + geojson=geojson, + name=name, + description=description, + tags=tags, + pad_to_resolution=pad_to_resolution, + ) + + @classmethod + def from_file( + cls, + path: Union[str, Path], + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, + pad_to_resolution: Optional[float] = None, + ) -> "Domain": + """Create a new Domain from a geospatial file. + + Reads any format geopandas supports (GeoJSON, Shapefile, KML, + GeoPackage, ...) and creates a domain from its geometry. + """ + return cls.from_geodataframe( + gpd.read_file(path), + name=name, + description=description, + tags=tags, + pad_to_resolution=pad_to_resolution, + ) + + def get(self, in_place: bool = False) -> "Domain": + """Retrieve the latest domain data from the API. + + Parameters + ---------- + in_place : bool, optional + If True, updates the current Domain instance with the new data + and returns self. If False, returns a new Domain instance with + the latest data, leaving the current instance unchanged. + """ + domain_id = self._require_id() + response = _checked( + get_domain.sync(client=get_client(), domain_id=domain_id), + DomainModel, + ) + if in_place: + return self._copy_fields_from(response) + return Domain._from_model(response) + + def update( + self, + name: Optional[str] = None, + description: Optional[str] = None, + tags: Optional[List[str]] = None, + in_place: bool = False, + ) -> "Domain": + """Update the domain's mutable properties (name, description, tags). + + Only provided fields are sent in the PATCH body (the generated + UNSET sentinel keeps omitted fields out of the request entirely). + If no fields are provided, no API call is made. + """ + if name is None and description is None and tags is None: + return self if in_place else Domain.from_dict(self.to_dict()) + + request_body = UpdateDomainRequestBody( + name=name if name is not None else UNSET, + description=description if description is not None else UNSET, + tags=tags if tags is not None else UNSET, + ) + domain_id = self._require_id() + response = _checked( + update_domain.sync( + client=get_client(), domain_id=domain_id, body=request_body + ), + DomainModel, + ) + if in_place: + return self._copy_fields_from(response) + return Domain._from_model(response) + + def delete(self) -> None: + """Delete this domain and all resources associated with it.""" + domain_id = self._require_id() + delete_domain.sync_detailed(client=get_client(), domain_id=domain_id) + + def to_json(self) -> str: + """Serialize the complete Domain object to a JSON string.""" + return json.dumps(self.to_dict(), default=str, indent=2) + + def to_geodataframe(self) -> gpd.GeoDataFrame: + """Convert the Domain to a GeoPandas GeoDataFrame. + + The v2 API returns two named features: "domain" (the working + extent / bounding box) and "input" (the original geometry), so the + GeoDataFrame has one row per feature. + """ + feature_collection: dict[str, Any] = { + "type": "FeatureCollection", + "features": ( + [feature.to_dict() for feature in self.features] + if self.features + else [] + ), + } + if self.crs and not isinstance(self.crs, Unset): + feature_collection["crs"] = self.crs.to_dict() + + gdf = gpd.read_file(json.dumps(feature_collection)) + + # Add domain metadata as columns (UNSET fields become None) + gdf["domain_id"] = None if isinstance(self.id, Unset) else self.id + gdf["domain_name"] = None if isinstance(self.name, Unset) else self.name + gdf["domain_description"] = ( + None if isinstance(self.description, Unset) else self.description + ) + if self.pad_to_resolution and not isinstance(self.pad_to_resolution, Unset): + gdf["pad_to_resolution"] = self.pad_to_resolution + if self.tags and not isinstance(self.tags, Unset): + gdf["tags"] = ", ".join(self.tags) + + return gdf + + +def list_domains( + page: int = 0, + size: int = 100, + sort_by: Optional[str] = None, + sort_order: Optional[str] = None, +) -> List[Domain]: + """List domains belonging to the authenticated user (single page).""" + response = _checked( + list_domains_endpoint.sync( + client=get_client(), + page=page, + size=size, + sort_by=DomainSortField(sort_by) if sort_by else UNSET, + sort_order=DomainSortOrder(sort_order) if sort_order else UNSET, + ), + ListDomainsResponse, + ) + return [Domain._from_model(d) for d in response.domains] diff --git a/fastfuels_sdk/v2/draft_features_opc.py b/fastfuels_sdk/v2/draft_features_opc.py new file mode 100644 index 0000000..f6bd344 --- /dev/null +++ b/fastfuels_sdk/v2/draft_features_opc.py @@ -0,0 +1,203 @@ +""" +fastfuels_sdk/v2/draft_features_opc.py + +DRAFT — Feature wrapper built on the **openapi-python-client** client +(`client_library`: attrs models + httpx). + +Part of the v2 client-library generator comparison; see COMPARISON.md. +Covers OSM road and water feature creation plus the get/wait/update/ +delete/list lifecycle. +""" + +import json +import time +from typing import Any, List, Optional + +import attrs + +from fastfuels_sdk.v2.draft_domains_opc import _checked, get_client + +from fastfuels_sdk.v2.client_library.api.features import ( + create_osm_road_feature, + create_osm_water_feature, + delete_feature, + get_feature, + list_features as list_features_endpoint, + update_feature, +) +from fastfuels_sdk.v2.client_library.models import ( + Feature as FeatureModel, + CreateOsmRoadFeatureRequest, + CreateOsmWaterFeatureRequest, + JobStatus, + ListFeaturesResponse, + UpdateFeatureRequestBody, +) +from fastfuels_sdk.v2.client_library.types import UNSET + +V2_BASE_URL = "https://api-v2-prod-782971006568.us-west1.run.app" + + +def _osm_request_dict( + name: str, + description: str, + tags: Optional[List[str]], + extent_buffer_m: float, +) -> dict: + """Build the request fields shared by the OSM feature endpoints.""" + body: dict[str, Any] = { + "name": name, + "description": description, + "extent_buffer_m": extent_buffer_m, + } + if tags is not None: + body["tags"] = tags + return body + + +class Feature(FeatureModel): + """Feature resource for the FastFuels v2 API (openapi-python-client draft). + + Inherits all attributes from the generated attrs model: id, domain_id, + type_, name, description, status, progress, source, georeference, + error, tags, created_on, modified_on. + + v2 unifies road and water features (separate sub-resources in v1) into + one job-based Feature resource distinguished by its `type_`/`source`. + """ + + @classmethod + def _from_model(cls, model: FeatureModel) -> "Feature": + return cls.from_dict(model.to_dict()) + + def _copy_fields_from(self, model: FeatureModel) -> "Feature": + """Copy all generated-model fields from `model` onto self (in-place).""" + for field in attrs.fields(FeatureModel): + if field.init: + setattr(self, field.name, getattr(model, field.name)) + self.additional_properties = dict(model.additional_properties) + return self + + @classmethod + def from_id(cls, domain_id: str, feature_id: str) -> "Feature": + """Retrieve an existing Feature by domain and feature ID.""" + response = get_feature.sync(domain_id, feature_id, client=get_client()) + return cls._from_model(_checked(response, FeatureModel)) + + @classmethod + def create_osm_road( + cls, + domain_id: str, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, + extent_buffer_m: float = 0, + ) -> "Feature": + """Create a road feature from OpenStreetMap data.""" + body = _osm_request_dict(name, description, tags, extent_buffer_m) + request = CreateOsmRoadFeatureRequest.from_dict(body) + response = create_osm_road_feature.sync( + domain_id, client=get_client(), body=request + ) + return cls._from_model(_checked(response, FeatureModel)) + + @classmethod + def create_osm_water( + cls, + domain_id: str, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, + extent_buffer_m: float = 0, + ) -> "Feature": + """Create a water feature from OpenStreetMap data.""" + body = _osm_request_dict(name, description, tags, extent_buffer_m) + request = CreateOsmWaterFeatureRequest.from_dict(body) + response = create_osm_water_feature.sync( + domain_id, client=get_client(), body=request + ) + return cls._from_model(_checked(response, FeatureModel)) + + def get(self, in_place: bool = False) -> "Feature": + """Retrieve the latest feature data from the API.""" + response = _checked( + get_feature.sync(self.domain_id, self.id, client=get_client()), + FeatureModel, + ) + if in_place: + return self._copy_fields_from(response) + return Feature._from_model(response) + + def wait_until_completed( + self, + step: float = 5, + timeout: float = 600, + in_place: bool = True, + verbose: bool = False, + ) -> "Feature": + """Poll the feature job until it reaches a terminal status.""" + elapsed = 0.0 + feature = self.get() + while feature.status not in (JobStatus.COMPLETED, JobStatus.FAILED): + if elapsed >= timeout: + raise TimeoutError( + f"Feature {self.id} did not complete within {timeout} seconds" + ) + time.sleep(step) + elapsed += step + feature = self.get() + if verbose: + print(f"Feature {self.id}: {feature.status} ({elapsed:.0f}s)") + if feature.status == JobStatus.FAILED: + raise RuntimeError(f"Feature {self.id} failed: {feature.error}") + if in_place: + return self._copy_fields_from(feature) + return feature + + def update( + self, + name: Optional[str] = None, + description: Optional[str] = None, + tags: Optional[List[str]] = None, + in_place: bool = False, + ) -> "Feature": + """Update the feature's mutable properties (name, description, tags).""" + if name is None and description is None and tags is None: + return self if in_place else Feature._from_model(self) + request = UpdateFeatureRequestBody( + name=name if name is not None else UNSET, + description=description if description is not None else UNSET, + tags=tags if tags is not None else UNSET, + ) + response = _checked( + update_feature.sync( + self.domain_id, self.id, client=get_client(), body=request + ), + FeatureModel, + ) + if in_place: + return self._copy_fields_from(response) + return Feature._from_model(response) + + def delete(self) -> None: + """Delete this feature.""" + delete_feature.sync_detailed(self.domain_id, self.id, client=get_client()) + + def to_json(self) -> str: + """Serialize the complete Feature object to a JSON string.""" + return json.dumps(self.to_dict(), default=str, indent=2) + + +def list_features( + domain_id: str, + page: int = 0, + size: int = 100, +) -> List[Feature]: + """List features in a domain (single page).""" + response = _checked( + list_features_endpoint.sync( + domain_id, client=get_client(), page=page, size=size + ), + ListFeaturesResponse, + ) + return [Feature._from_model(f) for f in response.features] diff --git a/fastfuels_sdk/v2/draft_grids_opc.py b/fastfuels_sdk/v2/draft_grids_opc.py new file mode 100644 index 0000000..a2a7fd4 --- /dev/null +++ b/fastfuels_sdk/v2/draft_grids_opc.py @@ -0,0 +1,214 @@ +""" +fastfuels_sdk/v2/draft_grids_opc.py + +DRAFT — Grid wrapper built on the **openapi-python-client** client +(`client_library`: attrs models + httpx). + +Part of the v2 client-library generator comparison; see COMPARISON.md. +Covers a representative slice of the grid endpoints: LANDFIRE FBFM40, +TreeMap (PIM), and 3DEP topography (elevation) creation, plus the +get/wait/delete/list lifecycle shared by all grid sources. +""" + +import json +import time +from typing import Any, List, Optional + +import attrs + +# NOTE (comparison): one AuthenticatedClient serves every resource — no +# per-resource API objects, so the domains draft's client is reused as-is. +from fastfuels_sdk.v2.draft_domains_opc import _checked, get_client + +from fastfuels_sdk.v2.client_library.api.grids import ( + create_3dep_topography, + create_landfire_fbfm40, + create_treemap, + delete_grid, + get_grid, + list_grids as list_grids_endpoint, +) +from fastfuels_sdk.v2.client_library.models import ( + Grid as GridModel, + CreateLandfireFbfm40Request, + CreateThreeDepTopographyRequest, + CreateTreeMapRequest, + JobStatus, + ListGridsResponse, +) + +V2_BASE_URL = "https://api-v2-prod-782971006568.us-west1.run.app" + + +def _base_request_dict( + name: str, + description: str, + tags: Optional[List[str]], + extent_buffer_cells: int, +) -> dict: + """Build the request fields shared by every grid creation endpoint.""" + body: dict[str, Any] = { + "name": name, + "description": description, + "extent_buffer_cells": extent_buffer_cells, + } + if tags is not None: + body["tags"] = tags + return body + + +class Grid(GridModel): + """Grid resource for the FastFuels v2 API (openapi-python-client draft). + + Inherits all attributes from the generated attrs model: id, domain_id, + name, description, status, progress, source, bands, georeference, + modifications, error, chunks, tags, created_on, modified_on. + + v2 unifies all grid types (surface/tree/topography/feature in v1) into + one job-based Grid resource distinguished by its `source`. + """ + + @classmethod + def _from_model(cls, model: GridModel) -> "Grid": + # The idiomatic opc conversion: round-trip through the generated + # to_dict/from_dict (from_dict constructs `cls`, i.e. this subclass). + return cls.from_dict(model.to_dict()) + + def _copy_fields_from(self, model: GridModel) -> "Grid": + """Copy all generated-model fields from `model` onto self (in-place).""" + for field in attrs.fields(GridModel): + if field.init: + setattr(self, field.name, getattr(model, field.name)) + self.additional_properties = dict(model.additional_properties) + return self + + @classmethod + def from_id(cls, domain_id: str, grid_id: str) -> "Grid": + """Retrieve an existing Grid by domain and grid ID.""" + response = get_grid.sync(domain_id, grid_id, client=get_client()) + return cls._from_model(_checked(response, GridModel)) + + @classmethod + def create_landfire_fbfm40( + cls, + domain_id: str, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, + extent_buffer_cells: int = 0, + version: str = "2024", + remove_non_burnable: Optional[List[str]] = None, + ) -> "Grid": + """Create a surface fuel model grid from LANDFIRE FBFM40.""" + body = _base_request_dict(name, description, tags, extent_buffer_cells) + body["version"] = str(version) # version enums are strings + if remove_non_burnable is not None: + body["remove_non_burnable"] = remove_non_burnable + request = CreateLandfireFbfm40Request.from_dict(body) + response = create_landfire_fbfm40.sync( + domain_id, client=get_client(), body=request + ) + return cls._from_model(_checked(response, GridModel)) + + @classmethod + def create_treemap( + cls, + domain_id: str, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, + extent_buffer_cells: int = 0, + version: str = "2022", + bands: Optional[List[str]] = None, + ) -> "Grid": + """Create a plot identification (PIM) grid from TreeMap.""" + body = _base_request_dict(name, description, tags, extent_buffer_cells) + body["version"] = str(version) # version enums are strings + if bands is not None: + body["bands"] = bands # API default: ["tm_id"] + request = CreateTreeMapRequest.from_dict(body) + response = create_treemap.sync(domain_id, client=get_client(), body=request) + return cls._from_model(_checked(response, GridModel)) + + @classmethod + def create_3dep_topography( + cls, + domain_id: str, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, + extent_buffer_cells: int = 0, + source_resolution: int = 10, + bands: Optional[List[str]] = None, + ) -> "Grid": + """Create an elevation/topography grid from USGS 3DEP.""" + body = _base_request_dict(name, description, tags, extent_buffer_cells) + body["source_resolution"] = source_resolution + if bands is not None: + body["bands"] = bands # API default: ["elevation"] + request = CreateThreeDepTopographyRequest.from_dict(body) + response = create_3dep_topography.sync( + domain_id, client=get_client(), body=request + ) + return cls._from_model(_checked(response, GridModel)) + + def get(self, in_place: bool = False) -> "Grid": + """Retrieve the latest grid data from the API.""" + response = _checked( + get_grid.sync(self.domain_id, self.id, client=get_client()), + GridModel, + ) + if in_place: + return self._copy_fields_from(response) + return Grid._from_model(response) + + def wait_until_completed( + self, + step: float = 5, + timeout: float = 600, + in_place: bool = True, + verbose: bool = False, + ) -> "Grid": + """Poll the grid job until it reaches a terminal status. + + Raises TimeoutError if the job does not finish within `timeout` + seconds, or RuntimeError if the job ends in `failed` status. + """ + elapsed = 0.0 + grid = self.get() + while grid.status not in (JobStatus.COMPLETED, JobStatus.FAILED): + if elapsed >= timeout: + raise TimeoutError( + f"Grid {self.id} did not complete within {timeout} seconds" + ) + time.sleep(step) + elapsed += step + grid = self.get() + if verbose: + print(f"Grid {self.id}: {grid.status} ({elapsed:.0f}s)") + if grid.status == JobStatus.FAILED: + raise RuntimeError(f"Grid {self.id} failed: {grid.error}") + if in_place: + return self._copy_fields_from(grid) + return grid + + def delete(self) -> None: + """Delete this grid.""" + delete_grid.sync_detailed(self.domain_id, self.id, client=get_client()) + + def to_json(self) -> str: + """Serialize the complete Grid object to a JSON string.""" + return json.dumps(self.to_dict(), default=str, indent=2) + + +def list_grids( + domain_id: str, + page: int = 0, + size: int = 100, +) -> List[Grid]: + """List grids in a domain (single page).""" + response = _checked( + list_grids_endpoint.sync(domain_id, client=get_client(), page=page, size=size), + ListGridsResponse, + ) + return [Grid._from_model(g) for g in response.grids] diff --git a/fastfuels_sdk/v2/draft_inventories_opc.py b/fastfuels_sdk/v2/draft_inventories_opc.py new file mode 100644 index 0000000..b0204c9 --- /dev/null +++ b/fastfuels_sdk/v2/draft_inventories_opc.py @@ -0,0 +1,159 @@ +""" +fastfuels_sdk/v2/draft_inventories_opc.py + +DRAFT — Inventory wrapper built on the **openapi-python-client** client +(`client_library`: attrs models + httpx). + +Part of the v2 client-library generator comparison; see COMPARISON.md. +Covers the PIM grid → tree inventory flow plus the get/wait/delete/list +lifecycle. + +The full PIM workflow across the drafts: + + grid = Grid.create_treemap(domain_id) # draft_grids_opc + grid.wait_until_completed() + inv = Inventory.create_from_pim_grid(domain_id, grid.id) + inv.wait_until_completed() +""" + +import json +import time +from typing import Any, List, Optional + +import attrs + +from fastfuels_sdk.v2.draft_domains_opc import _checked, get_client + +from fastfuels_sdk.v2.client_library.api.inventories import ( + create_pim_inventory, + delete_inventory, + get_inventory, + list_inventories as list_inventories_endpoint, +) +from fastfuels_sdk.v2.client_library.models import ( + Inventory as InventoryModel, + CreatePimInventoryRequest, + JobStatus, + ListInventoriesResponse, +) + +V2_BASE_URL = "https://api-v2-prod-782971006568.us-west1.run.app" + + +class Inventory(InventoryModel): + """Inventory resource for the FastFuels v2 API (openapi-python-client draft). + + Inherits all attributes from the generated attrs model: id, domain_id, + type_, name, description, status, progress, source, modifications, + treatments, columns, georeference, error, tags, created_on, + modified_on. + """ + + @classmethod + def _from_model(cls, model: InventoryModel) -> "Inventory": + return cls.from_dict(model.to_dict()) + + def _copy_fields_from(self, model: InventoryModel) -> "Inventory": + """Copy all generated-model fields from `model` onto self (in-place).""" + for field in attrs.fields(InventoryModel): + if field.init: + setattr(self, field.name, getattr(model, field.name)) + self.additional_properties = dict(model.additional_properties) + return self + + @classmethod + def from_id(cls, domain_id: str, inventory_id: str) -> "Inventory": + """Retrieve an existing Inventory by domain and inventory ID.""" + response = get_inventory.sync(domain_id, inventory_id, client=get_client()) + return cls._from_model(_checked(response, InventoryModel)) + + @classmethod + def create_from_pim_grid( + cls, + domain_id: str, + source_pim_grid_id: str, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, + seed: Optional[int] = None, + point_process: str = "inhomogeneous_poisson", + ) -> "Inventory": + """Create a tree inventory from a completed PIM (TreeMap) grid. + + The PIM grid must be in `completed` status before the inventory + job can sample tree records from it. + """ + body: dict[str, Any] = { + "name": name, + "description": description, + "source_pim_grid_id": source_pim_grid_id, + "point_process": point_process, + } + if tags is not None: + body["tags"] = tags + if seed is not None: + body["seed"] = seed + request = CreatePimInventoryRequest.from_dict(body) + response = create_pim_inventory.sync( + domain_id, client=get_client(), body=request + ) + return cls._from_model(_checked(response, InventoryModel)) + + def get(self, in_place: bool = False) -> "Inventory": + """Retrieve the latest inventory data from the API.""" + response = _checked( + get_inventory.sync(self.domain_id, self.id, client=get_client()), + InventoryModel, + ) + if in_place: + return self._copy_fields_from(response) + return Inventory._from_model(response) + + def wait_until_completed( + self, + step: float = 5, + timeout: float = 600, + in_place: bool = True, + verbose: bool = False, + ) -> "Inventory": + """Poll the inventory job until it reaches a terminal status.""" + elapsed = 0.0 + inventory = self.get() + while inventory.status not in (JobStatus.COMPLETED, JobStatus.FAILED): + if elapsed >= timeout: + raise TimeoutError( + f"Inventory {self.id} did not complete within {timeout} seconds" + ) + time.sleep(step) + elapsed += step + inventory = self.get() + if verbose: + print(f"Inventory {self.id}: {inventory.status} ({elapsed:.0f}s)") + if inventory.status == JobStatus.FAILED: + raise RuntimeError(f"Inventory {self.id} failed: {inventory.error}") + if in_place: + return self._copy_fields_from(inventory) + return inventory + + def delete(self) -> None: + """Delete this inventory.""" + delete_inventory.sync_detailed(self.domain_id, self.id, client=get_client()) + + def to_json(self) -> str: + """Serialize the complete Inventory object to a JSON string.""" + return json.dumps(self.to_dict(), default=str, indent=2) + + +def list_inventories( + domain_id: str, + page: int = 0, + size: int = 100, +) -> List[Inventory]: + """List inventories in a domain (single page).""" + response = _checked( + list_inventories_endpoint.sync( + domain_id, client=get_client(), page=page, size=size + ), + ListInventoriesResponse, + ) + return [Inventory._from_model(i) for i in response.inventories] From 29966f313c22004509506ed3d1b32779a101cfce Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Fri, 5 Jun 2026 12:33:16 -0600 Subject: [PATCH 03/71] Rename regen script to generate_client.sh and pin the generator version generate_clients.sh (plural) was named for the generator comparison era when it produced both candidate clients. It is now the canonical regeneration tool for fastfuels_sdk/v2/client_library/, so: singular name, openapi-python-client pinned to 0.29.0 (regen diffs reflect API changes only; bump the pin deliberately), and a header documenting the regenerate-and-commit workflow. Verified: running the renamed script reproduces the committed client_library byte-for-byte (zero diff). --- fastfuels_sdk/v2/COMPARISON.md | 4 ++-- .../{generate_clients.sh => generate_client.sh} | 15 +++++++++++---- 2 files changed, 13 insertions(+), 6 deletions(-) rename fastfuels_sdk/v2/{generate_clients.sh => generate_client.sh} (68%) diff --git a/fastfuels_sdk/v2/COMPARISON.md b/fastfuels_sdk/v2/COMPARISON.md index 2218b52..11a8bca 100644 --- a/fastfuels_sdk/v2/COMPARISON.md +++ b/fastfuels_sdk/v2/COMPARISON.md @@ -113,7 +113,7 @@ Shared findings (identical code in both drafts): (the FastFuels feature resource and geojson-pydantic's GeoJSON Feature). opc names classes from titles and refuses duplicates — without the patch it silently drops every domain endpoint. - `generate_clients.sh` re-titles the GeoJSON one to `GeoJsonFeature`. + `generate_client.sh` re-titles the GeoJSON one to `GeoJsonFeature`. The proper fix is in FastFuels-API-v2 (re-title the model) so the spec has no collision. 2. **Dependencies**: `httpx`, `attrs`, `python-dateutil` (currently in the @@ -144,7 +144,7 @@ Shared findings (identical code in both drafts): ## Regeneration ```bash -cd fastfuels_sdk/v2 && bash generate_clients.sh # regenerates client_library/ +cd fastfuels_sdk/v2 && bash generate_client.sh # regenerates client_library/ ``` To reproduce the deleted openapi-generator client for re-evaluation: diff --git a/fastfuels_sdk/v2/generate_clients.sh b/fastfuels_sdk/v2/generate_client.sh similarity index 68% rename from fastfuels_sdk/v2/generate_clients.sh rename to fastfuels_sdk/v2/generate_client.sh index a250228..b42485f 100644 --- a/fastfuels_sdk/v2/generate_clients.sh +++ b/fastfuels_sdk/v2/generate_client.sh @@ -1,10 +1,17 @@ #!/usr/bin/env bash -# Regenerate the v2 client library (openapi-python-client). -# Run from this directory: bash generate_clients.sh +# Regenerate the v2 client library (fastfuels_sdk/v2/client_library/) from +# the live API spec. Run from this directory: bash generate_client.sh # -# Requirements: uv (openapi-python-client is run via uvx, no install needed) +# Generated code is committed — rerun this when the v2 API spec changes +# and commit the diff so API-surface changes are reviewable in PRs. +# +# Requirements: uv (openapi-python-client is run via uvx, no install +# needed). The generator version is pinned so regen diffs reflect API +# changes only; bump the pin deliberately. set -euo pipefail +OPC_VERSION="0.29.0" + URL="https://api-v2-prod-782971006568.us-west1.run.app" SPEC=$(mktemp /tmp/v2_openapi.XXXXXX.json) PATCHED=$(mktemp /tmp/v2_openapi_patched.XXXXXX.json) @@ -24,7 +31,7 @@ spec["components"]["schemas"]["geojson_pydantic__features__Feature"]["title"] = json.dump(spec, open(sys.argv[2], "w")) EOF -uvx openapi-python-client generate \ +uvx "openapi-python-client@${OPC_VERSION}" generate \ --path "$PATCHED" \ --meta none \ --output-path client_library \ From a103c05f6fcfa3fdd8ed5a913b9c4b14d602f2a4 Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Fri, 5 Jun 2026 15:21:41 -0600 Subject: [PATCH 04/71] Regenerate v2 client; record the deployment URL alongside it generate_client.sh now writes client_library/base_url.py so the script is the single source of truth for the deployment URL (openapi-python-client embeds no server URL and the spec has no servers entry). The regen also picked up a new endpoint added to the spec since the last generation: apply_modifications on tree inventories. --- .../api/inventories/apply_modifications.py | 472 ++++++++++++++++++ fastfuels_sdk/v2/client_library/base_url.py | 8 + .../v2/client_library/models/__init__.py | 2 + .../models/apply_modifications_request.py | 81 +++ fastfuels_sdk/v2/generate_client.sh | 16 + 5 files changed, 579 insertions(+) create mode 100644 fastfuels_sdk/v2/client_library/api/inventories/apply_modifications.py create mode 100644 fastfuels_sdk/v2/client_library/base_url.py create mode 100644 fastfuels_sdk/v2/client_library/models/apply_modifications_request.py diff --git a/fastfuels_sdk/v2/client_library/api/inventories/apply_modifications.py b/fastfuels_sdk/v2/client_library/api/inventories/apply_modifications.py new file mode 100644 index 0000000..302a581 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/inventories/apply_modifications.py @@ -0,0 +1,472 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.apply_modifications_request import ApplyModificationsRequest +from ...models.http_validation_error import HTTPValidationError +from ...models.inventory import Inventory +from ...types import Response + + +def _get_kwargs( + domain_id: str, + inventory_id: str, + *, + body: ApplyModificationsRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/domains/{domain_id}/inventories/{inventory_id}/modifications".format( + domain_id=quote(str(domain_id), safe=""), + inventory_id=quote(str(inventory_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> HTTPValidationError | Inventory | None: + if response.status_code == 200: + response_200 = Inventory.from_dict(response.json()) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[HTTPValidationError | Inventory]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + inventory_id: str, + *, + client: AuthenticatedClient, + body: ApplyModificationsRequest, +) -> Response[HTTPValidationError | Inventory]: + r"""Apply modifications to an inventory in place + + # Apply Modifications to an Inventory (in place) + + Applies modifications to **this** inventory in place — the inventory keeps + its ID and the submitted rules are appended to its cumulative + `modifications` list, then the tree data is re-derived asynchronously. To + keep the original data instead, duplicate the inventory first + (`POST .../{inventory_id}/duplicate`) and modify the copy. + + Modifications filter trees by conditions and apply actions (remove, + multiply, divide, add, subtract, replace) to matching rows. Conditions + within a single rule are ANDed together; multiple rules are evaluated + independently in order. + + ## Conditions + + **Attribute conditions** compare a single tree attribute against a value: + - `attribute`: one of `dbh`, `height`, `crown_ratio`, `fia_species_code` + - `operator`: `eq`, `ne`, `gt`, `lt`, `ge`, `le` + (`fia_species_code` only supports `eq`/`ne`) + - `value`: number, string, or list for `eq`/`ne` + - `unit`: (optional) pint-compatible unit string (e.g., `\"in\"`, `\"ft\"`) + + **Expression conditions** use a boolean expression: + - `expression`: e.g., `\"dbh < 5 and height < 2\"` + - Only `dbh`, `height`, `crown_ratio` are allowed in expressions + - Expressions always use native units (cm, m, 0-1 fraction) + + **Spatial conditions** test each tree's location (a point) against a + geometry. Two variants discriminated by the required `source` field: + + - `source: \"geometry\"` — supply GeoJSON directly via `geometry` (plus + optional `crs`; defaults to the domain CRS). + - `source: \"feature\"` — reference a persisted Feature resource by + `feature_id` (road, water, layerset). The Feature must belong to the + same domain as this inventory and be in `completed` status; + cross-domain, missing, or unfinished references are rejected with 422. + + Both spatial variants accept: + - `operator`: `within`, `outside`, or `intersects` + - `buffer_m`: (optional, meters) expands the geometry outward in the + domain's projected CRS before testing. Effectively required for + linestring features (e.g. roads) because a tree point almost never + intersects a bare linestring. + + Spatial conditions have **no `target` field** — trees are points, so + the test is always point-in-(optionally-buffered)-geometry. + + Spatial and attribute conditions can be combined in a single rule + (AND semantics). For example: `{conditions: [feature within road + buffer, dbh > 30], actions: [remove]}` removes only large trees that + fall inside the buffered road. + + ## Actions + + - `{\"modifier\": \"remove\"}` — remove matching trees (must be sole action) + - `{\"attribute\": \"...\", \"modifier\": \"multiply|divide|add|subtract|replace\", \"value\": ...}` + - `unit` on actions converts the value before applying + + ## Response + + Returns this inventory (same ID) with status `\"pending\"` and the submitted + rules appended to `modifications`. Its `checksum` changes, so any resource + derived from it can detect that the source has changed. Poll the inventory + until status returns to `\"completed\"`. + + Args: + domain_id (str): + inventory_id (str): + body (ApplyModificationsRequest): Request body for applying modifications to an inventory + in place. + + Metadata (name, description, tags) is not accepted here — the inventory + keeps its identity; use PATCH to edit metadata. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | Inventory] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + inventory_id=inventory_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + inventory_id: str, + *, + client: AuthenticatedClient, + body: ApplyModificationsRequest, +) -> HTTPValidationError | Inventory | None: + r"""Apply modifications to an inventory in place + + # Apply Modifications to an Inventory (in place) + + Applies modifications to **this** inventory in place — the inventory keeps + its ID and the submitted rules are appended to its cumulative + `modifications` list, then the tree data is re-derived asynchronously. To + keep the original data instead, duplicate the inventory first + (`POST .../{inventory_id}/duplicate`) and modify the copy. + + Modifications filter trees by conditions and apply actions (remove, + multiply, divide, add, subtract, replace) to matching rows. Conditions + within a single rule are ANDed together; multiple rules are evaluated + independently in order. + + ## Conditions + + **Attribute conditions** compare a single tree attribute against a value: + - `attribute`: one of `dbh`, `height`, `crown_ratio`, `fia_species_code` + - `operator`: `eq`, `ne`, `gt`, `lt`, `ge`, `le` + (`fia_species_code` only supports `eq`/`ne`) + - `value`: number, string, or list for `eq`/`ne` + - `unit`: (optional) pint-compatible unit string (e.g., `\"in\"`, `\"ft\"`) + + **Expression conditions** use a boolean expression: + - `expression`: e.g., `\"dbh < 5 and height < 2\"` + - Only `dbh`, `height`, `crown_ratio` are allowed in expressions + - Expressions always use native units (cm, m, 0-1 fraction) + + **Spatial conditions** test each tree's location (a point) against a + geometry. Two variants discriminated by the required `source` field: + + - `source: \"geometry\"` — supply GeoJSON directly via `geometry` (plus + optional `crs`; defaults to the domain CRS). + - `source: \"feature\"` — reference a persisted Feature resource by + `feature_id` (road, water, layerset). The Feature must belong to the + same domain as this inventory and be in `completed` status; + cross-domain, missing, or unfinished references are rejected with 422. + + Both spatial variants accept: + - `operator`: `within`, `outside`, or `intersects` + - `buffer_m`: (optional, meters) expands the geometry outward in the + domain's projected CRS before testing. Effectively required for + linestring features (e.g. roads) because a tree point almost never + intersects a bare linestring. + + Spatial conditions have **no `target` field** — trees are points, so + the test is always point-in-(optionally-buffered)-geometry. + + Spatial and attribute conditions can be combined in a single rule + (AND semantics). For example: `{conditions: [feature within road + buffer, dbh > 30], actions: [remove]}` removes only large trees that + fall inside the buffered road. + + ## Actions + + - `{\"modifier\": \"remove\"}` — remove matching trees (must be sole action) + - `{\"attribute\": \"...\", \"modifier\": \"multiply|divide|add|subtract|replace\", \"value\": ...}` + - `unit` on actions converts the value before applying + + ## Response + + Returns this inventory (same ID) with status `\"pending\"` and the submitted + rules appended to `modifications`. Its `checksum` changes, so any resource + derived from it can detect that the source has changed. Poll the inventory + until status returns to `\"completed\"`. + + Args: + domain_id (str): + inventory_id (str): + body (ApplyModificationsRequest): Request body for applying modifications to an inventory + in place. + + Metadata (name, description, tags) is not accepted here — the inventory + keeps its identity; use PATCH to edit metadata. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | Inventory + """ + + return sync_detailed( + domain_id=domain_id, + inventory_id=inventory_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + inventory_id: str, + *, + client: AuthenticatedClient, + body: ApplyModificationsRequest, +) -> Response[HTTPValidationError | Inventory]: + r"""Apply modifications to an inventory in place + + # Apply Modifications to an Inventory (in place) + + Applies modifications to **this** inventory in place — the inventory keeps + its ID and the submitted rules are appended to its cumulative + `modifications` list, then the tree data is re-derived asynchronously. To + keep the original data instead, duplicate the inventory first + (`POST .../{inventory_id}/duplicate`) and modify the copy. + + Modifications filter trees by conditions and apply actions (remove, + multiply, divide, add, subtract, replace) to matching rows. Conditions + within a single rule are ANDed together; multiple rules are evaluated + independently in order. + + ## Conditions + + **Attribute conditions** compare a single tree attribute against a value: + - `attribute`: one of `dbh`, `height`, `crown_ratio`, `fia_species_code` + - `operator`: `eq`, `ne`, `gt`, `lt`, `ge`, `le` + (`fia_species_code` only supports `eq`/`ne`) + - `value`: number, string, or list for `eq`/`ne` + - `unit`: (optional) pint-compatible unit string (e.g., `\"in\"`, `\"ft\"`) + + **Expression conditions** use a boolean expression: + - `expression`: e.g., `\"dbh < 5 and height < 2\"` + - Only `dbh`, `height`, `crown_ratio` are allowed in expressions + - Expressions always use native units (cm, m, 0-1 fraction) + + **Spatial conditions** test each tree's location (a point) against a + geometry. Two variants discriminated by the required `source` field: + + - `source: \"geometry\"` — supply GeoJSON directly via `geometry` (plus + optional `crs`; defaults to the domain CRS). + - `source: \"feature\"` — reference a persisted Feature resource by + `feature_id` (road, water, layerset). The Feature must belong to the + same domain as this inventory and be in `completed` status; + cross-domain, missing, or unfinished references are rejected with 422. + + Both spatial variants accept: + - `operator`: `within`, `outside`, or `intersects` + - `buffer_m`: (optional, meters) expands the geometry outward in the + domain's projected CRS before testing. Effectively required for + linestring features (e.g. roads) because a tree point almost never + intersects a bare linestring. + + Spatial conditions have **no `target` field** — trees are points, so + the test is always point-in-(optionally-buffered)-geometry. + + Spatial and attribute conditions can be combined in a single rule + (AND semantics). For example: `{conditions: [feature within road + buffer, dbh > 30], actions: [remove]}` removes only large trees that + fall inside the buffered road. + + ## Actions + + - `{\"modifier\": \"remove\"}` — remove matching trees (must be sole action) + - `{\"attribute\": \"...\", \"modifier\": \"multiply|divide|add|subtract|replace\", \"value\": ...}` + - `unit` on actions converts the value before applying + + ## Response + + Returns this inventory (same ID) with status `\"pending\"` and the submitted + rules appended to `modifications`. Its `checksum` changes, so any resource + derived from it can detect that the source has changed. Poll the inventory + until status returns to `\"completed\"`. + + Args: + domain_id (str): + inventory_id (str): + body (ApplyModificationsRequest): Request body for applying modifications to an inventory + in place. + + Metadata (name, description, tags) is not accepted here — the inventory + keeps its identity; use PATCH to edit metadata. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | Inventory] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + inventory_id=inventory_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + inventory_id: str, + *, + client: AuthenticatedClient, + body: ApplyModificationsRequest, +) -> HTTPValidationError | Inventory | None: + r"""Apply modifications to an inventory in place + + # Apply Modifications to an Inventory (in place) + + Applies modifications to **this** inventory in place — the inventory keeps + its ID and the submitted rules are appended to its cumulative + `modifications` list, then the tree data is re-derived asynchronously. To + keep the original data instead, duplicate the inventory first + (`POST .../{inventory_id}/duplicate`) and modify the copy. + + Modifications filter trees by conditions and apply actions (remove, + multiply, divide, add, subtract, replace) to matching rows. Conditions + within a single rule are ANDed together; multiple rules are evaluated + independently in order. + + ## Conditions + + **Attribute conditions** compare a single tree attribute against a value: + - `attribute`: one of `dbh`, `height`, `crown_ratio`, `fia_species_code` + - `operator`: `eq`, `ne`, `gt`, `lt`, `ge`, `le` + (`fia_species_code` only supports `eq`/`ne`) + - `value`: number, string, or list for `eq`/`ne` + - `unit`: (optional) pint-compatible unit string (e.g., `\"in\"`, `\"ft\"`) + + **Expression conditions** use a boolean expression: + - `expression`: e.g., `\"dbh < 5 and height < 2\"` + - Only `dbh`, `height`, `crown_ratio` are allowed in expressions + - Expressions always use native units (cm, m, 0-1 fraction) + + **Spatial conditions** test each tree's location (a point) against a + geometry. Two variants discriminated by the required `source` field: + + - `source: \"geometry\"` — supply GeoJSON directly via `geometry` (plus + optional `crs`; defaults to the domain CRS). + - `source: \"feature\"` — reference a persisted Feature resource by + `feature_id` (road, water, layerset). The Feature must belong to the + same domain as this inventory and be in `completed` status; + cross-domain, missing, or unfinished references are rejected with 422. + + Both spatial variants accept: + - `operator`: `within`, `outside`, or `intersects` + - `buffer_m`: (optional, meters) expands the geometry outward in the + domain's projected CRS before testing. Effectively required for + linestring features (e.g. roads) because a tree point almost never + intersects a bare linestring. + + Spatial conditions have **no `target` field** — trees are points, so + the test is always point-in-(optionally-buffered)-geometry. + + Spatial and attribute conditions can be combined in a single rule + (AND semantics). For example: `{conditions: [feature within road + buffer, dbh > 30], actions: [remove]}` removes only large trees that + fall inside the buffered road. + + ## Actions + + - `{\"modifier\": \"remove\"}` — remove matching trees (must be sole action) + - `{\"attribute\": \"...\", \"modifier\": \"multiply|divide|add|subtract|replace\", \"value\": ...}` + - `unit` on actions converts the value before applying + + ## Response + + Returns this inventory (same ID) with status `\"pending\"` and the submitted + rules appended to `modifications`. Its `checksum` changes, so any resource + derived from it can detect that the source has changed. Poll the inventory + until status returns to `\"completed\"`. + + Args: + domain_id (str): + inventory_id (str): + body (ApplyModificationsRequest): Request body for applying modifications to an inventory + in place. + + Metadata (name, description, tags) is not accepted here — the inventory + keeps its identity; use PATCH to edit metadata. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | Inventory + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + inventory_id=inventory_id, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/base_url.py b/fastfuels_sdk/v2/client_library/base_url.py new file mode 100644 index 0000000..e4c13ce --- /dev/null +++ b/fastfuels_sdk/v2/client_library/base_url.py @@ -0,0 +1,8 @@ +"""Deployment URL of the FastFuels v2 API this client was generated against. + +Written by generate_client.sh (the OpenAPI spec carries no `servers` +entry and openapi-python-client takes base_url at construction time, so +the regen script records the URL alongside the client it generates). +""" + +DEFAULT_BASE_URL = "https://api-v2-prod-782971006568.us-west1.run.app" diff --git a/fastfuels_sdk/v2/client_library/models/__init__.py b/fastfuels_sdk/v2/client_library/models/__init__.py index 518df43..3bdaf00 100644 --- a/fastfuels_sdk/v2/client_library/models/__init__.py +++ b/fastfuels_sdk/v2/client_library/models/__init__.py @@ -7,6 +7,7 @@ ) from .allometry_max_crown_radius_source import AllometryMaxCrownRadiusSource from .application import Application +from .apply_modifications_request import ApplyModificationsRequest from .band import Band from .band_type import BandType from .base_model import BaseModel @@ -221,6 +222,7 @@ "AllometryBiomassSourceComponentStates", "AllometryMaxCrownRadiusSource", "Application", + "ApplyModificationsRequest", "Band", "BandType", "BaseModel", diff --git a/fastfuels_sdk/v2/client_library/models/apply_modifications_request.py b/fastfuels_sdk/v2/client_library/models/apply_modifications_request.py new file mode 100644 index 0000000..5d84958 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/apply_modifications_request.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.inventory_modification import InventoryModification + + +T = TypeVar("T", bound="ApplyModificationsRequest") + + +@_attrs_define +class ApplyModificationsRequest: + """Request body for applying modifications to an inventory in place. + + Metadata (name, description, tags) is not accepted here — the inventory + keeps its identity; use PATCH to edit metadata. + + Attributes: + modifications (list[InventoryModification]): Modifications to append to this inventory and apply to its data. + """ + + modifications: list[InventoryModification] + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + modifications = [] + for modifications_item_data in self.modifications: + modifications_item = modifications_item_data.to_dict() + modifications.append(modifications_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "modifications": modifications, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.inventory_modification import InventoryModification + + d = dict(src_dict) + modifications = [] + _modifications = d.pop("modifications") + for modifications_item_data in _modifications: + modifications_item = InventoryModification.from_dict( + modifications_item_data + ) + + modifications.append(modifications_item) + + apply_modifications_request = cls( + modifications=modifications, + ) + + apply_modifications_request.additional_properties = d + return apply_modifications_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/generate_client.sh b/fastfuels_sdk/v2/generate_client.sh index b42485f..819f069 100644 --- a/fastfuels_sdk/v2/generate_client.sh +++ b/fastfuels_sdk/v2/generate_client.sh @@ -37,5 +37,21 @@ uvx "openapi-python-client@${OPC_VERSION}" generate \ --output-path client_library \ --overwrite +# openapi-python-client embeds no server URL (the spec has no `servers` +# entry, and the generated client takes base_url at construction time), so +# record the deployment URL this client was generated against. api.py +# imports it as the SDK default — this script is the single source of +# truth for the URL. +cat > client_library/base_url.py < Date: Fri, 5 Jun 2026 15:21:42 -0600 Subject: [PATCH 05/71] Add v2 shared infrastructure: typed exceptions and cached client - exceptions.py: ApiException hierarchy (BadRequest/Unauthorized/ Forbidden/NotFound/UnprocessableEntity/Service) with expect() and raise_for_response() translating sync_detailed() responses at a single choke point; tolerant HTTPValidationError parsing for the API's string-detail 422s - api.py: set_api_key/get_client/ensure_client around one cached AuthenticatedClient (raise_on_unexpected_status=False so undocumented statuses reach the wrapper translation); base URL from the generated client_library.base_url with FASTFUELS_API_V2_URL override - remaining drafts repointed at the shared client (the draft-era _checked helper moves into draft_grids_opc until #178-#180) --- fastfuels_sdk/v2/api.py | 97 ++++++++++++ fastfuels_sdk/v2/draft_features_opc.py | 5 +- fastfuels_sdk/v2/draft_grids_opc.py | 26 +++- fastfuels_sdk/v2/draft_inventories_opc.py | 5 +- fastfuels_sdk/v2/exceptions.py | 177 ++++++++++++++++++++++ 5 files changed, 300 insertions(+), 10 deletions(-) create mode 100644 fastfuels_sdk/v2/api.py create mode 100644 fastfuels_sdk/v2/exceptions.py diff --git a/fastfuels_sdk/v2/api.py b/fastfuels_sdk/v2/api.py new file mode 100644 index 0000000..73ab375 --- /dev/null +++ b/fastfuels_sdk/v2/api.py @@ -0,0 +1,97 @@ +""" +fastfuels_sdk/v2/api.py + +Client configuration for the FastFuels v2 API: API key management and the +shared HTTP client used by the wrapper modules. +""" + +import os +from typing import Optional + +# DEFAULT_BASE_URL is recorded by generate_client.sh alongside the generated +# client (openapi-python-client embeds no server URL). It is the Cloud Run +# deployment for now — a stable domain should front it before GA (tracked in +# #176); until then FASTFUELS_API_V2_URL overrides it without an SDK upgrade. +from fastfuels_sdk.v2.client_library.base_url import DEFAULT_BASE_URL +from fastfuels_sdk.v2.client_library.client import AuthenticatedClient + +_client: Optional[AuthenticatedClient] = None + + +def set_api_key(api_key: str) -> None: + """Set the API key for the FastFuels v2 SDK. + + This invalidates the cached client, ensuring that subsequent API calls + use the new credentials. + + Parameters + ---------- + api_key : str + The API key to use for authentication. + """ + global _client + _client = None + os.environ["FASTFUELS_API_KEY"] = api_key + + +def get_client() -> Optional[AuthenticatedClient]: + """Get the current API client, creating one if necessary. + + The API key is read from: + + 1. The cached client, if :func:`set_api_key` was called + 2. The ``FASTFUELS_API_KEY`` environment variable + + Returns + ------- + Optional[AuthenticatedClient] + The client instance, or None if no API key is configured. + """ + global _client + + if _client is not None: + return _client + + # Whether v2 keeps sharing FASTFUELS_API_KEY with the v1 deployment or + # grows its own variable is an open decision tracked in #176. + api_key = os.getenv("FASTFUELS_API_KEY") + if not api_key: + return None + + _client = AuthenticatedClient( + base_url=os.getenv("FASTFUELS_API_V2_URL", DEFAULT_BASE_URL), + token=api_key, + prefix="", # raw key, not "Bearer " + auth_header_name="api-key", + # Error translation happens in one place — exceptions.expect() on + # sync_detailed() responses — so the generated client must hand back + # undocumented statuses (e.g. 404) instead of raising its own + # UnexpectedStatus. + raise_on_unexpected_status=False, + ) + + return _client + + +def ensure_client() -> AuthenticatedClient: + """Ensure an API client is configured and return it. + + Returns + ------- + AuthenticatedClient + The client instance. + + Raises + ------ + RuntimeError + If no API key is configured. + """ + client = get_client() + if client is None: + raise RuntimeError( + "FastFuels API key not configured. Please either:\n" + " 1. Set the FASTFUELS_API_KEY environment variable, or\n" + " 2. Call fastfuels_sdk.v2.api.set_api_key('your-api-key') " + "before making API calls" + ) + return client diff --git a/fastfuels_sdk/v2/draft_features_opc.py b/fastfuels_sdk/v2/draft_features_opc.py index f6bd344..e6e3cfe 100644 --- a/fastfuels_sdk/v2/draft_features_opc.py +++ b/fastfuels_sdk/v2/draft_features_opc.py @@ -15,7 +15,8 @@ import attrs -from fastfuels_sdk.v2.draft_domains_opc import _checked, get_client +from fastfuels_sdk.v2.api import ensure_client as get_client +from fastfuels_sdk.v2.draft_grids_opc import _checked from fastfuels_sdk.v2.client_library.api.features import ( create_osm_road_feature, @@ -35,8 +36,6 @@ ) from fastfuels_sdk.v2.client_library.types import UNSET -V2_BASE_URL = "https://api-v2-prod-782971006568.us-west1.run.app" - def _osm_request_dict( name: str, diff --git a/fastfuels_sdk/v2/draft_grids_opc.py b/fastfuels_sdk/v2/draft_grids_opc.py index a2a7fd4..900d4b2 100644 --- a/fastfuels_sdk/v2/draft_grids_opc.py +++ b/fastfuels_sdk/v2/draft_grids_opc.py @@ -12,13 +12,16 @@ import json import time -from typing import Any, List, Optional +from typing import Any, List, Optional, Type, TypeVar import attrs # NOTE (comparison): one AuthenticatedClient serves every resource — no -# per-resource API objects, so the domains draft's client is reused as-is. -from fastfuels_sdk.v2.draft_domains_opc import _checked, get_client +# per-resource API objects. The domains draft has been promoted to the +# real fastfuels_sdk/v2/domains.py (built on api.py/exceptions.py); the +# remaining drafts borrow the shared client and keep the draft-era +# _checked() helper below until their own promotion (#178-#180). +from fastfuels_sdk.v2.api import ensure_client as get_client from fastfuels_sdk.v2.client_library.api.grids import ( create_3dep_topography, @@ -33,11 +36,26 @@ CreateLandfireFbfm40Request, CreateThreeDepTopographyRequest, CreateTreeMapRequest, + HTTPValidationError, JobStatus, ListGridsResponse, ) -V2_BASE_URL = "https://api-v2-prod-782971006568.us-west1.run.app" +T = TypeVar("T") + + +def _checked(response: Any, expected: Type[T]) -> T: + """Unwrap a generated sync() response, raising on error results. + + Draft-era helper retained from the promoted domains draft; the real + wrappers use sync_detailed() + fastfuels_sdk.v2.exceptions.expect() + instead (see domains.py). Goes away as #178-#180 promote these drafts. + """ + if isinstance(response, expected): + return response + if isinstance(response, HTTPValidationError): + raise RuntimeError(f"API validation error (422): {response.detail}") + raise RuntimeError(f"Unexpected API response: {response!r}") def _base_request_dict( diff --git a/fastfuels_sdk/v2/draft_inventories_opc.py b/fastfuels_sdk/v2/draft_inventories_opc.py index b0204c9..1a9c6af 100644 --- a/fastfuels_sdk/v2/draft_inventories_opc.py +++ b/fastfuels_sdk/v2/draft_inventories_opc.py @@ -22,7 +22,8 @@ import attrs -from fastfuels_sdk.v2.draft_domains_opc import _checked, get_client +from fastfuels_sdk.v2.api import ensure_client as get_client +from fastfuels_sdk.v2.draft_grids_opc import _checked from fastfuels_sdk.v2.client_library.api.inventories import ( create_pim_inventory, @@ -37,8 +38,6 @@ ListInventoriesResponse, ) -V2_BASE_URL = "https://api-v2-prod-782971006568.us-west1.run.app" - class Inventory(InventoryModel): """Inventory resource for the FastFuels v2 API (openapi-python-client draft). diff --git a/fastfuels_sdk/v2/exceptions.py b/fastfuels_sdk/v2/exceptions.py new file mode 100644 index 0000000..25dbed0 --- /dev/null +++ b/fastfuels_sdk/v2/exceptions.py @@ -0,0 +1,177 @@ +""" +fastfuels_sdk/v2/exceptions.py + +Typed exceptions for the FastFuels v2 SDK. + +Every API error surfaces as a subclass of :class:`ApiException` carrying +the HTTP status code and the error detail reported by the API. +""" + +import json +from collections.abc import Mapping +from http import HTTPStatus +from typing import Any, NoReturn + +from fastfuels_sdk.v2.client_library.models import HTTPValidationError +from fastfuels_sdk.v2.client_library.types import Response + +# Implementation note: the generated client (openapi-python-client) does +# not raise per-status exceptions — documented error responses are +# *returned* as parsed models and undocumented statuses are only surfaced +# through the raw ``Response`` object. The wrapper modules therefore call +# the generated ``sync_detailed()`` endpoints (the shared client is created +# with ``raise_on_unexpected_status=False``) and funnel every ``Response`` +# through ``expect()``, the single choke point where errors become +# exceptions. + +# The v2 API raises some 422s with a plain-string ``detail`` (e.g. invalid +# EPSG codes), while the OpenAPI spec types ``detail`` as a list of +# validation errors. The generated parser crashes on the string form +# *inside* sync_detailed(), before the wrapper can translate the response, +# so wrap it to tolerate undocumented body shapes; the actual detail is +# re-read from ``response.content`` in ``raise_for_response()``. +_generated_from_dict = HTTPValidationError.from_dict.__func__ + + +def _tolerant_from_dict(cls, src_dict): + try: + return _generated_from_dict(cls, src_dict) + except (TypeError, ValueError): + model = cls() + if isinstance(src_dict, Mapping): + model.additional_properties = dict(src_dict) + return model + + +HTTPValidationError.from_dict = classmethod(_tolerant_from_dict) + + +class ApiException(Exception): + """Base exception for FastFuels v2 API errors. + + Attributes + ---------- + status_code : int + The HTTP status code returned by the API. + detail : Any + The error detail extracted from the response body (FastAPI's + ``detail`` field when present, otherwise the raw response text). + """ + + def __init__(self, status_code: int, detail: Any = None): + self.status_code = status_code + self.detail = detail + super().__init__(f"({status_code}) {detail}") + + +class BadRequestException(ApiException): + """Raised when the API returns 400 Bad Request.""" + + +class UnauthorizedException(ApiException): + """Raised when the API returns 401 Unauthorized (invalid API key).""" + + +class ForbiddenException(ApiException): + """Raised when the API returns 403 Forbidden.""" + + +class NotFoundException(ApiException): + """Raised when the API returns 404 Not Found. + + The v2 API returns 404 both for missing resources and for ownership + mismatches, to avoid leaking information about resource existence. + """ + + +class UnprocessableEntityException(ApiException): + """Raised when the API returns 422 Unprocessable Entity. + + The ``detail`` attribute carries FastAPI's validation error detail: + either a human-readable message or a list of per-field errors. + """ + + +class ServiceException(ApiException): + """Raised when the API returns a 5xx server error.""" + + +_STATUS_TO_EXCEPTION = { + HTTPStatus.BAD_REQUEST: BadRequestException, + HTTPStatus.UNAUTHORIZED: UnauthorizedException, + HTTPStatus.FORBIDDEN: ForbiddenException, + HTTPStatus.NOT_FOUND: NotFoundException, + HTTPStatus.UNPROCESSABLE_ENTITY: UnprocessableEntityException, +} + + +def _extract_detail(response: Response) -> Any: + """Pull FastAPI's ``detail`` field out of an error response body.""" + try: + return json.loads(response.content)["detail"] + except (json.JSONDecodeError, KeyError, UnicodeDecodeError): + return ( + response.content.decode(errors="ignore") + or HTTPStatus(response.status_code).phrase + ) + + +def raise_for_response(response: Response) -> NoReturn: + """Translate an error ``Response`` into a typed SDK exception. + + Parameters + ---------- + response : Response + A response from a generated ``sync_detailed()`` endpoint call. + + Raises + ------ + BadRequestException + If the response status is 400. + UnauthorizedException + If the response status is 401. + ForbiddenException + If the response status is 403. + NotFoundException + If the response status is 404. + UnprocessableEntityException + If the response status is 422. + ServiceException + If the response status is 5xx. + ApiException + For any other unexpected status. + """ + status_code = int(response.status_code) + exception_class = _STATUS_TO_EXCEPTION.get(response.status_code) + if exception_class is None: + exception_class = ServiceException if status_code >= 500 else ApiException + raise exception_class(status_code, _extract_detail(response)) + + +def expect(response: Response, expected_status: HTTPStatus = HTTPStatus.OK) -> Any: + """Return a response's parsed body, raising typed exceptions on error. + + Parameters + ---------- + response : Response + A response from a generated ``sync_detailed()`` endpoint call. + expected_status : HTTPStatus, optional + The success status documented for the endpoint (default 200 OK; + pass ``HTTPStatus.NO_CONTENT`` for deletes). + + Returns + ------- + Any + ``response.parsed`` — the generated success model (``None`` for + 204 No Content). + + Raises + ------ + ApiException + A subclass matching the response status (see + :func:`raise_for_response`) when it differs from + ``expected_status``. + """ + if response.status_code == expected_status: + return response.parsed + raise_for_response(response) From e8f8db697c510933e50beb2b41512778fbecefa3 Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Fri, 5 Jun 2026 15:21:42 -0600 Subject: [PATCH 06/71] Promote the Domain wrapper to fastfuels_sdk/v2/domains.py (#177) Full v1-parity surface (from_id/from_geojson/from_geodataframe/from_file, get, update, delete, to_json, to_geodataframe, list_domains) built on the shared api/exceptions infrastructure, plus the v2-only endpoints: Domain.preview, Domain.get_lattice, and reproject_geojson. from_geojson accepts a single Feature and wraps it in a FeatureCollection; from_geodataframe forwards the GeoDataFrame CRS. Exported from fastfuels_sdk.v2. tests/v2/test_domains.py: 31 tests against the live v2 API (all passing), covering creation across formats and CRSs, the read/update/delete lifecycle, preview/reproject/lattice, and the typed exception paths (404 -> NotFoundException, 422 -> UnprocessableEntityException). --- fastfuels_sdk/v2/__init__.py | 9 + fastfuels_sdk/v2/domains.py | 715 ++++++++++++++++++++++++++ fastfuels_sdk/v2/draft_domains_opc.py | 336 ------------ tests/v2/__init__.py | 0 tests/v2/test_domains.py | 289 +++++++++++ tests/v2/utils.py | 24 + 6 files changed, 1037 insertions(+), 336 deletions(-) create mode 100644 fastfuels_sdk/v2/domains.py delete mode 100644 fastfuels_sdk/v2/draft_domains_opc.py create mode 100644 tests/v2/__init__.py create mode 100644 tests/v2/test_domains.py create mode 100644 tests/v2/utils.py diff --git a/fastfuels_sdk/v2/__init__.py b/fastfuels_sdk/v2/__init__.py index e69de29..38dc0b4 100644 --- a/fastfuels_sdk/v2/__init__.py +++ b/fastfuels_sdk/v2/__init__.py @@ -0,0 +1,9 @@ +from fastfuels_sdk.v2.api import set_api_key +from fastfuels_sdk.v2.domains import Domain, list_domains, reproject_geojson + +__all__ = [ + "set_api_key", + "Domain", + "list_domains", + "reproject_geojson", +] diff --git a/fastfuels_sdk/v2/domains.py b/fastfuels_sdk/v2/domains.py new file mode 100644 index 0000000..8f7802a --- /dev/null +++ b/fastfuels_sdk/v2/domains.py @@ -0,0 +1,715 @@ +""" +fastfuels_sdk/v2/domains.py +""" + +# Core imports +import json +from http import HTTPStatus +from pathlib import Path +from typing import Any, List, Optional, Union + +# Internal imports +from fastfuels_sdk.v2.api import ensure_client +from fastfuels_sdk.v2.exceptions import expect +from fastfuels_sdk.v2.client_library.api.domains import ( + create_domain, + delete_domain, + get_domain, + get_domain_lattice, + list_domains as list_domains_endpoint, + preview_domain, + reproject_domain, + update_domain, +) +from fastfuels_sdk.v2.client_library.models import ( + Domain as DomainModel, + CreateDomainRequestBody, + DomainLattice, + DomainSortField, + DomainSortOrder, + ListDomainsResponse, + UpdateDomainRequestBody, +) +from fastfuels_sdk.v2.client_library.types import UNSET, Unset + +# External imports +import attrs +import geopandas as gpd + + +def _build_create_request_body( + geojson: dict, + name: str, + description: str, + tags: Optional[List[str]], + pad_to_resolution: Optional[float], +) -> CreateDomainRequestBody: + """Build a domain creation request body from GeoJSON input. + + The v2 API accepts FeatureCollection input only; a single Feature is + wrapped in a FeatureCollection here (carrying along a feature-level + ``crs`` member if present) so v1-style Feature input keeps working. + """ + geojson_type = geojson.get("type") + if geojson_type == "Feature": + feature_collection = {"type": "FeatureCollection", "features": [geojson]} + if "crs" in geojson: + feature_collection["crs"] = geojson["crs"] + geojson = feature_collection + elif geojson_type != "FeatureCollection": + raise ValueError( + "GeoJSON type must be 'Feature' or 'FeatureCollection', " + f"got {geojson_type!r}" + ) + + return CreateDomainRequestBody.from_dict( + { + **geojson, + "name": name, + "description": description, + "tags": tags, + "pad_to_resolution": pad_to_resolution, + } + ) + + +class Domain(DomainModel): + """Domain resource for the FastFuels v2 API. + + Represents a spatial container that defines geographic boundaries for + fire behavior modeling and analysis. A Domain includes metadata like + name and description along with geometric data defining its spatial + extent. Domains must specify a valid area between 0 and 16 square + kilometers, located within CONUS. + + The Domain handles coordinate system transformations automatically: + + 1. Geographic coordinates (e.g. EPSG:4326) are projected to the + appropriate UTM zone + 2. Projected coordinates are preserved in their original CRS + 3. Geometries are optionally padded to align with a grid resolution + (``pad_to_resolution``) + + Attributes + ---------- + id : str + Unique identifier for the domain. + name : str + Human-readable name for the domain. + description : str + Detailed description of the domain. + type_ : str + Always "FeatureCollection". + features : List[GeoJsonFeature] + Two named GeoJSON features: "domain" (the working extent / + bounding box) and "input" (the original geometry). + bbox : List[float] + Bounding box of the "domain" feature. + crs : GeoJsonCRS + Coordinate reference system specification (always projected). + tags : List[str], optional + User-defined tags for organization. + pad_to_resolution : float, optional + Resolution in meters the domain bounding box was padded to align + with. + created_on : datetime + When the domain was created. + modified_on : datetime + When the domain was last modified. + + Examples + -------- + Create a domain from a file: + >>> domain = Domain.from_file("area.geojson", name="my domain") + + Get a domain by ID: + >>> domain = Domain.from_id("abc123") + >>> print(domain.name) + 'my domain' + + See Also + -------- + Domain.from_geojson : Create a domain from GeoJSON data. + Domain.from_geodataframe : Create a domain from a GeoPandas GeoDataFrame. + Domain.from_file : Create a domain from a geospatial file. + Domain.preview : Validate and project a domain without persisting it. + Domain.get_lattice : Get the pixel lattice for a domain at a resolution. + Domain.to_geodataframe : Convert a domain to a GeoPandas GeoDataFrame. + list_domains : List available domains. + reproject_geojson : Reproject a GeoJSON FeatureCollection (stateless). + """ + + @classmethod + def _from_model(cls, model: DomainModel) -> "Domain": + """Build a Domain from a generated DomainModel instance. + + Round-trips through the generated to_dict/from_dict — from_dict + constructs ``cls``, i.e. this subclass. + """ + return cls.from_dict(model.to_dict()) + + def _copy_fields_from(self, model: DomainModel) -> "Domain": + """Copy all generated-model fields from `model` onto self (in-place).""" + for field in attrs.fields(DomainModel): + if field.init: + setattr(self, field.name, getattr(model, field.name)) + self.additional_properties = dict(model.additional_properties) + return self + + def _require_id(self) -> str: + """Return the domain id, raising if this instance has none.""" + if isinstance(self.id, Unset): + raise ValueError( + "This Domain has no id (it was not created through the API)." + ) + return self.id + + @classmethod + def from_id(cls, domain_id: str) -> "Domain": + """Retrieve an existing Domain resource by its ID. + + Parameters + ---------- + domain_id : str + The unique identifier of the domain to retrieve. + + Returns + ------- + Domain + The requested Domain object. + + Raises + ------ + NotFoundException + If no domain exists with the given ID, or the user does not + have access to it. + + Examples + -------- + >>> domain = Domain.from_id("abc123") + >>> domain.id + 'abc123' + """ + response = get_domain.sync_detailed(client=ensure_client(), domain_id=domain_id) + return cls._from_model(expect(response)) + + @classmethod + def from_geojson( + cls, + geojson: dict, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, + pad_to_resolution: Optional[float] = None, + ) -> "Domain": + """Create a new Domain from GeoJSON data. + + Parameters + ---------- + geojson : dict + A GeoJSON FeatureCollection or Feature. (The v2 API accepts + FeatureCollection input only; a single Feature is wrapped + automatically.) The source CRS is read from the ``crs`` member + if present; otherwise EPSG:4326 is assumed. + name : str, optional + Name for the domain. + description : str, optional + Description of the domain. + tags : List[str], optional + Tags for organizing the domain. + pad_to_resolution : float, optional + Pad the domain bounding box outward so its extent is a + multiple of this resolution (meters). + + Returns + ------- + Domain + The created Domain object. + + Raises + ------ + ValueError + If the GeoJSON is not a Feature or FeatureCollection. + UnprocessableEntityException + If the geometry is invalid: zero area, larger than 16 square + kilometers, outside CONUS, or an invalid CRS. + + Examples + -------- + >>> with open("area.geojson") as f: + ... geojson = json.load(f) + >>> domain = Domain.from_geojson(geojson, name="my domain") + """ + request_body = _build_create_request_body( + geojson, name, description, tags, pad_to_resolution + ) + response = create_domain.sync_detailed( + client=ensure_client(), body=request_body + ) + return cls._from_model(expect(response, HTTPStatus.CREATED)) + + @classmethod + def from_geodataframe( + cls, + geodataframe: gpd.GeoDataFrame, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, + pad_to_resolution: Optional[float] = None, + ) -> "Domain": + """Create a new Domain from a GeoPandas GeoDataFrame. + + The GeoDataFrame's CRS (when set) is forwarded to the API as the + FeatureCollection's ``crs`` member, so projected inputs (e.g. + EPSG:5070) are interpreted correctly. + + Parameters + ---------- + geodataframe : gpd.GeoDataFrame + GeoDataFrame containing the domain geometry. + name : str, optional + Name for the domain. + description : str, optional + Description of the domain. + tags : List[str], optional + Tags for organizing the domain. + pad_to_resolution : float, optional + Pad the domain bounding box outward so its extent is a + multiple of this resolution (meters). + + Returns + ------- + Domain + The created Domain object. + + Raises + ------ + ValueError + If the GeoDataFrame CRS has no authority code (e.g. a custom + CRS). + UnprocessableEntityException + If the geometry is invalid: zero area, larger than 16 square + kilometers, outside CONUS, or an invalid CRS. + + Examples + -------- + >>> gdf = gpd.read_file("area.shp") + >>> domain = Domain.from_geodataframe(gdf, name="my domain") + """ + geojson = json.loads(geodataframe.to_json()) + if geodataframe.crs is not None: + authority = geodataframe.crs.to_authority() + if authority is None: + raise ValueError( + "GeoDataFrame CRS has no authority code (e.g. a custom " + "CRS). Reproject to a known CRS such as EPSG:4326 with " + "geodataframe.to_crs(...) before creating a domain." + ) + geojson["crs"] = { + "type": "name", + "properties": {"name": ":".join(authority)}, + } + return cls.from_geojson( + geojson=geojson, + name=name, + description=description, + tags=tags, + pad_to_resolution=pad_to_resolution, + ) + + @classmethod + def from_file( + cls, + path: Union[str, Path], + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, + pad_to_resolution: Optional[float] = None, + ) -> "Domain": + """Create a new Domain from a geospatial file. + + Reads any format geopandas supports (GeoJSON, Shapefile, KML, + GeoPackage, ...) and creates a domain from its geometry. + + Parameters + ---------- + path : str or Path + Path to the geospatial file. + name : str, optional + Name for the domain. + description : str, optional + Description of the domain. + tags : List[str], optional + Tags for organizing the domain. + pad_to_resolution : float, optional + Pad the domain bounding box outward so its extent is a + multiple of this resolution (meters). + + Returns + ------- + Domain + The created Domain object. + + Examples + -------- + >>> domain = Domain.from_file("area.geojson", name="my domain") + """ + return cls.from_geodataframe( + gpd.read_file(path), + name=name, + description=description, + tags=tags, + pad_to_resolution=pad_to_resolution, + ) + + @classmethod + def preview( + cls, + geojson: dict, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, + pad_to_resolution: Optional[float] = None, + ) -> "Domain": + """Preview a domain without persisting it. + + Runs the same validation and projection pipeline as domain + creation but writes nothing: use this to inspect the projected, + padded bounding box before committing to a create. Takes the same + arguments as :meth:`from_geojson`. + + Parameters + ---------- + geojson : dict + A GeoJSON FeatureCollection or Feature (a single Feature is + wrapped automatically). + name : str, optional + Name for the domain. + description : str, optional + Description of the domain. + tags : List[str], optional + Tags for organizing the domain. + pad_to_resolution : float, optional + Pad the domain bounding box outward so its extent is a + multiple of this resolution (meters). + + Returns + ------- + Domain + The previewed Domain object. Its ``id`` is always + ``"preview"`` — not a real domain identifier, so API-backed + instance methods (``get``, ``update``, ``delete``, + ``get_lattice``) will not work on it. + + Raises + ------ + UnprocessableEntityException + Same validation errors as :meth:`from_geojson`. + + Examples + -------- + >>> previewed = Domain.preview(geojson, pad_to_resolution=2.0) + >>> previewed.id + 'preview' + """ + request_body = _build_create_request_body( + geojson, name, description, tags, pad_to_resolution + ) + response = preview_domain.sync_detailed( + client=ensure_client(), body=request_body + ) + return cls._from_model(expect(response)) + + def get(self, in_place: bool = False) -> "Domain": + """Retrieve the latest domain data from the API. + + Parameters + ---------- + in_place : bool, optional + If True, updates the current Domain instance with the new data + and returns self. If False (default), returns a new Domain + instance with the latest data, leaving the current instance + unchanged. + + Returns + ------- + Domain + The updated Domain object. + + Raises + ------ + NotFoundException + If the domain no longer exists. + + Examples + -------- + >>> domain = Domain.from_id("abc123") + >>> updated_domain = domain.get() # new instance + >>> domain.get(in_place=True) # refresh in place + """ + domain_id = self._require_id() + response = get_domain.sync_detailed(client=ensure_client(), domain_id=domain_id) + model = expect(response) + if in_place: + return self._copy_fields_from(model) + return Domain._from_model(model) + + def update( + self, + name: Optional[str] = None, + description: Optional[str] = None, + tags: Optional[List[str]] = None, + in_place: bool = False, + ) -> "Domain": + """Update the domain's mutable properties (name, description, tags). + + Only provided fields are sent in the PATCH body (the generated + UNSET sentinel keeps omitted fields out of the request entirely). + If no fields are provided, no API call is made. + + Parameters + ---------- + name : str, optional + New name for the domain. + description : str, optional + New description for the domain. + tags : List[str], optional + New tags for the domain (replaces existing tags). + in_place : bool, optional + If True, updates the current Domain instance with the new data + and returns self. If False (default), returns a new Domain + instance, leaving the current instance unchanged. + + Returns + ------- + Domain + The updated Domain object. + + Raises + ------ + NotFoundException + If the domain no longer exists. + + Examples + -------- + >>> domain = domain.update(name="new name", tags=["test"]) + """ + if name is None and description is None and tags is None: + return self if in_place else Domain.from_dict(self.to_dict()) + + request_body = UpdateDomainRequestBody( + name=name if name is not None else UNSET, + description=description if description is not None else UNSET, + tags=tags if tags is not None else UNSET, + ) + domain_id = self._require_id() + response = update_domain.sync_detailed( + client=ensure_client(), domain_id=domain_id, body=request_body + ) + model = expect(response) + if in_place: + return self._copy_fields_from(model) + return Domain._from_model(model) + + def delete(self) -> None: + """Delete this domain and all resources associated with it. + + Raises + ------ + NotFoundException + If the domain no longer exists. + + Examples + -------- + >>> domain = Domain.from_id("abc123") + >>> domain.delete() + """ + domain_id = self._require_id() + response = delete_domain.sync_detailed( + client=ensure_client(), domain_id=domain_id + ) + expect(response, HTTPStatus.NO_CONTENT) + + def get_lattice( + self, resolution: float, num_buffer_cells: int = 0 + ) -> DomainLattice: + """Get the pixel lattice for this domain at a given resolution. + + Returns the pixel lattice (affine transform + raster shape) for + the domain at the requested resolution. Use this to align a + GeoTIFF before uploading it as a custom grid. + + Parameters + ---------- + resolution : float + Pixel size in meters (domain CRS units, always projected). + num_buffer_cells : int, optional + Expand the lattice by N cells on each side (default 0). + Mirrors the buffer semantics of the grid creation endpoints. + + Returns + ------- + DomainLattice + With attributes ``crs`` (authority string), ``resolution``, + ``num_buffer_cells``, ``transform`` (affine coefficients + ``[a, b, c, d, e, f]``, rasterio convention), and ``shape`` + (``[height, width]`` in pixels). + + Raises + ------ + NotFoundException + If the domain no longer exists. + UnprocessableEntityException + If ``resolution`` is non-positive or ``num_buffer_cells`` is + negative. + + Examples + -------- + >>> lattice = domain.get_lattice(resolution=2.0) + >>> lattice.shape + [400, 400] + """ + domain_id = self._require_id() + response = get_domain_lattice.sync_detailed( + domain_id=domain_id, + client=ensure_client(), + resolution=resolution, + num_buffer_cells=num_buffer_cells, + ) + return expect(response) + + def to_json(self) -> str: + """Serialize the complete Domain object to a JSON string. + + Returns + ------- + str + The Domain as a pretty-printed JSON string. + """ + return json.dumps(self.to_dict(), default=str, indent=2) + + def to_geodataframe(self) -> gpd.GeoDataFrame: + """Convert the Domain to a GeoPandas GeoDataFrame. + + The v2 API returns two named features: "domain" (the working + extent / bounding box) and "input" (the original geometry), so the + GeoDataFrame has one row per feature. + + Returns + ------- + gpd.GeoDataFrame + GeoDataFrame in the domain CRS with the domain features as + rows and domain metadata as columns. + + Examples + -------- + >>> gdf = domain.to_geodataframe() + >>> gdf[gdf["name"] == "input"].geometry # the original geometry + """ + feature_collection: dict[str, Any] = { + "type": "FeatureCollection", + "features": ( + [feature.to_dict() for feature in self.features] + if self.features + else [] + ), + } + if self.crs and not isinstance(self.crs, Unset): + feature_collection["crs"] = self.crs.to_dict() + + gdf = gpd.read_file(json.dumps(feature_collection)) + + # Add domain metadata as columns (UNSET fields become None) + gdf["domain_id"] = None if isinstance(self.id, Unset) else self.id + gdf["domain_name"] = None if isinstance(self.name, Unset) else self.name + gdf["domain_description"] = ( + None if isinstance(self.description, Unset) else self.description + ) + if self.pad_to_resolution and not isinstance(self.pad_to_resolution, Unset): + gdf["pad_to_resolution"] = self.pad_to_resolution + if self.tags and not isinstance(self.tags, Unset): + gdf["tags"] = ", ".join(self.tags) + + return gdf + + +def list_domains( + page: int = 0, + size: int = 100, + sort_by: Optional[str] = None, + sort_order: Optional[str] = None, +) -> List[Domain]: + """List domains belonging to the authenticated user (single page). + + Parameters + ---------- + page : int, optional + The page number to retrieve, zero-indexed (default 0). + size : int, optional + The number of domains per page (default 100). + sort_by : str, optional + Field to sort by: "name", "created_on", or "modified_on". + sort_order : str, optional + Sort direction: "ascending" or "descending". + + Returns + ------- + List[Domain] + The requested page of Domain objects. + + Examples + -------- + >>> domains = list_domains(sort_by="created_on", sort_order="descending") + """ + response = list_domains_endpoint.sync_detailed( + client=ensure_client(), + page=page, + size=size, + sort_by=DomainSortField(sort_by) if sort_by else UNSET, + sort_order=DomainSortOrder(sort_order) if sort_order else UNSET, + ) + list_response: ListDomainsResponse = expect(response) + return [Domain._from_model(d) for d in list_response.domains] + + +def reproject_geojson(geojson: dict, target_epsg: int) -> dict: + """Reproject a GeoJSON FeatureCollection to a target CRS. + + Stateless utility — no resource is created; the reprojected + FeatureCollection is returned immediately. + + Parameters + ---------- + geojson : dict + A GeoJSON FeatureCollection or Feature (a single Feature is + wrapped automatically). The source CRS is read from the ``crs`` + member if present; otherwise EPSG:4326 is assumed. + target_epsg : int + EPSG code of the target CRS (e.g. 4326 for WGS84, 32611 for UTM + zone 11N). + + Returns + ------- + dict + The reprojected FeatureCollection, with original feature + properties preserved and ``crs`` set to the target EPSG code. + + Raises + ------ + UnprocessableEntityException + If the source CRS is invalid, the target EPSG is invalid, or a + geometry cannot be reprojected. + + Examples + -------- + >>> projected = reproject_geojson(geojson, target_epsg=5070) + >>> projected["crs"]["properties"]["name"] + 'EPSG:5070' + """ + request_body = _build_create_request_body( + geojson, name="", description="", tags=None, pad_to_resolution=None + ) + response = reproject_domain.sync_detailed( + client=ensure_client(), body=request_body, target_epsg=target_epsg + ) + return expect(response).to_dict() diff --git a/fastfuels_sdk/v2/draft_domains_opc.py b/fastfuels_sdk/v2/draft_domains_opc.py deleted file mode 100644 index 2e2e938..0000000 --- a/fastfuels_sdk/v2/draft_domains_opc.py +++ /dev/null @@ -1,336 +0,0 @@ -""" -fastfuels_sdk/v2/draft_domains_opc.py - -DRAFT — Domain wrapper built on the **openapi-python-client** client -(`client_library`: attrs models + httpx). - -Part of the v2 client-library generator comparison; see COMPARISON.md. -Mirrors the v1 wrapper ergonomics: a Domain class extending the generated -model with classmethod constructors and instance methods. - -v1 parity notes: -- `from_geojson`/`from_geodataframe` lose `horizontal_resolution`/ - `vertical_resolution` (resolution moved to grids in v2) and gain - `tags`/`pad_to_resolution`. -- `from_file` is new convenience (v1 users went through geopandas manually). -- v1's `export()` has no v2 endpoint equivalent (no - `/domains/{id}/export` in the v2 spec) and is omitted. -- v2-only endpoints (preview_domain, reproject_domain, get_domain_lattice) - are not wrapped yet — out of scope for the generator comparison. -""" - -import json -import os -from pathlib import Path -from typing import Any, List, Optional, Type, TypeVar, Union - -import attrs -import geopandas as gpd - -from fastfuels_sdk.v2.client_library.client import ( - AuthenticatedClient, -) -from fastfuels_sdk.v2.client_library.api.domains import ( - create_domain, - delete_domain, - get_domain, - list_domains as list_domains_endpoint, - update_domain, -) -from fastfuels_sdk.v2.client_library.models import ( - Domain as DomainModel, - CreateDomainRequestBody, - HTTPValidationError, - ListDomainsResponse, - UpdateDomainRequestBody, - DomainSortField, - DomainSortOrder, -) -from fastfuels_sdk.v2.client_library.types import UNSET, Unset - -V2_BASE_URL = "https://api-v2-prod-782971006568.us-west1.run.app" - -T = TypeVar("T") - -_client: Optional[AuthenticatedClient] = None - - -def get_client() -> AuthenticatedClient: - """Get the cached AuthenticatedClient, creating it if necessary.""" - global _client - client = _client - if client is None: - api_key = os.getenv("FASTFUELS_API_KEY") - if not api_key: - raise RuntimeError( - "FastFuels API key not configured. " - "Set the FASTFUELS_API_KEY environment variable." - ) - client = AuthenticatedClient( - base_url=V2_BASE_URL, - token=api_key, - prefix="", # raw key, not "Bearer " - auth_header_name="api-key", - raise_on_unexpected_status=True, - ) - _client = client - return client - - -def _checked(response: Any, expected: Type[T]) -> T: - """Unwrap a generated sync() response, raising on error results. - - The generated client returns documented error models instead of raising: - a 422 comes back as an HTTPValidationError instance (only *undocumented* - statuses raise errors.UnexpectedStatus, even with - raise_on_unexpected_status=True). A real v2 SDK should translate these - into typed SDK exceptions in a shared exceptions layer; the draft raises - RuntimeError. - """ - if isinstance(response, expected): - return response - if isinstance(response, HTTPValidationError): - raise RuntimeError(f"API validation error (422): {response.detail}") - raise RuntimeError(f"Unexpected API response: {response!r}") - - -class Domain(DomainModel): - """Domain resource for the FastFuels v2 API (openapi-python-client draft). - - Inherits all attributes from the generated attrs model: id, name, - description, type_, features, bbox, crs, tags, pad_to_resolution, style, - created_on, modified_on. - """ - - @classmethod - def _from_model(cls, model: DomainModel) -> "Domain": - # The idiomatic opc conversion: round-trip through the generated - # to_dict/from_dict (from_dict constructs `cls`, i.e. this subclass). - return cls.from_dict(model.to_dict()) - - def _copy_fields_from(self, model: DomainModel) -> "Domain": - """Copy all generated-model fields from `model` onto self (in-place).""" - for field in attrs.fields(DomainModel): - if field.init: - setattr(self, field.name, getattr(model, field.name)) - self.additional_properties = dict(model.additional_properties) - return self - - def _require_id(self) -> str: - """Return the domain id, raising if this instance has none.""" - if isinstance(self.id, Unset): - raise ValueError( - "This Domain has no id (it was not created through the API)." - ) - return self.id - - @classmethod - def from_id(cls, domain_id: str) -> "Domain": - """Retrieve an existing Domain by its ID. - - Raises errors.UnexpectedStatus for any non-2xx response (the - generated client has no per-status exception types). - """ - response = get_domain.sync(client=get_client(), domain_id=domain_id) - return cls._from_model(_checked(response, DomainModel)) - - @classmethod - def from_geojson( - cls, - geojson: dict, - name: str = "", - description: str = "", - tags: Optional[List[str]] = None, - pad_to_resolution: Optional[float] = None, - ) -> "Domain": - """Create a new Domain from a GeoJSON FeatureCollection. - - Note: unlike v1, the v2 API takes no horizontal/vertical resolution - on the domain (resolution moved to grids); `pad_to_resolution` - optionally snaps the bounding box outward for grid alignment. - Also unlike v1, the v2 API accepts FeatureCollection input only — - wrap a single Feature in a FeatureCollection. - """ - request_body = CreateDomainRequestBody.from_dict( - { - **geojson, - "name": name, - "description": description, - "tags": tags, - "pad_to_resolution": pad_to_resolution, - } - ) - response = create_domain.sync(client=get_client(), body=request_body) - return cls._from_model(_checked(response, DomainModel)) - - @classmethod - def from_geodataframe( - cls, - geodataframe: gpd.GeoDataFrame, - name: str = "", - description: str = "", - tags: Optional[List[str]] = None, - pad_to_resolution: Optional[float] = None, - ) -> "Domain": - """Create a new Domain from a GeoPandas GeoDataFrame. - - The GeoDataFrame's CRS (when set) is forwarded to the API as the - FeatureCollection's `crs` member, so projected inputs (e.g. - EPSG:5070) are interpreted correctly. (The v1 SDK dropped the - GeoDataFrame CRS and relied on the API's EPSG:4326 default.) - """ - geojson = json.loads(geodataframe.to_json()) - if geodataframe.crs is not None: - authority = geodataframe.crs.to_authority() - if authority is None: - raise ValueError( - "GeoDataFrame CRS has no authority code (e.g. a custom " - "CRS). Reproject to a known CRS such as EPSG:4326 with " - "geodataframe.to_crs(...) before creating a domain." - ) - geojson["crs"] = { - "type": "name", - "properties": {"name": ":".join(authority)}, - } - return cls.from_geojson( - geojson=geojson, - name=name, - description=description, - tags=tags, - pad_to_resolution=pad_to_resolution, - ) - - @classmethod - def from_file( - cls, - path: Union[str, Path], - name: str = "", - description: str = "", - tags: Optional[List[str]] = None, - pad_to_resolution: Optional[float] = None, - ) -> "Domain": - """Create a new Domain from a geospatial file. - - Reads any format geopandas supports (GeoJSON, Shapefile, KML, - GeoPackage, ...) and creates a domain from its geometry. - """ - return cls.from_geodataframe( - gpd.read_file(path), - name=name, - description=description, - tags=tags, - pad_to_resolution=pad_to_resolution, - ) - - def get(self, in_place: bool = False) -> "Domain": - """Retrieve the latest domain data from the API. - - Parameters - ---------- - in_place : bool, optional - If True, updates the current Domain instance with the new data - and returns self. If False, returns a new Domain instance with - the latest data, leaving the current instance unchanged. - """ - domain_id = self._require_id() - response = _checked( - get_domain.sync(client=get_client(), domain_id=domain_id), - DomainModel, - ) - if in_place: - return self._copy_fields_from(response) - return Domain._from_model(response) - - def update( - self, - name: Optional[str] = None, - description: Optional[str] = None, - tags: Optional[List[str]] = None, - in_place: bool = False, - ) -> "Domain": - """Update the domain's mutable properties (name, description, tags). - - Only provided fields are sent in the PATCH body (the generated - UNSET sentinel keeps omitted fields out of the request entirely). - If no fields are provided, no API call is made. - """ - if name is None and description is None and tags is None: - return self if in_place else Domain.from_dict(self.to_dict()) - - request_body = UpdateDomainRequestBody( - name=name if name is not None else UNSET, - description=description if description is not None else UNSET, - tags=tags if tags is not None else UNSET, - ) - domain_id = self._require_id() - response = _checked( - update_domain.sync( - client=get_client(), domain_id=domain_id, body=request_body - ), - DomainModel, - ) - if in_place: - return self._copy_fields_from(response) - return Domain._from_model(response) - - def delete(self) -> None: - """Delete this domain and all resources associated with it.""" - domain_id = self._require_id() - delete_domain.sync_detailed(client=get_client(), domain_id=domain_id) - - def to_json(self) -> str: - """Serialize the complete Domain object to a JSON string.""" - return json.dumps(self.to_dict(), default=str, indent=2) - - def to_geodataframe(self) -> gpd.GeoDataFrame: - """Convert the Domain to a GeoPandas GeoDataFrame. - - The v2 API returns two named features: "domain" (the working - extent / bounding box) and "input" (the original geometry), so the - GeoDataFrame has one row per feature. - """ - feature_collection: dict[str, Any] = { - "type": "FeatureCollection", - "features": ( - [feature.to_dict() for feature in self.features] - if self.features - else [] - ), - } - if self.crs and not isinstance(self.crs, Unset): - feature_collection["crs"] = self.crs.to_dict() - - gdf = gpd.read_file(json.dumps(feature_collection)) - - # Add domain metadata as columns (UNSET fields become None) - gdf["domain_id"] = None if isinstance(self.id, Unset) else self.id - gdf["domain_name"] = None if isinstance(self.name, Unset) else self.name - gdf["domain_description"] = ( - None if isinstance(self.description, Unset) else self.description - ) - if self.pad_to_resolution and not isinstance(self.pad_to_resolution, Unset): - gdf["pad_to_resolution"] = self.pad_to_resolution - if self.tags and not isinstance(self.tags, Unset): - gdf["tags"] = ", ".join(self.tags) - - return gdf - - -def list_domains( - page: int = 0, - size: int = 100, - sort_by: Optional[str] = None, - sort_order: Optional[str] = None, -) -> List[Domain]: - """List domains belonging to the authenticated user (single page).""" - response = _checked( - list_domains_endpoint.sync( - client=get_client(), - page=page, - size=size, - sort_by=DomainSortField(sort_by) if sort_by else UNSET, - sort_order=DomainSortOrder(sort_order) if sort_order else UNSET, - ), - ListDomainsResponse, - ) - return [Domain._from_model(d) for d in response.domains] diff --git a/tests/v2/__init__.py b/tests/v2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/v2/test_domains.py b/tests/v2/test_domains.py new file mode 100644 index 0000000..0d10043 --- /dev/null +++ b/tests/v2/test_domains.py @@ -0,0 +1,289 @@ +""" +tests/v2/test_domains.py +""" + +# Core imports +import json +from uuid import uuid4 + +# Internal imports +from tests import TEST_DATA_DIR +from tests.v2.utils import create_default_domain +from fastfuels_sdk.v2.domains import Domain, list_domains, reproject_geojson +from fastfuels_sdk.v2.exceptions import ( + NotFoundException, + UnprocessableEntityException, +) + +# External imports +import pytest +import geopandas as gpd + + +@pytest.fixture(scope="module") +def test_domain(): + """Fixture that creates a test domain to be used by the tests""" + domain = create_default_domain() + + # Return the domain for use in tests + yield domain + + # Cleanup: Delete the domain after the tests + domain.delete() + + +def feature_names(domain: Domain) -> set: + """Return the named features in a domain response.""" + return {feature.to_dict()["properties"]["name"] for feature in domain.features} + + +class TestCreateDomain: + test_files = ["blue_mtn", "blue_mtn_5070"] + test_formats = ["geojson", "kml", "shp"] + + domain_name = "test_domain" + domain_description = "Domain for testing v2 domain operations" + pad_to_resolution = 2.0 + + def assert_valid_domain( + self, + domain: Domain, + input_gdf: gpd.GeoDataFrame, + expected_crs_name: str = None, + ): + """Shared assertions for a freshly created domain.""" + assert len(domain.id) > 0 + assert domain.name == self.domain_name + assert domain.description == self.domain_description + assert domain.pad_to_resolution == self.pad_to_resolution + + # v2 responses carry two named features: the working extent and + # the original input geometry + assert feature_names(domain) == {"domain", "input"} + + # If the input CRS is projected, the domain preserves it (echoing + # the CRS name as sent, e.g. "urn:ogc:def:crs:EPSG::5070"); + # otherwise the geometry is projected to the appropriate UTM zone + if input_gdf.crs.is_projected: + expected = expected_crs_name or ":".join(input_gdf.crs.to_authority()) + assert domain.crs.properties.name == expected + else: + assert domain.crs.properties.name == input_gdf.estimate_utm_crs().srs + + @pytest.mark.parametrize("test_name", test_files) + @pytest.mark.parametrize("geojson_type", ["Feature", "FeatureCollection"]) + def test_from_geojson(self, test_name, geojson_type): + # Load test GeoJSON data + geojson_gdf = gpd.GeoDataFrame.from_file(TEST_DATA_DIR / f"{test_name}.geojson") + with open(TEST_DATA_DIR / f"{test_name}.geojson") as f: + geojson = json.load(f) + + if geojson_type == "Feature": + feature_geojson = geojson["features"][0] + if "crs" in geojson: + feature_geojson["crs"] = geojson["crs"] + geojson = feature_geojson + + # Create a domain using the GeoJSON (a single Feature is wrapped + # into a FeatureCollection by the SDK) + domain = Domain.from_geojson( + geojson, + name=self.domain_name, + description=self.domain_description, + pad_to_resolution=self.pad_to_resolution, + ) + # A projected domain echoes the CRS name exactly as the file + # declares it + file_crs = geojson.get("crs", {}).get("properties", {}).get("name") + self.assert_valid_domain(domain, geojson_gdf, expected_crs_name=file_crs) + domain.delete() + + @pytest.mark.parametrize("test_name", test_files) + def test_from_geodataframe(self, test_name): + geojson_gdf = gpd.GeoDataFrame.from_file(TEST_DATA_DIR / f"{test_name}.geojson") + + # Create a domain using the GeoDataFrame. The GeoDataFrame CRS is + # forwarded to the API, so projected inputs (blue_mtn_5070) must + # come back in their original CRS, not the EPSG:4326 default. + domain = Domain.from_geodataframe( + geojson_gdf, + name=self.domain_name, + description=self.domain_description, + pad_to_resolution=self.pad_to_resolution, + ) + self.assert_valid_domain(domain, geojson_gdf) + domain.delete() + + @pytest.mark.parametrize("test_format", test_formats) + def test_from_file(self, test_format): + geojson_gdf = gpd.GeoDataFrame.from_file(TEST_DATA_DIR / "blue_mtn.geojson") + + domain = Domain.from_file( + TEST_DATA_DIR / f"blue_mtn.{test_format}", + name=self.domain_name, + description=self.domain_description, + pad_to_resolution=self.pad_to_resolution, + ) + self.assert_valid_domain(domain, geojson_gdf) + domain.delete() + + def test_from_geojson_invalid_type(self): + with pytest.raises(ValueError, match="FeatureCollection"): + Domain.from_geojson({"type": "Point", "coordinates": [0, 0]}) + + def test_tags(self): + with open(TEST_DATA_DIR / "blue_mtn.geojson") as f: + geojson = json.load(f) + domain = Domain.from_geojson(geojson, tags=["test", "v2"]) + assert domain.tags == ["test", "v2"] + domain.delete() + + +class TestPreviewDomain: + def test_preview(self): + with open(TEST_DATA_DIR / "blue_mtn.geojson") as f: + geojson = json.load(f) + + previewed = Domain.preview(geojson, pad_to_resolution=2.0) + + # A preview is never persisted; its id is the "preview" sentinel + assert previewed.id == "preview" + assert feature_names(previewed) == {"domain", "input"} + assert previewed.pad_to_resolution == 2.0 + assert len(previewed.bbox) >= 4 + + +class TestFromId: + def test_success(self, test_domain): + domain = Domain.from_id(test_domain.id) + assert domain.id == test_domain.id + assert domain.name == test_domain.name + + def test_not_found(self): + with pytest.raises(NotFoundException): + Domain.from_id(uuid4().hex) + + +class TestGetDomain: + def test_get_default(self, test_domain): + domain = test_domain.get() + assert domain.id == test_domain.id + assert domain is not test_domain + + def test_get_in_place(self, test_domain): + domain = test_domain.get(in_place=True) + assert domain is test_domain + + +class TestUpdateDomain: + def test_update_name(self, test_domain): + updated = test_domain.update(name="updated_name") + assert updated.name == "updated_name" + assert updated is not test_domain + # The remote resource reflects the update + assert Domain.from_id(test_domain.id).name == "updated_name" + + def test_update_in_place(self, test_domain): + updated = test_domain.update(description="updated description", in_place=True) + assert updated is test_domain + assert test_domain.description == "updated description" + + def test_update_tags(self, test_domain): + updated = test_domain.update(tags=["updated"], in_place=True) + assert updated.tags == ["updated"] + + def test_update_no_fields_makes_no_api_call(self, test_domain): + # No fields provided: returns a copy without touching the API + copy = test_domain.update() + assert copy is not test_domain + assert copy.id == test_domain.id + assert test_domain.update(in_place=True) is test_domain + + +class TestGetLattice: + def test_get_lattice(self, test_domain): + lattice = test_domain.get_lattice(resolution=2.0) + assert lattice.resolution == 2.0 + assert lattice.num_buffer_cells == 0 + assert lattice.crs == test_domain.crs.properties.name + # Affine coefficients [a, b, c, d, e, f] (rasterio convention) + assert len(lattice.transform) == 6 + assert lattice.transform[0] == 2.0 + assert abs(lattice.transform[4]) == 2.0 + # [height, width] in pixels + assert len(lattice.shape) == 2 + assert all(dim > 0 for dim in lattice.shape) + + def test_buffer_cells_expand_the_lattice(self, test_domain): + lattice = test_domain.get_lattice(resolution=2.0) + buffered = test_domain.get_lattice(resolution=2.0, num_buffer_cells=5) + assert buffered.shape[0] == lattice.shape[0] + 10 + assert buffered.shape[1] == lattice.shape[1] + 10 + + def test_invalid_resolution(self, test_domain): + with pytest.raises(UnprocessableEntityException): + test_domain.get_lattice(resolution=-1.0) + + +class TestListDomains: + def test_list_domains(self, test_domain): + domains = list_domains() + assert test_domain.id in [domain.id for domain in domains] + + def test_sorting(self, test_domain): + domains = list_domains(sort_by="created_on", sort_order="descending") + assert len(domains) > 0 + + def test_invalid_sort_field(self): + with pytest.raises(ValueError): + list_domains(sort_by="not_a_field") + + +class TestReprojectGeojson: + def test_reproject(self): + with open(TEST_DATA_DIR / "blue_mtn.geojson") as f: + geojson = json.load(f) + + projected = reproject_geojson(geojson, target_epsg=5070) + + assert projected["crs"]["properties"]["name"] == "EPSG:5070" + assert len(projected["features"]) == len(geojson["features"]) + # Coordinates actually moved out of degrees + original_coord = geojson["features"][0]["geometry"]["coordinates"][0][0] + projected_coord = projected["features"][0]["geometry"]["coordinates"][0][0] + assert original_coord != projected_coord + + def test_invalid_target_epsg(self): + with open(TEST_DATA_DIR / "blue_mtn.geojson") as f: + geojson = json.load(f) + with pytest.raises(UnprocessableEntityException): + reproject_geojson(geojson, target_epsg=999999) + + +class TestToGeodataframe: + def test_to_geodataframe(self, test_domain): + gdf = test_domain.to_geodataframe() + + assert len(gdf) == len(test_domain.features) + assert set(gdf["name"]) == {"domain", "input"} + assert (gdf["domain_id"] == test_domain.id).all() + assert ":".join(gdf.crs.to_authority()) == test_domain.crs.properties.name + + +class TestToJson: + def test_to_json(self, test_domain): + domain_dict = json.loads(test_domain.to_json()) + assert domain_dict["id"] == test_domain.id + assert domain_dict["type"] == "FeatureCollection" + + +class TestDeleteDomain: + def test_delete_domain(self): + domain = create_default_domain() + domain.delete() + + with pytest.raises(NotFoundException): + Domain.from_id(domain.id) + + with pytest.raises(NotFoundException): + domain.delete() diff --git a/tests/v2/utils.py b/tests/v2/utils.py new file mode 100644 index 0000000..c3bf2f3 --- /dev/null +++ b/tests/v2/utils.py @@ -0,0 +1,24 @@ +""" +tests/v2/utils.py +""" + +import json +from tests import TEST_DATA_DIR +from fastfuels_sdk.v2.domains import Domain + + +def create_default_domain() -> Domain: + """Creates a default v2 Domain resource for testing.""" + # Load test GeoJSON data + with open(TEST_DATA_DIR / "blue_mtn.geojson") as f: + geojson = json.load(f) + + # Create a domain using the GeoJSON + domain = Domain.from_geojson( + geojson, + name="test_domain", + description="Domain for testing v2 domain operations", + pad_to_resolution=2.0, + ) + + return domain From 06050d4e6a211c97cfb0e1b84ac1b2162a9ef453 Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Fri, 5 Jun 2026 15:22:01 -0600 Subject: [PATCH 07/71] Version the docs with mike and add the v2 section (#177) - mike powers a version selector in the header (mkdocs-material's extra.version.provider): v1 (current, aliased latest) and v2 (preview) deploy as independent site snapshots, so existing /v1/... deep links keep working - per-version configs (mkdocs.yml for v1, mkdocs-v2.yml for v2) inherit shared settings from mkdocs-base.yml; site_url set so switching versions keeps the reader on the equivalent page - docs.yml deploys both versions via mike on release (plus manual workflow_dispatch); first run should follow a one-time gh-pages wipe to drop the stale pre-restructure root-level pages - v2 docs: home, 'Migrating from v1' guide (per-resource what-changed sections seeded for grids/features/inventories), domains how-to guide, and an mkdocstrings reference for domains/api/exceptions - styled the version selector as an explicit pill control (extra.css) - mike added to the docs dependency group --- .github/workflows/docs.yml | 21 +++- docs/{ => v1}/index.md | 0 docs/v1/stylesheets/extra.css | 55 ++++++++++ docs/v2/guides/domains.md | 199 ++++++++++++++++++++++++++++++++++ docs/v2/guides/migration.md | 113 +++++++++++++++++++ docs/v2/index.md | 40 +++++++ docs/v2/reference.md | 5 + docs/v2/stylesheets/extra.css | 55 ++++++++++ mkdocs-base.yml | 54 +++++++++ mkdocs-v2.yml | 12 ++ mkdocs.yml | 59 +++------- pyproject.toml | 8 +- uv.lock | 37 +++++++ 13 files changed, 612 insertions(+), 46 deletions(-) rename docs/{ => v1}/index.md (100%) create mode 100644 docs/v1/stylesheets/extra.css create mode 100644 docs/v2/guides/domains.md create mode 100644 docs/v2/guides/migration.md create mode 100644 docs/v2/index.md create mode 100644 docs/v2/reference.md create mode 100644 docs/v2/stylesheets/extra.css create mode 100644 mkdocs-base.yml create mode 100644 mkdocs-v2.yml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 2dffd2e..e18b998 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -3,14 +3,33 @@ name: build mkdocs on: release: types: [published] + workflow_dispatch: + +permissions: + contents: write jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Install uv uses: astral-sh/setup-uv@v6 - name: Install docs dependencies run: uv sync --group docs - - run: uv run mkdocs gh-deploy --force --clean --verbose + - name: Configure git for mike + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + # mike deploys each version as its own site snapshot on gh-pages and + # powers the version dropdown in the header (mkdocs-material's + # extra.version.provider). v1 keeps the "latest" alias and is the + # default the site root redirects to. + - name: Deploy v1 docs + run: uv run mike deploy --push --update-aliases v1 latest --title "v1 (current)" + - name: Deploy v2 docs + run: uv run mike deploy --push --config-file mkdocs-v2.yml v2 --title "v2 (preview)" + - name: Set default version + run: uv run mike set-default --push latest diff --git a/docs/index.md b/docs/v1/index.md similarity index 100% rename from docs/index.md rename to docs/v1/index.md diff --git a/docs/v1/stylesheets/extra.css b/docs/v1/stylesheets/extra.css new file mode 100644 index 0000000..c76b44b --- /dev/null +++ b/docs/v1/stylesheets/extra.css @@ -0,0 +1,55 @@ +/* Version selector (mike + mkdocs-material). The stock theme renders the + selector as bare header text; style it as an explicit control. + (Duplicated in docs/v1 and docs/v2 — each version's site builds from its + own docs_dir.) */ +.md-version { + margin-left: 0.8rem; +} + +.md-version__current { + display: inline-flex; + align-items: center; + gap: 0.3rem; + background-color: rgba(0, 0, 0, 0.18); + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 2rem; + padding: 0.25rem 0.8rem; + font-size: 0.65rem; + font-weight: 600; + letter-spacing: 0.02em; + cursor: pointer; + transition: background-color 125ms, border-color 125ms; +} + +.md-version__current:hover, +.md-version__current:focus { + background-color: rgba(0, 0, 0, 0.32); + border-color: rgba(255, 255, 255, 0.6); +} + +.md-version__list { + margin-top: 0.5rem; + border-radius: 0.3rem; + box-shadow: var(--md-shadow-z3); + overflow: hidden; + min-width: 8rem; +} + +.md-version__item { + line-height: 1; +} + +.md-version__link { + display: block; + width: 100%; + padding: 0.6rem 1rem; + font-size: 0.7rem; + color: var(--md-default-fg-color); + transition: background-color 125ms, color 125ms; +} + +.md-version__link:hover, +.md-version__link:focus { + background-color: var(--md-default-fg-color--lightest); + color: var(--md-typeset-a-color); +} diff --git a/docs/v2/guides/domains.md b/docs/v2/guides/domains.md new file mode 100644 index 0000000..4d56ab5 --- /dev/null +++ b/docs/v2/guides/domains.md @@ -0,0 +1,199 @@ +# How to Work with Domains in FastFuels SDK + +!!! warning "Preview" + The v2 SDK targets the new FastFuels v2 API and is under active + development. The v1 SDK remains the default — import v2 explicitly + from `fastfuels_sdk.v2`. + +## Authentication + +The v2 SDK reads your API key from the `FASTFUELS_API_KEY` environment +variable, the same variable the v1 SDK uses: + +```bash +export FASTFUELS_API_KEY="your-api-key" +``` + +Or set it programmatically: + +```python +from fastfuels_sdk.v2 import set_api_key + +set_api_key("your-api-key") +``` + +Coming from the v1 SDK? Start with the +[migration guide](migration.md#domains). + +## Create a Domain from GeoJSON + +To create a domain from a GeoJSON file: + +```python +import json +from fastfuels_sdk.v2.domains import Domain + +with open("area.geojson") as f: + geojson = json.load(f) + +domain = Domain.from_geojson( + geojson=geojson, + name="My Domain", + description="Forest area for analysis", + pad_to_resolution=2.0, +) +``` + +The v2 API accepts a GeoJSON FeatureCollection; the SDK automatically +wraps a single Feature for you. + +## Create a Domain from a GeoDataFrame or a File + +If your spatial data is in a format supported by GeoPandas (Shapefile, +KML, GeoPackage, etc.): + +```python +import geopandas as gpd +from fastfuels_sdk.v2.domains import Domain + +gdf = gpd.read_file("forest_area.shp") + +domain = Domain.from_geodataframe( + geodataframe=gdf, + name="Forest Domain", + description="Imported from shapefile", +) + +# Or skip the GeoPandas step entirely: +domain = Domain.from_file("forest_area.shp", name="Forest Domain") +``` + +## Preview a Domain Before Creating It + +`Domain.preview` runs the same validation and projection pipeline as +creation but persists nothing — useful for showing a user the projected, +padded bounding box before committing: + +```python +previewed = Domain.preview(geojson, pad_to_resolution=2.0) + +print(previewed.id) # always "preview" — not a real identifier +print(previewed.bbox) # the projected, padded bounding box +``` + +## Retrieve an Existing Domain + +To fetch a domain using its ID: + +```python +domain = Domain.from_id("abc123") +``` + +## Update Domain Properties + +To modify a domain's name, description or tags: + +```python +# Create new instance with updates +updated_domain = domain.update( + name="New Name", + description="Updated description", + tags=["forest", "analysis"], +) + +# Or update in-place +domain.update(name="New Name", in_place=True) +``` + +## Get Fresh Domain Data + +To fetch the latest data for a domain: + +```python +# Get new instance with fresh data +fresh_domain = domain.get() + +# Or refresh existing instance +domain.get(in_place=True) +``` + +## Get the Pixel Lattice for a Domain + +To align a raster with the grids FastFuels will produce for a domain, +fetch its pixel lattice (affine transform + shape) at a resolution: + +```python +lattice = domain.get_lattice(resolution=2.0) + +print(lattice.crs) # e.g. "EPSG:32611" +print(lattice.transform) # affine coefficients [a, b, c, d, e, f] +print(lattice.shape) # [height, width] in pixels + +# Expand the lattice by N cells on each side, mirroring the +# extent_buffer_cells semantics of the grid creation endpoints +buffered = domain.get_lattice(resolution=2.0, num_buffer_cells=5) +``` + +## Reproject GeoJSON + +A stateless utility — no resource is created: + +```python +from fastfuels_sdk.v2.domains import reproject_geojson + +projected = reproject_geojson(geojson, target_epsg=5070) +print(projected["crs"]["properties"]["name"]) # "EPSG:5070" +``` + +## List Available Domains + +To list domains with pagination: + +```python +from fastfuels_sdk.v2.domains import list_domains + +# Get first page with default size (100) +domains = list_domains() + +# Custom page and size +domains = list_domains( + page=2, + size=50, + sort_by="name", + sort_order="ascending", +) + +for domain in domains: + print(f"{domain.id}: {domain.name}") +``` + +## Delete a Domain + +To permanently delete a domain and all resources associated with it: + +```python +domain.delete() +``` + +## Error Handling + +Wrapper methods raise typed exceptions from +`fastfuels_sdk.v2.exceptions`: + +```python +from fastfuels_sdk.v2.domains import Domain +from fastfuels_sdk.v2.exceptions import ( + NotFoundException, + UnprocessableEntityException, +) + +try: + domain = Domain.from_id("does-not-exist") +except NotFoundException: + print("No such domain (or you don't have access to it)") + +try: + domain = Domain.from_geojson(too_big_geojson) +except UnprocessableEntityException as exc: + print(f"Invalid domain: {exc.detail}") +``` diff --git a/docs/v2/guides/migration.md b/docs/v2/guides/migration.md new file mode 100644 index 0000000..d4c7bf1 --- /dev/null +++ b/docs/v2/guides/migration.md @@ -0,0 +1,113 @@ +# Migrating from v1 to v2 + +The FastFuels v1 and v2 APIs are separate live services. Resources created +in v1 are not visible to v2 (and vice versa) — migrating a workflow means +creating new resources through v2. The SDK ships both interfaces as +versioned subpackages, so you can run them side by side and migrate one +workflow at a time: + +```python +from fastfuels_sdk import Domain # v1 (the default today) +from fastfuels_sdk.v1 import Domain # v1, explicit +from fastfuels_sdk.v2 import Domain # v2 +``` + +Both subpackages read the same `FASTFUELS_API_KEY` environment variable. + +## At a glance + +| v1 | v2 | What changed | +|---|---|---| +| `Domain.from_geojson(..., horizontal_resolution=2.0, vertical_resolution=1.0)` | `Domain.from_geojson(..., pad_to_resolution=2.0)` | Resolution belongs to grids now; domains only pad their extent for grid alignment | +| `domain.export()` | grid exports | No domain-level export in v2; data exports hang off grids | +| — | `Domain.preview`, `Domain.get_lattice`, `reproject_geojson` | New domain capabilities | +| `Grids`, `SurfaceGrid`, `TreeGrid`, `TopographyGrid`, `FeatureGrid` + builders | unified `Grid` resource *(SDK module in development)* | One job-based resource per grid, distinguished by data source | +| `Features`, `RoadFeature`, `WaterFeature` | unified `Feature` resource *(SDK module in development)* | One job-based resource for OSM road and water features | +| `Inventories`, `TreeInventory` | `Inventory` resource *(SDK module in development)* | Job-based; tree inventories are generated from PIM grids | + +## Domains + +Available now — see the [Domains guide](domains.md) and the +[Reference](../reference.md). + +### What changed from v1 + +- **Resolution moved to grids.** Domains no longer take + `horizontal_resolution`/`vertical_resolution`. Use the optional + `pad_to_resolution` argument to pad the domain bounding box outward so + grids of that resolution align with it. +- **Responses are richer.** A v2 domain carries two named GeoJSON + features: `"domain"` (the projected working extent) and `"input"` + (your original geometry). +- **GeoDataFrame CRS is honored.** `from_geodataframe` forwards the + GeoDataFrame's CRS to the API, so projected inputs (e.g. EPSG:5070) + are interpreted correctly. The v1 SDK assumed EPSG:4326. +- **No `Domain.export()`.** The v2 API has no domain export endpoint; + domain data is exported through grid exports instead (coming with the + v2 grids module). +- **New endpoints.** `Domain.preview` validates and projects a domain + without creating it, `Domain.get_lattice` returns the pixel lattice + for grid alignment, and `reproject_geojson` is a stateless + reprojection utility. +- **Typed exceptions.** Errors raise `fastfuels_sdk.v2.exceptions` + classes (`NotFoundException`, `UnprocessableEntityException`, ...) + carrying the HTTP status code and API error detail. + +### Before and after + +v1: + +```python +from fastfuels_sdk import Domain + +domain = Domain.from_geojson( + geojson, + name="My Domain", + horizontal_resolution=2.0, + vertical_resolution=1.0, +) +``` + +v2: + +```python +from fastfuels_sdk.v2 import Domain + +domain = Domain.from_geojson( + geojson, + name="My Domain", + pad_to_resolution=2.0, +) +``` + +## Grids + +!!! note "In development" + The v2 grids module is under development and will be documented here + when it ships. + +What to expect from the v2 API: the per-type grid resources +(surface/tree/topography/feature) collapse into a single job-based `Grid` +resource distinguished by its data source — LANDFIRE FBFM40 fuel models, +TreeMap (PIM), 3DEP topography, or custom GeoTIFF uploads. Every grid +shares the same `status`/`progress` lifecycle. + +## Features + +!!! note "In development" + The v2 features module is under development and will be documented + here when it ships. + +What to expect from the v2 API: road and water features unify into a +single job-based `Feature` resource sourced from OSM, with the same +`status`/`progress` lifecycle as grids. + +## Inventories + +!!! note "In development" + The v2 inventories module is under development and will be documented + here when it ships. + +What to expect from the v2 API: tree inventories are generated from a +PIM (TreeMap) grid, so the workflow becomes: create a TreeMap grid, wait +for it to complete, then create an inventory from it. diff --git a/docs/v2/index.md b/docs/v2/index.md new file mode 100644 index 0000000..1e3ad9e --- /dev/null +++ b/docs/v2/index.md @@ -0,0 +1,40 @@ +# Welcome to the FastFuels Python SDK Documentation! + +!!! warning "Preview" + You are reading the documentation for the **v2 SDK**, which targets + the new FastFuels v2 API and is under active development. The v1 SDK + remains the default — switch versions with the selector in the + header. Import v2 explicitly from `fastfuels_sdk.v2`. + +## What is FastFuels? + +FastFuels is a cloud-based platform for generating forest inventory data. It +uses a combination of satellite imagery and machine learning to generate +tabular tree data and voxelized 3D fuel models. These data products can be used +to support wildfire risk assessment, fire behavior modeling, and other +applications. + +## What is the FastFuels Python SDK? + +The FastFuels Python SDK is a Python package that provides a convenient +interface for interacting with the FastFuels API. It can be used to create and +manage FastFuels resources. It can also be used to download and process +generated data products. + +## Installation + +The FastFuels Python SDK can be installed using `pip`: + +```bash +pip install fastfuels-sdk +``` + +## Using the v2 SDK + +The v1 and v2 APIs are separate live services, and the SDK ships both as +versioned subpackages, so you can use them side by side during migration: + +```python +from fastfuels_sdk.v1 import Domain # v1 (current default) +from fastfuels_sdk.v2 import Domain # v2 (preview) +``` diff --git a/docs/v2/reference.md b/docs/v2/reference.md new file mode 100644 index 0000000..49d210b --- /dev/null +++ b/docs/v2/reference.md @@ -0,0 +1,5 @@ +::: fastfuels_sdk.v2.domains + +::: fastfuels_sdk.v2.api + +::: fastfuels_sdk.v2.exceptions diff --git a/docs/v2/stylesheets/extra.css b/docs/v2/stylesheets/extra.css new file mode 100644 index 0000000..c76b44b --- /dev/null +++ b/docs/v2/stylesheets/extra.css @@ -0,0 +1,55 @@ +/* Version selector (mike + mkdocs-material). The stock theme renders the + selector as bare header text; style it as an explicit control. + (Duplicated in docs/v1 and docs/v2 — each version's site builds from its + own docs_dir.) */ +.md-version { + margin-left: 0.8rem; +} + +.md-version__current { + display: inline-flex; + align-items: center; + gap: 0.3rem; + background-color: rgba(0, 0, 0, 0.18); + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 2rem; + padding: 0.25rem 0.8rem; + font-size: 0.65rem; + font-weight: 600; + letter-spacing: 0.02em; + cursor: pointer; + transition: background-color 125ms, border-color 125ms; +} + +.md-version__current:hover, +.md-version__current:focus { + background-color: rgba(0, 0, 0, 0.32); + border-color: rgba(255, 255, 255, 0.6); +} + +.md-version__list { + margin-top: 0.5rem; + border-radius: 0.3rem; + box-shadow: var(--md-shadow-z3); + overflow: hidden; + min-width: 8rem; +} + +.md-version__item { + line-height: 1; +} + +.md-version__link { + display: block; + width: 100%; + padding: 0.6rem 1rem; + font-size: 0.7rem; + color: var(--md-default-fg-color); + transition: background-color 125ms, color 125ms; +} + +.md-version__link:hover, +.md-version__link:focus { + background-color: var(--md-default-fg-color--lightest); + color: var(--md-typeset-a-color); +} diff --git a/mkdocs-base.yml b/mkdocs-base.yml new file mode 100644 index 0000000..6b8160e --- /dev/null +++ b/mkdocs-base.yml @@ -0,0 +1,54 @@ +# mkdocs-base.yml +# +# Shared configuration inherited by the per-version configs: +# mkdocs.yml -> v1 docs (docs/v1/) +# mkdocs-v2.yml -> v2 docs (docs/v2/) +# +# Versions are deployed with mike (one site snapshot per version, selected +# via the header dropdown). See .github/workflows/docs.yml. Local preview: +# uv run mkdocs serve # v1 +# uv run mkdocs serve -f mkdocs-v2.yml # v2 +site_name: FastFuels SDK Documentation +# site_url is required for mike's version selector to keep the reader on +# the same page when switching versions +site_url: https://silvxlabs.github.io/fastfuels-sdk-python/ + +theme: + name: "material" + features: + - content.code.copy + +extra: + version: + provider: mike + alias: true + +plugins: + - search + - mkdocstrings: + handlers: + python: + options: + docstring_style: numpy + docstring_section_style: table + docstring_options: + ignore_init_summary: true + show_root_heading: true + show_source: false + members_order: source + annotations_path: brief + separate_signature: true + show_signature_annotations: true + +markdown_extensions: + - admonition + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.superfences + +extra_css: + - stylesheets/extra.css diff --git a/mkdocs-v2.yml b/mkdocs-v2.yml new file mode 100644 index 0000000..c438ba5 --- /dev/null +++ b/mkdocs-v2.yml @@ -0,0 +1,12 @@ +# mkdocs-v2.yml — v2 docs (see mkdocs-base.yml for the shared config and +# an overview of the versioned-docs setup) +INHERIT: mkdocs-base.yml + +docs_dir: docs/v2 + +nav: + - Home: index.md + - Migrating from v1: guides/migration.md + - How-To Guides: + - Domains: guides/domains.md + - Reference: reference.md diff --git a/mkdocs.yml b/mkdocs.yml index 05c329f..dcfb8cc 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,48 +1,19 @@ -# mkdocs.yml -site_name: FastFuels SDK Documentation +# mkdocs.yml — v1 docs (see mkdocs-base.yml for the shared config and +# an overview of the versioned-docs setup) +INHERIT: mkdocs-base.yml -theme: - name: "material" - features: - - content.code.copy +docs_dir: docs/v1 nav: - Home: index.md - - v1 (current): - - How-To Guides: - - Authentication: v1/guides/authentication.md - - Domains: v1/guides/domains.md - - Inventories: v1/guides/inventories.md - - Point Clouds: v1/guides/point_clouds.md - - Features: v1/guides/features.md - - Grids: v1/guides/grids.md - - Tutorials: - - Export to QUIC-Fire: v1/tutorials/export_to_quicfire.md - - ALS Point Cloud: v1/tutorials/point_cloud_example.md - - Reference: v1/reference.md - -plugins: - - search - - mkdocstrings: - handlers: - python: - options: - docstring_style: numpy - docstring_section_style: table - docstring_options: - ignore_init_summary: true - show_root_heading: true - show_source: false - members_order: source - annotations_path: brief - separate_signature: true - show_signature_annotations: true - -markdown_extensions: - - pymdownx.highlight: - anchor_linenums: true - line_spans: __span - pygments_lang_class: true - - pymdownx.inlinehilite - - pymdownx.snippets - - pymdownx.superfences + - How-To Guides: + - Authentication: guides/authentication.md + - Domains: guides/domains.md + - Inventories: guides/inventories.md + - Point Clouds: guides/point_clouds.md + - Features: guides/features.md + - Grids: guides/grids.md + - Tutorials: + - Export to QUIC-Fire: tutorials/export_to_quicfire.md + - ALS Point Cloud: tutorials/point_cloud_example.md + - Reference: reference.md diff --git a/pyproject.toml b/pyproject.toml index 210557f..0c65b9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,13 @@ dev = [ "attrs>=22.2.0", "python-dateutil>=2.9.0.post0", ] -docs = ["mkdocs", "mkdocs-material", "mkdocstrings", "mkdocstrings-python"] +docs = [ + "mike>=2.2.0", + "mkdocs", + "mkdocs-material", + "mkdocstrings", + "mkdocstrings-python", +] [tool.hatch.version] source = "vcs" diff --git a/uv.lock b/uv.lock index d5df030..653897a 100644 --- a/uv.lock +++ b/uv.lock @@ -356,6 +356,7 @@ dev = [ { name = "python-dateutil" }, ] docs = [ + { name = "mike" }, { name = "mkdocs" }, { name = "mkdocs-material" }, { name = "mkdocstrings" }, @@ -384,6 +385,7 @@ dev = [ { name = "python-dateutil", specifier = ">=2.9.0.post0" }, ] docs = [ + { name = "mike", specifier = ">=2.2.0" }, { name = "mkdocs" }, { name = "mkdocs-material" }, { name = "mkdocstrings" }, @@ -743,6 +745,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, ] +[[package]] +name = "mike" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "mkdocs" }, + { name = "pyparsing" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "verspec" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/47/fa87e9d56bef16cdfe34b059a437e8c6f7ec6f1b9c378871c3cf95ebea9c/mike-2.2.0.tar.gz", hash = "sha256:1e3858e32c0f125aac14432fc7848434358f9ae0962c5c5cde387ad47f6ad25e", size = 38450, upload-time = "2026-04-14T04:59:03.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/8e/56ccb09c7232a55403a7637caa21922f3b65901a37f5e8bdb405d0de0946/mike-2.2.0-py3-none-any.whl", hash = "sha256:e1f4981c1152eec7c2490a3401142292cc47d686194188416db2648fdfe1d040", size = 34026, upload-time = "2026-04-14T04:59:02.602Z" }, +] + [[package]] name = "mkdocs" version = "1.6.1" @@ -1372,6 +1391,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/31/93/4641dc5d952f6bdb71dabad2c50e3f8a5d58396cdea6ff8f8a08bfd4f4a6/pyogrio-0.12.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5399f66730978d8852ef5f44dbafa0f738e7f28f4f784349f36830b69a9d2134", size = 23620996, upload-time = "2025-11-28T19:04:51.132Z" }, ] +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + [[package]] name = "pyproj" version = "3.7.2" @@ -1847,6 +1875,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/56/190ceb8cb10511b730b564fb1e0293fa468363dbad26145c34928a60cb0c/urllib3-2.6.1-py3-none-any.whl", hash = "sha256:e67d06fe947c36a7ca39f4994b08d73922d40e6cca949907be05efa6fd75110b", size = 131138, upload-time = "2025-12-08T15:25:25.51Z" }, ] +[[package]] +name = "verspec" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/44/8126f9f0c44319b2efc65feaad589cadef4d77ece200ae3c9133d58464d0/verspec-0.1.0.tar.gz", hash = "sha256:c4504ca697b2056cdb4bfa7121461f5a0e81809255b41c03dda4ba823637c01e", size = 27123, upload-time = "2020-11-30T02:24:09.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/ce/3b6fee91c85626eaf769d617f1be9d2e15c1cca027bbdeb2e0d751469355/verspec-0.1.0-py3-none-any.whl", hash = "sha256:741877d5633cc9464c45a469ae2a31e801e6dbbaa85b9675d481cda100f11c31", size = 19640, upload-time = "2020-11-30T02:24:08.387Z" }, +] + [[package]] name = "virtualenv" version = "21.4.2" From 1a7f09729bd07545c9917b201ace2e8f9b69ce26 Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Fri, 5 Jun 2026 15:35:00 -0600 Subject: [PATCH 08/71] Collapse the docs config to a single mkdocs.yml One config now builds either version: DOCS_DIR selects the docs tree (default docs/v1; docs/v2 for v2 via mkdocs' !ENV substitution) and each tree carries its own nav in a SUMMARY.md (mkdocs-literate-nav). Replaces the mkdocs-base.yml/mkdocs-v2.yml INHERIT split. literate-nav is pinned to 0.6.2: 0.6.3 co-installs the properdocs mkdocs-fork and warns when run under mkdocs proper; revisit when the fork situation settles. check-yaml runs with --unsafe so it can parse mkdocs' !ENV tag. --- .github/workflows/docs.yml | 4 ++- .pre-commit-config.yaml | 3 ++ docs/v1/SUMMARY.md | 12 +++++++ docs/v2/SUMMARY.md | 5 +++ mkdocs-base.yml | 54 ---------------------------- mkdocs-v2.yml | 12 ------- mkdocs.yml | 72 +++++++++++++++++++++++++++++--------- pyproject.toml | 3 ++ uv.lock | 14 ++++++++ 9 files changed, 95 insertions(+), 84 deletions(-) create mode 100644 docs/v1/SUMMARY.md create mode 100644 docs/v2/SUMMARY.md delete mode 100644 mkdocs-base.yml delete mode 100644 mkdocs-v2.yml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e18b998..cc95cbd 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -30,6 +30,8 @@ jobs: - name: Deploy v1 docs run: uv run mike deploy --push --update-aliases v1 latest --title "v1 (current)" - name: Deploy v2 docs - run: uv run mike deploy --push --config-file mkdocs-v2.yml v2 --title "v2 (preview)" + run: uv run mike deploy --push v2 --title "v2 (preview)" + env: + DOCS_DIR: docs/v2 - name: Set default version run: uv run mike set-default --push latest diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9a7a9d7..47e115f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,6 +4,9 @@ repos: rev: v4.6.0 hooks: - id: check-yaml + # mkdocs.yml uses mkdocs' !ENV tag, which plain yaml.safe_load + # cannot construct; --unsafe still checks syntax + args: [--unsafe] - id: end-of-file-fixer - id: trailing-whitespace - id: detect-private-key diff --git a/docs/v1/SUMMARY.md b/docs/v1/SUMMARY.md new file mode 100644 index 0000000..3b98b9b --- /dev/null +++ b/docs/v1/SUMMARY.md @@ -0,0 +1,12 @@ +* [Home](index.md) +* How-To Guides + * [Authentication](guides/authentication.md) + * [Domains](guides/domains.md) + * [Inventories](guides/inventories.md) + * [Point Clouds](guides/point_clouds.md) + * [Features](guides/features.md) + * [Grids](guides/grids.md) +* Tutorials + * [Export to QUIC-Fire](tutorials/export_to_quicfire.md) + * [ALS Point Cloud](tutorials/point_cloud_example.md) +* [Reference](reference.md) diff --git a/docs/v2/SUMMARY.md b/docs/v2/SUMMARY.md new file mode 100644 index 0000000..32cff6d --- /dev/null +++ b/docs/v2/SUMMARY.md @@ -0,0 +1,5 @@ +* [Home](index.md) +* [Migrating from v1](guides/migration.md) +* How-To Guides + * [Domains](guides/domains.md) +* [Reference](reference.md) diff --git a/mkdocs-base.yml b/mkdocs-base.yml deleted file mode 100644 index 6b8160e..0000000 --- a/mkdocs-base.yml +++ /dev/null @@ -1,54 +0,0 @@ -# mkdocs-base.yml -# -# Shared configuration inherited by the per-version configs: -# mkdocs.yml -> v1 docs (docs/v1/) -# mkdocs-v2.yml -> v2 docs (docs/v2/) -# -# Versions are deployed with mike (one site snapshot per version, selected -# via the header dropdown). See .github/workflows/docs.yml. Local preview: -# uv run mkdocs serve # v1 -# uv run mkdocs serve -f mkdocs-v2.yml # v2 -site_name: FastFuels SDK Documentation -# site_url is required for mike's version selector to keep the reader on -# the same page when switching versions -site_url: https://silvxlabs.github.io/fastfuels-sdk-python/ - -theme: - name: "material" - features: - - content.code.copy - -extra: - version: - provider: mike - alias: true - -plugins: - - search - - mkdocstrings: - handlers: - python: - options: - docstring_style: numpy - docstring_section_style: table - docstring_options: - ignore_init_summary: true - show_root_heading: true - show_source: false - members_order: source - annotations_path: brief - separate_signature: true - show_signature_annotations: true - -markdown_extensions: - - admonition - - pymdownx.highlight: - anchor_linenums: true - line_spans: __span - pygments_lang_class: true - - pymdownx.inlinehilite - - pymdownx.snippets - - pymdownx.superfences - -extra_css: - - stylesheets/extra.css diff --git a/mkdocs-v2.yml b/mkdocs-v2.yml deleted file mode 100644 index c438ba5..0000000 --- a/mkdocs-v2.yml +++ /dev/null @@ -1,12 +0,0 @@ -# mkdocs-v2.yml — v2 docs (see mkdocs-base.yml for the shared config and -# an overview of the versioned-docs setup) -INHERIT: mkdocs-base.yml - -docs_dir: docs/v2 - -nav: - - Home: index.md - - Migrating from v1: guides/migration.md - - How-To Guides: - - Domains: guides/domains.md - - Reference: reference.md diff --git a/mkdocs.yml b/mkdocs.yml index dcfb8cc..91dc215 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,19 +1,57 @@ -# mkdocs.yml — v1 docs (see mkdocs-base.yml for the shared config and -# an overview of the versioned-docs setup) -INHERIT: mkdocs-base.yml +# mkdocs.yml +# +# One config builds either docs version: DOCS_DIR selects the tree +# (defaults to docs/v1; set DOCS_DIR=docs/v2 for v2), and each tree +# defines its own nav in a SUMMARY.md (literate-nav). Versions deploy via +# mike as independent site snapshots picked from the header version +# selector; see .github/workflows/docs.yml. Local preview: +# uv run mkdocs serve # v1 +# DOCS_DIR=docs/v2 uv run mkdocs serve # v2 +site_name: FastFuels SDK Documentation +# site_url is required for mike's version selector to keep the reader on +# the same page when switching versions +site_url: https://silvxlabs.github.io/fastfuels-sdk-python/ -docs_dir: docs/v1 +docs_dir: !ENV [DOCS_DIR, docs/v1] -nav: - - Home: index.md - - How-To Guides: - - Authentication: guides/authentication.md - - Domains: guides/domains.md - - Inventories: guides/inventories.md - - Point Clouds: guides/point_clouds.md - - Features: guides/features.md - - Grids: guides/grids.md - - Tutorials: - - Export to QUIC-Fire: tutorials/export_to_quicfire.md - - ALS Point Cloud: tutorials/point_cloud_example.md - - Reference: reference.md +theme: + name: "material" + features: + - content.code.copy + +extra: + version: + provider: mike + alias: true + +extra_css: + - stylesheets/extra.css + +plugins: + - search + - literate-nav: + nav_file: SUMMARY.md + - mkdocstrings: + handlers: + python: + options: + docstring_style: numpy + docstring_section_style: table + docstring_options: + ignore_init_summary: true + show_root_heading: true + show_source: false + members_order: source + annotations_path: brief + separate_signature: true + show_signature_annotations: true + +markdown_extensions: + - admonition + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.superfences diff --git a/pyproject.toml b/pyproject.toml index 0c65b9a..fe720af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,9 @@ dev = [ docs = [ "mike>=2.2.0", "mkdocs", + # pinned pre-0.6.3: 0.6.3 co-installs the properdocs mkdocs-fork and + # warns when run under mkdocs; revisit when the fork situation settles + "mkdocs-literate-nav==0.6.2", "mkdocs-material", "mkdocstrings", "mkdocstrings-python", diff --git a/uv.lock b/uv.lock index 653897a..3a4e357 100644 --- a/uv.lock +++ b/uv.lock @@ -358,6 +358,7 @@ dev = [ docs = [ { name = "mike" }, { name = "mkdocs" }, + { name = "mkdocs-literate-nav" }, { name = "mkdocs-material" }, { name = "mkdocstrings" }, { name = "mkdocstrings-python" }, @@ -387,6 +388,7 @@ dev = [ docs = [ { name = "mike", specifier = ">=2.2.0" }, { name = "mkdocs" }, + { name = "mkdocs-literate-nav", specifier = "==0.6.2" }, { name = "mkdocs-material" }, { name = "mkdocstrings" }, { name = "mkdocstrings-python" }, @@ -814,6 +816,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" }, ] +[[package]] +name = "mkdocs-literate-nav" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/5f/99aa379b305cd1c2084d42db3d26f6de0ea9bf2cc1d10ed17f61aff35b9a/mkdocs_literate_nav-0.6.2.tar.gz", hash = "sha256:760e1708aa4be86af81a2b56e82c739d5a8388a0eab1517ecfd8e5aa40810a75", size = 17419, upload-time = "2025-03-18T21:53:09.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/84/b5b14d2745e4dd1a90115186284e9ee1b4d0863104011ab46abb7355a1c3/mkdocs_literate_nav-0.6.2-py3-none-any.whl", hash = "sha256:0a6489a26ec7598477b56fa112056a5e3a6c15729f0214bea8a4dbc55bd5f630", size = 13261, upload-time = "2025-03-18T21:53:08.1Z" }, +] + [[package]] name = "mkdocs-material" version = "9.7.6" From 861961f9baa9b8398525d78bfd97c0eed6f15d09 Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Fri, 5 Jun 2026 15:44:15 -0600 Subject: [PATCH 09/71] Apply Material best practices and align docs with the platform docs service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mkdocs.yml, per the official mkdocs-material setup guides: automatic light/dark palette toggle, repo link, navigation features sized for a small site (sections/top/tracking/footer, toc.follow; instant nav deliberately skipped — it conflicts with mike's cross-version redirect), search suggest/highlight/share, code annotations, content tabs, and the curated baseline markdown extensions. mkdocs-material pinned to <10. Alignment with the FastFuels-Web documentation service (docs.fastfuels.silvxlabs.com), which both properties' Diataxis rules now codify in CLAUDE.md: version labels mirror the platform docs (v1 / 'v2 (Beta)', replacing 'preview'), the v2 pages cross-link to the platform docs for concept explanations instead of duplicating them, the domains guide opens with a Prerequisites section, and the migration guide's before/after snippets use v1/v2 content tabs. CLAUDE.md: documentation principles as shared context — the two-property division of responsibility, the four Diataxis kinds with their authoring rules, docstrings-are-user-facing-reference policy, and the versioned docs conventions (DOCS_DIR/SUMMARY.md/mike, duplicated extra.css, live-verified examples, frozen docs/v1). --- .github/workflows/docs.yml | 5 +-- CLAUDE.md | 67 +++++++++++++++++++++++++++++++++++++ docs/v2/guides/domains.md | 18 ++++++---- docs/v2/guides/migration.md | 48 +++++++++++++------------- docs/v2/index.md | 13 +++++-- mkdocs.yml | 52 ++++++++++++++++++++++++++++ pyproject.toml | 3 +- uv.lock | 2 +- 8 files changed, 171 insertions(+), 37 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index cc95cbd..dacedb5 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -27,10 +27,11 @@ jobs: # powers the version dropdown in the header (mkdocs-material's # extra.version.provider). v1 keeps the "latest" alias and is the # default the site root redirects to. + # Version labels mirror the FastFuels-Web docs (docs.fastfuels.silvxlabs.com) - name: Deploy v1 docs - run: uv run mike deploy --push --update-aliases v1 latest --title "v1 (current)" + run: uv run mike deploy --push --update-aliases v1 latest --title "v1" - name: Deploy v2 docs - run: uv run mike deploy --push v2 --title "v2 (preview)" + run: uv run mike deploy --push v2 --title "v2 (Beta)" env: DOCS_DIR: docs/v2 - name: Set default version diff --git a/CLAUDE.md b/CLAUDE.md index b065830..b43b187 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,3 +4,70 @@ Do NOT add "Co-Authored-By: Claude", "Generated with Claude Code", or any other AI-attribution lines to commit messages, PR descriptions, or GitHub issues. + +## Documentation + +FastFuels has two documentation properties that must stay in concert: + +- **This repo (`docs/`)** — the Python SDK docs (mkdocs-material + mike, + published to silvxlabs.github.io/fastfuels-sdk-python). Scope: how to use + FastFuels **from Python** — SDK how-to guides, SDK tutorials, migration + guides, and the mkdocstrings API reference. +- **FastFuels-Web `documentation/`** (sibling repo) — the platform docs at + docs.fastfuels.silvxlabs.com (Astro/Starlight). Scope: the web + application, the HTTP API (language-agnostic: curl + raw requests), and + **all concept explanations**. + +Division of responsibility: platform concepts (what a domain is, how grids +work, why a design is the way it is) are explained once, in the platform +docs — the SDK docs link to docs.fastfuels.silvxlabs.com rather than +duplicating them. The SDK docs own Python idioms, signatures, and +SDK-specific behavior. Cross-link in both directions; never copy content +between the two. + +### Diátaxis + +Both properties follow the Diátaxis framework (reference copy: +`FastFuels-Web/documentation/diataxis.rst`). Every page is exactly ONE of +four kinds — decide which before writing; if a page wants to be two kinds, +split it: + +- **Tutorial** — learning by doing. First-person plural ("we'll create…"), + prerequisites up front, expected output shown at each step, reliable + end-to-end. No explanation digressions — link instead. +- **How-to guide** — a goal, for a competent user. Conditional imperatives + ("To create a domain from a file, …"). Action only: no teaching, no + background. Opens with a short Prerequisites section. +- **Reference** — neutral facts. In this repo it is generated from + docstrings via mkdocstrings; do not hand-write opinions into it. +- **Explanation** — the "why". Belongs in the platform docs unless it is + SDK-specific (e.g. the v1→v2 migration guide's what-changed sections). + +### Docstrings are user-facing reference documentation + +mkdocstrings renders docstrings directly into the published Reference +pages. A docstring describes what the object does, parameters, returns, +raises, and examples — nothing else. No editorial or comparative +commentary (no v1-vs-v2 asides, no GitHub issue references, no design +rationale). Migration notes belong in `docs/v2/guides/migration.md`; +rationale and issue references belong in `#` code comments. + +### Conventions + +- Version labels mirror the platform docs: **"v1"** (alias `latest`) and + **"v2 (Beta)"** — set as mike titles in `.github/workflows/docs.yml`. +- One `mkdocs.yml` builds both versions: `DOCS_DIR` selects `docs/v1` + (default) or `docs/v2`; each tree owns its nav in a `SUMMARY.md` + (literate-nav). Versions deploy as independent mike snapshots picked + from the header version selector. +- `docs/v1/stylesheets/extra.css` and `docs/v2/stylesheets/extra.css` are + duplicates by construction — edit both or the version snapshots diverge. +- Code examples must be real: verified against the live API (the live test + suite in `tests/` is the verification path), with realistic output — + never hand-written response shapes. +- Use admonitions (`!!! tip` / `warning` / `danger`) instead of bold + "Note:" prose; use content tabs (`=== "v1"` / `=== "v2"`) for + side-by-side variants; v2 pages open with the `!!! warning "Beta"` + admonition. +- `docs/v1/` is frozen alongside the v1 SDK (bugfix-level edits only); + new documentation lands in `docs/v2/`. diff --git a/docs/v2/guides/domains.md b/docs/v2/guides/domains.md index 4d56ab5..3a22038 100644 --- a/docs/v2/guides/domains.md +++ b/docs/v2/guides/domains.md @@ -1,11 +1,20 @@ # How to Work with Domains in FastFuels SDK -!!! warning "Preview" - The v2 SDK targets the new FastFuels v2 API and is under active +!!! warning "Beta" + The v2 SDK targets the FastFuels v2 API and is under active development. The v1 SDK remains the default — import v2 explicitly from `fastfuels_sdk.v2`. -## Authentication +A domain is the spatial container every other FastFuels resource lives in. +This guide covers working with domains from Python; for what domains *are* +and how the platform treats them, see the +[FastFuels documentation](https://docs.fastfuels.silvxlabs.com). Coming +from the v1 SDK? Start with the [migration guide](migration.md#domains). + +## Prerequisites + +- The FastFuels SDK installed: `pip install fastfuels-sdk` +- A FastFuels API key The v2 SDK reads your API key from the `FASTFUELS_API_KEY` environment variable, the same variable the v1 SDK uses: @@ -22,9 +31,6 @@ from fastfuels_sdk.v2 import set_api_key set_api_key("your-api-key") ``` -Coming from the v1 SDK? Start with the -[migration guide](migration.md#domains). - ## Create a Domain from GeoJSON To create a domain from a GeoJSON file: diff --git a/docs/v2/guides/migration.md b/docs/v2/guides/migration.md index d4c7bf1..cd7d7db 100644 --- a/docs/v2/guides/migration.md +++ b/docs/v2/guides/migration.md @@ -55,30 +55,30 @@ Available now — see the [Domains guide](domains.md) and the ### Before and after -v1: - -```python -from fastfuels_sdk import Domain - -domain = Domain.from_geojson( - geojson, - name="My Domain", - horizontal_resolution=2.0, - vertical_resolution=1.0, -) -``` - -v2: - -```python -from fastfuels_sdk.v2 import Domain - -domain = Domain.from_geojson( - geojson, - name="My Domain", - pad_to_resolution=2.0, -) -``` +=== "v1" + + ```python + from fastfuels_sdk import Domain + + domain = Domain.from_geojson( + geojson, + name="My Domain", + horizontal_resolution=2.0, + vertical_resolution=1.0, + ) + ``` + +=== "v2" + + ```python + from fastfuels_sdk.v2 import Domain + + domain = Domain.from_geojson( + geojson, + name="My Domain", + pad_to_resolution=2.0, + ) + ``` ## Grids diff --git a/docs/v2/index.md b/docs/v2/index.md index 1e3ad9e..cbb377b 100644 --- a/docs/v2/index.md +++ b/docs/v2/index.md @@ -1,8 +1,8 @@ # Welcome to the FastFuels Python SDK Documentation! -!!! warning "Preview" +!!! warning "Beta" You are reading the documentation for the **v2 SDK**, which targets - the new FastFuels v2 API and is under active development. The v1 SDK + the FastFuels v2 API and is under active development. The v1 SDK remains the default — switch versions with the selector in the header. Import v2 explicitly from `fastfuels_sdk.v2`. @@ -14,6 +14,11 @@ tabular tree data and voxelized 3D fuel models. These data products can be used to support wildfire risk assessment, fire behavior modeling, and other applications. +The [FastFuels documentation](https://docs.fastfuels.silvxlabs.com) covers +the platform itself: the web application, the HTTP API, and explanations of +the core concepts (domains, grids, inventories, features). This site covers +how to work with FastFuels from Python. + ## What is the FastFuels Python SDK? The FastFuels Python SDK is a Python package that provides a convenient @@ -36,5 +41,7 @@ versioned subpackages, so you can use them side by side during migration: ```python from fastfuels_sdk.v1 import Domain # v1 (current default) -from fastfuels_sdk.v2 import Domain # v2 (preview) +from fastfuels_sdk.v2 import Domain # v2 (Beta) ``` + +Coming from v1? Start with [Migrating from v1](guides/migration.md). diff --git a/mkdocs.yml b/mkdocs.yml index 91dc215..7d33dc1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -14,15 +14,49 @@ site_url: https://silvxlabs.github.io/fastfuels-sdk-python/ docs_dir: !ENV [DOCS_DIR, docs/v1] +repo_url: https://github.com/silvxlabs/fastfuels-sdk-python +repo_name: silvxlabs/fastfuels-sdk-python + theme: name: "material" + icon: + repo: fontawesome/brands/github + palette: + # Palette toggle: light mode -> dark mode + - media: "(prefers-color-scheme: light)" + scheme: default + toggle: + icon: material/brightness-7 + name: Switch to dark mode + # Palette toggle: dark mode -> light mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + toggle: + icon: material/brightness-4 + name: Switch to light mode features: + - navigation.sections + - navigation.top + - navigation.tracking + - navigation.footer + - toc.follow + - search.suggest + - search.highlight + - search.share - content.code.copy + - content.code.annotate + - content.tooltips extra: version: provider: mike alias: true + social: + - icon: fontawesome/brands/github + link: https://github.com/silvxlabs/fastfuels-sdk-python + name: FastFuels SDK on GitHub + +copyright: Copyright © Silvx Labs extra_css: - stylesheets/extra.css @@ -47,7 +81,18 @@ plugins: show_signature_annotations: true markdown_extensions: + # Python Markdown + - abbr - admonition + - attr_list + - def_list + - footnotes + - md_in_html + - tables + - toc: + permalink: true + # PyMdown Extensions + - pymdownx.details - pymdownx.highlight: anchor_linenums: true line_spans: __span @@ -55,3 +100,10 @@ markdown_extensions: - pymdownx.inlinehilite - pymdownx.snippets - pymdownx.superfences + - pymdownx.tabbed: + alternate_style: true + - pymdownx.tasklist: + custom_checkbox: true + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg diff --git a/pyproject.toml b/pyproject.toml index fe720af..5f9f2f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,8 @@ docs = [ # pinned pre-0.6.3: 0.6.3 co-installs the properdocs mkdocs-fork and # warns when run under mkdocs; revisit when the fork situation settles "mkdocs-literate-nav==0.6.2", - "mkdocs-material", + # major pinned per the Material docs (semver; majors break config) + "mkdocs-material>=9.7,<10", "mkdocstrings", "mkdocstrings-python", ] diff --git a/uv.lock b/uv.lock index 3a4e357..f448fad 100644 --- a/uv.lock +++ b/uv.lock @@ -389,7 +389,7 @@ docs = [ { name = "mike", specifier = ">=2.2.0" }, { name = "mkdocs" }, { name = "mkdocs-literate-nav", specifier = "==0.6.2" }, - { name = "mkdocs-material" }, + { name = "mkdocs-material", specifier = ">=9.7,<10" }, { name = "mkdocstrings" }, { name = "mkdocstrings-python" }, ] From 6abeaba26de2823d244f452f2a2fe95d1d0fac9b Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Fri, 5 Jun 2026 15:47:56 -0600 Subject: [PATCH 10/71] Drop navigation.sections; keep the collapsible sidebar style --- mkdocs.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/mkdocs.yml b/mkdocs.yml index 7d33dc1..52ead0a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -35,7 +35,6 @@ theme: icon: material/brightness-4 name: Switch to light mode features: - - navigation.sections - navigation.top - navigation.tracking - navigation.footer From decf68f1b44a38222d57a806d2da9ea124601d8b Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Fri, 5 Jun 2026 16:38:59 -0600 Subject: [PATCH 11/71] Promote the Feature wrapper to fastfuels_sdk/v2/features.py (#180) Rewrite the draft on the domains.py pattern: every call goes sync_detailed() + exceptions.expect() (201 creates, 204 delete -- the draft fired the delete request and ignored the response entirely). New surface beyond the draft: create_layerset and create_layerset_from_geodataframe (projected-CRS fuelbed polygons, synchronous upload), partitioned data access (get_data_metadata, get_data_partition, get_data, to_geodataframe), and list_features grows an optional domain_id -- omitting it routes to the cross-domain endpoint (GET /domains/-/features) -- plus the type/product/tag filters. The job polling loop lands in _jobs.py as a plain shared helper (Grid/Inventory will delegate to the same function), not a mixin; wrappers keep their own small model-copy helpers. --- fastfuels_sdk/v2/__init__.py | 3 + fastfuels_sdk/v2/_jobs.py | 61 ++ fastfuels_sdk/v2/draft_features_opc.py | 202 ------- fastfuels_sdk/v2/features.py | 803 +++++++++++++++++++++++++ 4 files changed, 867 insertions(+), 202 deletions(-) create mode 100644 fastfuels_sdk/v2/_jobs.py delete mode 100644 fastfuels_sdk/v2/draft_features_opc.py create mode 100644 fastfuels_sdk/v2/features.py diff --git a/fastfuels_sdk/v2/__init__.py b/fastfuels_sdk/v2/__init__.py index 38dc0b4..6c600a3 100644 --- a/fastfuels_sdk/v2/__init__.py +++ b/fastfuels_sdk/v2/__init__.py @@ -1,9 +1,12 @@ from fastfuels_sdk.v2.api import set_api_key from fastfuels_sdk.v2.domains import Domain, list_domains, reproject_geojson +from fastfuels_sdk.v2.features import Feature, list_features __all__ = [ "set_api_key", "Domain", "list_domains", "reproject_geojson", + "Feature", + "list_features", ] diff --git a/fastfuels_sdk/v2/_jobs.py b/fastfuels_sdk/v2/_jobs.py new file mode 100644 index 0000000..b669f9d --- /dev/null +++ b/fastfuels_sdk/v2/_jobs.py @@ -0,0 +1,61 @@ +""" +fastfuels_sdk/v2/_jobs.py + +Shared polling helper for job-based v2 resources (Feature, Grid, +Inventory). Private module: each resource exposes its own documented +``wait_until_completed`` method that delegates here. +""" + +import time + +from fastfuels_sdk.v2.client_library.models import JobStatus +from fastfuels_sdk.v2.client_library.types import Unset + +_TERMINAL_STATUSES = (JobStatus.COMPLETED, JobStatus.FAILED) + + +def _format_job_error(resource, error) -> str: + """Build a human-readable message from a generated JobError.""" + label = f"{type(resource).__name__} {resource.id}" + if error is None or isinstance(error, Unset): + return f"{label} failed (no error detail returned)." + message = f"{label} failed [{error.code}]: {error.message}" + suggestion = getattr(error, "suggestion", None) + if suggestion and not isinstance(suggestion, Unset): + message += f" Suggestion: {suggestion}" + return message + + +def wait_until_completed( + resource, + step: float = 5, + timeout: float = 600, + in_place: bool = True, + verbose: bool = False, +): + """Poll a job resource until it reaches a terminal status. + + Duck-typed on the resource wrapper: requires ``get()``, ``status``, + ``error``, ``id``, and ``_copy_fields_from()``. + """ + elapsed = 0.0 + latest = resource.get() + while latest.status not in _TERMINAL_STATUSES: + if elapsed >= timeout: + raise TimeoutError( + f"{type(resource).__name__} {resource.id} did not complete " + f"within {timeout} seconds" + ) + time.sleep(step) + elapsed += step + latest = resource.get() + if verbose: + print( + f"{type(resource).__name__} {resource.id}: " + f"{latest.status} ({elapsed:.0f}s)" + ) + if latest.status == JobStatus.FAILED: + raise RuntimeError(_format_job_error(resource, latest.error)) + if in_place: + return resource._copy_fields_from(latest) + return latest diff --git a/fastfuels_sdk/v2/draft_features_opc.py b/fastfuels_sdk/v2/draft_features_opc.py deleted file mode 100644 index e6e3cfe..0000000 --- a/fastfuels_sdk/v2/draft_features_opc.py +++ /dev/null @@ -1,202 +0,0 @@ -""" -fastfuels_sdk/v2/draft_features_opc.py - -DRAFT — Feature wrapper built on the **openapi-python-client** client -(`client_library`: attrs models + httpx). - -Part of the v2 client-library generator comparison; see COMPARISON.md. -Covers OSM road and water feature creation plus the get/wait/update/ -delete/list lifecycle. -""" - -import json -import time -from typing import Any, List, Optional - -import attrs - -from fastfuels_sdk.v2.api import ensure_client as get_client -from fastfuels_sdk.v2.draft_grids_opc import _checked - -from fastfuels_sdk.v2.client_library.api.features import ( - create_osm_road_feature, - create_osm_water_feature, - delete_feature, - get_feature, - list_features as list_features_endpoint, - update_feature, -) -from fastfuels_sdk.v2.client_library.models import ( - Feature as FeatureModel, - CreateOsmRoadFeatureRequest, - CreateOsmWaterFeatureRequest, - JobStatus, - ListFeaturesResponse, - UpdateFeatureRequestBody, -) -from fastfuels_sdk.v2.client_library.types import UNSET - - -def _osm_request_dict( - name: str, - description: str, - tags: Optional[List[str]], - extent_buffer_m: float, -) -> dict: - """Build the request fields shared by the OSM feature endpoints.""" - body: dict[str, Any] = { - "name": name, - "description": description, - "extent_buffer_m": extent_buffer_m, - } - if tags is not None: - body["tags"] = tags - return body - - -class Feature(FeatureModel): - """Feature resource for the FastFuels v2 API (openapi-python-client draft). - - Inherits all attributes from the generated attrs model: id, domain_id, - type_, name, description, status, progress, source, georeference, - error, tags, created_on, modified_on. - - v2 unifies road and water features (separate sub-resources in v1) into - one job-based Feature resource distinguished by its `type_`/`source`. - """ - - @classmethod - def _from_model(cls, model: FeatureModel) -> "Feature": - return cls.from_dict(model.to_dict()) - - def _copy_fields_from(self, model: FeatureModel) -> "Feature": - """Copy all generated-model fields from `model` onto self (in-place).""" - for field in attrs.fields(FeatureModel): - if field.init: - setattr(self, field.name, getattr(model, field.name)) - self.additional_properties = dict(model.additional_properties) - return self - - @classmethod - def from_id(cls, domain_id: str, feature_id: str) -> "Feature": - """Retrieve an existing Feature by domain and feature ID.""" - response = get_feature.sync(domain_id, feature_id, client=get_client()) - return cls._from_model(_checked(response, FeatureModel)) - - @classmethod - def create_osm_road( - cls, - domain_id: str, - name: str = "", - description: str = "", - tags: Optional[List[str]] = None, - extent_buffer_m: float = 0, - ) -> "Feature": - """Create a road feature from OpenStreetMap data.""" - body = _osm_request_dict(name, description, tags, extent_buffer_m) - request = CreateOsmRoadFeatureRequest.from_dict(body) - response = create_osm_road_feature.sync( - domain_id, client=get_client(), body=request - ) - return cls._from_model(_checked(response, FeatureModel)) - - @classmethod - def create_osm_water( - cls, - domain_id: str, - name: str = "", - description: str = "", - tags: Optional[List[str]] = None, - extent_buffer_m: float = 0, - ) -> "Feature": - """Create a water feature from OpenStreetMap data.""" - body = _osm_request_dict(name, description, tags, extent_buffer_m) - request = CreateOsmWaterFeatureRequest.from_dict(body) - response = create_osm_water_feature.sync( - domain_id, client=get_client(), body=request - ) - return cls._from_model(_checked(response, FeatureModel)) - - def get(self, in_place: bool = False) -> "Feature": - """Retrieve the latest feature data from the API.""" - response = _checked( - get_feature.sync(self.domain_id, self.id, client=get_client()), - FeatureModel, - ) - if in_place: - return self._copy_fields_from(response) - return Feature._from_model(response) - - def wait_until_completed( - self, - step: float = 5, - timeout: float = 600, - in_place: bool = True, - verbose: bool = False, - ) -> "Feature": - """Poll the feature job until it reaches a terminal status.""" - elapsed = 0.0 - feature = self.get() - while feature.status not in (JobStatus.COMPLETED, JobStatus.FAILED): - if elapsed >= timeout: - raise TimeoutError( - f"Feature {self.id} did not complete within {timeout} seconds" - ) - time.sleep(step) - elapsed += step - feature = self.get() - if verbose: - print(f"Feature {self.id}: {feature.status} ({elapsed:.0f}s)") - if feature.status == JobStatus.FAILED: - raise RuntimeError(f"Feature {self.id} failed: {feature.error}") - if in_place: - return self._copy_fields_from(feature) - return feature - - def update( - self, - name: Optional[str] = None, - description: Optional[str] = None, - tags: Optional[List[str]] = None, - in_place: bool = False, - ) -> "Feature": - """Update the feature's mutable properties (name, description, tags).""" - if name is None and description is None and tags is None: - return self if in_place else Feature._from_model(self) - request = UpdateFeatureRequestBody( - name=name if name is not None else UNSET, - description=description if description is not None else UNSET, - tags=tags if tags is not None else UNSET, - ) - response = _checked( - update_feature.sync( - self.domain_id, self.id, client=get_client(), body=request - ), - FeatureModel, - ) - if in_place: - return self._copy_fields_from(response) - return Feature._from_model(response) - - def delete(self) -> None: - """Delete this feature.""" - delete_feature.sync_detailed(self.domain_id, self.id, client=get_client()) - - def to_json(self) -> str: - """Serialize the complete Feature object to a JSON string.""" - return json.dumps(self.to_dict(), default=str, indent=2) - - -def list_features( - domain_id: str, - page: int = 0, - size: int = 100, -) -> List[Feature]: - """List features in a domain (single page).""" - response = _checked( - list_features_endpoint.sync( - domain_id, client=get_client(), page=page, size=size - ), - ListFeaturesResponse, - ) - return [Feature._from_model(f) for f in response.features] diff --git a/fastfuels_sdk/v2/features.py b/fastfuels_sdk/v2/features.py new file mode 100644 index 0000000..0175d8a --- /dev/null +++ b/fastfuels_sdk/v2/features.py @@ -0,0 +1,803 @@ +""" +fastfuels_sdk/v2/features.py +""" + +# Core imports +import json +from http import HTTPStatus +from typing import Any, Dict, List, Optional + +# Internal imports +from fastfuels_sdk.v2._jobs import wait_until_completed as _wait_until_completed +from fastfuels_sdk.v2.api import ensure_client +from fastfuels_sdk.v2.exceptions import expect +from fastfuels_sdk.v2.client_library.api.features import ( + create_layerset, + create_osm_road_feature, + create_osm_water_feature, + delete_feature, + get_feature, + get_feature_data_metadata, + get_feature_data_partition, + list_features as list_features_endpoint, + list_features_cross_domain, + update_feature, +) +from fastfuels_sdk.v2.client_library.models import ( + Feature as FeatureModel, + CreateLayersetRequestBody, + CreateOsmRoadFeatureRequest, + CreateOsmWaterFeatureRequest, + FeatureDataMetadata, + FeatureSortField, + FeatureType, + ListFeaturesResponse, + SortOrder, + UpdateFeatureRequestBody, +) +from fastfuels_sdk.v2.client_library.types import UNSET + +# External imports +import attrs +import geopandas as gpd + + +def _as_feature_collection(geojson: dict) -> dict: + """Return ``geojson`` as a FeatureCollection dict. + + A single Feature is wrapped in a FeatureCollection (carrying along a + feature-level ``crs`` member if present) so Feature input keeps + working. + """ + geojson_type = geojson.get("type") + if geojson_type == "Feature": + feature_collection = {"type": "FeatureCollection", "features": [geojson]} + if "crs" in geojson: + feature_collection["crs"] = geojson["crs"] + return feature_collection + if geojson_type != "FeatureCollection": + raise ValueError( + "GeoJSON type must be 'Feature' or 'FeatureCollection', " + f"got {geojson_type!r}" + ) + return geojson + + +class Feature(FeatureModel): + """Feature resource for the FastFuels v2 API. + + Represents geographic features within a domain: roads and water + bodies sourced from OpenStreetMap, or user-uploaded layersets of + fuelbed polygons. Features are asynchronous job resources — OSM + features are generated in the background and expose a + ``status``/``progress`` lifecycle, while layerset uploads complete + synchronously. + + The Feature resource itself is geometry-free; the generated geodata + is retrieved through the data methods (:meth:`get_data_metadata`, + :meth:`get_data_partition`, :meth:`get_data`, + :meth:`to_geodataframe`) once the feature is completed. + + Attributes + ---------- + id : str + Unique identifier for the feature. + domain_id : str + Identifier of the domain the feature belongs to. + type_ : FeatureType + Type of geographic feature: "road", "water", or "layerset". + status : JobStatus + Job status: "pending", "running", "completed", or "failed". + source : FeatureSource + Where the feature data comes from (e.g. "osm"). + name : str + Human-readable name for the feature. + description : str + Detailed description of the feature. + progress : JobProgress, optional + Progress info while the job is running. + georeference : FeatureGeoreference, optional + Spatial reference of the generated data; populated when the job + completes. + error : JobError, optional + Error details if the job failed. + tags : List[str], optional + User-defined tags for organization. + created_on : datetime + When the feature was created. + modified_on : datetime + When the feature was last modified. + + Examples + -------- + Create a road feature and wait for it to complete: + >>> feature = Feature.create_osm_road("abc123") + >>> feature.wait_until_completed() + >>> roads = feature.to_geodataframe() + + Get a feature by ID: + >>> feature = Feature.from_id("abc123", "def456") + + See Also + -------- + Feature.create_osm_road : Create a road feature from OpenStreetMap. + Feature.create_osm_water : Create a water feature from OpenStreetMap. + Feature.create_layerset : Upload a custom layerset of fuelbed polygons. + Feature.to_geodataframe : Retrieve the feature data as a GeoDataFrame. + list_features : List features in a domain or across all domains. + """ + + @classmethod + def _from_model(cls, model: FeatureModel) -> "Feature": + """Build a Feature from a generated FeatureModel instance. + + Round-trips through the generated to_dict/from_dict — from_dict + constructs ``cls``, i.e. this subclass. + """ + return cls.from_dict(model.to_dict()) + + def _copy_fields_from(self, model: FeatureModel) -> "Feature": + """Copy all generated-model fields from `model` onto self (in-place).""" + for field in attrs.fields(FeatureModel): + if field.init: + setattr(self, field.name, getattr(model, field.name)) + self.additional_properties = dict(model.additional_properties) + return self + + @classmethod + def from_id(cls, domain_id: str, feature_id: str) -> "Feature": + """Retrieve an existing Feature resource by its ID. + + Parameters + ---------- + domain_id : str + The unique identifier of the domain the feature belongs to. + feature_id : str + The unique identifier of the feature to retrieve. + + Returns + ------- + Feature + The requested Feature object. + + Raises + ------ + NotFoundException + If no feature exists with the given IDs, or the user does not + have access to it. + + Examples + -------- + >>> feature = Feature.from_id("abc123", "def456") + >>> feature.id + 'def456' + """ + response = get_feature.sync_detailed( + domain_id, feature_id, client=ensure_client() + ) + return cls._from_model(expect(response)) + + @classmethod + def create_osm_road( + cls, + domain_id: str, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, + extent_buffer_m: float = 0.0, + ) -> "Feature": + """Create a road feature from OpenStreetMap data. + + Starts a background job that extracts road geometries from + OpenStreetMap within the domain extent. Use + :meth:`wait_until_completed` to block until the data is ready. + + Parameters + ---------- + domain_id : str + The unique identifier of the domain to create the feature in. + name : str, optional + Name for the feature. + description : str, optional + Description of the feature. + tags : List[str], optional + Tags for organizing the feature. + extent_buffer_m : float, optional + Distance in meters (0-100, default 0) to buffer the domain + extent outward when querying OpenStreetMap. + + Returns + ------- + Feature + The created Feature object (job status "pending" or + "running"). + + Raises + ------ + NotFoundException + If the domain does not exist. + UnprocessableEntityException + If ``extent_buffer_m`` is outside the 0-100 meter range. + + Examples + -------- + >>> feature = Feature.create_osm_road("abc123", name="roads") + >>> feature.type_ + + """ + request_body = CreateOsmRoadFeatureRequest( + name=name, + description=description, + tags=tags if tags is not None else UNSET, + extent_buffer_m=extent_buffer_m, + ) + response = create_osm_road_feature.sync_detailed( + domain_id, client=ensure_client(), body=request_body + ) + return cls._from_model(expect(response, HTTPStatus.CREATED)) + + @classmethod + def create_osm_water( + cls, + domain_id: str, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, + extent_buffer_m: float = 0.0, + ) -> "Feature": + """Create a water feature from OpenStreetMap data. + + Starts a background job that extracts water-body geometries from + OpenStreetMap within the domain extent. Use + :meth:`wait_until_completed` to block until the data is ready. + + Parameters + ---------- + domain_id : str + The unique identifier of the domain to create the feature in. + name : str, optional + Name for the feature. + description : str, optional + Description of the feature. + tags : List[str], optional + Tags for organizing the feature. + extent_buffer_m : float, optional + Distance in meters (0-100, default 0) to buffer the domain + extent outward when querying OpenStreetMap. + + Returns + ------- + Feature + The created Feature object (job status "pending" or + "running"). + + Raises + ------ + NotFoundException + If the domain does not exist. + UnprocessableEntityException + If ``extent_buffer_m`` is outside the 0-100 meter range. + + Examples + -------- + >>> feature = Feature.create_osm_water("abc123", name="water") + >>> feature.type_ + + """ + request_body = CreateOsmWaterFeatureRequest( + name=name, + description=description, + tags=tags if tags is not None else UNSET, + extent_buffer_m=extent_buffer_m, + ) + response = create_osm_water_feature.sync_detailed( + domain_id, client=ensure_client(), body=request_body + ) + return cls._from_model(expect(response, HTTPStatus.CREATED)) + + @classmethod + def create_layerset( + cls, + domain_id: str, + geojson: dict, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, + ) -> "Feature": + """Upload a custom layerset of fuelbed polygons. + + Uploads a GeoJSON FeatureCollection where each feature is one + fuelbed polygon. The upload is validated and stored synchronously: + the returned Feature has status "completed" and its data is + immediately available through the data methods. + + Each feature's ``properties`` must carry the fuelbed input + columns: + + - ``fuel_type`` (str) + - ``fuel_loading`` (float) + - ``fuel_height`` (float) + - ``percent_cover`` (float) + - ``distribution`` (str): "homogeneous", "random_clusters", or + "uniform_random" + + Optional columns: ``strata_fb``, ``patch_size``, + ``live_fuel_moisture``, ``dead_fuel_moisture``, + ``heat_of_combustion``, ``patch_std_dev``. + + Parameters + ---------- + domain_id : str + The unique identifier of the domain to create the feature in. + geojson : dict + A GeoJSON FeatureCollection or Feature (a single Feature is + wrapped automatically) with Polygon or MultiPolygon + geometries. The ``crs`` member must declare a **projected** + CRS (e.g. EPSG:5070); geographic coordinates are rejected. + name : str, optional + Name for the feature. + description : str, optional + Description of the feature. + tags : List[str], optional + Tags for organizing the feature. + + Returns + ------- + Feature + The created Feature object with status "completed". + + Raises + ------ + ValueError + If the GeoJSON is not a Feature or FeatureCollection. + NotFoundException + If the domain does not exist. + UnprocessableEntityException + If the CRS is missing or geographic, a geometry is not a + Polygon/MultiPolygon, or a feature is missing required + fuelbed properties. + + Examples + -------- + >>> feature = Feature.create_layerset("abc123", geojson) + >>> feature.status + + """ + feature_collection = _as_feature_collection(geojson) + body = {**feature_collection, "name": name, "description": description} + if tags is not None: + body["tags"] = tags + request_body = CreateLayersetRequestBody.from_dict(body) + response = create_layerset.sync_detailed( + domain_id, client=ensure_client(), body=request_body + ) + return cls._from_model(expect(response, HTTPStatus.CREATED)) + + @classmethod + def create_layerset_from_geodataframe( + cls, + domain_id: str, + geodataframe: gpd.GeoDataFrame, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, + ) -> "Feature": + """Upload a custom layerset from a GeoPandas GeoDataFrame. + + The GeoDataFrame's CRS is forwarded to the API as the + FeatureCollection's ``crs`` member. The CRS must be **projected** + (e.g. EPSG:5070); the GeoDataFrame is not reprojected — use + ``geodataframe.to_crs(...)`` first if needed. Each row's columns + must carry the fuelbed properties described in + :meth:`create_layerset`. + + Parameters + ---------- + domain_id : str + The unique identifier of the domain to create the feature in. + geodataframe : gpd.GeoDataFrame + GeoDataFrame with one fuelbed polygon per row. + name : str, optional + Name for the feature. + description : str, optional + Description of the feature. + tags : List[str], optional + Tags for organizing the feature. + + Returns + ------- + Feature + The created Feature object with status "completed". + + Raises + ------ + ValueError + If the GeoDataFrame CRS has no authority code (e.g. a custom + CRS). + UnprocessableEntityException + Same validation errors as :meth:`create_layerset`. + + Examples + -------- + >>> gdf = gpd.read_file("fuelbeds.shp").to_crs(epsg=5070) + >>> feature = Feature.create_layerset_from_geodataframe("abc123", gdf) + """ + geojson = json.loads(geodataframe.to_json()) + if geodataframe.crs is not None: + authority = geodataframe.crs.to_authority() + if authority is None: + raise ValueError( + "GeoDataFrame CRS has no authority code (e.g. a custom " + "CRS). Reproject to a known projected CRS with " + "geodataframe.to_crs(...) before creating a layerset." + ) + geojson["crs"] = { + "type": "name", + "properties": {"name": ":".join(authority)}, + } + return cls.create_layerset( + domain_id, + geojson, + name=name, + description=description, + tags=tags, + ) + + def get(self, in_place: bool = False) -> "Feature": + """Retrieve the latest feature data from the API. + + Parameters + ---------- + in_place : bool, optional + If True, updates the current Feature instance with the new + data and returns self. If False (default), returns a new + Feature instance with the latest data, leaving the current + instance unchanged. + + Returns + ------- + Feature + The updated Feature object. + + Raises + ------ + NotFoundException + If the feature no longer exists. + + Examples + -------- + >>> updated_feature = feature.get() # new instance + >>> feature.get(in_place=True) # refresh in place + """ + response = get_feature.sync_detailed( + self.domain_id, self.id, client=ensure_client() + ) + model = expect(response) + if in_place: + return self._copy_fields_from(model) + return Feature._from_model(model) + + def wait_until_completed( + self, + step: float = 5, + timeout: float = 600, + in_place: bool = True, + verbose: bool = False, + ) -> "Feature": + """Poll the feature job until it reaches a terminal status. + + Parameters + ---------- + step : float, optional + Seconds between polls (default 5). + timeout : float, optional + Maximum seconds to wait before raising (default 600). + in_place : bool, optional + If True (default), updates the current Feature instance with + the completed data and returns self. If False, returns a new + Feature instance, leaving the current instance unchanged. + verbose : bool, optional + If True, print the job status at each poll. + + Returns + ------- + Feature + The completed Feature object. + + Raises + ------ + TimeoutError + If the job does not reach a terminal status within + ``timeout`` seconds. + RuntimeError + If the job finished with status "failed". + + Examples + -------- + >>> feature = Feature.create_osm_road("abc123") + >>> feature.wait_until_completed(verbose=True) + Feature def456: JobStatus.COMPLETED (5s) + """ + return _wait_until_completed( + self, step=step, timeout=timeout, in_place=in_place, verbose=verbose + ) + + def update( + self, + name: Optional[str] = None, + description: Optional[str] = None, + tags: Optional[List[str]] = None, + in_place: bool = False, + ) -> "Feature": + """Update the feature's mutable properties (name, description, tags). + + Only provided fields are sent in the PATCH body (the generated + UNSET sentinel keeps omitted fields out of the request entirely). + If no fields are provided, no API call is made. + + Parameters + ---------- + name : str, optional + New name for the feature. + description : str, optional + New description for the feature. + tags : List[str], optional + New tags for the feature (replaces existing tags). + in_place : bool, optional + If True, updates the current Feature instance with the new + data and returns self. If False (default), returns a new + Feature instance, leaving the current instance unchanged. + + Returns + ------- + Feature + The updated Feature object. + + Raises + ------ + NotFoundException + If the feature no longer exists. + + Examples + -------- + >>> feature = feature.update(name="new name", tags=["test"]) + """ + if name is None and description is None and tags is None: + return self if in_place else Feature.from_dict(self.to_dict()) + + request_body = UpdateFeatureRequestBody( + name=name if name is not None else UNSET, + description=description if description is not None else UNSET, + tags=tags if tags is not None else UNSET, + ) + response = update_feature.sync_detailed( + self.domain_id, self.id, client=ensure_client(), body=request_body + ) + model = expect(response) + if in_place: + return self._copy_fields_from(model) + return Feature._from_model(model) + + def delete(self) -> None: + """Delete this feature and its generated data. + + Raises + ------ + NotFoundException + If the feature no longer exists. + + Examples + -------- + >>> feature = Feature.from_id("abc123", "def456") + >>> feature.delete() + """ + response = delete_feature.sync_detailed( + self.domain_id, self.id, client=ensure_client() + ) + expect(response, HTTPStatus.NO_CONTENT) + + def get_data_metadata(self) -> FeatureDataMetadata: + """Get the partition layout of the feature's generated data. + + The generated geodata is served in fixed-size partitions. Use + this to discover how many partitions exist before retrieving them + with :meth:`get_data_partition` (or call :meth:`get_data` / + :meth:`to_geodataframe` to retrieve everything at once). + + Returns + ------- + FeatureDataMetadata + With attributes ``total_features`` (count across all + partitions), ``partition_count`` (number of valid partition + indices), and ``partitions`` (per-partition feature counts). + A feature with no data has ``partition_count`` 0. + + Raises + ------ + NotFoundException + If the feature no longer exists. + UnprocessableEntityException + If the feature is not in "completed" status. + + Examples + -------- + >>> metadata = feature.get_data_metadata() + >>> metadata.partition_count + 1 + """ + response = get_feature_data_metadata.sync_detailed( + self.domain_id, self.id, client=ensure_client() + ) + return expect(response) + + def get_data_partition(self, partition_index: int) -> Dict[str, Any]: + """Get one partition of the feature's generated data. + + Parameters + ---------- + partition_index : int + Zero-indexed partition number. Must be less than the + ``partition_count`` reported by :meth:`get_data_metadata`. + + Returns + ------- + dict + A self-contained GeoJSON FeatureCollection with this + partition's features. + + Raises + ------ + NotFoundException + If the feature no longer exists. + UnprocessableEntityException + If ``partition_index`` is past the last partition, or the + feature is not in "completed" status. + ApiException + With ``status_code`` 413 if the serialized partition exceeds + the 30 MB response cap. + + Examples + -------- + >>> partition = feature.get_data_partition(0) + >>> partition["type"] + 'FeatureCollection' + """ + response = get_feature_data_partition.sync_detailed( + self.domain_id, self.id, partition_index, client=ensure_client() + ) + return expect(response) + + def get_data(self) -> Dict[str, Any]: + """Get the feature's complete generated data as GeoJSON. + + Retrieves every partition and concatenates them into a single + FeatureCollection, preserving source order. + + Returns + ------- + dict + A GeoJSON FeatureCollection with all of the feature's data. + Empty (no features) if the source data contained nothing + within the domain extent. + + Raises + ------ + NotFoundException + If the feature no longer exists. + UnprocessableEntityException + If the feature is not in "completed" status. + + Examples + -------- + >>> data = feature.get_data() + >>> len(data["features"]) + 42 + """ + metadata = self.get_data_metadata() + feature_collection: Dict[str, Any] = { + "type": "FeatureCollection", + "features": [], + } + for partition_index in range(metadata.partition_count): + partition = self.get_data_partition(partition_index) + if "crs" in partition and "crs" not in feature_collection: + feature_collection["crs"] = partition["crs"] + feature_collection["features"].extend(partition.get("features", [])) + return feature_collection + + def to_geodataframe(self) -> gpd.GeoDataFrame: + """Retrieve the feature's generated data as a GeoPandas GeoDataFrame. + + Returns + ------- + gpd.GeoDataFrame + GeoDataFrame with one row per generated feature. Empty if the + source data contained nothing within the domain extent. + + Raises + ------ + NotFoundException + If the feature no longer exists. + UnprocessableEntityException + If the feature is not in "completed" status. + + Examples + -------- + >>> feature.wait_until_completed() + >>> gdf = feature.to_geodataframe() + """ + data = self.get_data() + if not data["features"]: + return gpd.GeoDataFrame() + return gpd.read_file(json.dumps(data)) + + def to_json(self) -> str: + """Serialize the complete Feature object to a JSON string. + + Returns + ------- + str + The Feature as a pretty-printed JSON string. + """ + return json.dumps(self.to_dict(), default=str, indent=2) + + +def list_features( + domain_id: Optional[str] = None, + page: int = 0, + size: int = 100, + sort_by: Optional[str] = None, + sort_order: Optional[str] = None, + feature_type: Optional[str] = None, + product: Optional[str] = None, + tag: Optional[str] = None, +) -> List[Feature]: + """List features in a domain, or across all domains (single page). + + Parameters + ---------- + domain_id : str, optional + The unique identifier of the domain to list features in. If + omitted, features from all the user's domains are listed. + page : int, optional + The page number to retrieve, zero-indexed (default 0). + size : int, optional + The number of features per page (default 100). + sort_by : str, optional + Field to sort by: "name", "created_on", or "modified_on". + sort_order : str, optional + Sort direction: "ascending" or "descending". + feature_type : str, optional + Only return features of this type: "road", "water", or + "layerset". + product : str, optional + Only return features from this data product (e.g. "osm"). + tag : str, optional + Only return features carrying this tag. + + Returns + ------- + List[Feature] + The requested page of Feature objects. + + Examples + -------- + >>> features = list_features("abc123", feature_type="road") + >>> all_features = list_features() # across all domains + """ + kwargs = dict( + client=ensure_client(), + page=page, + size=size, + sort_by=FeatureSortField(sort_by) if sort_by else UNSET, + sort_order=SortOrder(sort_order) if sort_order else UNSET, + type_=FeatureType(feature_type) if feature_type else UNSET, + product=product if product is not None else UNSET, + tag=tag if tag is not None else UNSET, + ) + if domain_id is None: + response = list_features_cross_domain.sync_detailed(**kwargs) + else: + response = list_features_endpoint.sync_detailed(domain_id, **kwargs) + list_response: ListFeaturesResponse = expect(response) + return [Feature._from_model(f) for f in list_response.features] From df11016be727fc4e3aec2ce088998711b7fd41d3 Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Fri, 5 Jun 2026 16:39:24 -0600 Subject: [PATCH 12/71] Add live v2 feature tests (#180) 34 tests against the live v2 API: OSM road/water creation, layerset upload (incl. single-Feature wrapping, GeoDataFrame path, and the geographic-CRS rejection), the wait/get/update/delete lifecycle, partitioned data access, and list filters incl. the cross-domain route. test_sorting is xfailed: every sort_by combination on the feature list endpoints returns 500 from the live API (missing composite indexes); the equivalent domain sorting works. create_default_layerset_geojson() builds the test layerset from blue_mtn_5070 (already projected) with the required fuelbed columns. --- tests/v2/test_features.py | 326 ++++++++++++++++++++++++++++++++++++++ tests/v2/utils.py | 23 +++ 2 files changed, 349 insertions(+) create mode 100644 tests/v2/test_features.py diff --git a/tests/v2/test_features.py b/tests/v2/test_features.py new file mode 100644 index 0000000..38e8eb3 --- /dev/null +++ b/tests/v2/test_features.py @@ -0,0 +1,326 @@ +""" +tests/v2/test_features.py +""" + +# Core imports +import json +from uuid import uuid4 + +# Internal imports +from tests import TEST_DATA_DIR +from tests.v2.utils import create_default_domain, create_default_layerset_geojson +from fastfuels_sdk.v2.features import Feature, list_features +from fastfuels_sdk.v2.client_library.models import FeatureType, JobStatus +from fastfuels_sdk.v2.exceptions import ( + NotFoundException, + UnprocessableEntityException, +) + +# External imports +import pytest +import geopandas as gpd + + +@pytest.fixture(scope="module") +def test_domain(): + """Fixture that creates a test domain to be used by the tests""" + domain = create_default_domain() + + # Return the domain for use in tests + yield domain + + # Cleanup: deleting the domain also deletes its features + domain.delete() + + +@pytest.fixture(scope="module") +def road_feature(test_domain): + """A completed OSM road feature shared by the read-only tests.""" + feature = Feature.create_osm_road( + test_domain.id, + name="test_road", + description="Road feature for testing v2 feature operations", + tags=["test"], + ) + feature.wait_until_completed(step=2) + return feature + + +@pytest.fixture(scope="module") +def layerset_feature(test_domain): + """A layerset feature (synchronous upload, immediately completed).""" + return Feature.create_layerset( + test_domain.id, + create_default_layerset_geojson(), + name="test_layerset", + description="Layerset feature for testing v2 feature operations", + tags=["layerset-test"], + ) + + +class TestCreateOsmRoadFeature: + def test_create(self, test_domain): + feature = Feature.create_osm_road(test_domain.id, name="throwaway_road") + + # Feature generation is an asynchronous job + assert len(feature.id) > 0 + assert feature.domain_id == test_domain.id + assert feature.type_ == FeatureType.ROAD + assert feature.status in (JobStatus.PENDING, JobStatus.RUNNING) + assert feature.source.additional_properties["product"] == "osm" + feature.delete() + + def test_completed_fixture(self, test_domain, road_feature): + assert road_feature.status == JobStatus.COMPLETED + assert road_feature.name == "test_road" + assert road_feature.tags == ["test"] + # The georeference is populated once the job completes + assert road_feature.georeference.crs.startswith("EPSG:") + assert len(road_feature.georeference.bounds) == 4 + + def test_invalid_extent_buffer(self, test_domain): + with pytest.raises(UnprocessableEntityException): + Feature.create_osm_road(test_domain.id, extent_buffer_m=500) + + +class TestCreateOsmWaterFeature: + def test_create(self, test_domain): + feature = Feature.create_osm_water(test_domain.id, name="throwaway_water") + + assert len(feature.id) > 0 + assert feature.domain_id == test_domain.id + assert feature.type_ == FeatureType.WATER + assert feature.status in (JobStatus.PENDING, JobStatus.RUNNING) + feature.delete() + + +class TestCreateLayerset: + def test_create(self, test_domain, layerset_feature): + # Layerset uploads are synchronous: the feature is already complete + assert layerset_feature.domain_id == test_domain.id + assert layerset_feature.type_ == FeatureType.LAYERSET + assert layerset_feature.status == JobStatus.COMPLETED + assert layerset_feature.name == "test_layerset" + + def test_single_feature_is_wrapped(self, test_domain): + layerset_geojson = create_default_layerset_geojson() + feature_geojson = layerset_geojson["features"][0] + feature_geojson["crs"] = layerset_geojson["crs"] + + feature = Feature.create_layerset(test_domain.id, feature_geojson) + assert feature.status == JobStatus.COMPLETED + feature.delete() + + def test_geographic_crs_rejected(self, test_domain): + # Layersets require a projected CRS; blue_mtn is EPSG:4326 + with open(TEST_DATA_DIR / "blue_mtn.geojson") as f: + geojson = json.load(f) + for feature in geojson["features"]: + feature["properties"] = create_default_layerset_geojson()["features"][0][ + "properties" + ] + + with pytest.raises(UnprocessableEntityException, match="geographic"): + Feature.create_layerset(test_domain.id, geojson) + + def test_invalid_geojson_type(self, test_domain): + with pytest.raises(ValueError, match="FeatureCollection"): + Feature.create_layerset( + test_domain.id, {"type": "Point", "coordinates": [0, 0]} + ) + + +class TestCreateLayersetFromGeodataframe: + def test_create(self, test_domain): + # The GeoDataFrame carries the projected CRS and the fuelbed + # property columns; both are forwarded to the API + gdf = gpd.read_file(json.dumps(create_default_layerset_geojson())) + assert gdf.crs.is_projected + + feature = Feature.create_layerset_from_geodataframe( + test_domain.id, gdf, name="gdf_layerset" + ) + assert feature.type_ == FeatureType.LAYERSET + assert feature.status == JobStatus.COMPLETED + assert feature.get_data_metadata().total_features == len(gdf) + feature.delete() + + +class TestFromId: + def test_success(self, test_domain, road_feature): + feature = Feature.from_id(test_domain.id, road_feature.id) + assert feature.id == road_feature.id + assert feature.domain_id == test_domain.id + assert feature.type_ == FeatureType.ROAD + + def test_not_found(self, test_domain): + with pytest.raises(NotFoundException): + Feature.from_id(test_domain.id, uuid4().hex) + + +class TestGetFeature: + def test_get_default(self, road_feature): + feature = road_feature.get() + assert feature.id == road_feature.id + assert feature is not road_feature + + def test_get_in_place(self, road_feature): + feature = road_feature.get(in_place=True) + assert feature is road_feature + + +class TestWaitUntilCompleted: + def test_timeout(self, test_domain): + feature = Feature.create_osm_water(test_domain.id) + with pytest.raises(TimeoutError): + feature.wait_until_completed(timeout=0) + feature.delete() + + +class TestGetDataMetadata: + def test_metadata(self, road_feature): + metadata = road_feature.get_data_metadata() + + assert metadata.total_features > 0 + assert metadata.partition_count >= 1 + assert len(metadata.partitions) == metadata.partition_count + assert ( + sum(partition.num_features for partition in metadata.partitions) + == metadata.total_features + ) + + def test_not_completed(self, test_domain): + # Data is only available once the job is complete + feature = Feature.create_osm_road(test_domain.id) + with pytest.raises(UnprocessableEntityException, match="status"): + feature.get_data_metadata() + feature.delete() + + +class TestGetDataPartition: + def test_partition(self, road_feature): + metadata = road_feature.get_data_metadata() + partition = road_feature.get_data_partition(0) + + assert partition["type"] == "FeatureCollection" + assert len(partition["features"]) == metadata.partitions[0].num_features + assert "crs" in partition + + def test_partition_out_of_range(self, road_feature): + metadata = road_feature.get_data_metadata() + with pytest.raises(UnprocessableEntityException, match="out of range"): + road_feature.get_data_partition(metadata.partition_count) + + +class TestGetData: + def test_get_data(self, road_feature): + metadata = road_feature.get_data_metadata() + data = road_feature.get_data() + + assert data["type"] == "FeatureCollection" + assert len(data["features"]) == metadata.total_features + assert "crs" in data + + +class TestToGeodataframe: + def test_to_geodataframe(self, road_feature): + metadata = road_feature.get_data_metadata() + gdf = road_feature.to_geodataframe() + + assert len(gdf) == metadata.total_features + # The data comes back in the projected CRS reported by the + # feature's georeference + assert ":".join(gdf.crs.to_authority()) == road_feature.georeference.crs + + +class TestListFeatures: + def test_list_in_domain(self, test_domain, road_feature, layerset_feature): + features = list_features(test_domain.id) + feature_ids = [feature.id for feature in features] + assert road_feature.id in feature_ids + assert layerset_feature.id in feature_ids + + def test_list_cross_domain(self, road_feature): + # No domain_id: list features across all the user's domains + features = list_features() + assert road_feature.id in [feature.id for feature in features] + + def test_filter_by_type(self, test_domain, road_feature, layerset_feature): + features = list_features(test_domain.id, feature_type="road") + feature_ids = [feature.id for feature in features] + assert all(feature.type_ == FeatureType.ROAD for feature in features) + assert road_feature.id in feature_ids + assert layerset_feature.id not in feature_ids + + def test_filter_by_product(self, test_domain, road_feature, layerset_feature): + features = list_features(test_domain.id, product="osm") + feature_ids = [feature.id for feature in features] + assert road_feature.id in feature_ids + assert layerset_feature.id not in feature_ids + + def test_filter_by_tag(self, test_domain, layerset_feature): + features = list_features(test_domain.id, tag="layerset-test") + assert [feature.id for feature in features] == [layerset_feature.id] + + @pytest.mark.xfail( + reason="v2 API returns 500 for feature sort_by combinations " + "(missing composite indexes)", + ) + def test_sorting(self, test_domain, road_feature): + features = list_features( + test_domain.id, sort_by="created_on", sort_order="descending" + ) + assert len(features) > 0 + + def test_invalid_sort_field(self): + with pytest.raises(ValueError): + list_features(sort_by="not_a_field") + + def test_invalid_feature_type(self): + with pytest.raises(ValueError): + list_features(feature_type="not_a_type") + + +class TestUpdateFeature: + def test_update_name(self, test_domain, road_feature): + updated = road_feature.update(name="updated_name") + assert updated.name == "updated_name" + assert updated is not road_feature + # The remote resource reflects the update + assert Feature.from_id(test_domain.id, road_feature.id).name == "updated_name" + + def test_update_in_place(self, road_feature): + updated = road_feature.update(description="updated description", in_place=True) + assert updated is road_feature + assert road_feature.description == "updated description" + + def test_update_tags(self, road_feature): + updated = road_feature.update(tags=["updated"], in_place=True) + assert updated.tags == ["updated"] + + def test_update_no_fields_makes_no_api_call(self, road_feature): + # No fields provided: returns a copy without touching the API + copy = road_feature.update() + assert copy is not road_feature + assert copy.id == road_feature.id + assert road_feature.update(in_place=True) is road_feature + + +class TestToJson: + def test_to_json(self, road_feature): + feature_dict = json.loads(road_feature.to_json()) + assert feature_dict["id"] == road_feature.id + assert feature_dict["domain_id"] == road_feature.domain_id + assert feature_dict["type"] == "road" + + +class TestDeleteFeature: + def test_delete_feature(self, test_domain): + feature = Feature.create_osm_water(test_domain.id) + feature.delete() + + with pytest.raises(NotFoundException): + Feature.from_id(test_domain.id, feature.id) + + with pytest.raises(NotFoundException): + feature.delete() diff --git a/tests/v2/utils.py b/tests/v2/utils.py index c3bf2f3..90e4a0b 100644 --- a/tests/v2/utils.py +++ b/tests/v2/utils.py @@ -6,6 +6,15 @@ from tests import TEST_DATA_DIR from fastfuels_sdk.v2.domains import Domain +# Required fuelbed input columns for layerset features +DEFAULT_LAYERSET_PROPERTIES = { + "fuel_type": "grass", + "fuel_loading": 0.5, + "fuel_height": 0.3, + "percent_cover": 80.0, + "distribution": "homogeneous", +} + def create_default_domain() -> Domain: """Creates a default v2 Domain resource for testing.""" @@ -22,3 +31,17 @@ def create_default_domain() -> Domain: ) return domain + + +def create_default_layerset_geojson() -> dict: + """Build a valid layerset FeatureCollection for testing. + + Layersets require a projected CRS, so this uses the EPSG:5070 variant + of the blue_mtn geometry with the required fuelbed properties on each + feature. + """ + with open(TEST_DATA_DIR / "blue_mtn_5070.geojson") as f: + geojson = json.load(f) + for feature in geojson["features"]: + feature["properties"] = dict(DEFAULT_LAYERSET_PROPERTIES) + return geojson From 94ec0a9699a58bb47c66ea2c84a257f1e8d51f0c Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Fri, 5 Jun 2026 16:39:39 -0600 Subject: [PATCH 13/71] Document the v2 Features resource (#180) Features how-to guide (create from OSM, wait, layerset upload with the projected-CRS warning, partitioned data access, list filters incl. cross-domain, delete, error handling) with outputs verified against the live API; nav and reference entries; migration guide's Features section replaces the in-development placeholder with what-changed bullets and before/after tabs. --- docs/v2/SUMMARY.md | 1 + docs/v2/guides/features.md | 238 ++++++++++++++++++++++++++++++++++++ docs/v2/guides/migration.md | 64 ++++++++-- docs/v2/reference.md | 2 + 4 files changed, 298 insertions(+), 7 deletions(-) create mode 100644 docs/v2/guides/features.md diff --git a/docs/v2/SUMMARY.md b/docs/v2/SUMMARY.md index 32cff6d..742c24b 100644 --- a/docs/v2/SUMMARY.md +++ b/docs/v2/SUMMARY.md @@ -2,4 +2,5 @@ * [Migrating from v1](guides/migration.md) * How-To Guides * [Domains](guides/domains.md) + * [Features](guides/features.md) * [Reference](reference.md) diff --git a/docs/v2/guides/features.md b/docs/v2/guides/features.md new file mode 100644 index 0000000..a167a62 --- /dev/null +++ b/docs/v2/guides/features.md @@ -0,0 +1,238 @@ +# How to Work with Features in FastFuels SDK + +!!! warning "Beta" + The v2 SDK targets the FastFuels v2 API and is under active + development. The v1 SDK remains the default — import v2 explicitly + from `fastfuels_sdk.v2`. + +Features are geographic data within a domain: roads and water bodies +extracted from OpenStreetMap, or custom layersets of fuelbed polygons you +upload yourself. This guide covers working with features from Python; for +what features *are* and how the platform treats them, see the +[FastFuels documentation](https://docs.fastfuels.silvxlabs.com). Coming +from the v1 SDK? Start with the [migration guide](migration.md#features). + +## Prerequisites + +- The FastFuels SDK installed: `pip install fastfuels-sdk` +- A FastFuels API key in the `FASTFUELS_API_KEY` environment variable +- An existing [domain](domains.md) to create features in + +## Create a Road Feature from OpenStreetMap + +To extract the road network within a domain's extent: + +```python +from fastfuels_sdk.v2 import Feature + +feature = Feature.create_osm_road( + domain.id, + name="My Roads", + description="OSM road network", +) + +print(feature.status) # JobStatus.PENDING +``` + +Feature generation runs as a background job — the returned feature starts +in `"pending"` status. To include roads just outside the domain, buffer +the query extent by up to 100 meters with `extent_buffer_m`: + +```python +feature = Feature.create_osm_road(domain.id, extent_buffer_m=50) +``` + +## Create a Water Feature from OpenStreetMap + +To extract water bodies, use the same calling convention: + +```python +feature = Feature.create_osm_water(domain.id, name="My Water") +``` + +## Wait for a Feature to Complete + +To block until a feature job finishes: + +```python +feature.wait_until_completed(verbose=True) +``` + +```text +Feature 36573c0205d147a08011693b7894c0fb: pending (5s) +Feature 36573c0205d147a08011693b7894c0fb: running (15s) +Feature 36573c0205d147a08011693b7894c0fb: completed (20s) +``` + +`wait_until_completed` raises `TimeoutError` if the job exceeds `timeout` +seconds (default 600) and `RuntimeError` if the job fails. Once +completed, the feature's `georeference` reports the CRS and bounds of the +generated data: + +```python +print(feature.georeference.crs) # 'EPSG:32611' +print(feature.georeference.bounds) # [720192.0, 5189446.0, 721918.0, 5190852.0] +``` + +## Upload a Custom Layerset + +A layerset is a FeatureCollection of fuelbed polygons you supply +yourself. Unlike OSM features, the upload is synchronous — the returned +feature is already `"completed"`. + +!!! warning "Projected CRS required" + The FeatureCollection's `crs` member must declare a **projected** CRS + (e.g. EPSG:5070 or a UTM zone). Geographic coordinates (EPSG:4326) + are rejected, because rasterization requires cell sizes in meters. + +Each polygon's `properties` must carry the fuelbed input columns +`fuel_type`, `fuel_loading`, `fuel_height`, `percent_cover`, and +`distribution` (`"homogeneous"`, `"random_clusters"`, or +`"uniform_random"`): + +```python +import json + +with open("fuelbeds.geojson") as f: # projected CRS + fuelbed properties + geojson = json.load(f) + +feature = Feature.create_layerset(domain.id, geojson, name="My Fuelbeds") + +print(feature.status) # JobStatus.COMPLETED +``` + +To upload from a GeoPandas GeoDataFrame instead, reproject first if +needed — the GeoDataFrame's CRS is forwarded as-is: + +```python +import geopandas as gpd + +gdf = gpd.read_file("fuelbeds.shp").to_crs(epsg=5070) +feature = Feature.create_layerset_from_geodataframe(domain.id, gdf) +``` + +## Retrieve an Existing Feature + +To fetch a feature using its domain and feature IDs: + +```python +feature = Feature.from_id(domain.id, "36573c0205d147a08011693b7894c0fb") +``` + +## Get Fresh Feature Data + +To fetch the latest state of a feature (for example, to check job +progress yourself): + +```python +# Get new instance with fresh data +fresh_feature = feature.get() + +# Or refresh the existing instance +feature.get(in_place=True) +``` + +## Access Feature Data + +The generated geodata is served in partitions. For most workflows, +retrieve everything at once as a GeoDataFrame: + +```python +gdf = feature.to_geodataframe() + +print(len(gdf)) # 43 +print(gdf.crs) # EPSG:32611 +``` + +Or as a single GeoJSON FeatureCollection: + +```python +data = feature.get_data() +print(len(data["features"])) # 43 +``` + +To control retrieval partition by partition (useful for large features), +read the partition layout first: + +```python +metadata = feature.get_data_metadata() +print(metadata.total_features) # 43 +print(metadata.partition_count) # 1 + +for index in range(metadata.partition_count): + partition = feature.get_data_partition(index) # GeoJSON FeatureCollection +``` + +Data is only available once the feature is `"completed"` — earlier calls +raise `UnprocessableEntityException`. + +## Update Feature Properties + +To modify a feature's name, description, or tags: + +```python +# Create new instance with updates +updated_feature = feature.update(name="New Name", tags=["roads", "osm"]) + +# Or update in-place +feature.update(name="New Name", in_place=True) +``` + +## List Features + +To list features in a domain: + +```python +from fastfuels_sdk.v2 import list_features + +features = list_features(domain.id) +``` + +To list features across all your domains, omit the domain ID: + +```python +all_features = list_features() +``` + +Narrow the results with filters: + +```python +roads = list_features(domain.id, feature_type="road") +osm_features = list_features(domain.id, product="osm") +tagged = list_features(domain.id, tag="roads") +``` + +## Delete a Feature + +To permanently delete a feature and its generated data: + +```python +feature.delete() +``` + +Deleting a domain also deletes all of its features. + +## Error Handling + +Wrapper methods raise typed exceptions from +`fastfuels_sdk.v2.exceptions`: + +```python +from fastfuels_sdk.v2 import Feature +from fastfuels_sdk.v2.exceptions import ( + NotFoundException, + UnprocessableEntityException, +) + +try: + feature = Feature.from_id(domain.id, "does-not-exist") +except NotFoundException: + print("No such feature (or you don't have access to it)") + +try: + metadata = pending_feature.get_data_metadata() +except UnprocessableEntityException as exc: + print(exc.detail) + # features/36573c0205d147a08011693b7894c0fb status is 'pending', + # expected 'completed'. +``` diff --git a/docs/v2/guides/migration.md b/docs/v2/guides/migration.md index cd7d7db..231982a 100644 --- a/docs/v2/guides/migration.md +++ b/docs/v2/guides/migration.md @@ -22,7 +22,8 @@ Both subpackages read the same `FASTFUELS_API_KEY` environment variable. | `domain.export()` | grid exports | No domain-level export in v2; data exports hang off grids | | — | `Domain.preview`, `Domain.get_lattice`, `reproject_geojson` | New domain capabilities | | `Grids`, `SurfaceGrid`, `TreeGrid`, `TopographyGrid`, `FeatureGrid` + builders | unified `Grid` resource *(SDK module in development)* | One job-based resource per grid, distinguished by data source | -| `Features`, `RoadFeature`, `WaterFeature` | unified `Feature` resource *(SDK module in development)* | One job-based resource for OSM road and water features | +| `Features`, `RoadFeature`, `WaterFeature` | unified `Feature` resource | One job-based resource for OSM roads, OSM water, and custom layersets | +| `feature.get_data()`, `feature.get_all_data()` | `feature.get_data_metadata()`, `feature.get_data_partition()`, `feature.get_data()`, `feature.to_geodataframe()` | Page-based data retrieval becomes partition-based | | `Inventories`, `TreeInventory` | `Inventory` resource *(SDK module in development)* | Job-based; tree inventories are generated from PIM grids | ## Domains @@ -94,13 +95,62 @@ shares the same `status`/`progress` lifecycle. ## Features -!!! note "In development" - The v2 features module is under development and will be documented - here when it ships. +Available now — see the [Features guide](features.md) and the +[Reference](../reference.md). + +### What changed from v1 + +- **Road and water unify into one resource.** v1's `Features` container + with `.road`/`.water` sub-resources becomes a single job-based + `Feature` distinguished by its type ("road", "water", or "layerset"). + The `Features.from_domain_id(...)` container is replaced by the + module-level `list_features(domain_id)`. +- **Creation methods are classmethods on `Feature`.** + `features.create_road_feature_from_osm()` becomes + `Feature.create_osm_road(domain_id)`, and likewise for water. The new + `extent_buffer_m` argument buffers the OSM query extent by up to + 100 meters. +- **User-supplied geometry becomes layersets.** v1's road-from-GeoJSON + path is gone. Instead, v2 accepts custom *layersets* — fuelbed + polygons carrying rasterizer properties — via + `Feature.create_layerset` and + `Feature.create_layerset_from_geodataframe`. Layersets require a + projected CRS and upload synchronously. +- **Data access is partitioned.** v1's `get_data(page, size)` / + `get_all_data()` become `get_data_metadata()` (partition layout), + `get_data_partition(index)` (one partition), and `get_data()` (all + partitions assembled) — plus `to_geodataframe()` for the common case + of loading everything into GeoPandas. +- **Cross-domain listing.** `list_features()` without a domain ID lists + features across all your domains, with `feature_type`, `product`, and + `tag` filters. +- **Typed exceptions.** Errors raise `fastfuels_sdk.v2.exceptions` + classes, the same as domains. + +### Before and after + +=== "v1" -What to expect from the v2 API: road and water features unify into a -single job-based `Feature` resource sourced from OSM, with the same -`status`/`progress` lifecycle as grids. + ```python + from fastfuels_sdk import Features + + features = Features.from_domain_id(domain.id) + road = features.create_road_feature_from_osm() + road.wait_until_completed(verbose=True) + + data = road.get_all_data() + ``` + +=== "v2" + + ```python + from fastfuels_sdk.v2 import Feature + + road = Feature.create_osm_road(domain.id) + road.wait_until_completed(verbose=True) + + roads = road.to_geodataframe() + ``` ## Inventories diff --git a/docs/v2/reference.md b/docs/v2/reference.md index 49d210b..e08c591 100644 --- a/docs/v2/reference.md +++ b/docs/v2/reference.md @@ -1,5 +1,7 @@ ::: fastfuels_sdk.v2.domains +::: fastfuels_sdk.v2.features + ::: fastfuels_sdk.v2.api ::: fastfuels_sdk.v2.exceptions From ac24e0a2f0f33f0d2751b3cb3a01af5db39ca5a6 Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Fri, 5 Jun 2026 16:48:01 -0600 Subject: [PATCH 14/71] Point the sorting xfail at FastFuels-API-v2#321 --- tests/v2/test_features.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/v2/test_features.py b/tests/v2/test_features.py index 38e8eb3..e5bb639 100644 --- a/tests/v2/test_features.py +++ b/tests/v2/test_features.py @@ -263,8 +263,8 @@ def test_filter_by_tag(self, test_domain, layerset_feature): assert [feature.id for feature in features] == [layerset_feature.id] @pytest.mark.xfail( - reason="v2 API returns 500 for feature sort_by combinations " - "(missing composite indexes)", + reason="v2 API returns 500 for feature sort_by combinations; " + "see FastFuels-API-v2#321", ) def test_sorting(self, test_domain, road_feature): features = list_features( From 1aa91dac191023ce1594f3f791fdaaaff502fe98 Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Fri, 5 Jun 2026 16:54:31 -0600 Subject: [PATCH 15/71] Share session-scoped test resources via tests/v2/conftest.py The v2 resources form a dependency chain (domain -> features; grids and inventories will hang off the same domain and can reference features as spatial masks), so expensive job resources should be built once per session, not once per module. conftest.py now owns test_domain, road_feature (completed), and layerset_feature at session scope; the grids/inventories modules will request them and add their own (e.g. a completed PIM grid). Convention: shared fixtures are READ-ONLY. The update test classes, which previously mutated the shared resources (safe only by in-file ordering), now mutate class-scoped throwaways. Test domains carry an sdk-test fingerprint tag, and a session-start sweeper deletes tagged domains older than two hours -- leftovers from crashed or interrupted runs, where teardown never fires. The age gate keeps concurrent runs (local + CI) from sweeping each other. --- tests/v2/conftest.py | 83 +++++++++++++++++++++++++++++++++++++++ tests/v2/test_domains.py | 51 ++++++++++++------------ tests/v2/test_features.py | 82 ++++++++++++++------------------------ tests/v2/utils.py | 5 +++ 4 files changed, 143 insertions(+), 78 deletions(-) create mode 100644 tests/v2/conftest.py diff --git a/tests/v2/conftest.py b/tests/v2/conftest.py new file mode 100644 index 0000000..30e0fa4 --- /dev/null +++ b/tests/v2/conftest.py @@ -0,0 +1,83 @@ +""" +tests/v2/conftest.py + +Session-scoped resources shared across the v2 test modules. + +These fixtures are READ-ONLY by convention: they are shared by every +module in the session, so any test that mutates or deletes a resource +must create its own throwaway instead. Expensive job resources (the +completed OSM road feature; later, grids and inventories) are built once +per session and torn down with the domain — deleting the domain cascades +to everything created inside it. +""" + +from datetime import datetime, timedelta, timezone + +import pytest + +from fastfuels_sdk.v2.domains import list_domains +from fastfuels_sdk.v2.features import Feature +from tests.v2.utils import ( + SWEEP_TAG, + create_default_domain, + create_default_layerset_geojson, +) + +# Leaked resources older than this are fair game for the sweeper. The +# age gate keeps the sweeper from deleting the live resources of another +# run happening at the same time (e.g. local + CI). +SWEEP_AGE = timedelta(hours=2) + + +@pytest.fixture(scope="session") +def _swept_leftover_domains(): + """Delete test domains leaked by crashed or interrupted runs. + + Teardown never runs when a session is killed mid-flight, so tagged + test domains can accumulate on the live account. Sweep anything + carrying the test fingerprint tag that is older than SWEEP_AGE. + """ + cutoff = datetime.now(timezone.utc) - SWEEP_AGE + for domain in list_domains(size=100): + tags = domain.tags if isinstance(domain.tags, list) else [] + created_on = domain.created_on + if ( + SWEEP_TAG in tags + and isinstance(created_on, datetime) + and created_on < cutoff + ): + domain.delete() + + +@pytest.fixture(scope="session") +def test_domain(_swept_leftover_domains): + """The session-wide test domain. READ-ONLY: shared by every module.""" + domain = create_default_domain() + yield domain + # Cleanup: deleting the domain also deletes its features + domain.delete() + + +@pytest.fixture(scope="session") +def road_feature(test_domain): + """A completed OSM road feature. READ-ONLY: shared by every module.""" + feature = Feature.create_osm_road( + test_domain.id, + name="test_road", + description="Road feature for testing v2 feature operations", + tags=["test"], + ) + feature.wait_until_completed(step=2) + return feature + + +@pytest.fixture(scope="session") +def layerset_feature(test_domain): + """A layerset feature (synchronous upload). READ-ONLY: shared by every module.""" + return Feature.create_layerset( + test_domain.id, + create_default_layerset_geojson(), + name="test_layerset", + description="Layerset feature for testing v2 feature operations", + tags=["layerset-test"], + ) diff --git a/tests/v2/test_domains.py b/tests/v2/test_domains.py index 0d10043..9531e05 100644 --- a/tests/v2/test_domains.py +++ b/tests/v2/test_domains.py @@ -19,17 +19,9 @@ import pytest import geopandas as gpd - -@pytest.fixture(scope="module") -def test_domain(): - """Fixture that creates a test domain to be used by the tests""" - domain = create_default_domain() - - # Return the domain for use in tests - yield domain - - # Cleanup: Delete the domain after the tests - domain.delete() +# The test_domain fixture is session-scoped and shared across modules +# (tests/v2/conftest.py). It is READ-ONLY: tests that mutate or delete +# create throwaways. def feature_names(domain: Domain) -> set: @@ -176,28 +168,35 @@ def test_get_in_place(self, test_domain): class TestUpdateDomain: - def test_update_name(self, test_domain): - updated = test_domain.update(name="updated_name") + @pytest.fixture(scope="class") + def update_domain(self): + """A throwaway domain to mutate (the shared fixture is read-only).""" + domain = create_default_domain() + yield domain + domain.delete() + + def test_update_name(self, update_domain): + updated = update_domain.update(name="updated_name") assert updated.name == "updated_name" - assert updated is not test_domain + assert updated is not update_domain # The remote resource reflects the update - assert Domain.from_id(test_domain.id).name == "updated_name" + assert Domain.from_id(update_domain.id).name == "updated_name" - def test_update_in_place(self, test_domain): - updated = test_domain.update(description="updated description", in_place=True) - assert updated is test_domain - assert test_domain.description == "updated description" + def test_update_in_place(self, update_domain): + updated = update_domain.update(description="updated description", in_place=True) + assert updated is update_domain + assert update_domain.description == "updated description" - def test_update_tags(self, test_domain): - updated = test_domain.update(tags=["updated"], in_place=True) + def test_update_tags(self, update_domain): + updated = update_domain.update(tags=["updated"], in_place=True) assert updated.tags == ["updated"] - def test_update_no_fields_makes_no_api_call(self, test_domain): + def test_update_no_fields_makes_no_api_call(self, update_domain): # No fields provided: returns a copy without touching the API - copy = test_domain.update() - assert copy is not test_domain - assert copy.id == test_domain.id - assert test_domain.update(in_place=True) is test_domain + copy = update_domain.update() + assert copy is not update_domain + assert copy.id == update_domain.id + assert update_domain.update(in_place=True) is update_domain class TestGetLattice: diff --git a/tests/v2/test_features.py b/tests/v2/test_features.py index e5bb639..fc12a0b 100644 --- a/tests/v2/test_features.py +++ b/tests/v2/test_features.py @@ -8,7 +8,7 @@ # Internal imports from tests import TEST_DATA_DIR -from tests.v2.utils import create_default_domain, create_default_layerset_geojson +from tests.v2.utils import create_default_layerset_geojson from fastfuels_sdk.v2.features import Feature, list_features from fastfuels_sdk.v2.client_library.models import FeatureType, JobStatus from fastfuels_sdk.v2.exceptions import ( @@ -20,42 +20,9 @@ import pytest import geopandas as gpd - -@pytest.fixture(scope="module") -def test_domain(): - """Fixture that creates a test domain to be used by the tests""" - domain = create_default_domain() - - # Return the domain for use in tests - yield domain - - # Cleanup: deleting the domain also deletes its features - domain.delete() - - -@pytest.fixture(scope="module") -def road_feature(test_domain): - """A completed OSM road feature shared by the read-only tests.""" - feature = Feature.create_osm_road( - test_domain.id, - name="test_road", - description="Road feature for testing v2 feature operations", - tags=["test"], - ) - feature.wait_until_completed(step=2) - return feature - - -@pytest.fixture(scope="module") -def layerset_feature(test_domain): - """A layerset feature (synchronous upload, immediately completed).""" - return Feature.create_layerset( - test_domain.id, - create_default_layerset_geojson(), - name="test_layerset", - description="Layerset feature for testing v2 feature operations", - tags=["layerset-test"], - ) +# The test_domain, road_feature, and layerset_feature fixtures are +# session-scoped and shared across modules (tests/v2/conftest.py). +# They are READ-ONLY: tests that mutate or delete create throwaways. class TestCreateOsmRoadFeature: @@ -282,28 +249,39 @@ def test_invalid_feature_type(self): class TestUpdateFeature: - def test_update_name(self, test_domain, road_feature): - updated = road_feature.update(name="updated_name") + @pytest.fixture(scope="class") + def update_feature(self, test_domain): + """A throwaway feature to mutate (the shared fixtures are read-only).""" + feature = Feature.create_layerset( + test_domain.id, create_default_layerset_geojson(), name="update_target" + ) + yield feature + feature.delete() + + def test_update_name(self, test_domain, update_feature): + updated = update_feature.update(name="updated_name") assert updated.name == "updated_name" - assert updated is not road_feature + assert updated is not update_feature # The remote resource reflects the update - assert Feature.from_id(test_domain.id, road_feature.id).name == "updated_name" + assert Feature.from_id(test_domain.id, update_feature.id).name == "updated_name" - def test_update_in_place(self, road_feature): - updated = road_feature.update(description="updated description", in_place=True) - assert updated is road_feature - assert road_feature.description == "updated description" + def test_update_in_place(self, update_feature): + updated = update_feature.update( + description="updated description", in_place=True + ) + assert updated is update_feature + assert update_feature.description == "updated description" - def test_update_tags(self, road_feature): - updated = road_feature.update(tags=["updated"], in_place=True) + def test_update_tags(self, update_feature): + updated = update_feature.update(tags=["updated"], in_place=True) assert updated.tags == ["updated"] - def test_update_no_fields_makes_no_api_call(self, road_feature): + def test_update_no_fields_makes_no_api_call(self, update_feature): # No fields provided: returns a copy without touching the API - copy = road_feature.update() - assert copy is not road_feature - assert copy.id == road_feature.id - assert road_feature.update(in_place=True) is road_feature + copy = update_feature.update() + assert copy is not update_feature + assert copy.id == update_feature.id + assert update_feature.update(in_place=True) is update_feature class TestToJson: diff --git a/tests/v2/utils.py b/tests/v2/utils.py index 90e4a0b..f92a413 100644 --- a/tests/v2/utils.py +++ b/tests/v2/utils.py @@ -6,6 +6,10 @@ from tests import TEST_DATA_DIR from fastfuels_sdk.v2.domains import Domain +# Fingerprint tag for test-created domains, so the session-start sweeper +# (tests/v2/conftest.py) can find resources leaked by crashed runs +SWEEP_TAG = "sdk-test" + # Required fuelbed input columns for layerset features DEFAULT_LAYERSET_PROPERTIES = { "fuel_type": "grass", @@ -27,6 +31,7 @@ def create_default_domain() -> Domain: geojson, name="test_domain", description="Domain for testing v2 domain operations", + tags=[SWEEP_TAG], pad_to_resolution=2.0, ) From 7c82c8f615d35b2d5425bcf953551f4d1bdb458c Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Fri, 5 Jun 2026 17:00:56 -0600 Subject: [PATCH 16/71] Clean up the test-resource lifecycle after review - The leak sweep is a plain function (tests/v2/utils.py sweep_leftover_domains), called in plain sight at the top of the test_domain fixture -- no more side-effect-as-fixture dependency injection, no isinstance paranoia around fields the live API always returns. - road_feature becomes completed_road_feature: fixtures that wait on a job carry a completed_ prefix (layersets are born complete, so layerset_feature stays unprefixed). - Tests that only need a feature to exist (from_id, get, to_json, cross-domain list) now use the cheap layerset_feature; completed_road_feature remains only where completion or road-ness is the point: the job-lifecycle test, the data-access tests, and the list-filter tests that need a road in the listing. --- tests/v2/conftest.py | 40 +++------------ tests/v2/test_features.py | 102 ++++++++++++++++++++------------------ tests/v2/utils.py | 23 +++++++-- 3 files changed, 83 insertions(+), 82 deletions(-) diff --git a/tests/v2/conftest.py b/tests/v2/conftest.py index 30e0fa4..dfdb57a 100644 --- a/tests/v2/conftest.py +++ b/tests/v2/conftest.py @@ -9,49 +9,25 @@ completed OSM road feature; later, grids and inventories) are built once per session and torn down with the domain — deleting the domain cascades to everything created inside it. -""" -from datetime import datetime, timedelta, timezone +Fixtures that wait on a job carry a ``completed_`` prefix; resources +that are born complete (layerset uploads) don't need one. +""" import pytest -from fastfuels_sdk.v2.domains import list_domains from fastfuels_sdk.v2.features import Feature from tests.v2.utils import ( - SWEEP_TAG, create_default_domain, create_default_layerset_geojson, + sweep_leftover_domains, ) -# Leaked resources older than this are fair game for the sweeper. The -# age gate keeps the sweeper from deleting the live resources of another -# run happening at the same time (e.g. local + CI). -SWEEP_AGE = timedelta(hours=2) - - -@pytest.fixture(scope="session") -def _swept_leftover_domains(): - """Delete test domains leaked by crashed or interrupted runs. - - Teardown never runs when a session is killed mid-flight, so tagged - test domains can accumulate on the live account. Sweep anything - carrying the test fingerprint tag that is older than SWEEP_AGE. - """ - cutoff = datetime.now(timezone.utc) - SWEEP_AGE - for domain in list_domains(size=100): - tags = domain.tags if isinstance(domain.tags, list) else [] - created_on = domain.created_on - if ( - SWEEP_TAG in tags - and isinstance(created_on, datetime) - and created_on < cutoff - ): - domain.delete() - @pytest.fixture(scope="session") -def test_domain(_swept_leftover_domains): +def test_domain(): """The session-wide test domain. READ-ONLY: shared by every module.""" + sweep_leftover_domains() domain = create_default_domain() yield domain # Cleanup: deleting the domain also deletes its features @@ -59,7 +35,7 @@ def test_domain(_swept_leftover_domains): @pytest.fixture(scope="session") -def road_feature(test_domain): +def completed_road_feature(test_domain): """A completed OSM road feature. READ-ONLY: shared by every module.""" feature = Feature.create_osm_road( test_domain.id, @@ -73,7 +49,7 @@ def road_feature(test_domain): @pytest.fixture(scope="session") def layerset_feature(test_domain): - """A layerset feature (synchronous upload). READ-ONLY: shared by every module.""" + """A layerset feature (born completed). READ-ONLY: shared by every module.""" return Feature.create_layerset( test_domain.id, create_default_layerset_geojson(), diff --git a/tests/v2/test_features.py b/tests/v2/test_features.py index fc12a0b..c88fceb 100644 --- a/tests/v2/test_features.py +++ b/tests/v2/test_features.py @@ -20,7 +20,7 @@ import pytest import geopandas as gpd -# The test_domain, road_feature, and layerset_feature fixtures are +# The test_domain, completed_road_feature, and layerset_feature fixtures are # session-scoped and shared across modules (tests/v2/conftest.py). # They are READ-ONLY: tests that mutate or delete create throwaways. @@ -37,13 +37,13 @@ def test_create(self, test_domain): assert feature.source.additional_properties["product"] == "osm" feature.delete() - def test_completed_fixture(self, test_domain, road_feature): - assert road_feature.status == JobStatus.COMPLETED - assert road_feature.name == "test_road" - assert road_feature.tags == ["test"] + def test_completed_fixture(self, test_domain, completed_road_feature): + assert completed_road_feature.status == JobStatus.COMPLETED + assert completed_road_feature.name == "test_road" + assert completed_road_feature.tags == ["test"] # The georeference is populated once the job completes - assert road_feature.georeference.crs.startswith("EPSG:") - assert len(road_feature.georeference.bounds) == 4 + assert completed_road_feature.georeference.crs.startswith("EPSG:") + assert len(completed_road_feature.georeference.bounds) == 4 def test_invalid_extent_buffer(self, test_domain): with pytest.raises(UnprocessableEntityException): @@ -114,11 +114,11 @@ def test_create(self, test_domain): class TestFromId: - def test_success(self, test_domain, road_feature): - feature = Feature.from_id(test_domain.id, road_feature.id) - assert feature.id == road_feature.id + def test_success(self, test_domain, layerset_feature): + feature = Feature.from_id(test_domain.id, layerset_feature.id) + assert feature.id == layerset_feature.id assert feature.domain_id == test_domain.id - assert feature.type_ == FeatureType.ROAD + assert feature.type_ == FeatureType.LAYERSET def test_not_found(self, test_domain): with pytest.raises(NotFoundException): @@ -126,14 +126,14 @@ def test_not_found(self, test_domain): class TestGetFeature: - def test_get_default(self, road_feature): - feature = road_feature.get() - assert feature.id == road_feature.id - assert feature is not road_feature + def test_get_default(self, layerset_feature): + feature = layerset_feature.get() + assert feature.id == layerset_feature.id + assert feature is not layerset_feature - def test_get_in_place(self, road_feature): - feature = road_feature.get(in_place=True) - assert feature is road_feature + def test_get_in_place(self, layerset_feature): + feature = layerset_feature.get(in_place=True) + assert feature is layerset_feature class TestWaitUntilCompleted: @@ -145,8 +145,8 @@ def test_timeout(self, test_domain): class TestGetDataMetadata: - def test_metadata(self, road_feature): - metadata = road_feature.get_data_metadata() + def test_metadata(self, completed_road_feature): + metadata = completed_road_feature.get_data_metadata() assert metadata.total_features > 0 assert metadata.partition_count >= 1 @@ -165,24 +165,24 @@ def test_not_completed(self, test_domain): class TestGetDataPartition: - def test_partition(self, road_feature): - metadata = road_feature.get_data_metadata() - partition = road_feature.get_data_partition(0) + def test_partition(self, completed_road_feature): + metadata = completed_road_feature.get_data_metadata() + partition = completed_road_feature.get_data_partition(0) assert partition["type"] == "FeatureCollection" assert len(partition["features"]) == metadata.partitions[0].num_features assert "crs" in partition - def test_partition_out_of_range(self, road_feature): - metadata = road_feature.get_data_metadata() + def test_partition_out_of_range(self, completed_road_feature): + metadata = completed_road_feature.get_data_metadata() with pytest.raises(UnprocessableEntityException, match="out of range"): - road_feature.get_data_partition(metadata.partition_count) + completed_road_feature.get_data_partition(metadata.partition_count) class TestGetData: - def test_get_data(self, road_feature): - metadata = road_feature.get_data_metadata() - data = road_feature.get_data() + def test_get_data(self, completed_road_feature): + metadata = completed_road_feature.get_data_metadata() + data = completed_road_feature.get_data() assert data["type"] == "FeatureCollection" assert len(data["features"]) == metadata.total_features @@ -190,39 +190,47 @@ def test_get_data(self, road_feature): class TestToGeodataframe: - def test_to_geodataframe(self, road_feature): - metadata = road_feature.get_data_metadata() - gdf = road_feature.to_geodataframe() + def test_to_geodataframe(self, completed_road_feature): + metadata = completed_road_feature.get_data_metadata() + gdf = completed_road_feature.to_geodataframe() assert len(gdf) == metadata.total_features # The data comes back in the projected CRS reported by the # feature's georeference - assert ":".join(gdf.crs.to_authority()) == road_feature.georeference.crs + assert ( + ":".join(gdf.crs.to_authority()) == completed_road_feature.georeference.crs + ) class TestListFeatures: - def test_list_in_domain(self, test_domain, road_feature, layerset_feature): + def test_list_in_domain( + self, test_domain, completed_road_feature, layerset_feature + ): features = list_features(test_domain.id) feature_ids = [feature.id for feature in features] - assert road_feature.id in feature_ids + assert completed_road_feature.id in feature_ids assert layerset_feature.id in feature_ids - def test_list_cross_domain(self, road_feature): + def test_list_cross_domain(self, layerset_feature): # No domain_id: list features across all the user's domains features = list_features() - assert road_feature.id in [feature.id for feature in features] + assert layerset_feature.id in [feature.id for feature in features] - def test_filter_by_type(self, test_domain, road_feature, layerset_feature): + def test_filter_by_type( + self, test_domain, completed_road_feature, layerset_feature + ): features = list_features(test_domain.id, feature_type="road") feature_ids = [feature.id for feature in features] assert all(feature.type_ == FeatureType.ROAD for feature in features) - assert road_feature.id in feature_ids + assert completed_road_feature.id in feature_ids assert layerset_feature.id not in feature_ids - def test_filter_by_product(self, test_domain, road_feature, layerset_feature): + def test_filter_by_product( + self, test_domain, completed_road_feature, layerset_feature + ): features = list_features(test_domain.id, product="osm") feature_ids = [feature.id for feature in features] - assert road_feature.id in feature_ids + assert completed_road_feature.id in feature_ids assert layerset_feature.id not in feature_ids def test_filter_by_tag(self, test_domain, layerset_feature): @@ -233,7 +241,7 @@ def test_filter_by_tag(self, test_domain, layerset_feature): reason="v2 API returns 500 for feature sort_by combinations; " "see FastFuels-API-v2#321", ) - def test_sorting(self, test_domain, road_feature): + def test_sorting(self, test_domain, layerset_feature): features = list_features( test_domain.id, sort_by="created_on", sort_order="descending" ) @@ -285,11 +293,11 @@ def test_update_no_fields_makes_no_api_call(self, update_feature): class TestToJson: - def test_to_json(self, road_feature): - feature_dict = json.loads(road_feature.to_json()) - assert feature_dict["id"] == road_feature.id - assert feature_dict["domain_id"] == road_feature.domain_id - assert feature_dict["type"] == "road" + def test_to_json(self, layerset_feature): + feature_dict = json.loads(layerset_feature.to_json()) + assert feature_dict["id"] == layerset_feature.id + assert feature_dict["domain_id"] == layerset_feature.domain_id + assert feature_dict["type"] == "layerset" class TestDeleteFeature: diff --git a/tests/v2/utils.py b/tests/v2/utils.py index f92a413..14b42ed 100644 --- a/tests/v2/utils.py +++ b/tests/v2/utils.py @@ -3,11 +3,13 @@ """ import json +from datetime import datetime, timedelta, timezone + from tests import TEST_DATA_DIR -from fastfuels_sdk.v2.domains import Domain +from fastfuels_sdk.v2.domains import Domain, list_domains -# Fingerprint tag for test-created domains, so the session-start sweeper -# (tests/v2/conftest.py) can find resources leaked by crashed runs +# Fingerprint tag for test-created domains, so the session-start sweep +# can find resources leaked by crashed runs SWEEP_TAG = "sdk-test" # Required fuelbed input columns for layerset features @@ -38,6 +40,21 @@ def create_default_domain() -> Domain: return domain +def sweep_leftover_domains(max_age: timedelta = timedelta(hours=2)) -> None: + """Delete test domains leaked by crashed or interrupted runs. + + Teardown never runs when a session is killed mid-flight, so test + domains can accumulate on the live account. Deletes domains carrying + SWEEP_TAG that are older than ``max_age``; the age gate keeps + concurrent runs (e.g. local + CI) from sweeping each other's live + resources. + """ + cutoff = datetime.now(timezone.utc) - max_age + for domain in list_domains(size=100): + if SWEEP_TAG in (domain.tags or []) and domain.created_on < cutoff: + domain.delete() + + def create_default_layerset_geojson() -> dict: """Build a valid layerset FeatureCollection for testing. From ba4ff1dd0d81a18908794113bc082f0bbbbc0af0 Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Fri, 5 Jun 2026 17:09:51 -0600 Subject: [PATCH 17/71] Collapse docs deployment into scripts/docs.sh One entry point for both workflows: ./scripts/docs.sh build v1 + v2 snapshots and serve the full site (version selector included) locally ./scripts/docs.sh deploy publish both versions to gh-pages The docs workflow now calls the script, so versions, titles, and aliases live in exactly one place. --- .github/workflows/docs.yml | 19 ++++--------------- CLAUDE.md | 4 +++- mkdocs.yml | 7 ++++--- scripts/docs.sh | 36 ++++++++++++++++++++++++++++++++++++ 4 files changed, 47 insertions(+), 19 deletions(-) create mode 100755 scripts/docs.sh diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index dacedb5..f41ee53 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -17,22 +17,11 @@ jobs: fetch-depth: 0 - name: Install uv uses: astral-sh/setup-uv@v6 - - name: Install docs dependencies - run: uv sync --group docs - name: Configure git for mike run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - # mike deploys each version as its own site snapshot on gh-pages and - # powers the version dropdown in the header (mkdocs-material's - # extra.version.provider). v1 keeps the "latest" alias and is the - # default the site root redirects to. - # Version labels mirror the FastFuels-Web docs (docs.fastfuels.silvxlabs.com) - - name: Deploy v1 docs - run: uv run mike deploy --push --update-aliases v1 latest --title "v1" - - name: Deploy v2 docs - run: uv run mike deploy --push v2 --title "v2 (Beta)" - env: - DOCS_DIR: docs/v2 - - name: Set default version - run: uv run mike set-default --push latest + # scripts/docs.sh is the single source of truth for versions, titles, + # and aliases — the same script serves the local preview. + - name: Deploy versioned docs (v1 + v2) + run: ./scripts/docs.sh deploy diff --git a/CLAUDE.md b/CLAUDE.md index b43b187..282cc03 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,7 +55,9 @@ rationale and issue references belong in `#` code comments. ### Conventions - Version labels mirror the platform docs: **"v1"** (alias `latest`) and - **"v2 (Beta)"** — set as mike titles in `.github/workflows/docs.yml`. + **"v2 (Beta)"** — set as mike titles in `scripts/docs.sh`, the single + entry point for both the local preview (`./scripts/docs.sh`) and the + gh-pages deploy (`./scripts/docs.sh deploy`, run by the docs workflow). - One `mkdocs.yml` builds both versions: `DOCS_DIR` selects `docs/v1` (default) or `docs/v2`; each tree owns its nav in a `SUMMARY.md` (literate-nav). Versions deploy as independent mike snapshots picked diff --git a/mkdocs.yml b/mkdocs.yml index 52ead0a..3e0fe26 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -4,9 +4,10 @@ # (defaults to docs/v1; set DOCS_DIR=docs/v2 for v2), and each tree # defines its own nav in a SUMMARY.md (literate-nav). Versions deploy via # mike as independent site snapshots picked from the header version -# selector; see .github/workflows/docs.yml. Local preview: -# uv run mkdocs serve # v1 -# DOCS_DIR=docs/v2 uv run mkdocs serve # v2 +# selector; see scripts/docs.sh. Local preview: +# uv run mkdocs serve # v1 only +# DOCS_DIR=docs/v2 uv run mkdocs serve # v2 only +# ./scripts/docs.sh # both versions + selector site_name: FastFuels SDK Documentation # site_url is required for mike's version selector to keep the reader on # the same page when switching versions diff --git a/scripts/docs.sh b/scripts/docs.sh new file mode 100755 index 0000000..cec090d --- /dev/null +++ b/scripts/docs.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# +# Build the versioned docs — v1 and v2 mike snapshots — and serve or publish. +# +# ./scripts/docs.sh build both versions, then serve the full site +# (version selector included) at localhost:8000 +# ./scripts/docs.sh deploy publish both versions to gh-pages on origin +# +# mike snapshots each version onto the gh-pages branch and maintains the +# versions.json that powers the header version selector; plain +# `mkdocs serve` can only ever show one version (and 404s versions.json). +# Without `deploy`, the snapshots are committed to your local gh-pages +# branch only — `git branch -D gh-pages` discards them. +# +# Version labels mirror the FastFuels-Web docs (docs.fastfuels.silvxlabs.com). + +set -euo pipefail +cd "$(dirname "$0")/.." + +push="" +case "${1:-serve}" in + serve) ;; + deploy) push="--push" ;; + *) + echo "usage: $0 [deploy]" >&2 + exit 1 + ;; +esac + +uv run --group docs mike deploy $push --update-aliases v1 latest --title "v1" +DOCS_DIR=docs/v2 uv run --group docs mike deploy $push v2 --title "v2 (Beta)" +uv run --group docs mike set-default $push latest + +if [[ -z "$push" ]]; then + uv run --group docs mike serve +fi From db3f444e35902a01d11e4cfc1a9a87a8b6a7dd32 Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Sun, 7 Jun 2026 12:08:13 -0600 Subject: [PATCH 18/71] Restructure docs to release-keyed versioning (pydantic pattern) The version selector was wired to the wrong axis. mike's model is frozen snapshots of one docs tree, one per package release; pydantic (which ships pydantic.v1 inside 2.x -- exactly our subpackage situation) keys its selector to releases (dev, 2.13..2.0, 1.10) and documents the legacy surface via a migration guide plus the frozen old-release snapshot. Selector entries keyed to API surfaces exist only in REST API portals (Stripe) -- the axis the platform docs correctly use for the two deployed HTTP services, but wrong for one package that ships both interfaces. Now: one docs/ tree -- v1 content at the root (restoring the original site URLs) and v2 as a 'v2 (Beta)' nav section -- with the nav inline in mkdocs.yml, and the workflow deploys + latest with mike on each GitHub release. Docs ship with the release that ships the code. Deletes scripts/docs.sh, the DOCS_DIR/!ENV switch, literate-nav and both SUMMARY.md navs, and the duplicate stylesheet. Also fixes the v1 create_tree_grid docstring (SAVR -> savr), which blocked --strict now that one build covers the v1 reference. --- .github/workflows/docs.yml | 28 ++++++++-- .pre-commit-config.yaml | 4 +- CLAUDE.md | 24 ++++---- docs/{v1 => }/guides/authentication.md | 0 docs/{v1 => }/guides/domains.md | 0 docs/{v1 => }/guides/features.md | 0 docs/{v1 => }/guides/grids.md | 0 docs/{v1 => }/guides/inventories.md | 0 docs/{v1 => }/guides/point_clouds.md | 0 docs/{v1 => }/index.md | 4 ++ docs/{v1 => }/reference.md | 0 docs/{v1 => }/stylesheets/extra.css | 0 docs/{v1 => }/tutorials/export_to_quicfire.md | 0 .../{v1 => }/tutorials/point_cloud_example.md | 0 docs/v1/SUMMARY.md | 12 ---- docs/v2/SUMMARY.md | 6 -- docs/v2/index.md | 54 ++++++------------ docs/v2/stylesheets/extra.css | 55 ------------------- fastfuels_sdk/v1/grids/grids.py | 2 +- mkdocs.yml | 40 ++++++++++---- pyproject.toml | 3 - scripts/docs.sh | 36 ------------ uv.lock | 14 ----- 23 files changed, 89 insertions(+), 193 deletions(-) rename docs/{v1 => }/guides/authentication.md (100%) rename docs/{v1 => }/guides/domains.md (100%) rename docs/{v1 => }/guides/features.md (100%) rename docs/{v1 => }/guides/grids.md (100%) rename docs/{v1 => }/guides/inventories.md (100%) rename docs/{v1 => }/guides/point_clouds.md (100%) rename docs/{v1 => }/index.md (79%) rename docs/{v1 => }/reference.md (100%) rename docs/{v1 => }/stylesheets/extra.css (100%) rename docs/{v1 => }/tutorials/export_to_quicfire.md (100%) rename docs/{v1 => }/tutorials/point_cloud_example.md (100%) delete mode 100644 docs/v1/SUMMARY.md delete mode 100644 docs/v2/SUMMARY.md delete mode 100644 docs/v2/stylesheets/extra.css delete mode 100755 scripts/docs.sh diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index f41ee53..2affc7d 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -4,6 +4,10 @@ on: release: types: [published] workflow_dispatch: + inputs: + version: + description: "Docs version to deploy as (major.minor), e.g. 0.21" + required: true permissions: contents: write @@ -21,7 +25,23 @@ jobs: run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - # scripts/docs.sh is the single source of truth for versions, titles, - # and aliases — the same script serves the local preview. - - name: Deploy versioned docs (v1 + v2) - run: ./scripts/docs.sh deploy + # The docs version is the release's major.minor (tag v0.21.1 -> + # 0.21); a workflow_dispatch passes it directly, e.g. to redeploy a + # version's docs after a docs-only fix. + - name: Resolve docs version + run: | + if [[ "${{ github.event_name }}" == "release" ]]; then + TAG="${{ github.event.release.tag_name }}" + VERSION="${TAG#v}" + VERSION="${VERSION%.*}" + else + VERSION="${{ github.event.inputs.version }}" + fi + echo "DOCS_VERSION=$VERSION" >> "$GITHUB_ENV" + # mike snapshots the current docs tree as / on gh-pages + # and re-points the `latest` alias (the site default) at it. Old + # release snapshots are never rebuilt. + - name: Deploy docs snapshot + run: | + uv run --group docs mike deploy --push --update-aliases "$DOCS_VERSION" latest + uv run --group docs mike set-default --push latest diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 47e115f..be375dd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,8 +4,8 @@ repos: rev: v4.6.0 hooks: - id: check-yaml - # mkdocs.yml uses mkdocs' !ENV tag, which plain yaml.safe_load - # cannot construct; --unsafe still checks syntax + # mkdocs.yml uses material's !!python/name: tags, which plain + # yaml.safe_load cannot construct; --unsafe still checks syntax args: [--unsafe] - id: end-of-file-fixer - id: trailing-whitespace diff --git a/CLAUDE.md b/CLAUDE.md index 282cc03..ee4be6e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,16 +54,18 @@ rationale and issue references belong in `#` code comments. ### Conventions -- Version labels mirror the platform docs: **"v1"** (alias `latest`) and - **"v2 (Beta)"** — set as mike titles in `scripts/docs.sh`, the single - entry point for both the local preview (`./scripts/docs.sh`) and the - gh-pages deploy (`./scripts/docs.sh deploy`, run by the docs workflow). -- One `mkdocs.yml` builds both versions: `DOCS_DIR` selects `docs/v1` - (default) or `docs/v2`; each tree owns its nav in a `SUMMARY.md` - (literate-nav). Versions deploy as independent mike snapshots picked - from the header version selector. -- `docs/v1/stylesheets/extra.css` and `docs/v2/stylesheets/extra.css` are - duplicates by construction — edit both or the version snapshots diverge. +- One `docs/` tree, nav in `mkdocs.yml`. The root sections (guides, + tutorials, reference) document the v1 SDK and are frozen alongside it + (bugfix-level edits only); new documentation lands in `docs/v2/` + ("v2 (Beta)" in the nav) until v2 becomes the default interface. +- Site versions are **release snapshots, not API surfaces**: on each + GitHub release the docs workflow runs mike, deploying the tree as + `` and re-pointing the `latest` alias (the pydantic + pattern). The platform docs version by API version because the HTTP + API is two deployed services; the SDK is one package shipping both + interfaces, so its version axis is the package release. Never publish + docs for code that hasn't shipped in a release. +- Local preview: `uv run mkdocs serve`. - Code examples must be real: verified against the live API (the live test suite in `tests/` is the verification path), with realistic output — never hand-written response shapes. @@ -71,5 +73,3 @@ rationale and issue references belong in `#` code comments. "Note:" prose; use content tabs (`=== "v1"` / `=== "v2"`) for side-by-side variants; v2 pages open with the `!!! warning "Beta"` admonition. -- `docs/v1/` is frozen alongside the v1 SDK (bugfix-level edits only); - new documentation lands in `docs/v2/`. diff --git a/docs/v1/guides/authentication.md b/docs/guides/authentication.md similarity index 100% rename from docs/v1/guides/authentication.md rename to docs/guides/authentication.md diff --git a/docs/v1/guides/domains.md b/docs/guides/domains.md similarity index 100% rename from docs/v1/guides/domains.md rename to docs/guides/domains.md diff --git a/docs/v1/guides/features.md b/docs/guides/features.md similarity index 100% rename from docs/v1/guides/features.md rename to docs/guides/features.md diff --git a/docs/v1/guides/grids.md b/docs/guides/grids.md similarity index 100% rename from docs/v1/guides/grids.md rename to docs/guides/grids.md diff --git a/docs/v1/guides/inventories.md b/docs/guides/inventories.md similarity index 100% rename from docs/v1/guides/inventories.md rename to docs/guides/inventories.md diff --git a/docs/v1/guides/point_clouds.md b/docs/guides/point_clouds.md similarity index 100% rename from docs/v1/guides/point_clouds.md rename to docs/guides/point_clouds.md diff --git a/docs/v1/index.md b/docs/index.md similarity index 79% rename from docs/v1/index.md rename to docs/index.md index 1181c14..00bef5b 100644 --- a/docs/v1/index.md +++ b/docs/index.md @@ -1,5 +1,9 @@ # Welcome to the FastFuels Python SDK Documentation! +!!! info "v2 (Beta)" + A new SDK targeting the FastFuels v2 API ships alongside v1 as the + `fastfuels_sdk.v2` subpackage — see the [v2 (Beta)](v2/index.md) + section and [Migrating from v1](v2/guides/migration.md). ## What is FastFuels? diff --git a/docs/v1/reference.md b/docs/reference.md similarity index 100% rename from docs/v1/reference.md rename to docs/reference.md diff --git a/docs/v1/stylesheets/extra.css b/docs/stylesheets/extra.css similarity index 100% rename from docs/v1/stylesheets/extra.css rename to docs/stylesheets/extra.css diff --git a/docs/v1/tutorials/export_to_quicfire.md b/docs/tutorials/export_to_quicfire.md similarity index 100% rename from docs/v1/tutorials/export_to_quicfire.md rename to docs/tutorials/export_to_quicfire.md diff --git a/docs/v1/tutorials/point_cloud_example.md b/docs/tutorials/point_cloud_example.md similarity index 100% rename from docs/v1/tutorials/point_cloud_example.md rename to docs/tutorials/point_cloud_example.md diff --git a/docs/v1/SUMMARY.md b/docs/v1/SUMMARY.md deleted file mode 100644 index 3b98b9b..0000000 --- a/docs/v1/SUMMARY.md +++ /dev/null @@ -1,12 +0,0 @@ -* [Home](index.md) -* How-To Guides - * [Authentication](guides/authentication.md) - * [Domains](guides/domains.md) - * [Inventories](guides/inventories.md) - * [Point Clouds](guides/point_clouds.md) - * [Features](guides/features.md) - * [Grids](guides/grids.md) -* Tutorials - * [Export to QUIC-Fire](tutorials/export_to_quicfire.md) - * [ALS Point Cloud](tutorials/point_cloud_example.md) -* [Reference](reference.md) diff --git a/docs/v2/SUMMARY.md b/docs/v2/SUMMARY.md deleted file mode 100644 index 742c24b..0000000 --- a/docs/v2/SUMMARY.md +++ /dev/null @@ -1,6 +0,0 @@ -* [Home](index.md) -* [Migrating from v1](guides/migration.md) -* How-To Guides - * [Domains](guides/domains.md) - * [Features](guides/features.md) -* [Reference](reference.md) diff --git a/docs/v2/index.md b/docs/v2/index.md index cbb377b..8d36db5 100644 --- a/docs/v2/index.md +++ b/docs/v2/index.md @@ -1,47 +1,29 @@ -# Welcome to the FastFuels Python SDK Documentation! +# FastFuels SDK v2 (Beta) !!! warning "Beta" You are reading the documentation for the **v2 SDK**, which targets the FastFuels v2 API and is under active development. The v1 SDK - remains the default — switch versions with the selector in the - header. Import v2 explicitly from `fastfuels_sdk.v2`. + remains the default — its documentation lives in the main sections + of this site. Import v2 explicitly from `fastfuels_sdk.v2`. -## What is FastFuels? - -FastFuels is a cloud-based platform for generating forest inventory data. It -uses a combination of satellite imagery and machine learning to generate -tabular tree data and voxelized 3D fuel models. These data products can be used -to support wildfire risk assessment, fire behavior modeling, and other -applications. - -The [FastFuels documentation](https://docs.fastfuels.silvxlabs.com) covers -the platform itself: the web application, the HTTP API, and explanations of -the core concepts (domains, grids, inventories, features). This site covers -how to work with FastFuels from Python. - -## What is the FastFuels Python SDK? - -The FastFuels Python SDK is a Python package that provides a convenient -interface for interacting with the FastFuels API. It can be used to create and -manage FastFuels resources. It can also be used to download and process -generated data products. - -## Installation - -The FastFuels Python SDK can be installed using `pip`: - -```bash -pip install fastfuels-sdk -``` - -## Using the v2 SDK - -The v1 and v2 APIs are separate live services, and the SDK ships both as -versioned subpackages, so you can use them side by side during migration: +The FastFuels v1 and v2 APIs are separate live services, and the SDK +ships an interface to each as versioned subpackages, so you can use them +side by side during migration: ```python from fastfuels_sdk.v1 import Domain # v1 (current default) from fastfuels_sdk.v2 import Domain # v2 (Beta) ``` -Coming from v1? Start with [Migrating from v1](guides/migration.md). +Both subpackages read the same `FASTFUELS_API_KEY` environment variable. + +The [FastFuels documentation](https://docs.fastfuels.silvxlabs.com) +covers the v2 platform itself: the web application, the HTTP API, and +explanations of the core concepts (domains, grids, inventories, +features). This section covers how to work with it from Python: + +- Coming from v1? Start with [Migrating from v1](guides/migration.md). +- How-to guides: [Domains](guides/domains.md) and + [Features](guides/features.md). +- The [v2 Reference](../v2/reference.md) documents the full + `fastfuels_sdk.v2` surface. diff --git a/docs/v2/stylesheets/extra.css b/docs/v2/stylesheets/extra.css deleted file mode 100644 index c76b44b..0000000 --- a/docs/v2/stylesheets/extra.css +++ /dev/null @@ -1,55 +0,0 @@ -/* Version selector (mike + mkdocs-material). The stock theme renders the - selector as bare header text; style it as an explicit control. - (Duplicated in docs/v1 and docs/v2 — each version's site builds from its - own docs_dir.) */ -.md-version { - margin-left: 0.8rem; -} - -.md-version__current { - display: inline-flex; - align-items: center; - gap: 0.3rem; - background-color: rgba(0, 0, 0, 0.18); - border: 1px solid rgba(255, 255, 255, 0.3); - border-radius: 2rem; - padding: 0.25rem 0.8rem; - font-size: 0.65rem; - font-weight: 600; - letter-spacing: 0.02em; - cursor: pointer; - transition: background-color 125ms, border-color 125ms; -} - -.md-version__current:hover, -.md-version__current:focus { - background-color: rgba(0, 0, 0, 0.32); - border-color: rgba(255, 255, 255, 0.6); -} - -.md-version__list { - margin-top: 0.5rem; - border-radius: 0.3rem; - box-shadow: var(--md-shadow-z3); - overflow: hidden; - min-width: 8rem; -} - -.md-version__item { - line-height: 1; -} - -.md-version__link { - display: block; - width: 100%; - padding: 0.6rem 1rem; - font-size: 0.7rem; - color: var(--md-default-fg-color); - transition: background-color 125ms, color 125ms; -} - -.md-version__link:hover, -.md-version__link:focus { - background-color: var(--md-default-fg-color--lightest); - color: var(--md-typeset-a-color); -} diff --git a/fastfuels_sdk/v1/grids/grids.py b/fastfuels_sdk/v1/grids/grids.py index 76c6719..4ea83f8 100644 --- a/fastfuels_sdk/v1/grids/grids.py +++ b/fastfuels_sdk/v1/grids/grids.py @@ -472,7 +472,7 @@ def create_tree_grid( "value": float # moisture content in % } - SAVR: dict, optional + savr : dict, optional Configuration for surface area to volume ratio. Sources available: - Inventory: { diff --git a/mkdocs.yml b/mkdocs.yml index 3e0fe26..61401f4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,23 +1,41 @@ # mkdocs.yml # -# One config builds either docs version: DOCS_DIR selects the tree -# (defaults to docs/v1; set DOCS_DIR=docs/v2 for v2), and each tree -# defines its own nav in a SUMMARY.md (literate-nav). Versions deploy via -# mike as independent site snapshots picked from the header version -# selector; see scripts/docs.sh. Local preview: -# uv run mkdocs serve # v1 only -# DOCS_DIR=docs/v2 uv run mkdocs serve # v2 only -# ./scripts/docs.sh # both versions + selector +# One docs tree: the root sections document the v1 SDK (the current +# default interface); the "v2 (Beta)" section documents the v2 +# subpackage shipping alongside it. Site versions are point-in-time +# release snapshots deployed by mike from .github/workflows/docs.yml on +# each GitHub release — the header selector lists releases (e.g. 0.21) +# with the `latest` alias as the default. Old snapshots are never +# rebuilt. Local preview: +# uv run mkdocs serve site_name: FastFuels SDK Documentation # site_url is required for mike's version selector to keep the reader on # the same page when switching versions site_url: https://silvxlabs.github.io/fastfuels-sdk-python/ -docs_dir: !ENV [DOCS_DIR, docs/v1] - repo_url: https://github.com/silvxlabs/fastfuels-sdk-python repo_name: silvxlabs/fastfuels-sdk-python +nav: + - Home: index.md + - How-To Guides: + - Authentication: guides/authentication.md + - Domains: guides/domains.md + - Inventories: guides/inventories.md + - Point Clouds: guides/point_clouds.md + - Features: guides/features.md + - Grids: guides/grids.md + - Tutorials: + - Export to QUIC-Fire: tutorials/export_to_quicfire.md + - ALS Point Cloud: tutorials/point_cloud_example.md + - Reference: reference.md + - v2 (Beta): + - Overview: v2/index.md + - Migrating from v1: v2/guides/migration.md + - Domains: v2/guides/domains.md + - Features: v2/guides/features.md + - Reference: v2/reference.md + theme: name: "material" icon: @@ -63,8 +81,6 @@ extra_css: plugins: - search - - literate-nav: - nav_file: SUMMARY.md - mkdocstrings: handlers: python: diff --git a/pyproject.toml b/pyproject.toml index 5f9f2f8..b7ebf8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,9 +47,6 @@ dev = [ docs = [ "mike>=2.2.0", "mkdocs", - # pinned pre-0.6.3: 0.6.3 co-installs the properdocs mkdocs-fork and - # warns when run under mkdocs; revisit when the fork situation settles - "mkdocs-literate-nav==0.6.2", # major pinned per the Material docs (semver; majors break config) "mkdocs-material>=9.7,<10", "mkdocstrings", diff --git a/scripts/docs.sh b/scripts/docs.sh deleted file mode 100755 index cec090d..0000000 --- a/scripts/docs.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env bash -# -# Build the versioned docs — v1 and v2 mike snapshots — and serve or publish. -# -# ./scripts/docs.sh build both versions, then serve the full site -# (version selector included) at localhost:8000 -# ./scripts/docs.sh deploy publish both versions to gh-pages on origin -# -# mike snapshots each version onto the gh-pages branch and maintains the -# versions.json that powers the header version selector; plain -# `mkdocs serve` can only ever show one version (and 404s versions.json). -# Without `deploy`, the snapshots are committed to your local gh-pages -# branch only — `git branch -D gh-pages` discards them. -# -# Version labels mirror the FastFuels-Web docs (docs.fastfuels.silvxlabs.com). - -set -euo pipefail -cd "$(dirname "$0")/.." - -push="" -case "${1:-serve}" in - serve) ;; - deploy) push="--push" ;; - *) - echo "usage: $0 [deploy]" >&2 - exit 1 - ;; -esac - -uv run --group docs mike deploy $push --update-aliases v1 latest --title "v1" -DOCS_DIR=docs/v2 uv run --group docs mike deploy $push v2 --title "v2 (Beta)" -uv run --group docs mike set-default $push latest - -if [[ -z "$push" ]]; then - uv run --group docs mike serve -fi diff --git a/uv.lock b/uv.lock index f448fad..4e97e8e 100644 --- a/uv.lock +++ b/uv.lock @@ -358,7 +358,6 @@ dev = [ docs = [ { name = "mike" }, { name = "mkdocs" }, - { name = "mkdocs-literate-nav" }, { name = "mkdocs-material" }, { name = "mkdocstrings" }, { name = "mkdocstrings-python" }, @@ -388,7 +387,6 @@ dev = [ docs = [ { name = "mike", specifier = ">=2.2.0" }, { name = "mkdocs" }, - { name = "mkdocs-literate-nav", specifier = "==0.6.2" }, { name = "mkdocs-material", specifier = ">=9.7,<10" }, { name = "mkdocstrings" }, { name = "mkdocstrings-python" }, @@ -816,18 +814,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" }, ] -[[package]] -name = "mkdocs-literate-nav" -version = "0.6.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mkdocs" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f6/5f/99aa379b305cd1c2084d42db3d26f6de0ea9bf2cc1d10ed17f61aff35b9a/mkdocs_literate_nav-0.6.2.tar.gz", hash = "sha256:760e1708aa4be86af81a2b56e82c739d5a8388a0eab1517ecfd8e5aa40810a75", size = 17419, upload-time = "2025-03-18T21:53:09.711Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/84/b5b14d2745e4dd1a90115186284e9ee1b4d0863104011ab46abb7355a1c3/mkdocs_literate_nav-0.6.2-py3-none-any.whl", hash = "sha256:0a6489a26ec7598477b56fa112056a5e3a6c15729f0214bea8a4dbc55bd5f630", size = 13261, upload-time = "2025-03-18T21:53:08.1Z" }, -] - [[package]] name = "mkdocs-material" version = "9.7.6" From b33ba41550ff4bcfcb1fffd65b99a30626fa6e0d Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Sun, 7 Jun 2026 12:12:12 -0600 Subject: [PATCH 19/71] Revert "Restructure docs to release-keyed versioning (pydantic pattern)" Seeing it rendered settled the question the other way: the SDK docs keep the v1/v2 version dropdown, deliberately mirroring the platform docs UX. The dual-tree mike setup is the accepted cost of that dropdown. Keeps the create_tree_grid docstring fix (SAVR -> savr) from the reverted commit. --- .github/workflows/docs.yml | 28 ++-------- .pre-commit-config.yaml | 4 +- CLAUDE.md | 24 ++++---- docs/v1/SUMMARY.md | 12 ++++ docs/{ => v1}/guides/authentication.md | 0 docs/{ => v1}/guides/domains.md | 0 docs/{ => v1}/guides/features.md | 0 docs/{ => v1}/guides/grids.md | 0 docs/{ => v1}/guides/inventories.md | 0 docs/{ => v1}/guides/point_clouds.md | 0 docs/{ => v1}/index.md | 4 -- docs/{ => v1}/reference.md | 0 docs/{ => v1}/stylesheets/extra.css | 0 docs/{ => v1}/tutorials/export_to_quicfire.md | 0 .../{ => v1}/tutorials/point_cloud_example.md | 0 docs/v2/SUMMARY.md | 6 ++ docs/v2/index.md | 54 ++++++++++++------ docs/v2/stylesheets/extra.css | 55 +++++++++++++++++++ mkdocs.yml | 40 ++++---------- pyproject.toml | 3 + scripts/docs.sh | 36 ++++++++++++ uv.lock | 14 +++++ 22 files changed, 192 insertions(+), 88 deletions(-) create mode 100644 docs/v1/SUMMARY.md rename docs/{ => v1}/guides/authentication.md (100%) rename docs/{ => v1}/guides/domains.md (100%) rename docs/{ => v1}/guides/features.md (100%) rename docs/{ => v1}/guides/grids.md (100%) rename docs/{ => v1}/guides/inventories.md (100%) rename docs/{ => v1}/guides/point_clouds.md (100%) rename docs/{ => v1}/index.md (79%) rename docs/{ => v1}/reference.md (100%) rename docs/{ => v1}/stylesheets/extra.css (100%) rename docs/{ => v1}/tutorials/export_to_quicfire.md (100%) rename docs/{ => v1}/tutorials/point_cloud_example.md (100%) create mode 100644 docs/v2/SUMMARY.md create mode 100644 docs/v2/stylesheets/extra.css create mode 100755 scripts/docs.sh diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 2affc7d..f41ee53 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -4,10 +4,6 @@ on: release: types: [published] workflow_dispatch: - inputs: - version: - description: "Docs version to deploy as (major.minor), e.g. 0.21" - required: true permissions: contents: write @@ -25,23 +21,7 @@ jobs: run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - # The docs version is the release's major.minor (tag v0.21.1 -> - # 0.21); a workflow_dispatch passes it directly, e.g. to redeploy a - # version's docs after a docs-only fix. - - name: Resolve docs version - run: | - if [[ "${{ github.event_name }}" == "release" ]]; then - TAG="${{ github.event.release.tag_name }}" - VERSION="${TAG#v}" - VERSION="${VERSION%.*}" - else - VERSION="${{ github.event.inputs.version }}" - fi - echo "DOCS_VERSION=$VERSION" >> "$GITHUB_ENV" - # mike snapshots the current docs tree as / on gh-pages - # and re-points the `latest` alias (the site default) at it. Old - # release snapshots are never rebuilt. - - name: Deploy docs snapshot - run: | - uv run --group docs mike deploy --push --update-aliases "$DOCS_VERSION" latest - uv run --group docs mike set-default --push latest + # scripts/docs.sh is the single source of truth for versions, titles, + # and aliases — the same script serves the local preview. + - name: Deploy versioned docs (v1 + v2) + run: ./scripts/docs.sh deploy diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index be375dd..47e115f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,8 +4,8 @@ repos: rev: v4.6.0 hooks: - id: check-yaml - # mkdocs.yml uses material's !!python/name: tags, which plain - # yaml.safe_load cannot construct; --unsafe still checks syntax + # mkdocs.yml uses mkdocs' !ENV tag, which plain yaml.safe_load + # cannot construct; --unsafe still checks syntax args: [--unsafe] - id: end-of-file-fixer - id: trailing-whitespace diff --git a/CLAUDE.md b/CLAUDE.md index ee4be6e..282cc03 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,18 +54,16 @@ rationale and issue references belong in `#` code comments. ### Conventions -- One `docs/` tree, nav in `mkdocs.yml`. The root sections (guides, - tutorials, reference) document the v1 SDK and are frozen alongside it - (bugfix-level edits only); new documentation lands in `docs/v2/` - ("v2 (Beta)" in the nav) until v2 becomes the default interface. -- Site versions are **release snapshots, not API surfaces**: on each - GitHub release the docs workflow runs mike, deploying the tree as - `` and re-pointing the `latest` alias (the pydantic - pattern). The platform docs version by API version because the HTTP - API is two deployed services; the SDK is one package shipping both - interfaces, so its version axis is the package release. Never publish - docs for code that hasn't shipped in a release. -- Local preview: `uv run mkdocs serve`. +- Version labels mirror the platform docs: **"v1"** (alias `latest`) and + **"v2 (Beta)"** — set as mike titles in `scripts/docs.sh`, the single + entry point for both the local preview (`./scripts/docs.sh`) and the + gh-pages deploy (`./scripts/docs.sh deploy`, run by the docs workflow). +- One `mkdocs.yml` builds both versions: `DOCS_DIR` selects `docs/v1` + (default) or `docs/v2`; each tree owns its nav in a `SUMMARY.md` + (literate-nav). Versions deploy as independent mike snapshots picked + from the header version selector. +- `docs/v1/stylesheets/extra.css` and `docs/v2/stylesheets/extra.css` are + duplicates by construction — edit both or the version snapshots diverge. - Code examples must be real: verified against the live API (the live test suite in `tests/` is the verification path), with realistic output — never hand-written response shapes. @@ -73,3 +71,5 @@ rationale and issue references belong in `#` code comments. "Note:" prose; use content tabs (`=== "v1"` / `=== "v2"`) for side-by-side variants; v2 pages open with the `!!! warning "Beta"` admonition. +- `docs/v1/` is frozen alongside the v1 SDK (bugfix-level edits only); + new documentation lands in `docs/v2/`. diff --git a/docs/v1/SUMMARY.md b/docs/v1/SUMMARY.md new file mode 100644 index 0000000..3b98b9b --- /dev/null +++ b/docs/v1/SUMMARY.md @@ -0,0 +1,12 @@ +* [Home](index.md) +* How-To Guides + * [Authentication](guides/authentication.md) + * [Domains](guides/domains.md) + * [Inventories](guides/inventories.md) + * [Point Clouds](guides/point_clouds.md) + * [Features](guides/features.md) + * [Grids](guides/grids.md) +* Tutorials + * [Export to QUIC-Fire](tutorials/export_to_quicfire.md) + * [ALS Point Cloud](tutorials/point_cloud_example.md) +* [Reference](reference.md) diff --git a/docs/guides/authentication.md b/docs/v1/guides/authentication.md similarity index 100% rename from docs/guides/authentication.md rename to docs/v1/guides/authentication.md diff --git a/docs/guides/domains.md b/docs/v1/guides/domains.md similarity index 100% rename from docs/guides/domains.md rename to docs/v1/guides/domains.md diff --git a/docs/guides/features.md b/docs/v1/guides/features.md similarity index 100% rename from docs/guides/features.md rename to docs/v1/guides/features.md diff --git a/docs/guides/grids.md b/docs/v1/guides/grids.md similarity index 100% rename from docs/guides/grids.md rename to docs/v1/guides/grids.md diff --git a/docs/guides/inventories.md b/docs/v1/guides/inventories.md similarity index 100% rename from docs/guides/inventories.md rename to docs/v1/guides/inventories.md diff --git a/docs/guides/point_clouds.md b/docs/v1/guides/point_clouds.md similarity index 100% rename from docs/guides/point_clouds.md rename to docs/v1/guides/point_clouds.md diff --git a/docs/index.md b/docs/v1/index.md similarity index 79% rename from docs/index.md rename to docs/v1/index.md index 00bef5b..1181c14 100644 --- a/docs/index.md +++ b/docs/v1/index.md @@ -1,9 +1,5 @@ # Welcome to the FastFuels Python SDK Documentation! -!!! info "v2 (Beta)" - A new SDK targeting the FastFuels v2 API ships alongside v1 as the - `fastfuels_sdk.v2` subpackage — see the [v2 (Beta)](v2/index.md) - section and [Migrating from v1](v2/guides/migration.md). ## What is FastFuels? diff --git a/docs/reference.md b/docs/v1/reference.md similarity index 100% rename from docs/reference.md rename to docs/v1/reference.md diff --git a/docs/stylesheets/extra.css b/docs/v1/stylesheets/extra.css similarity index 100% rename from docs/stylesheets/extra.css rename to docs/v1/stylesheets/extra.css diff --git a/docs/tutorials/export_to_quicfire.md b/docs/v1/tutorials/export_to_quicfire.md similarity index 100% rename from docs/tutorials/export_to_quicfire.md rename to docs/v1/tutorials/export_to_quicfire.md diff --git a/docs/tutorials/point_cloud_example.md b/docs/v1/tutorials/point_cloud_example.md similarity index 100% rename from docs/tutorials/point_cloud_example.md rename to docs/v1/tutorials/point_cloud_example.md diff --git a/docs/v2/SUMMARY.md b/docs/v2/SUMMARY.md new file mode 100644 index 0000000..742c24b --- /dev/null +++ b/docs/v2/SUMMARY.md @@ -0,0 +1,6 @@ +* [Home](index.md) +* [Migrating from v1](guides/migration.md) +* How-To Guides + * [Domains](guides/domains.md) + * [Features](guides/features.md) +* [Reference](reference.md) diff --git a/docs/v2/index.md b/docs/v2/index.md index 8d36db5..cbb377b 100644 --- a/docs/v2/index.md +++ b/docs/v2/index.md @@ -1,29 +1,47 @@ -# FastFuels SDK v2 (Beta) +# Welcome to the FastFuels Python SDK Documentation! !!! warning "Beta" You are reading the documentation for the **v2 SDK**, which targets the FastFuels v2 API and is under active development. The v1 SDK - remains the default — its documentation lives in the main sections - of this site. Import v2 explicitly from `fastfuels_sdk.v2`. + remains the default — switch versions with the selector in the + header. Import v2 explicitly from `fastfuels_sdk.v2`. -The FastFuels v1 and v2 APIs are separate live services, and the SDK -ships an interface to each as versioned subpackages, so you can use them -side by side during migration: +## What is FastFuels? + +FastFuels is a cloud-based platform for generating forest inventory data. It +uses a combination of satellite imagery and machine learning to generate +tabular tree data and voxelized 3D fuel models. These data products can be used +to support wildfire risk assessment, fire behavior modeling, and other +applications. + +The [FastFuels documentation](https://docs.fastfuels.silvxlabs.com) covers +the platform itself: the web application, the HTTP API, and explanations of +the core concepts (domains, grids, inventories, features). This site covers +how to work with FastFuels from Python. + +## What is the FastFuels Python SDK? + +The FastFuels Python SDK is a Python package that provides a convenient +interface for interacting with the FastFuels API. It can be used to create and +manage FastFuels resources. It can also be used to download and process +generated data products. + +## Installation + +The FastFuels Python SDK can be installed using `pip`: + +```bash +pip install fastfuels-sdk +``` + +## Using the v2 SDK + +The v1 and v2 APIs are separate live services, and the SDK ships both as +versioned subpackages, so you can use them side by side during migration: ```python from fastfuels_sdk.v1 import Domain # v1 (current default) from fastfuels_sdk.v2 import Domain # v2 (Beta) ``` -Both subpackages read the same `FASTFUELS_API_KEY` environment variable. - -The [FastFuels documentation](https://docs.fastfuels.silvxlabs.com) -covers the v2 platform itself: the web application, the HTTP API, and -explanations of the core concepts (domains, grids, inventories, -features). This section covers how to work with it from Python: - -- Coming from v1? Start with [Migrating from v1](guides/migration.md). -- How-to guides: [Domains](guides/domains.md) and - [Features](guides/features.md). -- The [v2 Reference](../v2/reference.md) documents the full - `fastfuels_sdk.v2` surface. +Coming from v1? Start with [Migrating from v1](guides/migration.md). diff --git a/docs/v2/stylesheets/extra.css b/docs/v2/stylesheets/extra.css new file mode 100644 index 0000000..c76b44b --- /dev/null +++ b/docs/v2/stylesheets/extra.css @@ -0,0 +1,55 @@ +/* Version selector (mike + mkdocs-material). The stock theme renders the + selector as bare header text; style it as an explicit control. + (Duplicated in docs/v1 and docs/v2 — each version's site builds from its + own docs_dir.) */ +.md-version { + margin-left: 0.8rem; +} + +.md-version__current { + display: inline-flex; + align-items: center; + gap: 0.3rem; + background-color: rgba(0, 0, 0, 0.18); + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 2rem; + padding: 0.25rem 0.8rem; + font-size: 0.65rem; + font-weight: 600; + letter-spacing: 0.02em; + cursor: pointer; + transition: background-color 125ms, border-color 125ms; +} + +.md-version__current:hover, +.md-version__current:focus { + background-color: rgba(0, 0, 0, 0.32); + border-color: rgba(255, 255, 255, 0.6); +} + +.md-version__list { + margin-top: 0.5rem; + border-radius: 0.3rem; + box-shadow: var(--md-shadow-z3); + overflow: hidden; + min-width: 8rem; +} + +.md-version__item { + line-height: 1; +} + +.md-version__link { + display: block; + width: 100%; + padding: 0.6rem 1rem; + font-size: 0.7rem; + color: var(--md-default-fg-color); + transition: background-color 125ms, color 125ms; +} + +.md-version__link:hover, +.md-version__link:focus { + background-color: var(--md-default-fg-color--lightest); + color: var(--md-typeset-a-color); +} diff --git a/mkdocs.yml b/mkdocs.yml index 61401f4..3e0fe26 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,41 +1,23 @@ # mkdocs.yml # -# One docs tree: the root sections document the v1 SDK (the current -# default interface); the "v2 (Beta)" section documents the v2 -# subpackage shipping alongside it. Site versions are point-in-time -# release snapshots deployed by mike from .github/workflows/docs.yml on -# each GitHub release — the header selector lists releases (e.g. 0.21) -# with the `latest` alias as the default. Old snapshots are never -# rebuilt. Local preview: -# uv run mkdocs serve +# One config builds either docs version: DOCS_DIR selects the tree +# (defaults to docs/v1; set DOCS_DIR=docs/v2 for v2), and each tree +# defines its own nav in a SUMMARY.md (literate-nav). Versions deploy via +# mike as independent site snapshots picked from the header version +# selector; see scripts/docs.sh. Local preview: +# uv run mkdocs serve # v1 only +# DOCS_DIR=docs/v2 uv run mkdocs serve # v2 only +# ./scripts/docs.sh # both versions + selector site_name: FastFuels SDK Documentation # site_url is required for mike's version selector to keep the reader on # the same page when switching versions site_url: https://silvxlabs.github.io/fastfuels-sdk-python/ +docs_dir: !ENV [DOCS_DIR, docs/v1] + repo_url: https://github.com/silvxlabs/fastfuels-sdk-python repo_name: silvxlabs/fastfuels-sdk-python -nav: - - Home: index.md - - How-To Guides: - - Authentication: guides/authentication.md - - Domains: guides/domains.md - - Inventories: guides/inventories.md - - Point Clouds: guides/point_clouds.md - - Features: guides/features.md - - Grids: guides/grids.md - - Tutorials: - - Export to QUIC-Fire: tutorials/export_to_quicfire.md - - ALS Point Cloud: tutorials/point_cloud_example.md - - Reference: reference.md - - v2 (Beta): - - Overview: v2/index.md - - Migrating from v1: v2/guides/migration.md - - Domains: v2/guides/domains.md - - Features: v2/guides/features.md - - Reference: v2/reference.md - theme: name: "material" icon: @@ -81,6 +63,8 @@ extra_css: plugins: - search + - literate-nav: + nav_file: SUMMARY.md - mkdocstrings: handlers: python: diff --git a/pyproject.toml b/pyproject.toml index b7ebf8d..5f9f2f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,9 @@ dev = [ docs = [ "mike>=2.2.0", "mkdocs", + # pinned pre-0.6.3: 0.6.3 co-installs the properdocs mkdocs-fork and + # warns when run under mkdocs; revisit when the fork situation settles + "mkdocs-literate-nav==0.6.2", # major pinned per the Material docs (semver; majors break config) "mkdocs-material>=9.7,<10", "mkdocstrings", diff --git a/scripts/docs.sh b/scripts/docs.sh new file mode 100755 index 0000000..cec090d --- /dev/null +++ b/scripts/docs.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# +# Build the versioned docs — v1 and v2 mike snapshots — and serve or publish. +# +# ./scripts/docs.sh build both versions, then serve the full site +# (version selector included) at localhost:8000 +# ./scripts/docs.sh deploy publish both versions to gh-pages on origin +# +# mike snapshots each version onto the gh-pages branch and maintains the +# versions.json that powers the header version selector; plain +# `mkdocs serve` can only ever show one version (and 404s versions.json). +# Without `deploy`, the snapshots are committed to your local gh-pages +# branch only — `git branch -D gh-pages` discards them. +# +# Version labels mirror the FastFuels-Web docs (docs.fastfuels.silvxlabs.com). + +set -euo pipefail +cd "$(dirname "$0")/.." + +push="" +case "${1:-serve}" in + serve) ;; + deploy) push="--push" ;; + *) + echo "usage: $0 [deploy]" >&2 + exit 1 + ;; +esac + +uv run --group docs mike deploy $push --update-aliases v1 latest --title "v1" +DOCS_DIR=docs/v2 uv run --group docs mike deploy $push v2 --title "v2 (Beta)" +uv run --group docs mike set-default $push latest + +if [[ -z "$push" ]]; then + uv run --group docs mike serve +fi diff --git a/uv.lock b/uv.lock index 4e97e8e..f448fad 100644 --- a/uv.lock +++ b/uv.lock @@ -358,6 +358,7 @@ dev = [ docs = [ { name = "mike" }, { name = "mkdocs" }, + { name = "mkdocs-literate-nav" }, { name = "mkdocs-material" }, { name = "mkdocstrings" }, { name = "mkdocstrings-python" }, @@ -387,6 +388,7 @@ dev = [ docs = [ { name = "mike", specifier = ">=2.2.0" }, { name = "mkdocs" }, + { name = "mkdocs-literate-nav", specifier = "==0.6.2" }, { name = "mkdocs-material", specifier = ">=9.7,<10" }, { name = "mkdocstrings" }, { name = "mkdocstrings-python" }, @@ -814,6 +816,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" }, ] +[[package]] +name = "mkdocs-literate-nav" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/5f/99aa379b305cd1c2084d42db3d26f6de0ea9bf2cc1d10ed17f61aff35b9a/mkdocs_literate_nav-0.6.2.tar.gz", hash = "sha256:760e1708aa4be86af81a2b56e82c739d5a8388a0eab1517ecfd8e5aa40810a75", size = 17419, upload-time = "2025-03-18T21:53:09.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/84/b5b14d2745e4dd1a90115186284e9ee1b4d0863104011ab46abb7355a1c3/mkdocs_literate_nav-0.6.2-py3-none-any.whl", hash = "sha256:0a6489a26ec7598477b56fa112056a5e3a6c15729f0214bea8a4dbc55bd5f630", size = 13261, upload-time = "2025-03-18T21:53:08.1Z" }, +] + [[package]] name = "mkdocs-material" version = "9.7.6" From 51751c6215b47b77685cddc4266ba31b64f10894 Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Sun, 7 Jun 2026 12:12:12 -0600 Subject: [PATCH 20/71] Document the docs layout and dev workflow in docs/README.md --- docs/README.md | 51 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 docs/README.md diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..9d38c01 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,51 @@ +# FastFuels SDK documentation + +This site is built with [mkdocs-material](https://squidfunk.github.io/mkdocs-material/) +and versioned with [mike](https://github.com/jimporter/mike). The header +has a version dropdown with two entries, mirroring the FastFuels +platform docs: **v1** (the current default SDK, alias `latest`) and +**v2 (Beta)** (the `fastfuels_sdk.v2` subpackage). + +## Layout + +| Path | What it is | +|---|---| +| `docs/v1/` | v1 SDK docs — frozen alongside the v1 SDK (bugfix-level edits only) | +| `docs/v2/` | v2 SDK docs — new documentation lands here | +| `docs/v1/SUMMARY.md`, `docs/v2/SUMMARY.md` | Each version's nav (mkdocs-literate-nav) | +| `docs/v1/stylesheets/extra.css`, `docs/v2/stylesheets/extra.css` | Duplicates by construction — edit both | +| `mkdocs.yml` (repo root) | One config for both versions: `DOCS_DIR` picks the tree (default `docs/v1`) | +| `scripts/docs.sh` | The docs entry point: build, serve, deploy | + +## Launch the docs server + +The full site, version dropdown included, at : + +```bash +./scripts/docs.sh +``` + +This builds both versions onto your **local** `gh-pages` branch and +serves it with mike (`git branch -D gh-pages` discards the preview). + +For quick edits to one version, plain mkdocs gives live reload: + +```bash +uv run mkdocs serve # v1 +DOCS_DIR=docs/v2 uv run mkdocs serve # v2 +``` + +> [!NOTE] +> The version dropdown only exists under mike — plain `mkdocs serve` +> shows a single version and logs a `versions.json` 404. That's +> expected, not a bug. + +## Deploy + +```bash +./scripts/docs.sh deploy +``` + +Publishes both versions to `gh-pages` on origin. The docs workflow +(`.github/workflows/docs.yml`) runs the same command on each GitHub +release (or manually via workflow_dispatch). From b129c1a65941745994f74acaec7f3dc164f1220e Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Sun, 7 Jun 2026 12:27:53 -0600 Subject: [PATCH 21/71] Move the docs entry point to docs/deploy.sh --- .github/workflows/docs.yml | 4 ++-- CLAUDE.md | 6 +++--- docs/README.md | 6 +++--- scripts/docs.sh => docs/deploy.sh | 4 ++-- mkdocs.yml | 4 ++-- 5 files changed, 12 insertions(+), 12 deletions(-) rename scripts/docs.sh => docs/deploy.sh (87%) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index f41ee53..63aab77 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -21,7 +21,7 @@ jobs: run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - # scripts/docs.sh is the single source of truth for versions, titles, + # docs/deploy.sh is the single source of truth for versions, titles, # and aliases — the same script serves the local preview. - name: Deploy versioned docs (v1 + v2) - run: ./scripts/docs.sh deploy + run: ./docs/deploy.sh deploy diff --git a/CLAUDE.md b/CLAUDE.md index 282cc03..7c6366f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,9 +55,9 @@ rationale and issue references belong in `#` code comments. ### Conventions - Version labels mirror the platform docs: **"v1"** (alias `latest`) and - **"v2 (Beta)"** — set as mike titles in `scripts/docs.sh`, the single - entry point for both the local preview (`./scripts/docs.sh`) and the - gh-pages deploy (`./scripts/docs.sh deploy`, run by the docs workflow). + **"v2 (Beta)"** — set as mike titles in `docs/deploy.sh`, the single + entry point for both the local preview (`./docs/deploy.sh`) and the + gh-pages deploy (`./docs/deploy.sh deploy`, run by the docs workflow). - One `mkdocs.yml` builds both versions: `DOCS_DIR` selects `docs/v1` (default) or `docs/v2`; each tree owns its nav in a `SUMMARY.md` (literate-nav). Versions deploy as independent mike snapshots picked diff --git a/docs/README.md b/docs/README.md index 9d38c01..d689a8a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,14 +15,14 @@ platform docs: **v1** (the current default SDK, alias `latest`) and | `docs/v1/SUMMARY.md`, `docs/v2/SUMMARY.md` | Each version's nav (mkdocs-literate-nav) | | `docs/v1/stylesheets/extra.css`, `docs/v2/stylesheets/extra.css` | Duplicates by construction — edit both | | `mkdocs.yml` (repo root) | One config for both versions: `DOCS_DIR` picks the tree (default `docs/v1`) | -| `scripts/docs.sh` | The docs entry point: build, serve, deploy | +| `docs/deploy.sh` | The docs entry point: build, serve, deploy | ## Launch the docs server The full site, version dropdown included, at : ```bash -./scripts/docs.sh +./docs/deploy.sh ``` This builds both versions onto your **local** `gh-pages` branch and @@ -43,7 +43,7 @@ DOCS_DIR=docs/v2 uv run mkdocs serve # v2 ## Deploy ```bash -./scripts/docs.sh deploy +./docs/deploy.sh deploy ``` Publishes both versions to `gh-pages` on origin. The docs workflow diff --git a/scripts/docs.sh b/docs/deploy.sh similarity index 87% rename from scripts/docs.sh rename to docs/deploy.sh index cec090d..c42aeb6 100755 --- a/scripts/docs.sh +++ b/docs/deploy.sh @@ -2,9 +2,9 @@ # # Build the versioned docs — v1 and v2 mike snapshots — and serve or publish. # -# ./scripts/docs.sh build both versions, then serve the full site +# ./docs/deploy.sh build both versions, then serve the full site # (version selector included) at localhost:8000 -# ./scripts/docs.sh deploy publish both versions to gh-pages on origin +# ./docs/deploy.sh deploy publish both versions to gh-pages on origin # # mike snapshots each version onto the gh-pages branch and maintains the # versions.json that powers the header version selector; plain diff --git a/mkdocs.yml b/mkdocs.yml index 3e0fe26..6908c26 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -4,10 +4,10 @@ # (defaults to docs/v1; set DOCS_DIR=docs/v2 for v2), and each tree # defines its own nav in a SUMMARY.md (literate-nav). Versions deploy via # mike as independent site snapshots picked from the header version -# selector; see scripts/docs.sh. Local preview: +# selector; see docs/deploy.sh. Local preview: # uv run mkdocs serve # v1 only # DOCS_DIR=docs/v2 uv run mkdocs serve # v2 only -# ./scripts/docs.sh # both versions + selector +# ./docs/deploy.sh # both versions + selector site_name: FastFuels SDK Documentation # site_url is required for mike's version selector to keep the reader on # the same page when switching versions From 1e30efa5febe49e9c8c2b18bf012ac88c4523f25 Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Tue, 9 Jun 2026 09:30:44 -0600 Subject: [PATCH 22/71] Add v2 grids module and refactor v2 surface to functional creators (#178) Implements the settled hybrid v2 API: creation is module-level functions (create_*_grid_from_*), and operations on a held resource are methods. grids.py (new, #178): - Grid record (subclasses the generated model) with refresh/wait/update/ delete and the transform methods resample, lookup_fuel_model_values, and export. - Creators: 3dep/landfire topography, landfire canopy, meta/naip CHM, landfire fbfm40/fccs, treemap (create_pim_grid_from_treemap), geotiff/ netcdf upload, and uniform. - Top-level list_grids/get_grid and check_3dep_coverage; shared _build_alignment helper translating output_resolution_m/align_to/align/ resampling into the alignment union. Wait model (_jobs.py): wait/wait_all polling refresh(), JobFailedError. features.py/domains.py: move creation to module-level functions; uniform lifecycle (get->refresh in place, wait, in-place update); Domain.delete gains force= for cascade. __init__.py: expose the grids/features submodules plus a flat top-level surface (Grid, list_grids, get_grid, wait_all, JobFailedError, ...). Remove the superseded drafts and COMPARISON.md. Add live tests in test_grids.py and update the feature/domain tests and shared fixtures for the function-based creators and the rename. v2_api_design.md captures the working design notes. Live-verified against the prod API: tests/v2/test_grids.py 50 passed, 2 skipped (deferred geotiff/netcdf upload paths). --- fastfuels_sdk/v2/COMPARISON.md | 159 --- fastfuels_sdk/v2/__init__.py | 42 +- fastfuels_sdk/v2/_jobs.py | 133 ++- fastfuels_sdk/v2/domains.py | 62 +- fastfuels_sdk/v2/draft_grids_opc.py | 232 ---- fastfuels_sdk/v2/draft_inventories_opc.py | 158 --- fastfuels_sdk/v2/features.py | 770 ++++++------ fastfuels_sdk/v2/grids.py | 1296 +++++++++++++++++++++ fastfuels_sdk/v2/v2_api_design.md | 212 ++++ tests/v2/conftest.py | 60 +- tests/v2/test_domains.py | 35 +- tests/v2/test_features.py | 95 +- tests/v2/test_grids.py | 478 ++++++++ tests/v2/utils.py | 2 +- 14 files changed, 2692 insertions(+), 1042 deletions(-) delete mode 100644 fastfuels_sdk/v2/COMPARISON.md delete mode 100644 fastfuels_sdk/v2/draft_grids_opc.py delete mode 100644 fastfuels_sdk/v2/draft_inventories_opc.py create mode 100644 fastfuels_sdk/v2/grids.py create mode 100644 fastfuels_sdk/v2/v2_api_design.md create mode 100644 tests/v2/test_grids.py diff --git a/fastfuels_sdk/v2/COMPARISON.md b/fastfuels_sdk/v2/COMPARISON.md deleted file mode 100644 index 11a8bca..0000000 --- a/fastfuels_sdk/v2/COMPARISON.md +++ /dev/null @@ -1,159 +0,0 @@ -# v2 Client Library Generator Comparison - -> **Status: decision executed (2026-06-05).** openapi-python-client won; -> its output now lives at `fastfuels_sdk/v2/client_library/` and the -> `draft_*_opc.py` modules import from there. The openapi-generator tree -> and the `draft_*_oag.py` modules have been deleted (regenerable via the -> recipe below if the comparison ever needs to be revisited). This -> document is the record of the evaluation. - -Two complete generated clients for the FastFuels v2 API (OpenAPI **3.1**, -FastAPI, pydantic/geojson-pydantic), each exercised through **four pairs of -draft wrapper modules** at v1-parity ergonomics, structured identically so -each `diff draft_X_oag.py draft_X_opc.py` reads as generator differences -only (`NOTE (comparison)` comments mark each forced divergence): - -| Resource | Drafts | Endpoints covered | -|---|---|---| -| Domains | `draft_domains_{oag,opc}.py` | create (from_geojson / from_geodataframe / from_file), get, update, delete, list, to_geodataframe/to_json | -| Grids | `draft_grids_{oag,opc}.py` | LANDFIRE FBFM40, TreeMap (PIM), 3DEP topography/elevation; get, wait_until_completed, delete, list | -| Features | `draft_features_{oag,opc}.py` | OSM road, OSM water; get, wait_until_completed, update, delete, list | -| Inventories | `draft_inventories_{oag,opc}.py` | **PIM grid → tree inventory** (`create_from_pim_grid`); get, wait_until_completed, delete, list | - -| | openapi-generator 7.22.0 | openapi-python-client 0.29.0 | -|---|---|---| -| Stack | pydantic v2 + urllib3 (same as v1) | attrs + httpx | -| Imports | absolute (fixed via dotted `--package-name`) | relative (relocatable as-is) | -| OpenAPI 3.1 support | "still in beta" (generator warning) | native | -| Generated size | 209 model files, **29 \*Api classes** (multi-tag operations duplicated into one class per tag) | 195 model files, 11 api/ packages (one per resource) | -| Functional against the live API | domains: **no** (broken geometry codegen); grids/features/inventories: yes (geometry-free payloads) | all resources: yes | - -## Verdict: openapi-python-client - -The deciding criterion is `anyOf` handling (next section): the v2 spec is -saturated with `anyOf`, openapi-generator's treatment of it ranges from -awkward (wrapper objects) to broken (geometry parsing crashes), and this -is structural to how FastAPI emits specs — not fixable by waiting. -openapi-generator's genuine advantages — typed exceptions and the -v1-familiar per-resource Api classes — are thin layers that the real v2 -wrappers can recreate over opc (see Design notes). - -## Why `anyOf` is the deciding criterion - -The v2 spec contains **202 `anyOf` occurrences across 68 of 177 schemas -(38%)**. This is structural: FastAPI + pydantic v2 emit OpenAPI 3.1 where -every `Optional[X]` field becomes `anyOf: [X, null]`, and geojson-pydantic -contributes geometry unions and `Position2D | Position3D` coordinate -arrays. Four resource pairs gave a precise taxonomy of how each generator -copes: - -| `anyOf` shape | Example | openapi-generator | openapi-python-client | -|---|---|---|---| -| `[X, null]` (nullable scalar/model) | `Feature.georeference`, `Grid.progress` | OK — `Optional[X]` | OK — `X \| None \| Unset` | -| `[X, Y, null]` (multi-model union) | `Grid.georeference` (2D/3D) | **wrapper dispatcher object** (`Georeference1(actual_instance=...)`) | native union — plain `Georeference` | -| union with plain values | `Domain.bbox` (4- or 6-tuple), `Band.nodata` | **wrapper models** (`Bbox(actual_instance=[...])`, `Nodata`) that reject plain values | plain `list[float]` / `int` | -| nested array unions (GeoJSON coordinates) | every geometry | **broken generated code** — `List[CoordinatesInner].from_dict(...)` raises `AttributeError`; the anyOf dispatcher only catches `ValidationError`, so parsing ANY domain crashes | parses to the right class (`Polygon`) | - -Reproduce the domains blocker: - -```python -from fastfuels_sdk.v2.draft_domains_oag import Domain -Domain.from_file("tests/data/blue_mtn.geojson") -# AttributeError: type object 'list' has no attribute 'from_dict' -# (crashes in the generated client before any API call is made) -``` - -The grids/features/inventories drafts sharpened the picture: their -payloads are geometry-free, so the oag client *parses them* — the hard -blocker is specifically geojson-pydantic's coordinate schemas, i.e. -exactly the schemas every Domain response contains. - -## What writing every wrapper twice revealed - -**546 of ~800 non-blank lines (≈ 68%) are identical across the four -pairs** (domains 187/287, grids 141/194, features 122/180, inventories -96/138). Everything that makes the SDK an SDK — geopandas conversions, -CRS forwarding, job polling, v1 ergonomics, docstrings — is -generator-independent. The differing third is plumbing: - -| Aspect | openapi-generator (oag) | openapi-python-client (opc) | -|---|---|---| -| Client bootstrap | `Configuration` → `ApiClient` → one `*Api` object **per resource** (each draft repeats the bootstrap; a real impl needs a v1-style `api.py`) | one `AuthenticatedClient(base_url, token, prefix="", auth_header_name="api-key")` imported by every draft | -| Endpoint call | `get_grids_api().get_grid(domain_id, grid_id)` | `get_grid.sync(domain_id, grid_id, client=...)` — module-per-endpoint functions | -| Error handling | **typed exceptions raised natively** (`NotFoundException`, `UnprocessableEntityException`, `ServiceException`) — same as v1 | returns documented error models (a 422 is a returned `HTTPValidationError` object, even with `raise_on_unexpected_status=True`); drafts share a `_checked()` unwrapper | -| Model ↔ wrapper conversion | `cls.from_dict(model.to_dict())` — the v1 `cls(**model.model_dump())` pattern **breaks** on anyOf wrapper fields | `cls.from_dict(model.to_dict())` — `from_dict` constructs `cls`, so subclassing just works | -| Request body construction | `from_dict` only (kwargs construction rejects plain values for anyOf wrapper fields) | `from_dict` or kwargs both work | -| `to_dict()` output | **leaks enum objects** (`LandfireFbfm40Version.ENUM_2024`, `PointProcess.INHOMOGENEOUS_POISSON`) — not JSON-safe without `to_json()` | plain values (`"2024"`, `"inhomogeneous_poisson"`) | -| "absent field" sentinel | `None` — conflates *omit* with *set to null*; PATCH bodies rely on `to_dict()` dropping `None`s | explicit `UNSET` sentinel — omitted fields stay out of the PATCH body by construction | -| Field-copy idiom (in-place refresh) | `for f in Model.model_fields: setattr(...)` | `for f in attrs.fields(Model): setattr(...)` + `additional_properties` | -| Reserved-ish names | `type` | `type_` | -| Digit-leading op names | `create3dep_topography` | `create_3dep_topography` | -| API surface organization | 29 `*Api` classes for 11 resources (operations duplicated per tag: `create_osm_road_feature` lives in both `FeaturesApi` and `FeaturesRoadApi`) | 11 `api//` packages, one module per operation | -| Async | none | free `asyncio`/`asyncio_detailed` variants of every endpoint | - -Shared findings (identical code in both drafts): - -- **v2 collapses v1's per-type resources** (surface/tree/topography grids; - road/water features) into unified job-based resources distinguished by - `source`/`type`. Grid, Feature, and Inventory all share the same - `status`/`progress`/`error` lifecycle — `wait_until_completed` is - line-identical across all six job-resource drafts, in both stacks - (`JobStatus` is exported under the same name by both clients). -- The **PIM workflow** chains cleanly through the wrappers: - `Grid.create_treemap(...)` → `wait_until_completed()` → - `Inventory.create_from_pim_grid(domain_id, grid.id)` → `wait_until_completed()`. -- Spec details encoded in both drafts: creation `version` fields are - *string* enums (`"2024"`); 3DEP `source_resolution` is an int enum - (1/10/30); `Band` objects in responses vs band-name strings in requests; - `FeatureGeoreference.bounds` is required. - -## What openapi-python-client needed - -1. **A spec patch**: the spec has two schemas titled `Feature` - (the FastFuels feature resource and geojson-pydantic's GeoJSON - Feature). opc names classes from titles and refuses duplicates — - without the patch it silently drops every domain endpoint. - `generate_client.sh` re-titles the GeoJSON one to `GeoJsonFeature`. - The proper fix is in FastFuels-API-v2 (re-title the model) so the spec - has no collision. -2. **Dependencies**: `httpx`, `attrs`, `python-dateutil` (currently in the - dev group; move to runtime deps when v2 ships). - -## Design notes for the real v2 wrappers (carried out of the drafts) - -- **Shared job-resource base.** Grid/Feature/Inventory repeat identical - `get`/`wait_until_completed`/`delete`/`_copy_fields_from` code — the - real SDK should factor a `_JobResource` mixin (status polling, error - raising, in-place refresh) used by all three. -- **`fastfuels_sdk/v2/exceptions.py`** translating opc's returned error - models / `UnexpectedStatus` into typed SDK exceptions - (`NotFoundException`, ...) — recreates v1 (and oag) ergonomics. The - drafts' shared `_checked()` is the seed. -- **One cached `AuthenticatedClient`** (the drafts' `get_client()`) - replaces v1's per-resource API singletons in `api.py`. -- The `Resource(GeneratedModel)` subclass pattern carries over from v1: - opc's generated `from_dict` constructs `cls`, so classmethod - constructors and in-place refresh work unchanged. -- `from_geodataframe` now forwards the GeoDataFrame CRS (v1 silently - dropped it and relied on the EPSG:4326 default — latent bug for - projected inputs). -- v1's `Domain.export()` has no v2 endpoint; v2-only endpoints - (preview/reproject domain, domain lattice, feature/inventory data - partitions, grid data/exports) are unwrapped so far. - -## Regeneration - -```bash -cd fastfuels_sdk/v2 && bash generate_client.sh # regenerates client_library/ -``` - -To reproduce the deleted openapi-generator client for re-evaluation: - -```bash -openapi-generator generate -i -g python \ - --package-name fastfuels_sdk.v2.client_library_openapi_generator -o -``` - -Remaining cleanup: the scratch `fastfuels_sdk/client_library_v2/` tree -(pre-dating this comparison) is fully superseded; the `draft_*_opc.py` -modules become the seeds of the real v2 wrappers. diff --git a/fastfuels_sdk/v2/__init__.py b/fastfuels_sdk/v2/__init__.py index 6c600a3..e431e31 100644 --- a/fastfuels_sdk/v2/__init__.py +++ b/fastfuels_sdk/v2/__init__.py @@ -1,12 +1,48 @@ +""" +FastFuels SDK v2. + +The settled access pattern is a single import reaching everything through the +package namespace:: + + import fastfuels_sdk.v2 as ff + + domain = ff.Domain.from_file("aoi.geojson") + grid = ff.grids.create_topography_grid_from_3dep(domain, output_resolution_m=10) + grid.wait() + +- Resource creators are module-qualified functions: ``ff.grids.create_*``, + ``ff.features.create_*``. +- Operations on a resource you hold are methods: ``grid.wait()``, + ``grid.resample(...)``, ``grid.delete()``. +- Cross-cutting helpers are top-level: ``ff.Domain``, ``ff.get_grid``, + ``ff.list_grids``, ``ff.wait_all``, ``ff.set_api_key``. +""" + +from fastfuels_sdk.v2 import features, grids +from fastfuels_sdk.v2._jobs import JobFailedError, wait_all from fastfuels_sdk.v2.api import set_api_key from fastfuels_sdk.v2.domains import Domain, list_domains, reproject_geojson -from fastfuels_sdk.v2.features import Feature, list_features +from fastfuels_sdk.v2.features import Feature, get_feature, list_features +from fastfuels_sdk.v2.grids import Grid, get_grid, list_grids __all__ = [ + # Submodules (resource creators live here: ff.grids.create_*, ff.features.create_*) + "features", + "grids", + # Configuration "set_api_key", + # Records "Domain", - "list_domains", - "reproject_geojson", "Feature", + "Grid", + # Fetch / list helpers + "list_domains", "list_features", + "get_feature", + "list_grids", + "get_grid", + "reproject_geojson", + # Jobs + "wait_all", + "JobFailedError", ] diff --git a/fastfuels_sdk/v2/_jobs.py b/fastfuels_sdk/v2/_jobs.py index b669f9d..5384ab8 100644 --- a/fastfuels_sdk/v2/_jobs.py +++ b/fastfuels_sdk/v2/_jobs.py @@ -1,9 +1,10 @@ """ fastfuels_sdk/v2/_jobs.py -Shared polling helper for job-based v2 resources (Feature, Grid, +Shared polling helpers for job-based v2 resources (Feature, Grid, Inventory). Private module: each resource exposes its own documented -``wait_until_completed`` method that delegates here. +``wait`` method that delegates to :func:`wait` here. :func:`wait_all` and +:class:`JobFailedError` are re-exported at the package top level. """ import time @@ -13,49 +14,117 @@ _TERMINAL_STATUSES = (JobStatus.COMPLETED, JobStatus.FAILED) +# Server-side poll interval. Not user-tunable: the job runs server-side +# regardless of how often we poll, so the interval is an implementation +# detail rather than part of the wait contract. +_POLL_SECONDS = 5 -def _format_job_error(resource, error) -> str: - """Build a human-readable message from a generated JobError.""" + +class JobFailedError(Exception): + """Raised when a job-based resource reaches the ``failed`` status. + + Attributes + ---------- + code : str or None + Machine-readable error code reported by the API (``None`` when the + API returned no error detail). + message : str or None + Human-readable error message reported by the API. + suggestion : str or None + Actionable advice reported by the API, when present. + """ + + def __init__(self, display, *, code=None, message=None, suggestion=None): + self.code = code + self.message = message + self.suggestion = suggestion + super().__init__(display) + + +def _job_failed_error(resource, error) -> JobFailedError: + """Build a :class:`JobFailedError` from a generated ``JobError``.""" label = f"{type(resource).__name__} {resource.id}" if error is None or isinstance(error, Unset): - return f"{label} failed (no error detail returned)." - message = f"{label} failed [{error.code}]: {error.message}" + return JobFailedError(f"{label} failed (no error detail returned).") suggestion = getattr(error, "suggestion", None) - if suggestion and not isinstance(suggestion, Unset): - message += f" Suggestion: {suggestion}" - return message - - -def wait_until_completed( - resource, - step: float = 5, - timeout: float = 600, - in_place: bool = True, - verbose: bool = False, -): + if isinstance(suggestion, Unset): + suggestion = None + display = f"{label} failed [{error.code}]: {error.message}" + if suggestion: + display += f" Suggestion: {suggestion}" + return JobFailedError( + display, code=error.code, message=error.message, suggestion=suggestion + ) + + +def wait(resource, timeout=None, verbose=False): """Poll a job resource until it reaches a terminal status. - Duck-typed on the resource wrapper: requires ``get()``, ``status``, - ``error``, ``id``, and ``_copy_fields_from()``. + Duck-typed on the resource wrapper: requires ``refresh()``, ``status``, + ``error``, and ``id``. Updates the resource in place on every poll and + returns it, so calls chain. + + Parameters + ---------- + resource + A job-based resource wrapper (Feature, Grid, ...). + timeout : float, optional + Maximum seconds to wait. ``None`` (default) waits indefinitely; the + job runs server-side regardless, so a bounded wait is resumable. + verbose : bool, optional + Print status updates while polling. + + Raises + ------ + TimeoutError + If ``timeout`` is set and elapsed before a terminal status. + JobFailedError + If the job reaches the ``failed`` status. """ elapsed = 0.0 - latest = resource.get() - while latest.status not in _TERMINAL_STATUSES: - if elapsed >= timeout: + resource.refresh() + while resource.status not in _TERMINAL_STATUSES: + if timeout is not None and elapsed >= timeout: raise TimeoutError( f"{type(resource).__name__} {resource.id} did not complete " f"within {timeout} seconds" ) - time.sleep(step) - elapsed += step - latest = resource.get() + time.sleep(_POLL_SECONDS) + elapsed += _POLL_SECONDS + resource.refresh() if verbose: print( f"{type(resource).__name__} {resource.id}: " - f"{latest.status} ({elapsed:.0f}s)" + f"{resource.status} ({elapsed:.0f}s)" ) - if latest.status == JobStatus.FAILED: - raise RuntimeError(_format_job_error(resource, latest.error)) - if in_place: - return resource._copy_fields_from(latest) - return latest + if resource.status == JobStatus.FAILED: + raise _job_failed_error(resource, resource.error) + return resource + + +def wait_all(resources, timeout=None, verbose=False): + """Wait for several job resources to reach a terminal status. + + Jobs run server-side in parallel, so creating resources without waiting + and then joining them here runs the work concurrently. Resources are + joined in iteration order; the first one to be found ``failed`` raises + its :class:`JobFailedError`. + + Parameters + ---------- + resources : iterable + Job-based resource wrappers to wait on. + timeout : float, optional + Per-resource timeout in seconds (``None`` waits indefinitely). + verbose : bool, optional + Print status updates while polling. + + Returns + ------- + list + The resources, each updated in place to its terminal state. + """ + resources = list(resources) + for resource in resources: + wait(resource, timeout=timeout, verbose=verbose) + return resources diff --git a/fastfuels_sdk/v2/domains.py b/fastfuels_sdk/v2/domains.py index 8f7802a..acc638a 100644 --- a/fastfuels_sdk/v2/domains.py +++ b/fastfuels_sdk/v2/domains.py @@ -398,7 +398,7 @@ def preview( Domain The previewed Domain object. Its ``id`` is always ``"preview"`` — not a real domain identifier, so API-backed - instance methods (``get``, ``update``, ``delete``, + instance methods (``refresh``, ``update``, ``delete``, ``get_lattice``) will not work on it. Raises @@ -420,21 +420,14 @@ def preview( ) return cls._from_model(expect(response)) - def get(self, in_place: bool = False) -> "Domain": - """Retrieve the latest domain data from the API. - - Parameters - ---------- - in_place : bool, optional - If True, updates the current Domain instance with the new data - and returns self. If False (default), returns a new Domain - instance with the latest data, leaving the current instance - unchanged. + def refresh(self) -> "Domain": + """Update this Domain in place with the latest data from the API. Returns ------- Domain - The updated Domain object. + ``self``, updated with the latest data (so calls chain). To fetch + a fresh, separate copy instead, use :meth:`Domain.from_id`. Raises ------ @@ -444,24 +437,19 @@ def get(self, in_place: bool = False) -> "Domain": Examples -------- >>> domain = Domain.from_id("abc123") - >>> updated_domain = domain.get() # new instance - >>> domain.get(in_place=True) # refresh in place + >>> domain.refresh() # refresh in place """ domain_id = self._require_id() response = get_domain.sync_detailed(client=ensure_client(), domain_id=domain_id) - model = expect(response) - if in_place: - return self._copy_fields_from(model) - return Domain._from_model(model) + return self._copy_fields_from(expect(response)) def update( self, name: Optional[str] = None, description: Optional[str] = None, tags: Optional[List[str]] = None, - in_place: bool = False, ) -> "Domain": - """Update the domain's mutable properties (name, description, tags). + """Update the domain's mutable properties (name, description, tags) in place. Only provided fields are sent in the PATCH body (the generated UNSET sentinel keeps omitted fields out of the request entirely). @@ -475,15 +463,11 @@ def update( New description for the domain. tags : List[str], optional New tags for the domain (replaces existing tags). - in_place : bool, optional - If True, updates the current Domain instance with the new data - and returns self. If False (default), returns a new Domain - instance, leaving the current instance unchanged. Returns ------- Domain - The updated Domain object. + ``self``, updated (so calls chain). Raises ------ @@ -492,10 +476,10 @@ def update( Examples -------- - >>> domain = domain.update(name="new name", tags=["test"]) + >>> domain.update(name="new name", tags=["test"]) """ if name is None and description is None and tags is None: - return self if in_place else Domain.from_dict(self.to_dict()) + return self request_body = UpdateDomainRequestBody( name=name if name is not None else UNSET, @@ -506,27 +490,35 @@ def update( response = update_domain.sync_detailed( client=ensure_client(), domain_id=domain_id, body=request_body ) - model = expect(response) - if in_place: - return self._copy_fields_from(model) - return Domain._from_model(model) + return self._copy_fields_from(expect(response)) - def delete(self) -> None: - """Delete this domain and all resources associated with it. + def delete(self, force: bool = False) -> None: + """Delete this domain. + + Parameters + ---------- + force : bool, optional + If True, cascade-delete every child resource (features, grids, + inventories, exports) in the domain. If False (default), the API + rejects the request with 412 when the domain still has child + resources. Raises ------ NotFoundException If the domain no longer exists. + ApiException + With ``status_code`` 412 if the domain has child resources and + ``force`` is False. Examples -------- >>> domain = Domain.from_id("abc123") - >>> domain.delete() + >>> domain.delete(force=True) # also deletes its grids and features """ domain_id = self._require_id() response = delete_domain.sync_detailed( - client=ensure_client(), domain_id=domain_id + client=ensure_client(), domain_id=domain_id, force=force ) expect(response, HTTPStatus.NO_CONTENT) diff --git a/fastfuels_sdk/v2/draft_grids_opc.py b/fastfuels_sdk/v2/draft_grids_opc.py deleted file mode 100644 index 900d4b2..0000000 --- a/fastfuels_sdk/v2/draft_grids_opc.py +++ /dev/null @@ -1,232 +0,0 @@ -""" -fastfuels_sdk/v2/draft_grids_opc.py - -DRAFT — Grid wrapper built on the **openapi-python-client** client -(`client_library`: attrs models + httpx). - -Part of the v2 client-library generator comparison; see COMPARISON.md. -Covers a representative slice of the grid endpoints: LANDFIRE FBFM40, -TreeMap (PIM), and 3DEP topography (elevation) creation, plus the -get/wait/delete/list lifecycle shared by all grid sources. -""" - -import json -import time -from typing import Any, List, Optional, Type, TypeVar - -import attrs - -# NOTE (comparison): one AuthenticatedClient serves every resource — no -# per-resource API objects. The domains draft has been promoted to the -# real fastfuels_sdk/v2/domains.py (built on api.py/exceptions.py); the -# remaining drafts borrow the shared client and keep the draft-era -# _checked() helper below until their own promotion (#178-#180). -from fastfuels_sdk.v2.api import ensure_client as get_client - -from fastfuels_sdk.v2.client_library.api.grids import ( - create_3dep_topography, - create_landfire_fbfm40, - create_treemap, - delete_grid, - get_grid, - list_grids as list_grids_endpoint, -) -from fastfuels_sdk.v2.client_library.models import ( - Grid as GridModel, - CreateLandfireFbfm40Request, - CreateThreeDepTopographyRequest, - CreateTreeMapRequest, - HTTPValidationError, - JobStatus, - ListGridsResponse, -) - -T = TypeVar("T") - - -def _checked(response: Any, expected: Type[T]) -> T: - """Unwrap a generated sync() response, raising on error results. - - Draft-era helper retained from the promoted domains draft; the real - wrappers use sync_detailed() + fastfuels_sdk.v2.exceptions.expect() - instead (see domains.py). Goes away as #178-#180 promote these drafts. - """ - if isinstance(response, expected): - return response - if isinstance(response, HTTPValidationError): - raise RuntimeError(f"API validation error (422): {response.detail}") - raise RuntimeError(f"Unexpected API response: {response!r}") - - -def _base_request_dict( - name: str, - description: str, - tags: Optional[List[str]], - extent_buffer_cells: int, -) -> dict: - """Build the request fields shared by every grid creation endpoint.""" - body: dict[str, Any] = { - "name": name, - "description": description, - "extent_buffer_cells": extent_buffer_cells, - } - if tags is not None: - body["tags"] = tags - return body - - -class Grid(GridModel): - """Grid resource for the FastFuels v2 API (openapi-python-client draft). - - Inherits all attributes from the generated attrs model: id, domain_id, - name, description, status, progress, source, bands, georeference, - modifications, error, chunks, tags, created_on, modified_on. - - v2 unifies all grid types (surface/tree/topography/feature in v1) into - one job-based Grid resource distinguished by its `source`. - """ - - @classmethod - def _from_model(cls, model: GridModel) -> "Grid": - # The idiomatic opc conversion: round-trip through the generated - # to_dict/from_dict (from_dict constructs `cls`, i.e. this subclass). - return cls.from_dict(model.to_dict()) - - def _copy_fields_from(self, model: GridModel) -> "Grid": - """Copy all generated-model fields from `model` onto self (in-place).""" - for field in attrs.fields(GridModel): - if field.init: - setattr(self, field.name, getattr(model, field.name)) - self.additional_properties = dict(model.additional_properties) - return self - - @classmethod - def from_id(cls, domain_id: str, grid_id: str) -> "Grid": - """Retrieve an existing Grid by domain and grid ID.""" - response = get_grid.sync(domain_id, grid_id, client=get_client()) - return cls._from_model(_checked(response, GridModel)) - - @classmethod - def create_landfire_fbfm40( - cls, - domain_id: str, - name: str = "", - description: str = "", - tags: Optional[List[str]] = None, - extent_buffer_cells: int = 0, - version: str = "2024", - remove_non_burnable: Optional[List[str]] = None, - ) -> "Grid": - """Create a surface fuel model grid from LANDFIRE FBFM40.""" - body = _base_request_dict(name, description, tags, extent_buffer_cells) - body["version"] = str(version) # version enums are strings - if remove_non_burnable is not None: - body["remove_non_burnable"] = remove_non_burnable - request = CreateLandfireFbfm40Request.from_dict(body) - response = create_landfire_fbfm40.sync( - domain_id, client=get_client(), body=request - ) - return cls._from_model(_checked(response, GridModel)) - - @classmethod - def create_treemap( - cls, - domain_id: str, - name: str = "", - description: str = "", - tags: Optional[List[str]] = None, - extent_buffer_cells: int = 0, - version: str = "2022", - bands: Optional[List[str]] = None, - ) -> "Grid": - """Create a plot identification (PIM) grid from TreeMap.""" - body = _base_request_dict(name, description, tags, extent_buffer_cells) - body["version"] = str(version) # version enums are strings - if bands is not None: - body["bands"] = bands # API default: ["tm_id"] - request = CreateTreeMapRequest.from_dict(body) - response = create_treemap.sync(domain_id, client=get_client(), body=request) - return cls._from_model(_checked(response, GridModel)) - - @classmethod - def create_3dep_topography( - cls, - domain_id: str, - name: str = "", - description: str = "", - tags: Optional[List[str]] = None, - extent_buffer_cells: int = 0, - source_resolution: int = 10, - bands: Optional[List[str]] = None, - ) -> "Grid": - """Create an elevation/topography grid from USGS 3DEP.""" - body = _base_request_dict(name, description, tags, extent_buffer_cells) - body["source_resolution"] = source_resolution - if bands is not None: - body["bands"] = bands # API default: ["elevation"] - request = CreateThreeDepTopographyRequest.from_dict(body) - response = create_3dep_topography.sync( - domain_id, client=get_client(), body=request - ) - return cls._from_model(_checked(response, GridModel)) - - def get(self, in_place: bool = False) -> "Grid": - """Retrieve the latest grid data from the API.""" - response = _checked( - get_grid.sync(self.domain_id, self.id, client=get_client()), - GridModel, - ) - if in_place: - return self._copy_fields_from(response) - return Grid._from_model(response) - - def wait_until_completed( - self, - step: float = 5, - timeout: float = 600, - in_place: bool = True, - verbose: bool = False, - ) -> "Grid": - """Poll the grid job until it reaches a terminal status. - - Raises TimeoutError if the job does not finish within `timeout` - seconds, or RuntimeError if the job ends in `failed` status. - """ - elapsed = 0.0 - grid = self.get() - while grid.status not in (JobStatus.COMPLETED, JobStatus.FAILED): - if elapsed >= timeout: - raise TimeoutError( - f"Grid {self.id} did not complete within {timeout} seconds" - ) - time.sleep(step) - elapsed += step - grid = self.get() - if verbose: - print(f"Grid {self.id}: {grid.status} ({elapsed:.0f}s)") - if grid.status == JobStatus.FAILED: - raise RuntimeError(f"Grid {self.id} failed: {grid.error}") - if in_place: - return self._copy_fields_from(grid) - return grid - - def delete(self) -> None: - """Delete this grid.""" - delete_grid.sync_detailed(self.domain_id, self.id, client=get_client()) - - def to_json(self) -> str: - """Serialize the complete Grid object to a JSON string.""" - return json.dumps(self.to_dict(), default=str, indent=2) - - -def list_grids( - domain_id: str, - page: int = 0, - size: int = 100, -) -> List[Grid]: - """List grids in a domain (single page).""" - response = _checked( - list_grids_endpoint.sync(domain_id, client=get_client(), page=page, size=size), - ListGridsResponse, - ) - return [Grid._from_model(g) for g in response.grids] diff --git a/fastfuels_sdk/v2/draft_inventories_opc.py b/fastfuels_sdk/v2/draft_inventories_opc.py deleted file mode 100644 index 1a9c6af..0000000 --- a/fastfuels_sdk/v2/draft_inventories_opc.py +++ /dev/null @@ -1,158 +0,0 @@ -""" -fastfuels_sdk/v2/draft_inventories_opc.py - -DRAFT — Inventory wrapper built on the **openapi-python-client** client -(`client_library`: attrs models + httpx). - -Part of the v2 client-library generator comparison; see COMPARISON.md. -Covers the PIM grid → tree inventory flow plus the get/wait/delete/list -lifecycle. - -The full PIM workflow across the drafts: - - grid = Grid.create_treemap(domain_id) # draft_grids_opc - grid.wait_until_completed() - inv = Inventory.create_from_pim_grid(domain_id, grid.id) - inv.wait_until_completed() -""" - -import json -import time -from typing import Any, List, Optional - -import attrs - -from fastfuels_sdk.v2.api import ensure_client as get_client -from fastfuels_sdk.v2.draft_grids_opc import _checked - -from fastfuels_sdk.v2.client_library.api.inventories import ( - create_pim_inventory, - delete_inventory, - get_inventory, - list_inventories as list_inventories_endpoint, -) -from fastfuels_sdk.v2.client_library.models import ( - Inventory as InventoryModel, - CreatePimInventoryRequest, - JobStatus, - ListInventoriesResponse, -) - - -class Inventory(InventoryModel): - """Inventory resource for the FastFuels v2 API (openapi-python-client draft). - - Inherits all attributes from the generated attrs model: id, domain_id, - type_, name, description, status, progress, source, modifications, - treatments, columns, georeference, error, tags, created_on, - modified_on. - """ - - @classmethod - def _from_model(cls, model: InventoryModel) -> "Inventory": - return cls.from_dict(model.to_dict()) - - def _copy_fields_from(self, model: InventoryModel) -> "Inventory": - """Copy all generated-model fields from `model` onto self (in-place).""" - for field in attrs.fields(InventoryModel): - if field.init: - setattr(self, field.name, getattr(model, field.name)) - self.additional_properties = dict(model.additional_properties) - return self - - @classmethod - def from_id(cls, domain_id: str, inventory_id: str) -> "Inventory": - """Retrieve an existing Inventory by domain and inventory ID.""" - response = get_inventory.sync(domain_id, inventory_id, client=get_client()) - return cls._from_model(_checked(response, InventoryModel)) - - @classmethod - def create_from_pim_grid( - cls, - domain_id: str, - source_pim_grid_id: str, - name: str = "", - description: str = "", - tags: Optional[List[str]] = None, - seed: Optional[int] = None, - point_process: str = "inhomogeneous_poisson", - ) -> "Inventory": - """Create a tree inventory from a completed PIM (TreeMap) grid. - - The PIM grid must be in `completed` status before the inventory - job can sample tree records from it. - """ - body: dict[str, Any] = { - "name": name, - "description": description, - "source_pim_grid_id": source_pim_grid_id, - "point_process": point_process, - } - if tags is not None: - body["tags"] = tags - if seed is not None: - body["seed"] = seed - request = CreatePimInventoryRequest.from_dict(body) - response = create_pim_inventory.sync( - domain_id, client=get_client(), body=request - ) - return cls._from_model(_checked(response, InventoryModel)) - - def get(self, in_place: bool = False) -> "Inventory": - """Retrieve the latest inventory data from the API.""" - response = _checked( - get_inventory.sync(self.domain_id, self.id, client=get_client()), - InventoryModel, - ) - if in_place: - return self._copy_fields_from(response) - return Inventory._from_model(response) - - def wait_until_completed( - self, - step: float = 5, - timeout: float = 600, - in_place: bool = True, - verbose: bool = False, - ) -> "Inventory": - """Poll the inventory job until it reaches a terminal status.""" - elapsed = 0.0 - inventory = self.get() - while inventory.status not in (JobStatus.COMPLETED, JobStatus.FAILED): - if elapsed >= timeout: - raise TimeoutError( - f"Inventory {self.id} did not complete within {timeout} seconds" - ) - time.sleep(step) - elapsed += step - inventory = self.get() - if verbose: - print(f"Inventory {self.id}: {inventory.status} ({elapsed:.0f}s)") - if inventory.status == JobStatus.FAILED: - raise RuntimeError(f"Inventory {self.id} failed: {inventory.error}") - if in_place: - return self._copy_fields_from(inventory) - return inventory - - def delete(self) -> None: - """Delete this inventory.""" - delete_inventory.sync_detailed(self.domain_id, self.id, client=get_client()) - - def to_json(self) -> str: - """Serialize the complete Inventory object to a JSON string.""" - return json.dumps(self.to_dict(), default=str, indent=2) - - -def list_inventories( - domain_id: str, - page: int = 0, - size: int = 100, -) -> List[Inventory]: - """List inventories in a domain (single page).""" - response = _checked( - list_inventories_endpoint.sync( - domain_id, client=get_client(), page=page, size=size - ), - ListInventoriesResponse, - ) - return [Inventory._from_model(i) for i in response.inventories] diff --git a/fastfuels_sdk/v2/features.py b/fastfuels_sdk/v2/features.py index 0175d8a..59e268f 100644 --- a/fastfuels_sdk/v2/features.py +++ b/fastfuels_sdk/v2/features.py @@ -8,7 +8,7 @@ from typing import Any, Dict, List, Optional # Internal imports -from fastfuels_sdk.v2._jobs import wait_until_completed as _wait_until_completed +from fastfuels_sdk.v2._jobs import wait as _wait from fastfuels_sdk.v2.api import ensure_client from fastfuels_sdk.v2.exceptions import expect from fastfuels_sdk.v2.client_library.api.features import ( @@ -16,7 +16,7 @@ create_osm_road_feature, create_osm_water_feature, delete_feature, - get_feature, + get_feature as get_feature_endpoint, get_feature_data_metadata, get_feature_data_partition, list_features as list_features_endpoint, @@ -31,6 +31,7 @@ FeatureDataMetadata, FeatureSortField, FeatureType, + JobStatus, ListFeaturesResponse, SortOrder, UpdateFeatureRequestBody, @@ -42,6 +43,16 @@ import geopandas as gpd +def _domain_id(domain) -> str: + """Resolve a Domain object or a domain-id string to the id string.""" + return getattr(domain, "id", domain) + + +def _opt(value): + """Map ``None`` to the generated UNSET sentinel, else pass through.""" + return value if value is not None else UNSET + + def _as_feature_collection(geojson: dict) -> dict: """Return ``geojson`` as a FeatureCollection dict. @@ -111,19 +122,21 @@ class Feature(FeatureModel): Examples -------- Create a road feature and wait for it to complete: - >>> feature = Feature.create_osm_road("abc123") - >>> feature.wait_until_completed() + >>> import fastfuels_sdk as ff + >>> feature = ff.features.create_road_feature_from_osm(domain) + >>> feature.wait() >>> roads = feature.to_geodataframe() Get a feature by ID: - >>> feature = Feature.from_id("abc123", "def456") + >>> feature = ff.get_feature(domain, "def456") See Also -------- - Feature.create_osm_road : Create a road feature from OpenStreetMap. - Feature.create_osm_water : Create a water feature from OpenStreetMap. - Feature.create_layerset : Upload a custom layerset of fuelbed polygons. + create_road_feature_from_osm : Create a road feature from OpenStreetMap. + create_water_feature_from_osm : Create a water feature from OpenStreetMap. + create_layerset_feature_from_geojson : Upload a custom layerset of fuelbed polygons. Feature.to_geodataframe : Retrieve the feature data as a GeoDataFrame. + Feature.rasterize : Rasterize a layerset feature into a grid. list_features : List features in a domain or across all domains. """ @@ -144,6 +157,14 @@ def _copy_fields_from(self, model: FeatureModel) -> "Feature": self.additional_properties = dict(model.additional_properties) return self + def _require_completed(self, action: str) -> None: + """Raise if the feature is not completed, before deriving from it.""" + if self.status != JobStatus.COMPLETED: + raise ValueError( + f"Cannot {action} a feature with status '{self.status}'. Call " + ".wait() until it completes first." + ) + @classmethod def from_id(cls, domain_id: str, feature_id: str) -> "Feature": """Retrieve an existing Feature resource by its ID. @@ -172,292 +193,18 @@ def from_id(cls, domain_id: str, feature_id: str) -> "Feature": >>> feature.id 'def456' """ - response = get_feature.sync_detailed( + response = get_feature_endpoint.sync_detailed( domain_id, feature_id, client=ensure_client() ) return cls._from_model(expect(response)) - @classmethod - def create_osm_road( - cls, - domain_id: str, - name: str = "", - description: str = "", - tags: Optional[List[str]] = None, - extent_buffer_m: float = 0.0, - ) -> "Feature": - """Create a road feature from OpenStreetMap data. - - Starts a background job that extracts road geometries from - OpenStreetMap within the domain extent. Use - :meth:`wait_until_completed` to block until the data is ready. - - Parameters - ---------- - domain_id : str - The unique identifier of the domain to create the feature in. - name : str, optional - Name for the feature. - description : str, optional - Description of the feature. - tags : List[str], optional - Tags for organizing the feature. - extent_buffer_m : float, optional - Distance in meters (0-100, default 0) to buffer the domain - extent outward when querying OpenStreetMap. + def refresh(self) -> "Feature": + """Update this Feature in place with the latest data from the API. Returns ------- Feature - The created Feature object (job status "pending" or - "running"). - - Raises - ------ - NotFoundException - If the domain does not exist. - UnprocessableEntityException - If ``extent_buffer_m`` is outside the 0-100 meter range. - - Examples - -------- - >>> feature = Feature.create_osm_road("abc123", name="roads") - >>> feature.type_ - - """ - request_body = CreateOsmRoadFeatureRequest( - name=name, - description=description, - tags=tags if tags is not None else UNSET, - extent_buffer_m=extent_buffer_m, - ) - response = create_osm_road_feature.sync_detailed( - domain_id, client=ensure_client(), body=request_body - ) - return cls._from_model(expect(response, HTTPStatus.CREATED)) - - @classmethod - def create_osm_water( - cls, - domain_id: str, - name: str = "", - description: str = "", - tags: Optional[List[str]] = None, - extent_buffer_m: float = 0.0, - ) -> "Feature": - """Create a water feature from OpenStreetMap data. - - Starts a background job that extracts water-body geometries from - OpenStreetMap within the domain extent. Use - :meth:`wait_until_completed` to block until the data is ready. - - Parameters - ---------- - domain_id : str - The unique identifier of the domain to create the feature in. - name : str, optional - Name for the feature. - description : str, optional - Description of the feature. - tags : List[str], optional - Tags for organizing the feature. - extent_buffer_m : float, optional - Distance in meters (0-100, default 0) to buffer the domain - extent outward when querying OpenStreetMap. - - Returns - ------- - Feature - The created Feature object (job status "pending" or - "running"). - - Raises - ------ - NotFoundException - If the domain does not exist. - UnprocessableEntityException - If ``extent_buffer_m`` is outside the 0-100 meter range. - - Examples - -------- - >>> feature = Feature.create_osm_water("abc123", name="water") - >>> feature.type_ - - """ - request_body = CreateOsmWaterFeatureRequest( - name=name, - description=description, - tags=tags if tags is not None else UNSET, - extent_buffer_m=extent_buffer_m, - ) - response = create_osm_water_feature.sync_detailed( - domain_id, client=ensure_client(), body=request_body - ) - return cls._from_model(expect(response, HTTPStatus.CREATED)) - - @classmethod - def create_layerset( - cls, - domain_id: str, - geojson: dict, - name: str = "", - description: str = "", - tags: Optional[List[str]] = None, - ) -> "Feature": - """Upload a custom layerset of fuelbed polygons. - - Uploads a GeoJSON FeatureCollection where each feature is one - fuelbed polygon. The upload is validated and stored synchronously: - the returned Feature has status "completed" and its data is - immediately available through the data methods. - - Each feature's ``properties`` must carry the fuelbed input - columns: - - - ``fuel_type`` (str) - - ``fuel_loading`` (float) - - ``fuel_height`` (float) - - ``percent_cover`` (float) - - ``distribution`` (str): "homogeneous", "random_clusters", or - "uniform_random" - - Optional columns: ``strata_fb``, ``patch_size``, - ``live_fuel_moisture``, ``dead_fuel_moisture``, - ``heat_of_combustion``, ``patch_std_dev``. - - Parameters - ---------- - domain_id : str - The unique identifier of the domain to create the feature in. - geojson : dict - A GeoJSON FeatureCollection or Feature (a single Feature is - wrapped automatically) with Polygon or MultiPolygon - geometries. The ``crs`` member must declare a **projected** - CRS (e.g. EPSG:5070); geographic coordinates are rejected. - name : str, optional - Name for the feature. - description : str, optional - Description of the feature. - tags : List[str], optional - Tags for organizing the feature. - - Returns - ------- - Feature - The created Feature object with status "completed". - - Raises - ------ - ValueError - If the GeoJSON is not a Feature or FeatureCollection. - NotFoundException - If the domain does not exist. - UnprocessableEntityException - If the CRS is missing or geographic, a geometry is not a - Polygon/MultiPolygon, or a feature is missing required - fuelbed properties. - - Examples - -------- - >>> feature = Feature.create_layerset("abc123", geojson) - >>> feature.status - - """ - feature_collection = _as_feature_collection(geojson) - body = {**feature_collection, "name": name, "description": description} - if tags is not None: - body["tags"] = tags - request_body = CreateLayersetRequestBody.from_dict(body) - response = create_layerset.sync_detailed( - domain_id, client=ensure_client(), body=request_body - ) - return cls._from_model(expect(response, HTTPStatus.CREATED)) - - @classmethod - def create_layerset_from_geodataframe( - cls, - domain_id: str, - geodataframe: gpd.GeoDataFrame, - name: str = "", - description: str = "", - tags: Optional[List[str]] = None, - ) -> "Feature": - """Upload a custom layerset from a GeoPandas GeoDataFrame. - - The GeoDataFrame's CRS is forwarded to the API as the - FeatureCollection's ``crs`` member. The CRS must be **projected** - (e.g. EPSG:5070); the GeoDataFrame is not reprojected — use - ``geodataframe.to_crs(...)`` first if needed. Each row's columns - must carry the fuelbed properties described in - :meth:`create_layerset`. - - Parameters - ---------- - domain_id : str - The unique identifier of the domain to create the feature in. - geodataframe : gpd.GeoDataFrame - GeoDataFrame with one fuelbed polygon per row. - name : str, optional - Name for the feature. - description : str, optional - Description of the feature. - tags : List[str], optional - Tags for organizing the feature. - - Returns - ------- - Feature - The created Feature object with status "completed". - - Raises - ------ - ValueError - If the GeoDataFrame CRS has no authority code (e.g. a custom - CRS). - UnprocessableEntityException - Same validation errors as :meth:`create_layerset`. - - Examples - -------- - >>> gdf = gpd.read_file("fuelbeds.shp").to_crs(epsg=5070) - >>> feature = Feature.create_layerset_from_geodataframe("abc123", gdf) - """ - geojson = json.loads(geodataframe.to_json()) - if geodataframe.crs is not None: - authority = geodataframe.crs.to_authority() - if authority is None: - raise ValueError( - "GeoDataFrame CRS has no authority code (e.g. a custom " - "CRS). Reproject to a known projected CRS with " - "geodataframe.to_crs(...) before creating a layerset." - ) - geojson["crs"] = { - "type": "name", - "properties": {"name": ":".join(authority)}, - } - return cls.create_layerset( - domain_id, - geojson, - name=name, - description=description, - tags=tags, - ) - - def get(self, in_place: bool = False) -> "Feature": - """Retrieve the latest feature data from the API. - - Parameters - ---------- - in_place : bool, optional - If True, updates the current Feature instance with the new - data and returns self. If False (default), returns a new - Feature instance with the latest data, leaving the current - instance unchanged. - - Returns - ------- - Feature - The updated Feature object. + ``self``, updated with the latest data (so calls chain). Raises ------ @@ -466,74 +213,54 @@ def get(self, in_place: bool = False) -> "Feature": Examples -------- - >>> updated_feature = feature.get() # new instance - >>> feature.get(in_place=True) # refresh in place + >>> feature.refresh() """ - response = get_feature.sync_detailed( + response = get_feature_endpoint.sync_detailed( self.domain_id, self.id, client=ensure_client() ) - model = expect(response) - if in_place: - return self._copy_fields_from(model) - return Feature._from_model(model) + return self._copy_fields_from(expect(response)) - def wait_until_completed( - self, - step: float = 5, - timeout: float = 600, - in_place: bool = True, - verbose: bool = False, - ) -> "Feature": + def wait(self, timeout: Optional[float] = None, verbose: bool = False) -> "Feature": """Poll the feature job until it reaches a terminal status. Parameters ---------- - step : float, optional - Seconds between polls (default 5). timeout : float, optional - Maximum seconds to wait before raising (default 600). - in_place : bool, optional - If True (default), updates the current Feature instance with - the completed data and returns self. If False, returns a new - Feature instance, leaving the current instance unchanged. + Maximum seconds to wait. ``None`` (default) waits indefinitely; the + job runs server-side regardless, so a bounded wait is resumable. verbose : bool, optional If True, print the job status at each poll. Returns ------- Feature - The completed Feature object. + ``self``, updated to its terminal state (so calls chain). Raises ------ TimeoutError - If the job does not reach a terminal status within - ``timeout`` seconds. - RuntimeError + If ``timeout`` is set and elapses before a terminal status. + JobFailedError If the job finished with status "failed". Examples -------- - >>> feature = Feature.create_osm_road("abc123") - >>> feature.wait_until_completed(verbose=True) + >>> feature = create_road_feature_from_osm(domain) + >>> feature.wait(verbose=True) Feature def456: JobStatus.COMPLETED (5s) """ - return _wait_until_completed( - self, step=step, timeout=timeout, in_place=in_place, verbose=verbose - ) + return _wait(self, timeout=timeout, verbose=verbose) def update( self, name: Optional[str] = None, description: Optional[str] = None, tags: Optional[List[str]] = None, - in_place: bool = False, ) -> "Feature": - """Update the feature's mutable properties (name, description, tags). + """Update the feature's mutable metadata (name, description, tags) in place. - Only provided fields are sent in the PATCH body (the generated - UNSET sentinel keeps omitted fields out of the request entirely). - If no fields are provided, no API call is made. + Only provided fields are sent. If no fields are provided, no API call + is made. Parameters ---------- @@ -543,15 +270,11 @@ def update( New description for the feature. tags : List[str], optional New tags for the feature (replaces existing tags). - in_place : bool, optional - If True, updates the current Feature instance with the new - data and returns self. If False (default), returns a new - Feature instance, leaving the current instance unchanged. Returns ------- Feature - The updated Feature object. + ``self``, updated (so calls chain). Raises ------ @@ -560,23 +283,18 @@ def update( Examples -------- - >>> feature = feature.update(name="new name", tags=["test"]) + >>> feature.update(name="new name", tags=["test"]) """ if name is None and description is None and tags is None: - return self if in_place else Feature.from_dict(self.to_dict()) + return self request_body = UpdateFeatureRequestBody( - name=name if name is not None else UNSET, - description=description if description is not None else UNSET, - tags=tags if tags is not None else UNSET, + name=_opt(name), description=_opt(description), tags=_opt(tags) ) response = update_feature.sync_detailed( self.domain_id, self.id, client=ensure_client(), body=request_body ) - model = expect(response) - if in_place: - return self._copy_fields_from(model) - return Feature._from_model(model) + return self._copy_fields_from(expect(response)) def delete(self) -> None: """Delete this feature and its generated data. @@ -596,6 +314,79 @@ def delete(self) -> None: ) expect(response, HTTPStatus.NO_CONTENT) + def rasterize( + self, + output_resolution_m: Optional[float] = None, + align_to=None, + align: Optional[str] = None, + resampling: Optional[str] = None, + overlap_method: Optional[str] = None, + extent_buffer_cells: int = 0, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, + modifications: Optional[list] = None, + ): + """Rasterize this layerset feature into a grid. + + Only valid for layerset features (uploaded fuelbed polygons). + + Parameters + ---------- + output_resolution_m : float, optional + Output cell size in meters, anchored to the domain origin. + align_to : Grid or str, optional + Match the lattice of an existing grid (or its id). + align : str, optional + Pass ``"native"`` to keep the source pixel anchor. + resampling : str, optional + Resampling method (e.g. "bilinear", "nearest"). + overlap_method : str, optional + Per-cell reduction when polygons of the same fuel type overlap: + "max", "mean", or "min". + extent_buffer_cells : int, optional + Result-grid cells to buffer around the domain extent (0-10). + name, description : str, optional + Metadata for the new grid. + tags : List[str], optional + Tags for the new grid. + modifications : list, optional + Modification rules applied after the grid is built. + + Returns + ------- + Grid + The new (pending) rasterized Grid. + """ + # Imported here rather than at module scope so the feature/grid + # modules stay decoupled (grids never imports features). + from fastfuels_sdk.v2.grids import Grid, _build_alignment + from fastfuels_sdk.v2.client_library.api.grids import create_layerset_rasterize + from fastfuels_sdk.v2.client_library.models import ( + CreateLayersetRasterizeRequest, + OverlapMethod, + ) + + self._require_completed("rasterize") + request_body = CreateLayersetRasterizeRequest( + layerset_id=self.id, + alignment=_build_alignment( + output_resolution_m, align_to, align, resampling + ), + overlap_method=( + OverlapMethod(overlap_method) if overlap_method is not None else UNSET + ), + extent_buffer_cells=extent_buffer_cells, + name=name, + description=description, + tags=_opt(tags), + modifications=_opt(modifications), + ) + response = create_layerset_rasterize.sync_detailed( + self.domain_id, client=ensure_client(), body=request_body + ) + return Grid._from_model(expect(response, HTTPStatus.CREATED)) + def get_data_metadata(self) -> FeatureDataMetadata: """Get the partition layout of the feature's generated data. @@ -723,7 +514,7 @@ def to_geodataframe(self) -> gpd.GeoDataFrame: Examples -------- - >>> feature.wait_until_completed() + >>> feature.wait() >>> gdf = feature.to_geodataframe() """ data = self.get_data() @@ -742,8 +533,294 @@ def to_json(self) -> str: return json.dumps(self.to_dict(), default=str, indent=2) +# --------------------------------------------------------------------------- +# Create features (module-level functions) +# --------------------------------------------------------------------------- + + +def create_road_feature_from_osm( + domain, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, + extent_buffer_m: float = 0.0, +) -> Feature: + """Create a road feature from OpenStreetMap data. + + Starts a background job that extracts road geometries from + OpenStreetMap within the domain extent. Use :meth:`Feature.wait` to + block until the data is ready. + + Parameters + ---------- + domain : Domain or str + The domain (or its id) to create the feature in. + name : str, optional + Name for the feature. + description : str, optional + Description of the feature. + tags : List[str], optional + Tags for organizing the feature. + extent_buffer_m : float, optional + Distance in meters (0-100, default 0) to buffer the domain extent + outward when querying OpenStreetMap. + + Returns + ------- + Feature + The created Feature object (job status "pending" or "running"). + + Raises + ------ + NotFoundException + If the domain does not exist. + UnprocessableEntityException + If ``extent_buffer_m`` is outside the 0-100 meter range. + + Examples + -------- + >>> feature = create_road_feature_from_osm(domain, name="roads") + >>> feature.type_ + + """ + request_body = CreateOsmRoadFeatureRequest( + name=name, + description=description, + tags=_opt(tags), + extent_buffer_m=extent_buffer_m, + ) + response = create_osm_road_feature.sync_detailed( + _domain_id(domain), client=ensure_client(), body=request_body + ) + return Feature._from_model(expect(response, HTTPStatus.CREATED)) + + +def create_water_feature_from_osm( + domain, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, + extent_buffer_m: float = 0.0, +) -> Feature: + """Create a water feature from OpenStreetMap data. + + Starts a background job that extracts water-body geometries from + OpenStreetMap within the domain extent. Use :meth:`Feature.wait` to + block until the data is ready. + + Parameters + ---------- + domain : Domain or str + The domain (or its id) to create the feature in. + name : str, optional + Name for the feature. + description : str, optional + Description of the feature. + tags : List[str], optional + Tags for organizing the feature. + extent_buffer_m : float, optional + Distance in meters (0-100, default 0) to buffer the domain extent + outward when querying OpenStreetMap. + + Returns + ------- + Feature + The created Feature object (job status "pending" or "running"). + + Raises + ------ + NotFoundException + If the domain does not exist. + UnprocessableEntityException + If ``extent_buffer_m`` is outside the 0-100 meter range. + + Examples + -------- + >>> feature = create_water_feature_from_osm(domain, name="water") + >>> feature.type_ + + """ + request_body = CreateOsmWaterFeatureRequest( + name=name, + description=description, + tags=_opt(tags), + extent_buffer_m=extent_buffer_m, + ) + response = create_osm_water_feature.sync_detailed( + _domain_id(domain), client=ensure_client(), body=request_body + ) + return Feature._from_model(expect(response, HTTPStatus.CREATED)) + + +def create_layerset_feature_from_geojson( + domain, + geojson: dict, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, +) -> Feature: + """Upload a custom layerset of fuelbed polygons from GeoJSON. + + Uploads a GeoJSON FeatureCollection where each feature is one fuelbed + polygon. The upload is validated and stored synchronously: the returned + Feature has status "completed" and its data is immediately available + through the data methods. + + Each feature's ``properties`` must carry the fuelbed input columns: + + - ``fuel_type`` (str) + - ``fuel_loading`` (float) + - ``fuel_height`` (float) + - ``percent_cover`` (float) + - ``distribution`` (str): "homogeneous", "random_clusters", or + "uniform_random" + + Optional columns: ``strata_fb``, ``patch_size``, + ``live_fuel_moisture``, ``dead_fuel_moisture``, ``heat_of_combustion``, + ``patch_std_dev``. + + Parameters + ---------- + domain : Domain or str + The domain (or its id) to create the feature in. + geojson : dict + A GeoJSON FeatureCollection or Feature (a single Feature is wrapped + automatically) with Polygon or MultiPolygon geometries. The ``crs`` + member must declare a **projected** CRS (e.g. EPSG:5070); geographic + coordinates are rejected. + name : str, optional + Name for the feature. + description : str, optional + Description of the feature. + tags : List[str], optional + Tags for organizing the feature. + + Returns + ------- + Feature + The created Feature object with status "completed". + + Raises + ------ + ValueError + If the GeoJSON is not a Feature or FeatureCollection. + NotFoundException + If the domain does not exist. + UnprocessableEntityException + If the CRS is missing or geographic, a geometry is not a + Polygon/MultiPolygon, or a feature is missing required fuelbed + properties. + + Examples + -------- + >>> feature = create_layerset_feature_from_geojson(domain, geojson) + >>> feature.status + + """ + feature_collection = _as_feature_collection(geojson) + body = {**feature_collection, "name": name, "description": description} + if tags is not None: + body["tags"] = tags + request_body = CreateLayersetRequestBody.from_dict(body) + response = create_layerset.sync_detailed( + _domain_id(domain), client=ensure_client(), body=request_body + ) + return Feature._from_model(expect(response, HTTPStatus.CREATED)) + + +def create_layerset_feature_from_geodataframe( + domain, + geodataframe: gpd.GeoDataFrame, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, +) -> Feature: + """Upload a custom layerset from a GeoPandas GeoDataFrame. + + The GeoDataFrame's CRS is forwarded to the API as the FeatureCollection's + ``crs`` member. The CRS must be **projected** (e.g. EPSG:5070); the + GeoDataFrame is not reprojected — use ``geodataframe.to_crs(...)`` first if + needed. Each row's columns must carry the fuelbed properties described in + :func:`create_layerset_feature_from_geojson`. + + Parameters + ---------- + domain : Domain or str + The domain (or its id) to create the feature in. + geodataframe : gpd.GeoDataFrame + GeoDataFrame with one fuelbed polygon per row. + name : str, optional + Name for the feature. + description : str, optional + Description of the feature. + tags : List[str], optional + Tags for organizing the feature. + + Returns + ------- + Feature + The created Feature object with status "completed". + + Raises + ------ + ValueError + If the GeoDataFrame CRS has no authority code (e.g. a custom CRS). + UnprocessableEntityException + Same validation errors as :func:`create_layerset_feature_from_geojson`. + + Examples + -------- + >>> gdf = gpd.read_file("fuelbeds.shp").to_crs(epsg=5070) + >>> feature = create_layerset_feature_from_geodataframe(domain, gdf) + """ + geojson = json.loads(geodataframe.to_json()) + if geodataframe.crs is not None: + authority = geodataframe.crs.to_authority() + if authority is None: + raise ValueError( + "GeoDataFrame CRS has no authority code (e.g. a custom " + "CRS). Reproject to a known projected CRS with " + "geodataframe.to_crs(...) before creating a layerset." + ) + geojson["crs"] = { + "type": "name", + "properties": {"name": ":".join(authority)}, + } + return create_layerset_feature_from_geojson( + domain, + geojson, + name=name, + description=description, + tags=tags, + ) + + +def get_feature(domain, feature_id: str) -> Feature: + """Retrieve a single feature by its ID. + + Parameters + ---------- + domain : Domain or str + The domain (or its id) the feature belongs to. + feature_id : str + The unique identifier of the feature. + + Returns + ------- + Feature + The requested Feature object. + + Raises + ------ + NotFoundException + If no feature exists with the given IDs, or the user does not have + access to it. + """ + return Feature.from_id(_domain_id(domain), feature_id) + + def list_features( - domain_id: Optional[str] = None, + domain=None, page: int = 0, size: int = 100, sort_by: Optional[str] = None, @@ -756,9 +833,9 @@ def list_features( Parameters ---------- - domain_id : str, optional - The unique identifier of the domain to list features in. If - omitted, features from all the user's domains are listed. + domain : Domain or str, optional + The domain (or its id) to list features in. If omitted, features from + all the user's domains are listed. page : int, optional The page number to retrieve, zero-indexed (default 0). size : int, optional @@ -768,8 +845,7 @@ def list_features( sort_order : str, optional Sort direction: "ascending" or "descending". feature_type : str, optional - Only return features of this type: "road", "water", or - "layerset". + Only return features of this type: "road", "water", or "layerset". product : str, optional Only return features from this data product (e.g. "osm"). tag : str, optional @@ -782,7 +858,7 @@ def list_features( Examples -------- - >>> features = list_features("abc123", feature_type="road") + >>> features = list_features(domain, feature_type="road") >>> all_features = list_features() # across all domains """ kwargs = dict( @@ -792,12 +868,12 @@ def list_features( sort_by=FeatureSortField(sort_by) if sort_by else UNSET, sort_order=SortOrder(sort_order) if sort_order else UNSET, type_=FeatureType(feature_type) if feature_type else UNSET, - product=product if product is not None else UNSET, - tag=tag if tag is not None else UNSET, + product=_opt(product), + tag=_opt(tag), ) - if domain_id is None: + if domain is None: response = list_features_cross_domain.sync_detailed(**kwargs) else: - response = list_features_endpoint.sync_detailed(domain_id, **kwargs) + response = list_features_endpoint.sync_detailed(_domain_id(domain), **kwargs) list_response: ListFeaturesResponse = expect(response) return [Feature._from_model(f) for f in list_response.features] diff --git a/fastfuels_sdk/v2/grids.py b/fastfuels_sdk/v2/grids.py new file mode 100644 index 0000000..ffc6cd1 --- /dev/null +++ b/fastfuels_sdk/v2/grids.py @@ -0,0 +1,1296 @@ +""" +fastfuels_sdk/v2/grids.py +""" + +# Core imports +import json +from http import HTTPStatus +from typing import Dict, List, Optional, Union + +# Internal imports +from fastfuels_sdk.v2._jobs import wait as _wait +from fastfuels_sdk.v2.api import ensure_client +from fastfuels_sdk.v2.exceptions import expect +from fastfuels_sdk.v2.client_library.api.grids import ( + check_3dep_coverage as check_3dep_coverage_endpoint, + create_3dep_topography, + create_fbfm40_lookup, + create_geotiff_upload, + create_grid_export, + create_landfire_canopy, + create_landfire_fbfm40, + create_landfire_fccs, + create_landfire_topography, + create_meta_chm, + create_naip_chm, + create_netcdf_upload, + create_resample, + create_treemap, + create_uniform_grid as create_uniform_grid_endpoint, + delete_grid, + get_grid as get_grid_endpoint, + list_grids as list_grids_endpoint, + list_grids_cross_domain, + update_grid, +) +from fastfuels_sdk.v2.client_library.models import ( + Grid as GridModel, + CreateFbfm40LookupRequest, + CreateGeoTIFFUploadRequest, + CreateLandfireCanopyRequest, + CreateLandfireFbfm40Request, + CreateLandfireFccsRequest, + CreateLandfireTopographyRequest, + CreateMetaChmRequest, + CreateNaipChmRequest, + CreateNetcdfUploadRequest, + CreateResampleRequest, + CreateThreeDepTopographyRequest, + CreateTreeMapRequest, + CreateUniformRequest, + ExportGridRequest, + Fbfm40LookupBand, + GridAlignmentDomainTarget, + GridAlignmentGridTarget, + GridAlignmentNativeTarget, + GridExportFormat, + GridSortField, + JobStatus, + LandfireCanopyFuelBand, + LandfireCanopyVersion, + LandfireFbfm40Version, + LandfireFccsVersion, + LandfireTopographyVersion, + ListGridsResponse, + MetaCHMVersion, + NonBurnableFuelModel, + ResamplingMethod, + SortOrder, + ThreeDepResolution, + TopographyBand, + TreeMapBand, + TreeMapVersion, + UniformBand, + UniformBandInput, + UpdateGridRequestBody, +) +from fastfuels_sdk.v2.client_library.types import UNSET + +# External imports +import attrs +import requests + +__all__ = [ + "Grid", + "create_topography_grid_from_3dep", + "create_topography_grid_from_landfire", + "create_canopy_fuel_grid_from_landfire", + "create_canopy_height_grid_from_meta", + "create_canopy_height_grid_from_naip", + "create_fuel_model_grid_from_landfire_fbfm40", + "create_fuel_model_grid_from_landfire_fccs", + "create_grid_from_geotiff", + "create_grid_from_netcdf", + "create_uniform_grid", + "list_grids", + "get_grid", + "check_3dep_coverage", +] + + +def _domain_id(domain) -> str: + """Resolve a Domain object or a domain-id string to the id string.""" + return getattr(domain, "id", domain) + + +def _enum_list(values, enum_cls): + """Coerce a list of strings/enum members to enum members, or UNSET.""" + if values is None: + return UNSET + return [v if isinstance(v, enum_cls) else enum_cls(v) for v in values] + + +def _opt(value): + """Map ``None`` to the generated UNSET sentinel, else pass through.""" + return value if value is not None else UNSET + + +def _build_alignment( + output_resolution_m: Optional[float] = None, + align_to=None, + align: Optional[str] = None, + resampling: Optional[str] = None, +): + """Translate friendly alignment keywords into a grid alignment target. + + Returns a ``GridAlignment{Domain,Grid,Native}Target`` or ``UNSET`` (let the + API choose its default, which anchors to the domain origin at the source's + native cell size). + + - ``output_resolution_m=N`` anchors output cells to the domain origin at N + meters (the default target). + - ``align_to=`` matches an existing grid's lattice exactly. + - ``align="native"`` keeps the source raster's pixel anchor. + - ``resampling=`` selects the resampling method for any of the above. + + ``output_resolution_m``, ``align_to``, and ``align`` are mutually exclusive. + """ + if align is not None and align != "native": + raise ValueError( + f'align must be "native" if given, got {align!r}. Use ' + "output_resolution_m to set a resolution or align_to to match a grid." + ) + if sum(x is not None for x in (output_resolution_m, align_to, align)) > 1: + raise ValueError( + "Specify at most one of output_resolution_m, align_to, or align." + ) + + method = ResamplingMethod(resampling) if resampling is not None else UNSET + + if align_to is not None: + return GridAlignmentGridTarget( + target="grid", grid_id=_domain_id(align_to), method=method + ) + if align is not None: # align == "native" + return GridAlignmentNativeTarget(target="native", method=method) + if output_resolution_m is not None or resampling is not None: + return GridAlignmentDomainTarget( + resolution=_opt(output_resolution_m), method=method + ) + return UNSET + + +def _put_upload(spec, path: str) -> None: + """Upload a local file to a signed upload URL with HTTP PUT.""" + with open(path, "rb") as file_obj: + response = requests.put( + spec.url, data=file_obj, headers={"Content-Type": spec.content_type} + ) + response.raise_for_status() + + +class Grid(GridModel): + """Grid resource for the FastFuels v2 API. + + A grid is a raster (2D) or voxel (3D) dataset within a domain: topography, + canopy, surface fuel models, uploaded rasters, or grids derived from other + grids. Grids are asynchronous job resources — creation starts a background + job and returns a *pending* record; call :meth:`wait` to block until it is + ready. + + Attributes + ---------- + id : str + Unique identifier for the grid. + domain_id : str + Identifier of the domain the grid belongs to. + status : JobStatus + Job status: "pending", "running", "completed", or "failed". + source : GridSource + Where the grid data comes from (e.g. "landfire", "3dep"). + bands : List[Band] + The grid's data bands (keys, types, units). + name : str + Human-readable name for the grid. + description : str + Detailed description of the grid. + progress : JobProgress, optional + Progress info while the job is running. + georeference : Georeference or Georeference3D, optional + Spatial reference of the data; populated when the job completes. + error : JobError, optional + Error details if the job failed. + chunks : Chunks, optional + Chunk layout; populated when processing completes. + tags : List[str], optional + User-defined tags for organization. + created_on : datetime + When the grid was created. + modified_on : datetime + When the grid was last modified. + + Examples + -------- + Create a topography grid and wait for it to complete: + >>> import fastfuels_sdk as ff + >>> grid = ff.grids.create_topography_grid_from_3dep(domain, output_resolution_m=10) + >>> grid.wait() + + Get a grid by ID: + >>> grid = ff.get_grid(domain, "def456") + + See Also + -------- + create_topography_grid_from_3dep : Create a topography grid from USGS 3DEP. + create_fuel_model_grid_from_landfire_fbfm40 : Create an FBFM40 fuel model grid. + create_uniform_grid : Create a constant-value grid. + list_grids : List grids in a domain or across all domains. + """ + + @classmethod + def _from_model(cls, model: GridModel) -> "Grid": + """Build a Grid from a generated Grid model instance. + + Round-trips through the generated to_dict/from_dict — from_dict + constructs ``cls``, i.e. this subclass. + """ + return cls.from_dict(model.to_dict()) + + def _copy_fields_from(self, model: GridModel) -> "Grid": + """Copy all generated-model fields from `model` onto self (in-place).""" + for field in attrs.fields(GridModel): + if field.init: + setattr(self, field.name, getattr(model, field.name)) + self.additional_properties = dict(model.additional_properties) + return self + + def _require_completed(self, action: str) -> None: + """Raise if the grid is not completed, before deriving from it.""" + if self.status != JobStatus.COMPLETED: + raise ValueError( + f"Cannot {action} a grid with status '{self.status}'. Call " + ".wait() until it completes first." + ) + + @classmethod + def from_id(cls, domain_id: str, grid_id: str) -> "Grid": + """Retrieve an existing Grid resource by its ID. + + Parameters + ---------- + domain_id : str + The unique identifier of the domain the grid belongs to. + grid_id : str + The unique identifier of the grid to retrieve. + + Returns + ------- + Grid + The requested Grid object. + + Raises + ------ + NotFoundException + If no grid exists with the given IDs, or the user does not have + access to it. + """ + response = get_grid_endpoint.sync_detailed( + domain_id, grid_id, client=ensure_client() + ) + return cls._from_model(expect(response)) + + def refresh(self) -> "Grid": + """Update this Grid in place with the latest data from the API. + + Returns + ------- + Grid + ``self``, updated with the latest data (so calls chain). + + Raises + ------ + NotFoundException + If the grid no longer exists. + """ + response = get_grid_endpoint.sync_detailed( + self.domain_id, self.id, client=ensure_client() + ) + return self._copy_fields_from(expect(response)) + + def wait(self, timeout: Optional[float] = None, verbose: bool = False) -> "Grid": + """Poll the grid job until it reaches a terminal status. + + Parameters + ---------- + timeout : float, optional + Maximum seconds to wait. ``None`` (default) waits indefinitely; the + job runs server-side regardless, so a bounded wait is resumable. + verbose : bool, optional + If True, print the job status at each poll. + + Returns + ------- + Grid + ``self``, updated to its terminal state (so calls chain). + + Raises + ------ + TimeoutError + If ``timeout`` is set and elapses before a terminal status. + JobFailedError + If the job finished with status "failed". + """ + return _wait(self, timeout=timeout, verbose=verbose) + + def update( + self, + name: Optional[str] = None, + description: Optional[str] = None, + tags: Optional[List[str]] = None, + ) -> "Grid": + """Update the grid's mutable metadata (name, description, tags) in place. + + Only provided fields are sent. If no fields are provided, no API call + is made. + + Parameters + ---------- + name : str, optional + New name for the grid. + description : str, optional + New description for the grid. + tags : List[str], optional + New tags for the grid (replaces existing tags). + + Returns + ------- + Grid + ``self``, updated (so calls chain). + + Raises + ------ + NotFoundException + If the grid no longer exists. + """ + if name is None and description is None and tags is None: + return self + request_body = UpdateGridRequestBody( + name=_opt(name), description=_opt(description), tags=_opt(tags) + ) + response = update_grid.sync_detailed( + self.domain_id, self.id, client=ensure_client(), body=request_body + ) + return self._copy_fields_from(expect(response)) + + def delete(self) -> None: + """Delete this grid and its data. + + Raises + ------ + NotFoundException + If the grid no longer exists. + """ + response = delete_grid.sync_detailed( + self.domain_id, self.id, client=ensure_client() + ) + expect(response, HTTPStatus.NO_CONTENT) + + def resample( + self, + output_resolution_m: Optional[float] = None, + align_to=None, + align: Optional[str] = None, + resampling: Optional[str] = None, + method_overrides=None, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, + modifications: Optional[list] = None, + ) -> "Grid": + """Create a new grid by resampling this grid onto a different lattice. + + Parameters + ---------- + output_resolution_m : float, optional + Output cell size in meters, anchored to the domain origin. + align_to : Grid or str, optional + Match the lattice of an existing grid (or its id). + align : str, optional + Pass ``"native"`` to keep this grid's pixel anchor. + resampling : str, optional + Resampling method (e.g. "bilinear", "nearest", "average"). + method_overrides : CreateResampleRequestMethodOverrides, optional + Per-band resampling method overrides. + name, description : str, optional + Metadata for the new grid. + tags : List[str], optional + Tags for the new grid. + modifications : list, optional + Modification rules applied after the grid is built. + + Returns + ------- + Grid + The new (pending) resampled Grid. + """ + self._require_completed("resample") + request_body = CreateResampleRequest( + source_grid_id=self.id, + alignment=_build_alignment( + output_resolution_m, align_to, align, resampling + ), + method_overrides=_opt(method_overrides), + name=name, + description=description, + tags=_opt(tags), + modifications=_opt(modifications), + ) + response = create_resample.sync_detailed( + self.domain_id, client=ensure_client(), body=request_body + ) + return Grid._from_model(expect(response, HTTPStatus.CREATED)) + + def lookup_fuel_model_values( + self, + bands: list, + source_band: str = "fbfm", + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, + modifications: Optional[list] = None, + ) -> "Grid": + """Create a fuel-parameter grid by looking up FBFM40 codes in this grid. + + Parameters + ---------- + bands : list + The FBFM40 lookup bands to produce (``Fbfm40LookupBand`` members or + their string keys, e.g. "fuel_load_1hr"). + source_band : str, optional + The band in this grid that holds FBFM40 codes (default "fbfm"). + name, description : str, optional + Metadata for the new grid. + tags : List[str], optional + Tags for the new grid. + modifications : list, optional + Modification rules applied after the grid is built. + + Returns + ------- + Grid + The new (pending) fuel-parameter Grid. + """ + self._require_completed("look up fuel parameters from") + request_body = CreateFbfm40LookupRequest( + source_grid_id=self.id, + bands=_enum_list(bands, Fbfm40LookupBand), + source_band=source_band, + name=name, + description=description, + tags=_opt(tags), + modifications=_opt(modifications), + ) + response = create_fbfm40_lookup.sync_detailed( + self.domain_id, client=ensure_client(), body=request_body + ) + return Grid._from_model(expect(response, HTTPStatus.CREATED)) + + def export( + self, + format: str = "geotiff", + bands: Optional[List[str]] = None, + expiration_days: int = 7, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, + ): + """Export this grid to a downloadable file format. + + Parameters + ---------- + format : str, optional + Export format: "geotiff" (2D only), "netcdf", or "zarr" + (default "geotiff"). + bands : List[str], optional + Band keys to include (default: all bands). + expiration_days : int, optional + Days until the signed download URL expires (max 7, default 7). + name, description : str, optional + Metadata for the export. + tags : List[str], optional + Tags for the export. + + Returns + ------- + Export + The generated Export resource (job status "pending"). Poll it until + completed to obtain the signed download URL. + """ + request_body = ExportGridRequest( + bands=_opt(bands), + expiration_days=expiration_days, + name=name, + description=description, + tags=_opt(tags), + ) + response = create_grid_export.sync_detailed( + self.domain_id, + self.id, + GridExportFormat(format), + client=ensure_client(), + body=request_body, + ) + return expect(response, HTTPStatus.CREATED) + + def to_json(self) -> str: + """Serialize the complete Grid object to a JSON string. + + Returns + ------- + str + The Grid as a pretty-printed JSON string. + """ + return json.dumps(self.to_dict(), default=str, indent=2) + + +# --------------------------------------------------------------------------- +# Create grids from an external source (module-level functions) +# --------------------------------------------------------------------------- + + +def create_topography_grid_from_3dep( + domain, + source_resolution_m: int = 10, + output_resolution_m: Optional[float] = None, + align_to=None, + align: Optional[str] = None, + resampling: Optional[str] = None, + bands: Optional[list] = None, + extent_buffer_cells: int = 0, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, + modifications: Optional[list] = None, +) -> Grid: + """Create a topography grid from USGS 3DEP elevation data. + + Parameters + ---------- + domain : Domain or str + The domain (or its id) to create the grid in. + source_resolution_m : int, optional + The 3DEP product family to draw from: 1, 10, or 30 meters + (default 10). Use :func:`check_3dep_coverage` first for 1 m data. + output_resolution_m : float, optional + Output cell size in meters, anchored to the domain origin. By default + the source resolution is kept. + align_to : Grid or str, optional + Match the lattice of an existing grid (or its id). + align : str, optional + Pass ``"native"`` to keep the source raster's pixel anchor. + resampling : str, optional + Resampling method (e.g. "bilinear", "cubic"). + bands : list, optional + Topography bands to produce (``TopographyBand`` members or their string + keys, e.g. "elevation", "slope", "aspect"). Defaults to all. + extent_buffer_cells : int, optional + Result-grid cells to buffer around the domain extent (0-10, default 0). + name, description : str, optional + Metadata for the grid. + tags : List[str], optional + Tags for the grid. + modifications : list, optional + Modification rules applied after the grid is built. + + Returns + ------- + Grid + The created Grid object (job status "pending" or "running"). + """ + request_body = CreateThreeDepTopographyRequest( + source_resolution=ThreeDepResolution(source_resolution_m), + alignment=_build_alignment(output_resolution_m, align_to, align, resampling), + bands=_enum_list(bands, TopographyBand), + extent_buffer_cells=extent_buffer_cells, + name=name, + description=description, + tags=_opt(tags), + modifications=_opt(modifications), + ) + response = create_3dep_topography.sync_detailed( + _domain_id(domain), client=ensure_client(), body=request_body + ) + return Grid._from_model(expect(response, HTTPStatus.CREATED)) + + +def create_topography_grid_from_landfire( + domain, + version: Optional[str] = None, + output_resolution_m: Optional[float] = None, + align_to=None, + align: Optional[str] = None, + resampling: Optional[str] = None, + bands: Optional[list] = None, + extent_buffer_cells: int = 0, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, + modifications: Optional[list] = None, +) -> Grid: + """Create a topography grid from LANDFIRE elevation data. + + Parameters + ---------- + domain : Domain or str + The domain (or its id) to create the grid in. + version : str, optional + LANDFIRE version (see ``LandfireTopographyVersion``). Defaults to the + API's current version. + output_resolution_m : float, optional + Output cell size in meters, anchored to the domain origin. + align_to : Grid or str, optional + Match the lattice of an existing grid (or its id). + align : str, optional + Pass ``"native"`` to keep the source raster's pixel anchor. + resampling : str, optional + Resampling method (e.g. "bilinear", "cubic"). + bands : list, optional + Topography bands to produce (``TopographyBand`` members or their string + keys). Defaults to all. + extent_buffer_cells : int, optional + Result-grid cells to buffer around the domain extent (0-10, default 0). + name, description : str, optional + Metadata for the grid. + tags : List[str], optional + Tags for the grid. + modifications : list, optional + Modification rules applied after the grid is built. + + Returns + ------- + Grid + The created Grid object (job status "pending" or "running"). + """ + request_body = CreateLandfireTopographyRequest( + version=(LandfireTopographyVersion(version) if version is not None else UNSET), + alignment=_build_alignment(output_resolution_m, align_to, align, resampling), + bands=_enum_list(bands, TopographyBand), + extent_buffer_cells=extent_buffer_cells, + name=name, + description=description, + tags=_opt(tags), + modifications=_opt(modifications), + ) + response = create_landfire_topography.sync_detailed( + _domain_id(domain), client=ensure_client(), body=request_body + ) + return Grid._from_model(expect(response, HTTPStatus.CREATED)) + + +def create_canopy_fuel_grid_from_landfire( + domain, + version: Optional[str] = None, + output_resolution_m: Optional[float] = None, + align_to=None, + align: Optional[str] = None, + resampling: Optional[str] = None, + bands: Optional[list] = None, + extent_buffer_cells: int = 0, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, + modifications: Optional[list] = None, +) -> Grid: + """Create a canopy fuel grid from LANDFIRE data. + + Produces canopy fuel bands (e.g. canopy bulk density, canopy base height, + canopy cover, canopy height) within the domain. + + Parameters + ---------- + domain : Domain or str + The domain (or its id) to create the grid in. + version : str, optional + LANDFIRE version (see ``LandfireCanopyVersion``). Defaults to the API's + current version. + output_resolution_m : float, optional + Output cell size in meters, anchored to the domain origin. + align_to : Grid or str, optional + Match the lattice of an existing grid (or its id). + align : str, optional + Pass ``"native"`` to keep the source raster's pixel anchor. + resampling : str, optional + Resampling method (e.g. "bilinear", "cubic"). + bands : list, optional + Canopy fuel bands to produce (``LandfireCanopyFuelBand`` members or + their string keys). Defaults to all. + extent_buffer_cells : int, optional + Result-grid cells to buffer around the domain extent (0-10, default 0). + name, description : str, optional + Metadata for the grid. + tags : List[str], optional + Tags for the grid. + modifications : list, optional + Modification rules applied after the grid is built. + + Returns + ------- + Grid + The created Grid object (job status "pending" or "running"). + """ + request_body = CreateLandfireCanopyRequest( + version=LandfireCanopyVersion(version) if version is not None else UNSET, + alignment=_build_alignment(output_resolution_m, align_to, align, resampling), + bands=_enum_list(bands, LandfireCanopyFuelBand), + extent_buffer_cells=extent_buffer_cells, + name=name, + description=description, + tags=_opt(tags), + modifications=_opt(modifications), + ) + response = create_landfire_canopy.sync_detailed( + _domain_id(domain), client=ensure_client(), body=request_body + ) + return Grid._from_model(expect(response, HTTPStatus.CREATED)) + + +def create_canopy_height_grid_from_meta( + domain, + version: Optional[str] = None, + output_resolution_m: Optional[float] = None, + align_to=None, + align: Optional[str] = None, + resampling: Optional[str] = None, + extent_buffer_cells: int = 0, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, + modifications: Optional[list] = None, +) -> Grid: + """Create a canopy height grid from the Meta Canopy Height Model. + + Parameters + ---------- + domain : Domain or str + The domain (or its id) to create the grid in. + version : str, optional + Meta CHM version (see ``MetaCHMVersion``). Defaults to the API's + current version. + output_resolution_m : float, optional + Output cell size in meters, anchored to the domain origin. + align_to : Grid or str, optional + Match the lattice of an existing grid (or its id). + align : str, optional + Pass ``"native"`` to keep the source raster's pixel anchor. + resampling : str, optional + Resampling method (e.g. "bilinear", "cubic"). + extent_buffer_cells : int, optional + Result-grid cells to buffer around the domain extent (0-10, default 0). + name, description : str, optional + Metadata for the grid. + tags : List[str], optional + Tags for the grid. + modifications : list, optional + Modification rules applied after the grid is built. + + Returns + ------- + Grid + The created Grid object (job status "pending" or "running"). + """ + request_body = CreateMetaChmRequest( + version=MetaCHMVersion(version) if version is not None else UNSET, + alignment=_build_alignment(output_resolution_m, align_to, align, resampling), + extent_buffer_cells=extent_buffer_cells, + name=name, + description=description, + tags=_opt(tags), + modifications=_opt(modifications), + ) + response = create_meta_chm.sync_detailed( + _domain_id(domain), client=ensure_client(), body=request_body + ) + return Grid._from_model(expect(response, HTTPStatus.CREATED)) + + +def create_canopy_height_grid_from_naip( + domain, + output_resolution_m: Optional[float] = None, + align_to=None, + align: Optional[str] = None, + resampling: Optional[str] = None, + extent_buffer_cells: int = 0, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, + modifications: Optional[list] = None, +) -> Grid: + """Create a canopy height grid from a NAIP-derived Canopy Height Model. + + Parameters + ---------- + domain : Domain or str + The domain (or its id) to create the grid in. + output_resolution_m : float, optional + Output cell size in meters, anchored to the domain origin. + align_to : Grid or str, optional + Match the lattice of an existing grid (or its id). + align : str, optional + Pass ``"native"`` to keep the source raster's pixel anchor. + resampling : str, optional + Resampling method (e.g. "bilinear", "cubic"). + extent_buffer_cells : int, optional + Result-grid cells to buffer around the domain extent (0-10, default 0). + name, description : str, optional + Metadata for the grid. + tags : List[str], optional + Tags for the grid. + modifications : list, optional + Modification rules applied after the grid is built. + + Returns + ------- + Grid + The created Grid object (job status "pending" or "running"). + """ + request_body = CreateNaipChmRequest( + alignment=_build_alignment(output_resolution_m, align_to, align, resampling), + extent_buffer_cells=extent_buffer_cells, + name=name, + description=description, + tags=_opt(tags), + modifications=_opt(modifications), + ) + response = create_naip_chm.sync_detailed( + _domain_id(domain), client=ensure_client(), body=request_body + ) + return Grid._from_model(expect(response, HTTPStatus.CREATED)) + + +def create_fuel_model_grid_from_landfire_fbfm40( + domain, + version: Optional[str] = None, + remove_non_burnable: Optional[list] = None, + output_resolution_m: Optional[float] = None, + align_to=None, + align: Optional[str] = None, + resampling: Optional[str] = None, + extent_buffer_cells: int = 0, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, + modifications: Optional[list] = None, +) -> Grid: + """Create a fuel model grid from LANDFIRE 40 Scott & Burgan fuel models. + + Parameters + ---------- + domain : Domain or str + The domain (or its id) to create the grid in. + version : str, optional + LANDFIRE version (see ``LandfireFbfm40Version``). Defaults to the API's + current version. + remove_non_burnable : list, optional + Non-burnable fuel models to drop (``NonBurnableFuelModel`` members or + their string keys, e.g. "NB1", "NB2"). + output_resolution_m : float, optional + Output cell size in meters, anchored to the domain origin. + align_to : Grid or str, optional + Match the lattice of an existing grid (or its id). + align : str, optional + Pass ``"native"`` to keep the source raster's pixel anchor. + resampling : str, optional + Resampling method (e.g. "nearest" for categorical fuel models). + extent_buffer_cells : int, optional + Result-grid cells to buffer around the domain extent (0-10, default 0). + name, description : str, optional + Metadata for the grid. + tags : List[str], optional + Tags for the grid. + modifications : list, optional + Modification rules applied after the grid is built. + + Returns + ------- + Grid + The created Grid object (job status "pending" or "running"). + """ + request_body = CreateLandfireFbfm40Request( + version=LandfireFbfm40Version(version) if version is not None else UNSET, + remove_non_burnable=_enum_list(remove_non_burnable, NonBurnableFuelModel), + alignment=_build_alignment(output_resolution_m, align_to, align, resampling), + extent_buffer_cells=extent_buffer_cells, + name=name, + description=description, + tags=_opt(tags), + modifications=_opt(modifications), + ) + response = create_landfire_fbfm40.sync_detailed( + _domain_id(domain), client=ensure_client(), body=request_body + ) + return Grid._from_model(expect(response, HTTPStatus.CREATED)) + + +def create_fuel_model_grid_from_landfire_fccs( + domain, + version: Optional[str] = None, + remove_bare_ground: bool = False, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, + modifications: Optional[list] = None, +) -> Grid: + """Create a fuel model grid from LANDFIRE FCCS fuelbeds. + + Parameters + ---------- + domain : Domain or str + The domain (or its id) to create the grid in. + version : str, optional + LANDFIRE version (see ``LandfireFccsVersion``). Defaults to the API's + current version. + remove_bare_ground : bool, optional + Drop bare-ground fuelbeds (default False). + name, description : str, optional + Metadata for the grid. + tags : List[str], optional + Tags for the grid. + modifications : list, optional + Modification rules applied after the grid is built. + + Returns + ------- + Grid + The created Grid object (job status "pending" or "running"). + """ + # FCCS is alignment-free: CreateLandfireFccsRequest has no `alignment` or + # `extent_buffer_cells` field, unlike the other LANDFIRE creators, so there + # are no output_resolution_m/align_to/align/resampling kwargs here. + # See fastfuels_sdk/v2/v2_api_design.md. + request_body = CreateLandfireFccsRequest( + version=LandfireFccsVersion(version) if version is not None else UNSET, + remove_bare_ground=remove_bare_ground, + name=name, + description=description, + tags=_opt(tags), + modifications=_opt(modifications), + ) + response = create_landfire_fccs.sync_detailed( + _domain_id(domain), client=ensure_client(), body=request_body + ) + return Grid._from_model(expect(response, HTTPStatus.CREATED)) + + +def create_pim_grid_from_treemap( + domain, + version: Optional[str] = None, + output_resolution_m: Optional[float] = None, + align_to=None, + align: Optional[str] = None, + resampling: Optional[str] = None, + bands: Optional[list] = None, + extent_buffer_cells: int = 0, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, + modifications: Optional[list] = None, +) -> Grid: + """Create a Plot Imputation Map (PIM) grid from TreeMap. + + TreeMap imputes an FIA plot to every forested 30 m pixel across the + conterminous US. The resulting grid carries up to two categorical bands: + + - ``tm_id``: the TreeMap raster pixel value (always present) + - ``plt_cn``: the FIA plot condition number (request via ``bands``) + + Parameters + ---------- + domain : Domain or str + The domain (or its id) to create the grid in. + version : str, optional + TreeMap version (see ``TreeMapVersion``). Defaults to the API's + current version. + output_resolution_m : float, optional + Output cell size in meters, anchored to the domain origin. + align_to : Grid or str, optional + Match the lattice of an existing grid (or its id). + align : str, optional + Pass ``"native"`` to keep the source raster's pixel anchor. + resampling : str, optional + Resampling method. TreeMap bands are categorical, so use "nearest" + (interpolating plot ids is meaningless). + bands : list, optional + TreeMap bands to produce (``TreeMapBand`` members or their string + keys: "tm_id", "plt_cn"). Defaults to ``tm_id`` only. + extent_buffer_cells : int, optional + Result-grid cells to buffer around the domain extent (0-10, default 0). + name, description : str, optional + Metadata for the grid. + tags : List[str], optional + Tags for the grid. + modifications : list, optional + Modification rules applied after the grid is built. + + Returns + ------- + Grid + The created Grid object (job status "pending" or "running"). + """ + request_body = CreateTreeMapRequest( + version=TreeMapVersion(version) if version is not None else UNSET, + alignment=_build_alignment(output_resolution_m, align_to, align, resampling), + bands=_enum_list(bands, TreeMapBand), + extent_buffer_cells=extent_buffer_cells, + name=name, + description=description, + tags=_opt(tags), + modifications=_opt(modifications), + ) + response = create_treemap.sync_detailed( + _domain_id(domain), client=ensure_client(), body=request_body + ) + return Grid._from_model(expect(response, HTTPStatus.CREATED)) + + +# --------------------------------------------------------------------------- +# Create grids from your own file / supplied values +# --------------------------------------------------------------------------- + + +def create_grid_from_geotiff( + domain, + path: str, + bands: list, + num_buffer_cells: int = 0, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, +) -> Grid: + """Create a grid by uploading a local GeoTIFF. + + Creates the grid resource, uploads the file to the returned signed URL, and + returns the (pending) Grid. The GeoTIFF's CRS must match the domain CRS. + + Parameters + ---------- + domain : Domain or str + The domain (or its id) to create the grid in. + path : str + Path to the local ``.tif``/``.tiff`` file (max 1 GB). + bands : list + Band definitions (``UploadBandDefinition``) mapping 1:1 to the GeoTIFF + raster bands in order. + num_buffer_cells : int, optional + Cells kept around the domain extent in the stored grid (default 0). + name, description : str, optional + Metadata for the grid. + tags : List[str], optional + Tags for the grid. + + Returns + ------- + Grid + The created Grid object (job status "pending"). Call :meth:`Grid.wait` + to block until the uploaded file is processed. + """ + request_body = CreateGeoTIFFUploadRequest( + bands=bands, + num_buffer_cells=num_buffer_cells, + name=name, + description=description, + tags=_opt(tags), + ) + response = create_geotiff_upload.sync_detailed( + _domain_id(domain), client=ensure_client(), body=request_body + ) + created = expect(response, HTTPStatus.CREATED) + _put_upload(created.upload, path) + return Grid._from_model(created.grid) + + +def create_grid_from_netcdf( + domain, + path: str, + num_buffer_cells: int = 0, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, +) -> Grid: + """Create a grid by uploading a local NetCDF file. + + Creates the grid resource, uploads the file to the returned signed URL, and + returns the (pending) Grid. + + Parameters + ---------- + domain : Domain or str + The domain (or its id) to create the grid in. + path : str + Path to the local NetCDF file. + num_buffer_cells : int, optional + Cells kept around the domain extent in the stored grid (default 0). + name, description : str, optional + Metadata for the grid. + tags : List[str], optional + Tags for the grid. + + Returns + ------- + Grid + The created Grid object (job status "pending"). Call :meth:`Grid.wait` + to block until the uploaded file is processed. + """ + request_body = CreateNetcdfUploadRequest( + num_buffer_cells=num_buffer_cells, + name=name, + description=description, + tags=_opt(tags), + ) + response = create_netcdf_upload.sync_detailed( + _domain_id(domain), client=ensure_client(), body=request_body + ) + created = expect(response, HTTPStatus.CREATED) + _put_upload(created.upload, path) + return Grid._from_model(created.grid) + + +def create_uniform_grid( + domain, + resolution_m: float, + bands: Dict[str, Union[float, int]], + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, + modifications: Optional[list] = None, +) -> Grid: + """Create a uniform (constant-value) grid. + + Each band fills the entire domain with a single value at the given + resolution. + + Parameters + ---------- + domain : Domain or str + The domain (or its id) to create the grid in. + resolution_m : float + Grid cell size in meters (required — uniform grids have no native + resolution). + bands : dict + Mapping of band key to constant value, e.g. + ``{"fuel_load": 0.5, "fuel_moisture": 15.0}``. Keys must be valid + ``UniformBand`` values. + name, description : str, optional + Metadata for the grid. + tags : List[str], optional + Tags for the grid. + modifications : list, optional + Modification rules applied after the grid is built. + + Returns + ------- + Grid + The created Grid object (job status "pending" or "running"). + """ + band_inputs = [ + UniformBandInput(key=UniformBand(key), value=value) + for key, value in bands.items() + ] + request_body = CreateUniformRequest( + resolution=resolution_m, + bands=band_inputs, + name=name, + description=description, + tags=_opt(tags), + modifications=_opt(modifications), + ) + response = create_uniform_grid_endpoint.sync_detailed( + _domain_id(domain), client=ensure_client(), body=request_body + ) + return Grid._from_model(expect(response, HTTPStatus.CREATED)) + + +# --------------------------------------------------------------------------- +# Listing, fetching, and utilities +# --------------------------------------------------------------------------- + + +def list_grids( + domain=None, + page: int = 0, + size: int = 100, + sort_by: Optional[str] = None, + sort_order: Optional[str] = None, + source: Optional[str] = None, + product: Optional[str] = None, + tag: Optional[str] = None, +) -> List[Grid]: + """List grids in a domain, or across all domains (single page). + + Parameters + ---------- + domain : Domain or str, optional + The domain (or its id) to list grids in. If omitted, grids from all the + user's domains are listed. + page : int, optional + The page number to retrieve, zero-indexed (default 0). + size : int, optional + The number of grids per page (default 100). + sort_by : str, optional + Field to sort by: "name", "created_on", or "modified_on". + sort_order : str, optional + Sort direction: "ascending" or "descending". + source : str, optional + Only return grids from this source (e.g. "landfire", "3dep"). + product : str, optional + Only return grids from this source product (e.g. "fbfm40", + "topography"). Requires ``source``. + tag : str, optional + Only return grids carrying this tag. + + Returns + ------- + List[Grid] + The requested page of Grid objects. + """ + kwargs = dict( + client=ensure_client(), + page=page, + size=size, + sort_by=GridSortField(sort_by) if sort_by else UNSET, + sort_order=SortOrder(sort_order) if sort_order else UNSET, + source=_opt(source), + product=_opt(product), + tag=_opt(tag), + ) + if domain is None: + response = list_grids_cross_domain.sync_detailed(**kwargs) + else: + response = list_grids_endpoint.sync_detailed(_domain_id(domain), **kwargs) + list_response: ListGridsResponse = expect(response) + return [Grid._from_model(g) for g in list_response.grids] + + +def get_grid(domain, grid_id: str) -> Grid: + """Retrieve a single grid by its ID. + + Parameters + ---------- + domain : Domain or str + The domain (or its id) the grid belongs to. + grid_id : str + The unique identifier of the grid. + + Returns + ------- + Grid + The requested Grid object. + + Raises + ------ + NotFoundException + If no grid exists with the given IDs, or the user does not have access. + """ + return Grid.from_id(_domain_id(domain), grid_id) + + +def check_3dep_coverage(domain, resolution_m: Optional[int] = None): + """Check USGS 3DEP tile coverage for a domain before creating a grid. + + Parameters + ---------- + domain : Domain or str + The domain (or its id) to check. + resolution_m : int, optional + Resolution to check: 1, 10, or 30 meters. Defaults to the API's default. + + Returns + ------- + ThreeDepCoverageResponse + Tile availability, count, URLs, and (for 1 m) acquisition dates. + """ + response = check_3dep_coverage_endpoint.sync_detailed( + _domain_id(domain), + client=ensure_client(), + resolution=ThreeDepResolution(resolution_m) if resolution_m else UNSET, + ) + return expect(response) diff --git a/fastfuels_sdk/v2/v2_api_design.md b/fastfuels_sdk/v2/v2_api_design.md new file mode 100644 index 0000000..007054b --- /dev/null +++ b/fastfuels_sdk/v2/v2_api_design.md @@ -0,0 +1,212 @@ +# v2 SDK — API Design (WORKING DRAFT) + +> Scratch design notes for the v2 SDK refactor (umbrella #176; grids = #178). +> **Temporary** — delete or fold into real docs before merge. +> Captures the surface settled in the 2026-06 design discussion. + +## Decision: hybrid — functional creation, method-based instances + +Two separate decisions, each resolved differently: + +1. **Creation lives in module-level functions.** Every class-home was rejected: + classmethod on the resource (`Feature.create_osm_road(domain.id)`, the + original complaint), method on `Domain` (God-class), method on a collection + object (`domain.grids.topography.from_3dep()`, the object tree). Functions + are what's left, and they read cold. +2. **Everything you do with a resource you already hold is a method** on the + returned record — `grid.wait()`, `grid.to_xarray()`, `trees.voxelize(...)`, + `grid.resample(...)`, `grid.delete()`. Methods read naturally, chain, answer + "what can I do with this?" via `grid.`, and are mostly what the shipped + class-based `domains.py`/`features.py` already have — so the migration is + "move creation off the classes into functions," not a rewrite, and there's + no split-brain. + +The rule a user learns: **don't have the resource yet → function from the +domain; have it → method on it.** + +Rejected wholesale: fully object-navigation (`domain.grids.topography.…`), +fully functional (free `ff.wait(grid)` / `ff.to_xarray(grid)` — loses chaining +and the shipped methods), typed spec objects, fluent builders, a "fuelscape" +façade. + +## Access pattern (locked) + +One import; reach everything through the package namespace (`np`/`pd`/`gpd` +idiom): + +```python +import fastfuels_sdk as ff +``` + +- **Creators are module-qualified functions:** + `ff.grids.create_topography_grid_from_3dep(domain, …)`, + `ff.features.create_road_feature_from_osm(domain)`. Scoped tab-completion: + `ff.grids.`. +- **Held-resource ops are methods:** `grid.wait()`, `grid.to_xarray()`, + `trees.voxelize(…)`, `grid.resample(…)`, `grid.export(…)`, `grid.delete()`. +- **Cross-cutting helpers are top-level functions** (operate on many / build + descriptors / start from nothing): `ff.wait_all([...])`, `ff.mask(...)`, + `ff.list_grids(domain)`, `ff.list_all_grids()`, `ff.get_grid(domain, id)`, + `ff.Domain`, `ff.set_api_key`. (Fuller modifications vocab — `modify`, + `within`, `remove`, `thin_to_*` — lives in `ff.modifications`, least-settled.) +- `ff.` is a **module, not an object** — no God-class. Not the rejected + object tree. +- The resource noun stays **in the create-function name** (`…_grid_…`, + `…_feature_…`) even though the module repeats it, so it reads cold under a + bare import too. + +## Guiding principles + +1. **Read it cold** — one call, you know exactly what it does. No jargon, no + magic values, units in names (`_m`), real domain nouns, named arguments. +2. **The shape encodes where the input comes from:** `create___from_(domain, …)` + = external dataset; `create__from_(domain, path)` = your + file; `create_(domain, …)` = you supply values; `resource.()` + = transform something you hold. +3. **Resource noun in every `create_*` name** (`grid`/`feature`/`inventory`). +4. **Concrete kind nouns**, grounded in the grid's bands — never vague + umbrellas (`canopy` → `canopy_height` vs `canopy_fuel`). + +## Wait / job model (decision: A — explicit) + +API is async-job (create → poll → terminal). Surfaced explicitly: + +- **Creation never blocks, never auto-waits** — returns a *pending* record. + Lets you fan out and join (the v1 `export_roi` pattern). +- `grid.wait(timeout=None, verbose=False)` — method; blocks to terminal, + updates & returns self (chains). `timeout=None` waits indefinitely; the job + runs server-side regardless, so a bounded timeout is resumable. +- `ff.wait_all([...])` — function; join many, raises naming the first failure. +- **Deriving from a still-pending resource raises** a clear error. No hidden + block. +- Failure → `JobFailedError(code, message, suggestion)`. Progress quiet by + default; `verbose=True`. + +## Resolution & alignment (verified against the models) + +**v2 moved resolution off the domain and onto each grid** (v1 set it on the +domain). `Domain` has only `pad_to_resolution` (optional footprint snapping). +Per-creator: + +- **2D source/derive grids** (`topography×2`, `canopy_height×2`, `canopy_fuel`, + `fuel_model/fbfm40`, `resample`, `rasterize`) carry an **`alignment`** union. + All three targets also hold `resolution: float` (horizontal) and an optional + `method` (resampling). Friendly mapping: + - `output_resolution_m=N` → `target="domain"` (anchor to domain origin at N m) — default. + - `align_to=` → `target="grid"` (match that grid's lattice). + - `align="native"` → `target="native"` (keep source pixels). + - `resampling=` → `method`; one of `average / bilinear / cubic / + cubic_spline / lanczos / min / max / median / mode / first_quartile`. +- **`create_uniform_grid`**: direct `resolution: float` → `resolution_m=`. +- **`voxelize`** (3D): direct `resolution: Resolution3D = {horizontal, vertical}` + (horizontal isotropic x/y, vertical independent), **no alignment** → + `horizontal_resolution_m=` + `vertical_resolution_m=`. +- **`lookup_fuel_model_values`, geotiff/netcdf upload**: no resolution/alignment — inherit + the source grid's lattice (lookup) or the file (uploads). +- ⚠️ **`landfire_fccs` carries neither `alignment` nor `resolution`** — an + asymmetry vs the other LANDFIRE creators. Confirm with the API team before + building (fixed resolution? inherits a default?). + +## Conventions + +- Every creator also accepts `name=`, `description=`, `tags=`, + `modifications=[...]` (applied server-side after build; `modifications` is a + real field on grid creators *and* `lookup_fuel_model_values`/`rasterize`). +- **Data out (methods, hide chunk/partition plumbing + signed-URL handshake):** + `grid.to_xarray()`, `grid.to_numpy(band)`, `feature.to_geodataframe()`. +- **Generic methods on any record:** `.wait()`, `.refresh()`, `.update(...)`, + `.delete()`. + +## Module layout + +``` +fastfuels_sdk/ + __init__.py set_api_key, Domain, wait_all, mask, list_*, get_*, ... + domains.py Domain (record + from_* + methods), list_domains + features.py create_*_feature_from_* (fns); Feature record + methods [rewrite creation only] + grids.py create_*_grid_from_* (fns); Grid record + methods + (wait/to_xarray/resample/lookup_fuel_model_values/export/...) [GREENFIELD #178] + inventories.py create_tree_inventory_from_* (fns); Inventory + methods [later] + exports.py create_quicfire_export (fn); Export record + methods [later] +``` + +## Features — 10 endpoints (rewrite creation only; keep instance methods) + +| API endpoint | SDK surface | Kind | +|---|---|---| +| `create_osm_road_feature` | `ff.features.create_road_feature_from_osm(domain)` | fn | +| `create_osm_water_feature` | `ff.features.create_water_feature_from_osm(domain)` | fn | +| `create_layerset` | `ff.features.create_layerset_feature_from_geojson(domain, geojson)` · `…_from_geodataframe(domain, gdf)` | fn | +| `get_feature` | `ff.get_feature(domain, feature_id)` · `feature.refresh()` | fn / method | +| `update_feature` | `feature.update(…)` | method | +| `delete_feature` | `feature.delete()` | method | +| `list_features` | `ff.list_features(domain)` | fn | +| `list_features_cross_domain` | `ff.list_all_features()` | fn | +| `get_feature_data_metadata` + `…_partition` | `feature.to_geodataframe()` | method (hides paging) | + +## Grids — 26 endpoints (greenfield, #178) + +All `create_*_grid_*` functions share an internal `_grid_request_base(...)` + +alignment-translation helper (plain function, not a base class). + +**Create from external source (functions)** + +| API endpoint | SDK surface | +|---|---| +| `create_3dep_topography` | `ff.grids.create_topography_grid_from_3dep(domain, source_resolution_m=10, output_resolution_m=…)` | +| `create_landfire_topography` | `ff.grids.create_topography_grid_from_landfire(domain, version=…)` | +| `create_landfire_canopy` | `ff.grids.create_canopy_fuel_grid_from_landfire(domain, version=…)` | +| `create_meta_chm` | `ff.grids.create_canopy_height_grid_from_meta(domain, version=…)` | +| `create_naip_chm` | `ff.grids.create_canopy_height_grid_from_naip(domain)` | +| `create_landfire_fbfm40` | `ff.grids.create_fuel_model_grid_from_landfire_fbfm40(domain, version=…, remove_non_burnable=…)` | +| `create_landfire_fccs` | `ff.grids.create_fuel_model_grid_from_landfire_fccs(domain, version=…, remove_bare_ground=…)` (⚠ no alignment — confirm) | +| `create_treemap` | `ff.grids.create_pim_grid_from_treemap(domain, version=…, bands=[…])` (PIM = Plot Imputation Map; bands tm_id/plt_cn) | + +**From your file / generated (functions)** + +| `create_geotiff_upload` | `ff.grids.create_grid_from_geotiff(domain, path, bands=[…])` | +| `create_netcdf_upload` | `ff.grids.create_grid_from_netcdf(domain, path)` | +| `create_uniform_grid` | `ff.grids.create_uniform_grid(domain, resolution_m=…, bands={…})` | + +**Transform a resource you hold (methods on the source)** + +| `create_fbfm40_lookup` | `fbfm_grid.lookup_fuel_model_values(bands=[…])` | +| `create_tree_inventory_grid` | `inventory.voxelize(horizontal_resolution_m=…, vertical_resolution_m=…, bands=…)` | +| `create_resample` | `grid.resample(output_resolution_m=… / align_to=…, resampling=…)` | +| `create_layerset_rasterize` | `layerset.rasterize(output_resolution_m=…, overlap_method=…)` | + +**Export** + +| `create_grid_export` | `grid.export(format="geotiff")` → Export (method) | +| `create_quicfire_export` | `ff.exports.create_quicfire_export(domain, topography=…, surface=…, canopy=…)` (fn — assembled from many) | + +**Lifecycle** · `get_grid` → `ff.get_grid(domain, grid_id)` + `grid.refresh()` · `update_grid` → `grid.update(…)` · `delete_grid` → `grid.delete()` · `list_grids` → `ff.list_grids(domain)` · `list_grids_cross_domain` → `ff.list_all_grids()` + +**Data out** · `get_chunk_metadata` + `get_grid_data_json` + `get_grid_data_binary` → `grid.to_xarray()` / `grid.to_numpy(band)` (chunk reassembly + signed-URL hidden) + +**Utility** · `check_3dep_coverage` → `ff.grids.check_3dep_coverage(domain)` + +(`create_treemap` is a first-class grid source — see `create_pim_grid_from_treemap` in the "Create from external source" table above. It is distinct from `create_tree_inventory_grid` → `inventory.voxelize`, which produces a 3D voxel grid from an inventory.) + +## v1 workflow findings (from mining `docs/v1`) + +- **Masking simplifies.** v1's separate feature grid + `feature_masks=["road", + "water"]` becomes `modifications=[ff.mask(feature)]` on the grids that need it + (the API `GridModification` references a Feature by id). One fewer resource. +- **`to_geodataframe`/`to_xarray` erase the pagination ritual** — v1's + `get_data` → `get_all_data` → `from_features` collapses to `feature.to_geodataframe()`. +- **Custom road/water is a model change to document:** v1 typed + `create_road_feature_from_geodataframe`; v2 does road/water from OSM only, so + bring-your-own geometry goes through `create_layerset_feature_from_*`. +- **`export_roi` wants a v2 home** — a plain function, not a façade. + +## Open / flagged + +- ⚠ `landfire_fccs` alignment/resolution asymmetry — confirm with API team. +- Single-grid export is a method (`grid.export`) but the QUIC-Fire bundle is a + function (`ff.exports.create_quicfire_export`) — consistent with the rule + (one held resource → method; assembled-from-many → function), but worth a look. +- Modifications/treatments vocab (`mask`/`modify`/`within`/`thin_to_*`) — least + settled; built with grids since grid creators accept `modifications=`. +- Returned-record implementation (wrap generated attrs model vs clean dataclass) + — parked; shows up on every record. diff --git a/tests/v2/conftest.py b/tests/v2/conftest.py index dfdb57a..afcfccf 100644 --- a/tests/v2/conftest.py +++ b/tests/v2/conftest.py @@ -6,9 +6,9 @@ These fixtures are READ-ONLY by convention: they are shared by every module in the session, so any test that mutates or deletes a resource must create its own throwaway instead. Expensive job resources (the -completed OSM road feature; later, grids and inventories) are built once -per session and torn down with the domain — deleting the domain cascades -to everything created inside it. +completed OSM road feature and the completed topography grid) are built +once per session and torn down with the domain — deleting the domain +cascades to everything created inside it. Fixtures that wait on a job carry a ``completed_`` prefix; resources that are born complete (layerset uploads) don't need one. @@ -16,7 +16,14 @@ import pytest -from fastfuels_sdk.v2.features import Feature +from fastfuels_sdk.v2.features import ( + create_layerset_feature_from_geojson, + create_road_feature_from_osm, +) +from fastfuels_sdk.v2.grids import ( + create_fuel_model_grid_from_landfire_fbfm40, + create_topography_grid_from_3dep, +) from tests.v2.utils import ( create_default_domain, create_default_layerset_geojson, @@ -30,30 +37,61 @@ def test_domain(): sweep_leftover_domains() domain = create_default_domain() yield domain - # Cleanup: deleting the domain also deletes its features - domain.delete() + # Cleanup: force-delete cascades to the domain's features and grids + domain.delete(force=True) @pytest.fixture(scope="session") def completed_road_feature(test_domain): """A completed OSM road feature. READ-ONLY: shared by every module.""" - feature = Feature.create_osm_road( - test_domain.id, + feature = create_road_feature_from_osm( + test_domain, name="test_road", description="Road feature for testing v2 feature operations", tags=["test"], ) - feature.wait_until_completed(step=2) + feature.wait() return feature @pytest.fixture(scope="session") def layerset_feature(test_domain): """A layerset feature (born completed). READ-ONLY: shared by every module.""" - return Feature.create_layerset( - test_domain.id, + return create_layerset_feature_from_geojson( + test_domain, create_default_layerset_geojson(), name="test_layerset", description="Layerset feature for testing v2 feature operations", tags=["layerset-test"], ) + + +@pytest.fixture(scope="session") +def completed_topography_grid(test_domain): + """A completed 3DEP topography grid. READ-ONLY: shared by every module.""" + grid = create_topography_grid_from_3dep( + test_domain, + output_resolution_m=10, + name="test_topography", + description="Topography grid for testing v2 grid operations", + tags=["test"], + ) + grid.wait() + return grid + + +@pytest.fixture(scope="session") +def completed_fbfm40_grid(test_domain): + """A completed LANDFIRE FBFM40 fuel model grid. READ-ONLY: shared by + every module. Provides the FBFM40 codes that + ``Grid.lookup_fuel_model_values`` reads. + """ + grid = create_fuel_model_grid_from_landfire_fbfm40( + test_domain, + output_resolution_m=30, + name="test_fbfm40", + description="FBFM40 grid for testing v2 grid lookup operations", + tags=["test"], + ) + grid.wait() + return grid diff --git a/tests/v2/test_domains.py b/tests/v2/test_domains.py index 9531e05..cfc2895 100644 --- a/tests/v2/test_domains.py +++ b/tests/v2/test_domains.py @@ -156,15 +156,12 @@ def test_not_found(self): Domain.from_id(uuid4().hex) -class TestGetDomain: - def test_get_default(self, test_domain): - domain = test_domain.get() - assert domain.id == test_domain.id - assert domain is not test_domain - - def test_get_in_place(self, test_domain): - domain = test_domain.get(in_place=True) - assert domain is test_domain +class TestRefreshDomain: + def test_refresh_returns_self(self, test_domain): + # refresh() re-fetches and updates in place, returning the same object + refreshed = test_domain.refresh() + assert refreshed is test_domain + assert refreshed.id == test_domain.id class TestUpdateDomain: @@ -176,27 +173,25 @@ def update_domain(self): domain.delete() def test_update_name(self, update_domain): + # update() mutates in place and returns self (chains) updated = update_domain.update(name="updated_name") - assert updated.name == "updated_name" - assert updated is not update_domain + assert updated is update_domain + assert update_domain.name == "updated_name" # The remote resource reflects the update assert Domain.from_id(update_domain.id).name == "updated_name" - def test_update_in_place(self, update_domain): - updated = update_domain.update(description="updated description", in_place=True) + def test_update_description(self, update_domain): + updated = update_domain.update(description="updated description") assert updated is update_domain assert update_domain.description == "updated description" def test_update_tags(self, update_domain): - updated = update_domain.update(tags=["updated"], in_place=True) - assert updated.tags == ["updated"] + update_domain.update(tags=["updated"]) + assert update_domain.tags == ["updated"] def test_update_no_fields_makes_no_api_call(self, update_domain): - # No fields provided: returns a copy without touching the API - copy = update_domain.update() - assert copy is not update_domain - assert copy.id == update_domain.id - assert update_domain.update(in_place=True) is update_domain + # No fields provided: returns self without touching the API + assert update_domain.update() is update_domain class TestGetLattice: diff --git a/tests/v2/test_features.py b/tests/v2/test_features.py index c88fceb..75b5f90 100644 --- a/tests/v2/test_features.py +++ b/tests/v2/test_features.py @@ -9,7 +9,15 @@ # Internal imports from tests import TEST_DATA_DIR from tests.v2.utils import create_default_layerset_geojson -from fastfuels_sdk.v2.features import Feature, list_features +from fastfuels_sdk.v2.features import ( + Feature, + create_layerset_feature_from_geodataframe, + create_layerset_feature_from_geojson, + create_road_feature_from_osm, + create_water_feature_from_osm, + get_feature, + list_features, +) from fastfuels_sdk.v2.client_library.models import FeatureType, JobStatus from fastfuels_sdk.v2.exceptions import ( NotFoundException, @@ -27,7 +35,7 @@ class TestCreateOsmRoadFeature: def test_create(self, test_domain): - feature = Feature.create_osm_road(test_domain.id, name="throwaway_road") + feature = create_road_feature_from_osm(test_domain, name="throwaway_road") # Feature generation is an asynchronous job assert len(feature.id) > 0 @@ -47,12 +55,12 @@ def test_completed_fixture(self, test_domain, completed_road_feature): def test_invalid_extent_buffer(self, test_domain): with pytest.raises(UnprocessableEntityException): - Feature.create_osm_road(test_domain.id, extent_buffer_m=500) + create_road_feature_from_osm(test_domain, extent_buffer_m=500) class TestCreateOsmWaterFeature: def test_create(self, test_domain): - feature = Feature.create_osm_water(test_domain.id, name="throwaway_water") + feature = create_water_feature_from_osm(test_domain, name="throwaway_water") assert len(feature.id) > 0 assert feature.domain_id == test_domain.id @@ -74,7 +82,7 @@ def test_single_feature_is_wrapped(self, test_domain): feature_geojson = layerset_geojson["features"][0] feature_geojson["crs"] = layerset_geojson["crs"] - feature = Feature.create_layerset(test_domain.id, feature_geojson) + feature = create_layerset_feature_from_geojson(test_domain, feature_geojson) assert feature.status == JobStatus.COMPLETED feature.delete() @@ -88,12 +96,12 @@ def test_geographic_crs_rejected(self, test_domain): ] with pytest.raises(UnprocessableEntityException, match="geographic"): - Feature.create_layerset(test_domain.id, geojson) + create_layerset_feature_from_geojson(test_domain, geojson) def test_invalid_geojson_type(self, test_domain): with pytest.raises(ValueError, match="FeatureCollection"): - Feature.create_layerset( - test_domain.id, {"type": "Point", "coordinates": [0, 0]} + create_layerset_feature_from_geojson( + test_domain, {"type": "Point", "coordinates": [0, 0]} ) @@ -104,8 +112,8 @@ def test_create(self, test_domain): gdf = gpd.read_file(json.dumps(create_default_layerset_geojson())) assert gdf.crs.is_projected - feature = Feature.create_layerset_from_geodataframe( - test_domain.id, gdf, name="gdf_layerset" + feature = create_layerset_feature_from_geodataframe( + test_domain, gdf, name="gdf_layerset" ) assert feature.type_ == FeatureType.LAYERSET assert feature.status == JobStatus.COMPLETED @@ -125,22 +133,25 @@ def test_not_found(self, test_domain): Feature.from_id(test_domain.id, uuid4().hex) -class TestGetFeature: - def test_get_default(self, layerset_feature): - feature = layerset_feature.get() +class TestRefreshFeature: + def test_refresh_returns_self(self, layerset_feature): + # refresh() updates in place and returns the same object (chains) + refreshed = layerset_feature.refresh() + assert refreshed is layerset_feature + assert refreshed.id == layerset_feature.id + + def test_get_feature_returns_new_instance(self, test_domain, layerset_feature): + # The "fetch a fresh, separate copy" use case is get_feature(...) + feature = get_feature(test_domain, layerset_feature.id) assert feature.id == layerset_feature.id assert feature is not layerset_feature - def test_get_in_place(self, layerset_feature): - feature = layerset_feature.get(in_place=True) - assert feature is layerset_feature - -class TestWaitUntilCompleted: +class TestWait: def test_timeout(self, test_domain): - feature = Feature.create_osm_water(test_domain.id) + feature = create_water_feature_from_osm(test_domain) with pytest.raises(TimeoutError): - feature.wait_until_completed(timeout=0) + feature.wait(timeout=0) feature.delete() @@ -158,7 +169,7 @@ def test_metadata(self, completed_road_feature): def test_not_completed(self, test_domain): # Data is only available once the job is complete - feature = Feature.create_osm_road(test_domain.id) + feature = create_road_feature_from_osm(test_domain) with pytest.raises(UnprocessableEntityException, match="status"): feature.get_data_metadata() feature.delete() @@ -206,20 +217,20 @@ class TestListFeatures: def test_list_in_domain( self, test_domain, completed_road_feature, layerset_feature ): - features = list_features(test_domain.id) + features = list_features(test_domain) feature_ids = [feature.id for feature in features] assert completed_road_feature.id in feature_ids assert layerset_feature.id in feature_ids def test_list_cross_domain(self, layerset_feature): - # No domain_id: list features across all the user's domains + # No domain: list features across all the user's domains features = list_features() assert layerset_feature.id in [feature.id for feature in features] def test_filter_by_type( self, test_domain, completed_road_feature, layerset_feature ): - features = list_features(test_domain.id, feature_type="road") + features = list_features(test_domain, feature_type="road") feature_ids = [feature.id for feature in features] assert all(feature.type_ == FeatureType.ROAD for feature in features) assert completed_road_feature.id in feature_ids @@ -228,13 +239,13 @@ def test_filter_by_type( def test_filter_by_product( self, test_domain, completed_road_feature, layerset_feature ): - features = list_features(test_domain.id, product="osm") + features = list_features(test_domain, product="osm") feature_ids = [feature.id for feature in features] assert completed_road_feature.id in feature_ids assert layerset_feature.id not in feature_ids def test_filter_by_tag(self, test_domain, layerset_feature): - features = list_features(test_domain.id, tag="layerset-test") + features = list_features(test_domain, tag="layerset-test") assert [feature.id for feature in features] == [layerset_feature.id] @pytest.mark.xfail( @@ -243,7 +254,7 @@ def test_filter_by_tag(self, test_domain, layerset_feature): ) def test_sorting(self, test_domain, layerset_feature): features = list_features( - test_domain.id, sort_by="created_on", sort_order="descending" + test_domain, sort_by="created_on", sort_order="descending" ) assert len(features) > 0 @@ -260,36 +271,32 @@ class TestUpdateFeature: @pytest.fixture(scope="class") def update_feature(self, test_domain): """A throwaway feature to mutate (the shared fixtures are read-only).""" - feature = Feature.create_layerset( - test_domain.id, create_default_layerset_geojson(), name="update_target" + feature = create_layerset_feature_from_geojson( + test_domain, create_default_layerset_geojson(), name="update_target" ) yield feature feature.delete() def test_update_name(self, test_domain, update_feature): + # update() mutates in place and returns self (chains) updated = update_feature.update(name="updated_name") - assert updated.name == "updated_name" - assert updated is not update_feature + assert updated is update_feature + assert update_feature.name == "updated_name" # The remote resource reflects the update - assert Feature.from_id(test_domain.id, update_feature.id).name == "updated_name" + assert get_feature(test_domain, update_feature.id).name == "updated_name" - def test_update_in_place(self, update_feature): - updated = update_feature.update( - description="updated description", in_place=True - ) + def test_update_description(self, update_feature): + updated = update_feature.update(description="updated description") assert updated is update_feature assert update_feature.description == "updated description" def test_update_tags(self, update_feature): - updated = update_feature.update(tags=["updated"], in_place=True) - assert updated.tags == ["updated"] + update_feature.update(tags=["updated"]) + assert update_feature.tags == ["updated"] def test_update_no_fields_makes_no_api_call(self, update_feature): - # No fields provided: returns a copy without touching the API - copy = update_feature.update() - assert copy is not update_feature - assert copy.id == update_feature.id - assert update_feature.update(in_place=True) is update_feature + # No fields provided: returns self without touching the API + assert update_feature.update() is update_feature class TestToJson: @@ -302,7 +309,7 @@ def test_to_json(self, layerset_feature): class TestDeleteFeature: def test_delete_feature(self, test_domain): - feature = Feature.create_osm_water(test_domain.id) + feature = create_water_feature_from_osm(test_domain) feature.delete() with pytest.raises(NotFoundException): diff --git a/tests/v2/test_grids.py b/tests/v2/test_grids.py new file mode 100644 index 0000000..4024194 --- /dev/null +++ b/tests/v2/test_grids.py @@ -0,0 +1,478 @@ +""" +tests/v2/test_grids.py +""" + +# Core imports +import inspect +import json +from types import SimpleNamespace +from uuid import uuid4 + +# Internal imports +from fastfuels_sdk.v2 import grids +from fastfuels_sdk.v2.grids import ( + Grid, + _build_alignment, + _domain_id, + _enum_list, + _opt, + check_3dep_coverage, + create_canopy_fuel_grid_from_landfire, + create_canopy_height_grid_from_meta, + create_canopy_height_grid_from_naip, + create_fuel_model_grid_from_landfire_fbfm40, + create_fuel_model_grid_from_landfire_fccs, + create_pim_grid_from_treemap, + create_topography_grid_from_3dep, + create_topography_grid_from_landfire, + create_uniform_grid, + get_grid, + list_grids, +) +from fastfuels_sdk.v2.client_library.models import ( + GridAlignmentDomainTarget, + GridAlignmentGridTarget, + GridAlignmentNativeTarget, + JobStatus, + ResamplingMethod, + TopographyBand, +) +from fastfuels_sdk.v2.client_library.types import UNSET +from fastfuels_sdk.v2.exceptions import NotFoundException + +# External imports +import pytest + +# The test_domain and completed_topography_grid fixtures are session-scoped +# and shared across modules (tests/v2/conftest.py). They are READ-ONLY: +# tests that mutate or delete create throwaways. + + +class TestBuildAlignment: + """Pure unit tests for the alignment-keyword translator (no API).""" + + def test_none_is_unset(self): + assert _build_alignment() is UNSET + + def test_output_resolution_is_domain_target(self): + alignment = _build_alignment(output_resolution_m=10) + assert isinstance(alignment, GridAlignmentDomainTarget) + assert alignment.resolution == 10 + + def test_align_native(self): + alignment = _build_alignment(align="native") + assert isinstance(alignment, GridAlignmentNativeTarget) + + def test_align_to_grid_id(self): + alignment = _build_alignment(align_to="abc123") + assert isinstance(alignment, GridAlignmentGridTarget) + assert alignment.grid_id == "abc123" + + def test_align_to_grid_object(self): + # A Grid (or any object with .id) is accepted, not just an id string + alignment = _build_alignment(align_to=SimpleNamespace(id="gid")) + assert alignment.grid_id == "gid" + + def test_resampling_method(self): + alignment = _build_alignment(output_resolution_m=10, resampling="bilinear") + assert alignment.method == ResamplingMethod.BILINEAR + + def test_conflicting_targets_raise(self): + with pytest.raises(ValueError): + _build_alignment(output_resolution_m=10, align="native") + with pytest.raises(ValueError): + _build_alignment(align_to="abc", align="native") + + def test_bad_resampling_raises(self): + with pytest.raises(ValueError): + _build_alignment(resampling="not_a_method") + + def test_bad_align_value_raises(self): + with pytest.raises(ValueError): + _build_alignment(align="bogus") + + +class TestHelpers: + """Pure unit tests for the small request-marshalling helpers (no API).""" + + def test_opt_none_is_unset(self): + assert _opt(None) is UNSET + + def test_opt_passes_values_through(self): + assert _opt("x") == "x" + # 0 and "" are not None, so they pass through unchanged + assert _opt(0) == 0 + assert _opt("") == "" + + def test_domain_id_from_string(self): + assert _domain_id("abc123") == "abc123" + + def test_domain_id_from_object(self): + assert _domain_id(SimpleNamespace(id="gid")) == "gid" + + def test_enum_list_none_is_unset(self): + assert _enum_list(None, TopographyBand) is UNSET + + def test_enum_list_coerces_strings(self): + assert _enum_list(["elevation", "slope"], TopographyBand) == [ + TopographyBand.ELEVATION, + TopographyBand.SLOPE, + ] + + def test_enum_list_passes_members_through(self): + assert _enum_list([TopographyBand.ASPECT], TopographyBand) == [ + TopographyBand.ASPECT + ] + + def test_enum_list_invalid_value_raises(self): + with pytest.raises(ValueError): + _enum_list(["not_a_band"], TopographyBand) + + +class TestFccsSignature: + """The FCCS request model has no alignment field, so the SDK creator + exposes no alignment kwargs (unlike the other LANDFIRE creators).""" + + def test_fccs_has_no_alignment_kwargs(self): + params = inspect.signature( + grids.create_fuel_model_grid_from_landfire_fccs + ).parameters + for forbidden in ( + "output_resolution_m", + "align_to", + "align", + "resampling", + "extent_buffer_cells", + ): + assert forbidden not in params + + def test_fbfm40_has_alignment_kwargs(self): + params = inspect.signature( + grids.create_fuel_model_grid_from_landfire_fbfm40 + ).parameters + assert "output_resolution_m" in params + assert "align_to" in params + + +class TestCreateTopographyGridFrom3dep: + def test_create(self, test_domain): + grid = create_topography_grid_from_3dep( + test_domain, output_resolution_m=10, name="throwaway_topo" + ) + + # Grid generation is an asynchronous job + assert len(grid.id) > 0 + assert grid.domain_id == test_domain.id + assert grid.status in (JobStatus.PENDING, JobStatus.RUNNING) + assert grid.source is not None + grid.delete() + + def test_completed_fixture(self, completed_topography_grid): + assert completed_topography_grid.status == JobStatus.COMPLETED + assert completed_topography_grid.name == "test_topography" + assert completed_topography_grid.tags == ["test"] + # The georeference and chunk layout are populated once complete + assert completed_topography_grid.georeference is not None + assert len(completed_topography_grid.bands) > 0 + + +class TestCreateTopographyGridFromLandfire: + def test_create(self, test_domain): + grid = create_topography_grid_from_landfire( + test_domain, + output_resolution_m=30, + bands=["elevation"], + name="throwaway_lf_topo", + ) + assert len(grid.id) > 0 + assert grid.domain_id == test_domain.id + assert grid.status in (JobStatus.PENDING, JobStatus.RUNNING) + assert grid.source is not None + grid.delete() + + +class TestCreateCanopyFuelGridFromLandfire: + def test_create(self, test_domain): + grid = create_canopy_fuel_grid_from_landfire( + test_domain, + output_resolution_m=30, + bands=["cbd"], + name="throwaway_canopy_fuel", + ) + assert len(grid.id) > 0 + assert grid.domain_id == test_domain.id + assert grid.status in (JobStatus.PENDING, JobStatus.RUNNING) + grid.delete() + + +class TestCreateCanopyHeightGridFromMeta: + def test_create(self, test_domain): + grid = create_canopy_height_grid_from_meta( + test_domain, output_resolution_m=30, name="throwaway_meta_chm" + ) + assert len(grid.id) > 0 + assert grid.domain_id == test_domain.id + assert grid.status in (JobStatus.PENDING, JobStatus.RUNNING) + grid.delete() + + +class TestCreateCanopyHeightGridFromNaip: + def test_create(self, test_domain): + grid = create_canopy_height_grid_from_naip( + test_domain, output_resolution_m=30, name="throwaway_naip_chm" + ) + assert len(grid.id) > 0 + assert grid.domain_id == test_domain.id + assert grid.status in (JobStatus.PENDING, JobStatus.RUNNING) + grid.delete() + + +class TestCreateFuelModelGridFromLandfireFbfm40: + def test_create(self, test_domain): + # remove_non_burnable exercises the string -> enum list coercion + grid = create_fuel_model_grid_from_landfire_fbfm40( + test_domain, + output_resolution_m=30, + remove_non_burnable=["NB1", "NB2"], + name="throwaway_fbfm40", + ) + assert len(grid.id) > 0 + assert grid.domain_id == test_domain.id + assert grid.status in (JobStatus.PENDING, JobStatus.RUNNING) + grid.delete() + + +class TestCreateFuelModelGridFromLandfireFccs: + def test_create(self, test_domain): + # FCCS is alignment-free; only metadata and remove_bare_ground apply + grid = create_fuel_model_grid_from_landfire_fccs( + test_domain, remove_bare_ground=True, name="throwaway_fccs" + ) + assert len(grid.id) > 0 + assert grid.domain_id == test_domain.id + assert grid.status in (JobStatus.PENDING, JobStatus.RUNNING) + grid.delete() + + +class TestCreatePimGridFromTreemap: + def test_create(self, test_domain): + grid = create_pim_grid_from_treemap( + test_domain, + output_resolution_m=30, + resampling="nearest", + name="throwaway_treemap", + ) + assert len(grid.id) > 0 + assert grid.domain_id == test_domain.id + assert grid.status in (JobStatus.PENDING, JobStatus.RUNNING) + grid.delete() + + +class TestUploadCreators: + """The GeoTIFF/NetCDF upload creators need a binary raster whose CRS + matches the domain CRS. There is no committed raster fixture and no + raster dependency (rasterio/xarray) in the test environment to build + one on the fly, so these paths are deferred rather than mocked. + """ + + @pytest.mark.skip( + reason="needs a committed GeoTIFF fixture matching the domain CRS" + ) + def test_create_grid_from_geotiff(self, test_domain): + pass + + @pytest.mark.skip(reason="needs a committed NetCDF fixture matching the domain CRS") + def test_create_grid_from_netcdf(self, test_domain): + pass + + +class TestCreateUniformGrid: + def test_create(self, test_domain): + grid = create_uniform_grid( + test_domain, + resolution_m=30, + bands={"fuel_depth": 0.5, "fuel_load.1hr": 0.2}, + name="throwaway_uniform", + ) + assert len(grid.id) > 0 + assert grid.domain_id == test_domain.id + assert grid.status in ( + JobStatus.PENDING, + JobStatus.RUNNING, + JobStatus.COMPLETED, + ) + grid.delete() + + def test_invalid_band_key(self, test_domain): + with pytest.raises(ValueError): + create_uniform_grid(test_domain, resolution_m=30, bands={"not_a_band": 1.0}) + + +class TestFromId: + def test_success(self, test_domain, completed_topography_grid): + grid = Grid.from_id(test_domain.id, completed_topography_grid.id) + assert grid.id == completed_topography_grid.id + assert grid.domain_id == test_domain.id + + def test_not_found(self, test_domain): + with pytest.raises(NotFoundException): + Grid.from_id(test_domain.id, uuid4().hex) + + +class TestGetGrid: + def test_get_grid_returns_new_instance( + self, test_domain, completed_topography_grid + ): + grid = get_grid(test_domain, completed_topography_grid.id) + assert grid.id == completed_topography_grid.id + assert grid is not completed_topography_grid + + +class TestRefreshGrid: + def test_refresh_returns_self(self, completed_topography_grid): + refreshed = completed_topography_grid.refresh() + assert refreshed is completed_topography_grid + assert refreshed.id == completed_topography_grid.id + + +class TestWait: + def test_timeout(self, test_domain): + grid = create_topography_grid_from_3dep(test_domain, output_resolution_m=10) + if grid.status == JobStatus.COMPLETED: + grid.delete() + pytest.skip("grid completed too quickly to test the timeout") + with pytest.raises(TimeoutError): + grid.wait(timeout=0) + grid.delete() + + +class TestUpdateGrid: + @pytest.fixture(scope="class") + def update_grid(self, test_domain): + """A throwaway grid to mutate (the shared fixtures are read-only).""" + grid = create_uniform_grid( + test_domain, + resolution_m=30, + bands={"fuel_depth": 0.5}, + name="update_target", + ) + yield grid + grid.delete() + + def test_update_name(self, test_domain, update_grid): + # update() mutates in place and returns self (chains) + updated = update_grid.update(name="updated_name") + assert updated is update_grid + assert update_grid.name == "updated_name" + assert get_grid(test_domain, update_grid.id).name == "updated_name" + + def test_update_tags(self, update_grid): + update_grid.update(tags=["updated"]) + assert update_grid.tags == ["updated"] + + def test_update_no_fields_makes_no_api_call(self, update_grid): + assert update_grid.update() is update_grid + + +class TestResample: + def test_resample_returns_new_pending_grid(self, completed_topography_grid): + resampled = completed_topography_grid.resample( + output_resolution_m=30, name="resampled" + ) + assert isinstance(resampled, Grid) + assert resampled.id != completed_topography_grid.id + assert resampled.domain_id == completed_topography_grid.domain_id + resampled.delete() + + def test_resample_requires_completed_source(self, test_domain): + grid = create_topography_grid_from_3dep(test_domain, output_resolution_m=10) + if grid.status == JobStatus.COMPLETED: + grid.delete() + pytest.skip("grid completed too quickly to test the guard") + with pytest.raises(ValueError, match="resample"): + grid.resample(output_resolution_m=30) + grid.delete() + + +class TestLookupFuelModelValues: + def test_lookup_returns_new_pending_grid(self, completed_fbfm40_grid): + fuel_grid = completed_fbfm40_grid.lookup_fuel_model_values( + bands=["fuel_load.1hr", "fuel_depth"], name="throwaway_lookup" + ) + assert isinstance(fuel_grid, Grid) + assert fuel_grid.id != completed_fbfm40_grid.id + assert fuel_grid.domain_id == completed_fbfm40_grid.domain_id + assert fuel_grid.status in (JobStatus.PENDING, JobStatus.RUNNING) + fuel_grid.delete() + + def test_lookup_requires_completed_source(self, test_domain): + grid = create_fuel_model_grid_from_landfire_fbfm40( + test_domain, output_resolution_m=30 + ) + if grid.status == JobStatus.COMPLETED: + grid.delete() + pytest.skip("grid completed too quickly to test the guard") + with pytest.raises(ValueError, match="look up fuel"): + grid.lookup_fuel_model_values(bands=["fuel_load.1hr"]) + grid.delete() + + +class TestExport: + def test_export_returns_pending_export(self, completed_topography_grid): + # Topography is 2D, so a GeoTIFF export is valid + export = completed_topography_grid.export(format="geotiff") + assert len(export.id) > 0 + assert export.domain_id == completed_topography_grid.domain_id + assert export.status in ( + JobStatus.PENDING, + JobStatus.RUNNING, + JobStatus.COMPLETED, + ) + + +class TestCheck3depCoverage: + def test_coverage(self, test_domain): + coverage = check_3dep_coverage(test_domain, resolution_m=10) + # The test domain sits in CONUS, which 3DEP 10 m covers + assert coverage.available is True + assert coverage.tile_count >= 1 + + +class TestListGrids: + def test_list_in_domain(self, test_domain, completed_topography_grid): + grid_ids = [grid.id for grid in list_grids(test_domain)] + assert completed_topography_grid.id in grid_ids + + def test_list_cross_domain(self, completed_topography_grid): + # No domain: list grids across all the user's domains + grid_ids = [grid.id for grid in list_grids()] + assert completed_topography_grid.id in grid_ids + + def test_filter_by_tag(self, test_domain, completed_topography_grid): + grids_with_tag = list_grids(test_domain, tag="test") + assert completed_topography_grid.id in [grid.id for grid in grids_with_tag] + + def test_invalid_sort_field(self): + with pytest.raises(ValueError): + list_grids(sort_by="not_a_field") + + +class TestToJson: + def test_to_json(self, completed_topography_grid): + grid_dict = json.loads(completed_topography_grid.to_json()) + assert grid_dict["id"] == completed_topography_grid.id + assert grid_dict["domain_id"] == completed_topography_grid.domain_id + + +class TestDeleteGrid: + def test_delete(self, test_domain): + grid = create_uniform_grid( + test_domain, resolution_m=30, bands={"fuel_depth": 0.5} + ) + grid.delete() + + with pytest.raises(NotFoundException): + Grid.from_id(test_domain.id, grid.id) + + with pytest.raises(NotFoundException): + grid.delete() diff --git a/tests/v2/utils.py b/tests/v2/utils.py index 14b42ed..cd16801 100644 --- a/tests/v2/utils.py +++ b/tests/v2/utils.py @@ -52,7 +52,7 @@ def sweep_leftover_domains(max_age: timedelta = timedelta(hours=2)) -> None: cutoff = datetime.now(timezone.utc) - max_age for domain in list_domains(size=100): if SWEEP_TAG in (domain.tags or []) and domain.created_on < cutoff: - domain.delete() + domain.delete(force=True) def create_default_layerset_geojson() -> dict: From 44f9f8de9cee0b598f90b965ea348f6916871252 Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Tue, 9 Jun 2026 12:38:12 -0600 Subject: [PATCH 23/71] Rename the NAIP creator to ..._from_naip_chm and document the model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename create_canopy_height_grid_from_naip -> create_canopy_height_grid_from_naip_chm (function, __all__, test class + call site, design-doc row) to match the NAIP-CHM product name. Rewrite the docstring from the upstream NAIP-CHM model docs: it produces a single continuous chm band (height in meters) at 0.6 m over CONUS, and is a surface model (nDSM) that retains buildings and infrastructure as well as vegetation — so vegetative-fuel-only workflows must mask built structures via modifications=. Add a References section citing the publication (Morford et al. 2025) and the dataset/model repository. --- fastfuels_sdk/v2/grids.py | 31 ++++++++++++++++++++++++++----- fastfuels_sdk/v2/v2_api_design.md | 2 +- tests/v2/test_grids.py | 6 +++--- 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/fastfuels_sdk/v2/grids.py b/fastfuels_sdk/v2/grids.py index ffc6cd1..0349486 100644 --- a/fastfuels_sdk/v2/grids.py +++ b/fastfuels_sdk/v2/grids.py @@ -86,7 +86,7 @@ "create_topography_grid_from_landfire", "create_canopy_fuel_grid_from_landfire", "create_canopy_height_grid_from_meta", - "create_canopy_height_grid_from_naip", + "create_canopy_height_grid_from_naip_chm", "create_fuel_model_grid_from_landfire_fbfm40", "create_fuel_model_grid_from_landfire_fccs", "create_grid_from_geotiff", @@ -793,7 +793,7 @@ def create_canopy_height_grid_from_meta( return Grid._from_model(expect(response, HTTPStatus.CREATED)) -def create_canopy_height_grid_from_naip( +def create_canopy_height_grid_from_naip_chm( domain, output_resolution_m: Optional[float] = None, align_to=None, @@ -805,20 +805,32 @@ def create_canopy_height_grid_from_naip( tags: Optional[List[str]] = None, modifications: Optional[list] = None, ) -> Grid: - """Create a canopy height grid from a NAIP-derived Canopy Height Model. + """Create a canopy height grid from the NAIP-CHM model. + + NAIP-CHM is a 0.6 m canopy height and structure model covering the + contiguous US (CONUS). The grid carries a single continuous ``chm`` band + (above-ground height in meters). + + NAIP-CHM is a surface model (nDSM): it captures the top of *all* + above-ground structure — vegetation **and** buildings/infrastructure — not + vegetation alone. To model vegetative fuels only, subtract built structures + with ``modifications=`` (e.g. a building-footprint mask). Coverage is + CONUS-only; domains outside it return no data. Parameters ---------- domain : Domain or str The domain (or its id) to create the grid in. output_resolution_m : float, optional - Output cell size in meters, anchored to the domain origin. + Output cell size in meters, anchored to the domain origin. The source + is 0.6 m; coarser outputs are resampled. align_to : Grid or str, optional Match the lattice of an existing grid (or its id). align : str, optional Pass ``"native"`` to keep the source raster's pixel anchor. resampling : str, optional - Resampling method (e.g. "bilinear", "cubic"). + Resampling method for the continuous height band (e.g. "bilinear", + "cubic"). extent_buffer_cells : int, optional Result-grid cells to buffer around the domain extent (0-10, default 0). name, description : str, optional @@ -832,6 +844,15 @@ def create_canopy_height_grid_from_naip( ------- Grid The created Grid object (job status "pending" or "running"). + + References + ---------- + Morford, S. L., Allred, B. W., Coons, S. P., Marcozzi, A. A., McCord, S. E., + Smith, J. T., & Naugle, D. E. (2025). A 0.6-meter resolution canopy height + and structure model for the contiguous United States. bioRxiv. + https://doi.org/10.64898/2025.12.12.694075 + + Dataset and model code: https://github.com/smorf-ntsg/naip-chm """ request_body = CreateNaipChmRequest( alignment=_build_alignment(output_resolution_m, align_to, align, resampling), diff --git a/fastfuels_sdk/v2/v2_api_design.md b/fastfuels_sdk/v2/v2_api_design.md index 007054b..ed7cb33 100644 --- a/fastfuels_sdk/v2/v2_api_design.md +++ b/fastfuels_sdk/v2/v2_api_design.md @@ -157,7 +157,7 @@ alignment-translation helper (plain function, not a base class). | `create_landfire_topography` | `ff.grids.create_topography_grid_from_landfire(domain, version=…)` | | `create_landfire_canopy` | `ff.grids.create_canopy_fuel_grid_from_landfire(domain, version=…)` | | `create_meta_chm` | `ff.grids.create_canopy_height_grid_from_meta(domain, version=…)` | -| `create_naip_chm` | `ff.grids.create_canopy_height_grid_from_naip(domain)` | +| `create_naip_chm` | `ff.grids.create_canopy_height_grid_from_naip_chm(domain)` | | `create_landfire_fbfm40` | `ff.grids.create_fuel_model_grid_from_landfire_fbfm40(domain, version=…, remove_non_burnable=…)` | | `create_landfire_fccs` | `ff.grids.create_fuel_model_grid_from_landfire_fccs(domain, version=…, remove_bare_ground=…)` (⚠ no alignment — confirm) | | `create_treemap` | `ff.grids.create_pim_grid_from_treemap(domain, version=…, bands=[…])` (PIM = Plot Imputation Map; bands tm_id/plt_cn) | diff --git a/tests/v2/test_grids.py b/tests/v2/test_grids.py index 4024194..99b8bff 100644 --- a/tests/v2/test_grids.py +++ b/tests/v2/test_grids.py @@ -19,7 +19,7 @@ check_3dep_coverage, create_canopy_fuel_grid_from_landfire, create_canopy_height_grid_from_meta, - create_canopy_height_grid_from_naip, + create_canopy_height_grid_from_naip_chm, create_fuel_model_grid_from_landfire_fbfm40, create_fuel_model_grid_from_landfire_fccs, create_pim_grid_from_treemap, @@ -216,9 +216,9 @@ def test_create(self, test_domain): grid.delete() -class TestCreateCanopyHeightGridFromNaip: +class TestCreateCanopyHeightGridFromNaipChm: def test_create(self, test_domain): - grid = create_canopy_height_grid_from_naip( + grid = create_canopy_height_grid_from_naip_chm( test_domain, output_resolution_m=30, name="throwaway_naip_chm" ) assert len(grid.id) > 0 From b0a3a80449f93b155d9995a435cbc8dc54d9ab64 Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Tue, 9 Jun 2026 16:32:55 -0600 Subject: [PATCH 24/71] Add ff.mask() grid modification primitive (#178) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build a GridModification (feature spatial condition + replace action) for a creator's modifications= list — the v2 replacement for v1 feature_masks. Lives in fastfuels_sdk/v2/modifications.py, exported as ff.mask; supports a single band or a list, a Feature or bare id, and the operator/buffer_m/target options. Covered by unit payload tests plus a live FBFM40-with-mask test. --- fastfuels_sdk/v2/__init__.py | 3 + fastfuels_sdk/v2/modifications.py | 104 ++++++++++++++++++++++++++++++ tests/v2/test_grids.py | 43 ++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 fastfuels_sdk/v2/modifications.py diff --git a/fastfuels_sdk/v2/__init__.py b/fastfuels_sdk/v2/__init__.py index e431e31..4f1c77e 100644 --- a/fastfuels_sdk/v2/__init__.py +++ b/fastfuels_sdk/v2/__init__.py @@ -24,6 +24,7 @@ from fastfuels_sdk.v2.domains import Domain, list_domains, reproject_geojson from fastfuels_sdk.v2.features import Feature, get_feature, list_features from fastfuels_sdk.v2.grids import Grid, get_grid, list_grids +from fastfuels_sdk.v2.modifications import mask __all__ = [ # Submodules (resource creators live here: ff.grids.create_*, ff.features.create_*) @@ -42,6 +43,8 @@ "list_grids", "get_grid", "reproject_geojson", + # Modifications + "mask", # Jobs "wait_all", "JobFailedError", diff --git a/fastfuels_sdk/v2/modifications.py b/fastfuels_sdk/v2/modifications.py new file mode 100644 index 0000000..ab3837d --- /dev/null +++ b/fastfuels_sdk/v2/modifications.py @@ -0,0 +1,104 @@ +""" +fastfuels_sdk/v2/modifications.py + +Modification primitives for grid creation. + +A grid modification is a rule applied to a grid *after* it is built from its +source: it pairs *conditions* (spatial or value tests) with *actions* that +rewrite band values for the cells those conditions select. The generated +models (``GridModification``, ``GridFeatureSpatialCondition``, +``GridModificationAction``) express the full vocabulary; ``mask`` is the one +primitive the core grids workflow needs and the v2 replacement for v1's +``feature_masks``. +""" + +from typing import List, Optional, Union + +from fastfuels_sdk.v2.client_library.models import ( + GridFeatureSpatialCondition, + GridModification, + GridModificationAction, + GridSpatialTarget, + Modifier, + SpatialOperator, +) +from fastfuels_sdk.v2.client_library.types import UNSET + +__all__ = ["mask"] + + +def _feature_id(feature) -> str: + """Accept a ``Feature`` record or a bare id string.""" + return getattr(feature, "id", feature) + + +def mask( + feature, + band: Union[str, List[str]], + value: Union[float, int, str] = 0.0, + *, + operator: str = "within", + buffer_m: Optional[float] = None, + target: Optional[str] = None, +) -> GridModification: + """Build a modification that overwrites a band where cells fall within a feature. + + Pass a completed feature (road, water, or layerset) in the same domain as + the grid, and every grid cell selected by the spatial ``operator`` has + ``band`` replaced with ``value``. The returned modification is suitable for + the ``modifications=`` argument of any grid creator. + + Parameters + ---------- + feature : Feature or str + The feature to mask against, or its id. Must be ``completed`` and + belong to the same domain as the grid being modified. + band : str or list of str + The band(s) to overwrite, using dot-notation keys (e.g. ``"fbfm"``, + ``"fuel_load.1hr"``). A list applies the same value to every band. + value : float, int, or str, default 0.0 + The replacement value written to ``band`` for matching cells. + operator : {"within", "intersects", "outside"}, default "within" + Which cells to select relative to the feature geometry. + buffer_m : float, optional + Buffer applied to the feature geometry, in meters in the domain's + projected CRS, before testing. Linestring features such as roads + usually need a buffer — or ``target="cell"`` — to catch the cells they + cross. Defaults to no buffer. + target : {"centroid", "cell"}, optional + Which part of each grid cell is tested against the geometry. Defaults + to the API default (``"centroid"``). Use ``"cell"`` to select every + cell a geometry touches. + + Returns + ------- + GridModification + A modification to pass in a creator's ``modifications=`` list. + + Examples + -------- + Mask roads to a non-burnable fuel model code, buffering the linestrings so + they cover whole cells: + + >>> import fastfuels_sdk.v2 as ff + >>> roads = ff.features.create_road_feature_from_osm(domain) + >>> roads.wait() + >>> grid = ff.grids.create_fuel_model_grid_from_landfire_fbfm40( + ... domain, + ... output_resolution_m=30, + ... modifications=[ff.mask(roads, "fbfm", 91, buffer_m=5)], + ... ) + """ + bands = [band] if isinstance(band, str) else list(band) + condition = GridFeatureSpatialCondition( + source="feature", + operator=SpatialOperator(operator), + feature_id=_feature_id(feature), + buffer_m=buffer_m if buffer_m is not None else UNSET, + target=GridSpatialTarget(target) if target is not None else UNSET, + ) + actions = [ + GridModificationAction(band=b, modifier=Modifier.REPLACE, value=value) + for b in bands + ] + return GridModification(conditions=[condition], actions=actions) diff --git a/tests/v2/test_grids.py b/tests/v2/test_grids.py index 99b8bff..93f33a8 100644 --- a/tests/v2/test_grids.py +++ b/tests/v2/test_grids.py @@ -39,6 +39,7 @@ ) from fastfuels_sdk.v2.client_library.types import UNSET from fastfuels_sdk.v2.exceptions import NotFoundException +from fastfuels_sdk.v2.modifications import mask # External imports import pytest @@ -242,6 +243,48 @@ def test_create(self, test_domain): grid.delete() +class TestMask: + def test_mask_payload_shape(self): + # Unit: a single band masks one feature to one replacement value + mod = mask("feat123", "fbfm", 91, buffer_m=5, target="cell") + payload = mod.to_dict() + assert payload["conditions"] == [ + { + "source": "feature", + "operator": "within", + "feature_id": "feat123", + "buffer_m": 5, + "target": "cell", + } + ] + assert payload["actions"] == [ + {"band": "fbfm", "modifier": "replace", "value": 91} + ] + + def test_mask_multiple_bands(self): + # A list of bands fans out to one action per band, sharing the condition + mod = mask("feat999", ["fuel_load.1hr", "fuel_depth"]) + bands = [action["band"] for action in mod.to_dict()["actions"]] + assert bands == ["fuel_load.1hr", "fuel_depth"] + + def test_mask_accepts_feature_object(self): + # A Feature-like object contributes its id + mod = mask(SimpleNamespace(id="feat-from-object"), "fbfm") + assert mod.to_dict()["conditions"][0]["feature_id"] == "feat-from-object" + + def test_mask_applied_to_grid_creation(self, test_domain, completed_road_feature): + # Live: masking an FBFM40 grid against a completed road feature + grid = create_fuel_model_grid_from_landfire_fbfm40( + test_domain, + output_resolution_m=30, + modifications=[mask(completed_road_feature, "fbfm", 91, buffer_m=5)], + name="throwaway_masked_fbfm40", + ) + assert len(grid.id) > 0 + assert grid.status in (JobStatus.PENDING, JobStatus.RUNNING) + grid.delete() + + class TestCreateFuelModelGridFromLandfireFccs: def test_create(self, test_domain): # FCCS is alignment-free; only metadata and remove_bare_ground apply From ec35dd1d1b629d9c07b7a301934d99aea672b9e4 Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Tue, 9 Jun 2026 16:33:07 -0600 Subject: [PATCH 25/71] Document v2 grids and update the v2 SDK guides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add workflow-first how-to guides for grids, split into Creating grids (by source, with an aligned-grid-set worked example) and Working with grids (lifecycle, inspection, resample, export). All examples are verified against the live API with real output — band catalogs and units, georeference, wait_all output, and the export record. Rewrite features.md for the functional creators and the wait/refresh/update lifecycle; fix the stale get()/in_place examples in domains.md; fill in the grids and features sections of migration.md; and add grids, modifications, and _jobs to the API reference plus a Grids group to the nav. --- docs/v2/SUMMARY.md | 3 + docs/v2/guides/creating-grids.md | 334 +++++++++++++++++++++++++++ docs/v2/guides/domains.md | 24 +- docs/v2/guides/features.md | 119 ++++++---- docs/v2/guides/migration.md | 83 +++++-- docs/v2/guides/working-with-grids.md | 210 +++++++++++++++++ docs/v2/reference.md | 6 + 7 files changed, 707 insertions(+), 72 deletions(-) create mode 100644 docs/v2/guides/creating-grids.md create mode 100644 docs/v2/guides/working-with-grids.md diff --git a/docs/v2/SUMMARY.md b/docs/v2/SUMMARY.md index 742c24b..440a700 100644 --- a/docs/v2/SUMMARY.md +++ b/docs/v2/SUMMARY.md @@ -3,4 +3,7 @@ * How-To Guides * [Domains](guides/domains.md) * [Features](guides/features.md) + * Grids + * [Creating grids](guides/creating-grids.md) + * [Working with grids](guides/working-with-grids.md) * [Reference](reference.md) diff --git a/docs/v2/guides/creating-grids.md b/docs/v2/guides/creating-grids.md new file mode 100644 index 0000000..ec28a09 --- /dev/null +++ b/docs/v2/guides/creating-grids.md @@ -0,0 +1,334 @@ +# How to Create Grids in FastFuels SDK + +!!! warning "Beta" + The v2 SDK targets the FastFuels v2 API and is under active + development. The v1 SDK remains the default — import v2 explicitly + from `fastfuels_sdk.v2`. + +A grid is a raster data product within a domain — topography, surface fuel +models, canopy fuels, and more — generated from a data source. This guide +shows how to create grids and assemble them into an aligned dataset for fire +modeling. For waiting on, inspecting, exporting, and managing grids you +already hold, see [Working with grids](working-with-grids.md); for what grids +*are*, see the +[FastFuels documentation](https://docs.fastfuels.silvxlabs.com). Coming from +the v1 SDK? Start with the [migration guide](migration.md#grids). + +Creation is functional: call a `create__grid_from_` function on +a domain. Each returns a [`Grid`](working-with-grids.md) whose generation runs +as a background job, so it starts `"pending"`. + +## Prerequisites + +- The FastFuels SDK installed: `pip install fastfuels-sdk` +- A FastFuels API key in the `FASTFUELS_API_KEY` environment variable +- An existing [domain](domains.md) — every creator's first argument is a + `Domain` (or a bare domain id string) + +## Build an aligned grid set + +The typical goal is several grids covering one domain on the *same* lattice, +so they stack cell-for-cell. Because each creator can resample to the domain +lattice, giving them all the same `output_resolution_m` is enough — here, a +30 m terrain + surface fuel + canopy set: + +```python +import fastfuels_sdk.v2 as ff + +topography = ff.grids.create_topography_grid_from_3dep( + domain, source_resolution_m=10, output_resolution_m=30, + bands=["elevation", "slope", "aspect"], +) +surface_fuel = ff.grids.create_fuel_model_grid_from_landfire_fbfm40( + domain, output_resolution_m=30, +) +canopy_fuel = ff.grids.create_canopy_fuel_grid_from_landfire( + domain, output_resolution_m=30, +) + +# Jobs run server-side in parallel — create first, then join. +ff.wait_all([topography, surface_fuel, canopy_fuel]) +``` + +Each grid reports the bands it carries and, once complete, its +georeference: + +```python +>>> [b.key for b in topography.bands] +['elevation', 'slope', 'aspect'] +>>> topography.georeference.crs +'EPSG:32611' +>>> topography.georeference.shape # [rows, cols] on the domain lattice +[47, 58] +``` + +Because all three share `output_resolution_m=30` on the same domain, they +share a CRS, transform, and shape — they overlay exactly. The sections below +cover each source in detail; [Working with grids](working-with-grids.md) +covers inspecting, resampling, exporting, and managing the results. + +## Topography grids + +From the USGS 3D Elevation Program (3DEP), which carries `elevation` (m), +`slope` (deg), and `aspect` (deg): + +```python +grid = ff.grids.create_topography_grid_from_3dep( + domain, + source_resolution_m=10, # 3DEP native resolution: 1, 10, or 30 + output_resolution_m=30, + bands=["elevation", "slope", "aspect"], +) +``` + +3DEP is the higher-resolution source where it has coverage. To check before +creating the grid: + +```python +>>> coverage = ff.grids.check_3dep_coverage(domain, resolution_m=10) +>>> coverage.available +True +>>> coverage.tile_count +1 +``` + +Where 3DEP lacks coverage, source topography from LANDFIRE (30 m, CONUS) +instead: + +```python +grid = ff.grids.create_topography_grid_from_landfire(domain, output_resolution_m=30) +``` + +## Surface fuel model grids + +LANDFIRE's 40 Scott & Burgan fire behavior fuel models (FBFM40) is the +standard surface fuels source. The grid carries a single categorical `fbfm` +band of fuel model *codes* (e.g. `GR1`, `TL3`, `SH5`): + +```python +grid = ff.grids.create_fuel_model_grid_from_landfire_fbfm40( + domain, output_resolution_m=30 +) +``` + +To replace non-burnable codes (urban, water, agriculture, …) with no-data, +pass them to `remove_non_burnable`: + +```python +grid = ff.grids.create_fuel_model_grid_from_landfire_fbfm40( + domain, output_resolution_m=30, remove_non_burnable=["NB1", "NB2"] +) +``` + +### Look up fuel parameters from FBFM40 codes + +The `fbfm` band holds codes, not the quantities a fire model consumes. To +turn the codes into fuel parameters — loadings by size class, fuel-bed depth, +surface-area-to-volume ratios — call `lookup_fuel_model_values` on the +completed grid. It returns a new grid whose bands are the requested +parameters: + +```python +grid.wait() + +fuels = grid.lookup_fuel_model_values( + bands=["fuel_load.1hr", "fuel_load.10hr", "fuel_depth"] +) +fuels.wait() +``` + +```python +>>> [(b.key, b.unit) for b in fuels.bands] +[('fuel_load.1hr', 'kg/m**2'), ('fuel_load.10hr', 'kg/m**2'), ('fuel_depth', 'm')] +``` + +### From FCCS instead + +Alternatively, build an already-parameterized fuels grid from the Fuel +Characteristic Classification System (FCCS), skipping the lookup step: + +```python +grid = ff.grids.create_fuel_model_grid_from_landfire_fccs( + domain, remove_bare_ground=True +) +``` + +FCCS grids currently come back on their native lattice, so this creator +takes no alignment arguments; +[resample](working-with-grids.md#resample-a-grid) the result if you need a +different resolution. + +## Canopy grids + +For crown-fire inputs, `create_canopy_fuel_grid_from_landfire` produces the +full LANDFIRE canopy set (30 m, CONUS) — canopy height, bulk density, base +height, and cover: + +```python +grid = ff.grids.create_canopy_fuel_grid_from_landfire(domain, output_resolution_m=30) +``` + +```python +>>> [(b.key, b.unit) for b in grid.bands] +[('chm', 'm'), ('cbd', 'kg/m**3'), ('cbh', 'm'), ('cc', '%')] +``` + +When you only need canopy *height* at higher resolution, use a dedicated +canopy height model — the Meta model (≈1 m, global) or NAIP-CHM (0.6 m, +CONUS) — each producing a single `chm` band: + +```python +meta = ff.grids.create_canopy_height_grid_from_meta(domain, output_resolution_m=1) +naip = ff.grids.create_canopy_height_grid_from_naip_chm(domain, output_resolution_m=1) +``` + +!!! tip "NAIP-CHM is a surface model" + NAIP-CHM is a digital surface model and retains buildings and other + infrastructure. To keep only vegetation, mask out built-up areas — see + [Mask out features](#mask-out-features). + +## 3D tree fuel grids (voxelization) + +!!! note "Inventories module in development" + The PIM grid below is available today. Generating a tree inventory and + voxelizing it arrive with the v2 inventories module, which is still in + development. + +The 3D canopy fuel grid — per-voxel bulk density, moisture, and +surface-area-to-volume ratio, the inputs 3D fire models consume — is built +in three steps: + +**1. Create a Plot Imputation Map (PIM) grid** that maps each cell to a +TreeMap forest inventory plot: + +```python +pim = ff.grids.create_pim_grid_from_treemap( + domain, output_resolution_m=30, resampling="nearest" +) +pim.wait() +``` + +By default the grid carries the TreeMap id band (`tm_id`); request the plot +control number as well with `bands=["tm_id", "plt_cn"]`. + +**2. Generate a tree inventory** from the PIM grid — a table of individual +trees imputed from the matched plots. *(Coming with the inventories +module.)* + +**3. Voxelize the inventory** with `inventory.voxelize(...)`, discretizing +each tree's crown onto a 3D lattice and computing per-voxel fuel properties. +Because it is a 3D product, a voxel grid supports neither resampling nor +post-hoc modifications — apply any tree modifications to the inventory +before voxelizing. *(Coming with the inventories module.)* + +For the concepts behind plot imputation and voxelization, see the +[FastFuels documentation](https://docs.fastfuels.silvxlabs.com). + +## Uniform grids + +To fill the whole domain with constant band values at a chosen resolution — +useful for testing or for holding a fuel parameter constant: + +```python +grid = ff.grids.create_uniform_grid( + domain, + resolution_m=2.0, + bands={"fuel_load": 0.5, "fuel_moisture": 15.0}, +) +``` + +## Grids from your own raster files + +To upload a local NetCDF whose CRS matches the domain CRS: + +```python +grid = ff.grids.create_grid_from_netcdf(domain, "fuels.nc") +grid.wait() +``` + +GeoTIFF uploads work the same way but additionally take a `bands` list of +`UploadBandDefinition`s mapping 1:1 to the raster's bands in order: + +```python +from fastfuels_sdk.v2.client_library.models import UploadBandDefinition + +grid = ff.grids.create_grid_from_geotiff( + domain, + "elevation.tif", + bands=[UploadBandDefinition(key="elevation", interpolation="bilinear")], +) +``` + +The SDK creates the grid, uploads the file to the returned signed URL, and +hands back a pending grid. + +## Align grids to each other + +The worked example above gave every grid the same `output_resolution_m` to +land them on the domain lattice. Most creators (everything except FCCS and +uploads) accept four arguments that control the output lattice; they are +mutually exclusive — passing more than one raises `ValueError`: + +- **`output_resolution_m`** — resample to this cell size on the **domain** + lattice. The common case, and what makes grids of equal resolution stack. +- **`align="native"`** — keep the source's native resolution and lattice. +- **`align_to=`** — match another grid's lattice exactly (pass a + `Grid` or its id), so the two stack even at the source's native + resolution. +- **`resampling`** — the method used when changing resolution: `nearest`, + `bilinear`, `cubic`, `average`, `mode`, `min`, `max`, `median`, and more. + An unrecognized value raises `ValueError`. + +```python +# Stack a canopy grid onto an existing topography grid's lattice exactly +canopy = ff.grids.create_canopy_fuel_grid_from_landfire( + domain, align_to=topography, resampling="nearest" +) +``` + +## Mask out features + +To overwrite grid cells that fall within a feature — the v2 replacement for +v1's `feature_masks` — build a mask with `ff.mask` and pass it in a creator's +`modifications` list. For example, to set the cells under roads to a +non-burnable fuel model code (FBFM 91): + +```python +roads = ff.features.create_road_feature_from_osm(domain) +roads.wait() + +grid = ff.grids.create_fuel_model_grid_from_landfire_fbfm40( + domain, + output_resolution_m=30, + modifications=[ff.mask(roads, "fbfm", 91, buffer_m=5)], +) +``` + +The feature must be `completed` and in the same domain as the grid. Roads +and other linestrings usually need a `buffer_m` (or `target="cell"`) so the +thin geometry covers whole cells. See `ff.mask` in the +[Reference](../reference.md) for masking multiple bands and the `operator` +and `target` options. + +## Error handling + +Creators raise typed exceptions from `fastfuels_sdk.v2.exceptions` on invalid +input: + +```python +from fastfuels_sdk.v2.exceptions import UnprocessableEntityException + +try: + grid = ff.grids.create_uniform_grid(domain, resolution_m=2.0, bands={"bad": 1}) +except UnprocessableEntityException as exc: + print(exc.detail) +``` + +See [Working with grids](working-with-grids.md#error-handling) for the full +exception model. + +## Next steps + +A freshly created grid is still a running job. [Working with +grids](working-with-grids.md) covers waiting on it, inspecting its bands and +georeference, resampling, exporting, and managing it. diff --git a/docs/v2/guides/domains.md b/docs/v2/guides/domains.md index 3a22038..7a40a0b 100644 --- a/docs/v2/guides/domains.md +++ b/docs/v2/guides/domains.md @@ -97,32 +97,30 @@ domain = Domain.from_id("abc123") ## Update Domain Properties -To modify a domain's name, description or tags: +To modify a domain's name, description, or tags: ```python -# Create new instance with updates -updated_domain = domain.update( +domain.update( name="New Name", description="Updated description", tags=["forest", "analysis"], ) - -# Or update in-place -domain.update(name="New Name", in_place=True) ``` -## Get Fresh Domain Data +`update` changes the domain in place and returns it, so calls chain. Only +the fields you pass are sent; passing none makes no API call. -To fetch the latest data for a domain: +## Refresh Domain Data -```python -# Get new instance with fresh data -fresh_domain = domain.get() +To reload a domain's latest state from the API in place: -# Or refresh existing instance -domain.get(in_place=True) +```python +domain.refresh() ``` +`refresh` updates the domain in place and returns it. To fetch a separate +copy by ID instead, use `Domain.from_id(domain.id)`. + ## Get the Pixel Lattice for a Domain To align a raster with the grids FastFuels will produce for a domain, diff --git a/docs/v2/guides/features.md b/docs/v2/guides/features.md index a167a62..0b66d21 100644 --- a/docs/v2/guides/features.md +++ b/docs/v2/guides/features.md @@ -12,6 +12,17 @@ what features *are* and how the platform treats them, see the [FastFuels documentation](https://docs.fastfuels.silvxlabs.com). Coming from the v1 SDK? Start with the [migration guide](migration.md#features). +The v2 surface is functional: you **create** a feature by calling a +`create_..._feature_from_...` function on a domain, and everything you do +with a feature you already hold is a **method** on it. + +```python +import fastfuels_sdk.v2 as ff + +feature = ff.features.create_road_feature_from_osm(domain) +feature.wait() +``` + ## Prerequisites - The FastFuels SDK installed: `pip install fastfuels-sdk` @@ -23,15 +34,15 @@ from the v1 SDK? Start with the [migration guide](migration.md#features). To extract the road network within a domain's extent: ```python -from fastfuels_sdk.v2 import Feature +import fastfuels_sdk.v2 as ff -feature = Feature.create_osm_road( - domain.id, +feature = ff.features.create_road_feature_from_osm( + domain, name="My Roads", description="OSM road network", ) -print(feature.status) # JobStatus.PENDING +print(feature.status) # pending ``` Feature generation runs as a background job — the returned feature starts @@ -39,15 +50,17 @@ in `"pending"` status. To include roads just outside the domain, buffer the query extent by up to 100 meters with `extent_buffer_m`: ```python -feature = Feature.create_osm_road(domain.id, extent_buffer_m=50) +feature = ff.features.create_road_feature_from_osm(domain, extent_buffer_m=50) ``` +The first argument accepts either a `Domain` or a bare domain id string. + ## Create a Water Feature from OpenStreetMap To extract water bodies, use the same calling convention: ```python -feature = Feature.create_osm_water(domain.id, name="My Water") +feature = ff.features.create_water_feature_from_osm(domain, name="My Water") ``` ## Wait for a Feature to Complete @@ -55,7 +68,7 @@ feature = Feature.create_osm_water(domain.id, name="My Water") To block until a feature job finishes: ```python -feature.wait_until_completed(verbose=True) +feature.wait(verbose=True) ``` ```text @@ -64,10 +77,21 @@ Feature 36573c0205d147a08011693b7894c0fb: running (15s) Feature 36573c0205d147a08011693b7894c0fb: completed (20s) ``` -`wait_until_completed` raises `TimeoutError` if the job exceeds `timeout` -seconds (default 600) and `RuntimeError` if the job fails. Once -completed, the feature's `georeference` reports the CRS and bounds of the -generated data: +`wait` polls until the job reaches a terminal status and returns the +feature (so calls chain). By default it waits indefinitely; pass `timeout` +(seconds) to bound the wait, which raises `TimeoutError` if exceeded. A +failed job raises `JobFailedError`, carrying the API's `code`, `message`, +and `suggestion`. To wait on several jobs at once, use `ff.wait_all`: + +```python +roads = ff.features.create_road_feature_from_osm(domain) +water = ff.features.create_water_feature_from_osm(domain) + +ff.wait_all([roads, water]) # jobs run server-side in parallel +``` + +Once completed, the feature's `georeference` reports the CRS and bounds of +the generated data: ```python print(feature.georeference.crs) # 'EPSG:32611' @@ -92,13 +116,16 @@ Each polygon's `properties` must carry the fuelbed input columns ```python import json +import fastfuels_sdk.v2 as ff with open("fuelbeds.geojson") as f: # projected CRS + fuelbed properties geojson = json.load(f) -feature = Feature.create_layerset(domain.id, geojson, name="My Fuelbeds") +feature = ff.features.create_layerset_feature_from_geojson( + domain, geojson, name="My Fuelbeds" +) -print(feature.status) # JobStatus.COMPLETED +print(feature.status) # completed ``` To upload from a GeoPandas GeoDataFrame instead, reproject first if @@ -108,30 +135,47 @@ needed — the GeoDataFrame's CRS is forwarded as-is: import geopandas as gpd gdf = gpd.read_file("fuelbeds.shp").to_crs(epsg=5070) -feature = Feature.create_layerset_from_geodataframe(domain.id, gdf) +feature = ff.features.create_layerset_feature_from_geodataframe(domain, gdf) ``` +## Rasterize a Layerset into a Grid + +A completed layerset feature carries vector fuelbed polygons; to burn them +onto a raster grid, call `rasterize`. It returns a pending +[`Grid`](working-with-grids.md): + +```python +grid = feature.rasterize(output_resolution_m=2.0) +grid.wait() +``` + +`rasterize` takes the same alignment arguments as the grid creators +(`output_resolution_m`, `align_to`, `align`, `resampling`) plus an +`overlap_method` controlling how overlapping polygons resolve. See +[Align grids to each other](creating-grids.md#align-grids-to-each-other) +for the alignment model. + ## Retrieve an Existing Feature To fetch a feature using its domain and feature IDs: ```python -feature = Feature.from_id(domain.id, "36573c0205d147a08011693b7894c0fb") +feature = ff.get_feature(domain, "36573c0205d147a08011693b7894c0fb") ``` -## Get Fresh Feature Data +## Refresh Feature Data -To fetch the latest state of a feature (for example, to check job -progress yourself): +To reload a feature's latest state from the API (for example, to check job +progress yourself) in place: ```python -# Get new instance with fresh data -fresh_feature = feature.get() - -# Or refresh the existing instance -feature.get(in_place=True) +feature.refresh() +print(feature.status) ``` +`refresh` updates the feature in place and returns it. To fetch a separate +copy by ID instead, use `ff.get_feature(domain, feature.id)`. + ## Access Feature Data The generated geodata is served in partitions. For most workflows, @@ -171,35 +215,32 @@ raise `UnprocessableEntityException`. To modify a feature's name, description, or tags: ```python -# Create new instance with updates -updated_feature = feature.update(name="New Name", tags=["roads", "osm"]) - -# Or update in-place -feature.update(name="New Name", in_place=True) +feature.update(name="New Name", tags=["roads", "osm"]) ``` +`update` changes the feature in place and returns it. Only the fields you +pass are sent; passing none makes no API call. + ## List Features To list features in a domain: ```python -from fastfuels_sdk.v2 import list_features - -features = list_features(domain.id) +features = ff.list_features(domain) ``` -To list features across all your domains, omit the domain ID: +To list features across all your domains, omit the domain: ```python -all_features = list_features() +all_features = ff.list_features() ``` Narrow the results with filters: ```python -roads = list_features(domain.id, feature_type="road") -osm_features = list_features(domain.id, product="osm") -tagged = list_features(domain.id, tag="roads") +roads = ff.list_features(domain, feature_type="road") +osm_features = ff.list_features(domain, product="osm") +tagged = ff.list_features(domain, tag="roads") ``` ## Delete a Feature @@ -214,18 +255,18 @@ Deleting a domain also deletes all of its features. ## Error Handling -Wrapper methods raise typed exceptions from +Wrapper functions and methods raise typed exceptions from `fastfuels_sdk.v2.exceptions`: ```python -from fastfuels_sdk.v2 import Feature +import fastfuels_sdk.v2 as ff from fastfuels_sdk.v2.exceptions import ( NotFoundException, UnprocessableEntityException, ) try: - feature = Feature.from_id(domain.id, "does-not-exist") + feature = ff.get_feature(domain, "does-not-exist") except NotFoundException: print("No such feature (or you don't have access to it)") diff --git a/docs/v2/guides/migration.md b/docs/v2/guides/migration.md index 231982a..00f8d14 100644 --- a/docs/v2/guides/migration.md +++ b/docs/v2/guides/migration.md @@ -21,7 +21,7 @@ Both subpackages read the same `FASTFUELS_API_KEY` environment variable. | `Domain.from_geojson(..., horizontal_resolution=2.0, vertical_resolution=1.0)` | `Domain.from_geojson(..., pad_to_resolution=2.0)` | Resolution belongs to grids now; domains only pad their extent for grid alignment | | `domain.export()` | grid exports | No domain-level export in v2; data exports hang off grids | | — | `Domain.preview`, `Domain.get_lattice`, `reproject_geojson` | New domain capabilities | -| `Grids`, `SurfaceGrid`, `TreeGrid`, `TopographyGrid`, `FeatureGrid` + builders | unified `Grid` resource *(SDK module in development)* | One job-based resource per grid, distinguished by data source | +| `Grids`, `SurfaceGrid`, `TreeGrid`, `TopographyGrid`, `FeatureGrid` + builders | unified `Grid` resource | One job-based resource per grid, distinguished by data source | | `Features`, `RoadFeature`, `WaterFeature` | unified `Feature` resource | One job-based resource for OSM roads, OSM water, and custom layersets | | `feature.get_data()`, `feature.get_all_data()` | `feature.get_data_metadata()`, `feature.get_data_partition()`, `feature.get_data()`, `feature.to_geodataframe()` | Page-based data retrieval becomes partition-based | | `Inventories`, `TreeInventory` | `Inventory` resource *(SDK module in development)* | Job-based; tree inventories are generated from PIM grids | @@ -83,15 +83,57 @@ Available now — see the [Domains guide](domains.md) and the ## Grids -!!! note "In development" - The v2 grids module is under development and will be documented here - when it ships. +Available now — see [Creating grids](creating-grids.md), +[Working with grids](working-with-grids.md), and the +[Reference](../reference.md). + +### What changed from v1 + +- **One unified `Grid` resource.** v1's per-type resources + (`SurfaceGrid`, `TreeGrid`, `TopographyGrid`, `FeatureGrid`) and their + builders collapse into a single job-based `Grid` distinguished by its + data source. The `Grids.from_domain_id(...)` container is replaced by + the module-level `list_grids(domain)`. +- **Creation is a function per source.** Instead of a builder, call a + `create__grid_from_` function on the domain — e.g. + `ff.grids.create_topography_grid_from_3dep(...)` or + `ff.grids.create_fuel_model_grid_from_landfire_fbfm40(...)`. +- **Alignment is explicit.** Resolution and lattice are set per grid with + `output_resolution_m`, `align="native"`, `align_to=`, and + `resampling` (see + [Align grids to each other](creating-grids.md#align-grids-to-each-other)). +- **`feature_masks` becomes `modifications`.** v1's + `feature_masks=["road", "water"]` becomes + `modifications=[ff.mask(feature, band, value)]`, which overwrites the + cells a feature covers. +- **Transforms are methods.** `grid.resample(...)`, + `grid.lookup_fuel_model_values(...)`, and `grid.export(...)` act on a + grid you already hold. -What to expect from the v2 API: the per-type grid resources -(surface/tree/topography/feature) collapse into a single job-based `Grid` -resource distinguished by its data source — LANDFIRE FBFM40 fuel models, -TreeMap (PIM), 3DEP topography, or custom GeoTIFF uploads. Every grid -shares the same `status`/`progress` lifecycle. +### Before and after + +=== "v1" + + ```python + from fastfuels_sdk import Grids + + grids = Grids.from_domain_id(domain.id) + topo = grids.create_topography_grid( + attributes=["elevation", "slope", "aspect"] + ) + topo.wait_until_completed() + ``` + +=== "v2" + + ```python + import fastfuels_sdk.v2 as ff + + topo = ff.grids.create_topography_grid_from_3dep( + domain, output_resolution_m=10, bands=["elevation", "slope", "aspect"] + ) + topo.wait() + ``` ## Features @@ -105,17 +147,18 @@ Available now — see the [Features guide](features.md) and the `Feature` distinguished by its type ("road", "water", or "layerset"). The `Features.from_domain_id(...)` container is replaced by the module-level `list_features(domain_id)`. -- **Creation methods are classmethods on `Feature`.** - `features.create_road_feature_from_osm()` becomes - `Feature.create_osm_road(domain_id)`, and likewise for water. The new - `extent_buffer_m` argument buffers the OSM query extent by up to - 100 meters. +- **Creation is a function per source.** The v1 + `features.create_road_feature_from_osm()` becomes the module-level + `ff.features.create_road_feature_from_osm(domain)`, and likewise + `create_water_feature_from_osm` for water. The new `extent_buffer_m` + argument buffers the OSM query extent by up to 100 meters. - **User-supplied geometry becomes layersets.** v1's road-from-GeoJSON path is gone. Instead, v2 accepts custom *layersets* — fuelbed polygons carrying rasterizer properties — via - `Feature.create_layerset` and - `Feature.create_layerset_from_geodataframe`. Layersets require a - projected CRS and upload synchronously. + `ff.features.create_layerset_feature_from_geojson` and + `create_layerset_feature_from_geodataframe`. Layersets require a + projected CRS and upload synchronously, and a completed layerset can be + burned onto a grid with `feature.rasterize(...)`. - **Data access is partitioned.** v1's `get_data(page, size)` / `get_all_data()` become `get_data_metadata()` (partition layout), `get_data_partition(index)` (one partition), and `get_data()` (all @@ -144,10 +187,10 @@ Available now — see the [Features guide](features.md) and the === "v2" ```python - from fastfuels_sdk.v2 import Feature + import fastfuels_sdk.v2 as ff - road = Feature.create_osm_road(domain.id) - road.wait_until_completed(verbose=True) + road = ff.features.create_road_feature_from_osm(domain) + road.wait(verbose=True) roads = road.to_geodataframe() ``` diff --git a/docs/v2/guides/working-with-grids.md b/docs/v2/guides/working-with-grids.md new file mode 100644 index 0000000..2a0caf3 --- /dev/null +++ b/docs/v2/guides/working-with-grids.md @@ -0,0 +1,210 @@ +# How to Work with Grids in FastFuels SDK + +!!! warning "Beta" + The v2 SDK targets the FastFuels v2 API and is under active + development. The v1 SDK remains the default — import v2 explicitly + from `fastfuels_sdk.v2`. + +This guide covers what you do with a grid you already hold: wait on its job, +inspect its bands and georeference, resample it, export it, and list or +delete grids. To create grids in the first place, see +[Creating grids](creating-grids.md). For what grids *are*, see the +[FastFuels documentation](https://docs.fastfuels.silvxlabs.com). + +You get a grid handle either from a creator (see +[Creating grids](creating-grids.md)) or by fetching one by id: + +```python +import fastfuels_sdk.v2 as ff + +grid = ff.get_grid(domain, "0eeed67e33df450f943a528fb1447dab") +``` + +Operations on a grid you hold are **methods** on it (`grid.wait()`, +`grid.resample(...)`, `grid.delete()`); listing and fetching are top-level +functions (`ff.list_grids`, `ff.get_grid`). + +## Prerequisites + +- The FastFuels SDK installed: `pip install fastfuels-sdk` +- A FastFuels API key in the `FASTFUELS_API_KEY` environment variable +- A grid — created per [Creating grids](creating-grids.md), or fetched by id + +## Wait for a grid to finish + +Because creators return a pending grid, block on the job before using its +data: + +```python +grid.wait() +``` + +```python +>>> grid.status + +``` + +`wait` polls until the job reaches a terminal status and returns the grid, +so calls chain. It waits indefinitely by default; pass `timeout` (seconds) +to bound it, which raises `TimeoutError`. A failed job raises +`JobFailedError`, carrying the API's `code`, `message`, and `suggestion`. + +Jobs run server-side in parallel, so create everything first and join with +`ff.wait_all`. With `verbose=True` it prints a line per poll for each grid +still running when it is reached: + +```python +ff.wait_all([topography, surface_fuel, canopy_fuel], verbose=True) +``` + +```text +Grid 0eeed67e33df450f943a528fb1447dab: running (5s) +Grid 0eeed67e33df450f943a528fb1447dab: completed (10s) +``` + +(Small domains finish fast — grids already complete when `wait_all` reaches +them print nothing.) + +## Inspect a grid's bands and georeference + +A grid reports its bands as soon as it is created — their keys, names, and +units — so you know what data it holds: + +```python +>>> [b.key for b in topography.bands] +['elevation', 'slope', 'aspect'] +>>> topography.bands[1].name, topography.bands[1].unit +('Slope', 'deg') +``` + +Its georeference is populated once the job completes (it is `None` while +pending), reporting the CRS, affine transform, and pixel shape of the +output: + +```python +>>> topography.georeference.crs +'EPSG:32611' +>>> topography.georeference.transform # affine [a, b, c, d, e, f] +[30.0, 0.0, 720192.0, 0.0, -30.0, 5190856.0] +>>> topography.georeference.shape # [rows, cols] +[47, 58] +``` + +Two grids built on the same domain at the same resolution share this +georeference, which is what lets them overlay cell-for-cell. + +## Retrieve a grid by id + +To fetch an existing grid using its domain and grid IDs: + +```python +grid = ff.get_grid(domain, "0eeed67e33df450f943a528fb1447dab") +``` + +## Refresh and update + +To reload a grid's latest state in place (for example, to check job progress +yourself): + +```python +grid.refresh() +``` + +To change a grid's name, description, or tags: + +```python +>>> grid.update(name="Topography (30 m)", tags=["terrain"]) +>>> grid.name, grid.tags +('Topography (30 m)', ['terrain']) +``` + +`refresh` and `update` both change the grid in place and return it. `update` +sends only the fields you pass; passing none makes no API call. + +## Resample a grid + +To resample a completed grid onto a new lattice, producing a new grid: + +```python +coarser = grid.resample(output_resolution_m=90, resampling="average") +``` + +`resample` takes the same alignment arguments as the creators (see +[Align grids to each other](creating-grids.md#align-grids-to-each-other)). +The source grid must be `completed`. + +## Export a grid + +To start an export of a completed grid to a downloadable file format: + +```python +export = grid.export(format="geotiff") # or "netcdf", "zarr" +``` + +The export runs as its own background job; `export` comes back pending, with +a signed download URL that fills in once it completes and stays valid for +seven days: + +```python +>>> print(export.id, export.status) +75d6403086484d519d0968e778d17ccf pending +>>> export.signed_url # None until the export job finishes +>>> export.expires_on +'2026-06-16T21:31:56.325358' +``` + +!!! note "Export polling and download in development" + `grid.export` returns the raw export record today. Convenience for + waiting on the export and saving the file lands with the v2 exports + module, which is still in development. + +## List grids + +To list grids in a domain, or across all your domains by omitting it: + +```python +grids = ff.list_grids(domain) +all_grids = ff.list_grids() +``` + +Narrow the results by source, source product (requires `source`), or tag — +the source and product names are the ones a grid reports in `grid.source`: + +```python +topo_grids = ff.list_grids(domain, source="3dep") +fbfm_grids = ff.list_grids(domain, source="landfire", product="fbfm40") +tagged = ff.list_grids(domain, tag="terrain") +``` + +## Delete a grid + +To permanently delete a grid and its generated data: + +```python +grid.delete() +``` + +Deleting a domain also deletes all of its grids. + +## Error handling + +Functions and methods raise typed exceptions from +`fastfuels_sdk.v2.exceptions`: + +```python +import fastfuels_sdk.v2 as ff +from fastfuels_sdk.v2.exceptions import ( + NotFoundException, + UnprocessableEntityException, +) + +try: + grid = ff.get_grid(domain, "does-not-exist") +except NotFoundException: + print("No such grid (or you don't have access to it)") + +try: + grid.resample(output_resolution_m=30) # source not completed yet +except UnprocessableEntityException as exc: + print(exc.detail) +``` diff --git a/docs/v2/reference.md b/docs/v2/reference.md index e08c591..fcae31a 100644 --- a/docs/v2/reference.md +++ b/docs/v2/reference.md @@ -2,6 +2,12 @@ ::: fastfuels_sdk.v2.features +::: fastfuels_sdk.v2.grids + +::: fastfuels_sdk.v2.modifications + +::: fastfuels_sdk.v2._jobs + ::: fastfuels_sdk.v2.api ::: fastfuels_sdk.v2.exceptions From 0b6b60bc9a15cb649dbb4529c97d39c2158240cb Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Wed, 10 Jun 2026 07:30:07 -0600 Subject: [PATCH 26/71] Add v2 inventories module (#179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inventory record with the uniform v2 lifecycle plus duplicate, apply_modifications, voxelize (3D tree fuel grid), export, and partitioned data access assembling to a pandas DataFrame. Creators: create_tree_inventory_from_pim_grid / _from_chm_grid / _from_file (signed-URL upload; the PUT must echo the signed x-goog-content-length-range header). list_inventories routes to the cross-domain endpoint when no domain is given. Live tests: 29 passed, 1 xfail — in-place apply_modifications fails server-side (FastFuels-API-v2#333). --- fastfuels_sdk/v2/__init__.py | 7 +- fastfuels_sdk/v2/inventories.py | 1006 +++++++++++++++++++++++++++++++ tests/v2/conftest.py | 35 ++ tests/v2/test_inventories.py | 330 ++++++++++ 4 files changed, 1377 insertions(+), 1 deletion(-) create mode 100644 fastfuels_sdk/v2/inventories.py create mode 100644 tests/v2/test_inventories.py diff --git a/fastfuels_sdk/v2/__init__.py b/fastfuels_sdk/v2/__init__.py index 4f1c77e..328c12b 100644 --- a/fastfuels_sdk/v2/__init__.py +++ b/fastfuels_sdk/v2/__init__.py @@ -18,30 +18,35 @@ ``ff.list_grids``, ``ff.wait_all``, ``ff.set_api_key``. """ -from fastfuels_sdk.v2 import features, grids +from fastfuels_sdk.v2 import features, grids, inventories from fastfuels_sdk.v2._jobs import JobFailedError, wait_all from fastfuels_sdk.v2.api import set_api_key from fastfuels_sdk.v2.domains import Domain, list_domains, reproject_geojson from fastfuels_sdk.v2.features import Feature, get_feature, list_features from fastfuels_sdk.v2.grids import Grid, get_grid, list_grids +from fastfuels_sdk.v2.inventories import Inventory, get_inventory, list_inventories from fastfuels_sdk.v2.modifications import mask __all__ = [ # Submodules (resource creators live here: ff.grids.create_*, ff.features.create_*) "features", "grids", + "inventories", # Configuration "set_api_key", # Records "Domain", "Feature", "Grid", + "Inventory", # Fetch / list helpers "list_domains", "list_features", "get_feature", "list_grids", "get_grid", + "list_inventories", + "get_inventory", "reproject_geojson", # Modifications "mask", diff --git a/fastfuels_sdk/v2/inventories.py b/fastfuels_sdk/v2/inventories.py new file mode 100644 index 0000000..adb40b5 --- /dev/null +++ b/fastfuels_sdk/v2/inventories.py @@ -0,0 +1,1006 @@ +""" +fastfuels_sdk/v2/inventories.py +""" + +# Core imports +import json +from http import HTTPStatus +from pathlib import Path +from typing import List, Optional + +# Internal imports +from fastfuels_sdk.v2._jobs import wait as _wait +from fastfuels_sdk.v2.api import ensure_client +from fastfuels_sdk.v2.exceptions import expect +from fastfuels_sdk.v2.client_library.api.inventories import ( + apply_modifications as apply_modifications_endpoint, + create_chm_inventory, + create_inventory_export, + create_inventory_upload, + create_pim_inventory, + delete_inventory, + duplicate_inventory as duplicate_inventory_endpoint, + get_inventory as get_inventory_endpoint, + get_inventory_data, + get_inventory_data_metadata, + list_inventories as list_inventories_endpoint, + list_inventories_cross_domain, + update_inventory, +) +from fastfuels_sdk.v2.client_library.models import ( + Inventory as InventoryModel, + ApplyModificationsRequest, + CreateChmInventoryRequest, + CreateInventoryUploadRequest, + CreatePimInventoryRequest, + CreateTreeInventoryRequest, + CrownProfileModel, + DuplicateInventoryRequest, + ExportInventoryRequest, + InventoryColumnMapping, + InventoryDataMetadata, + InventoryDataResponse, + InventoryExportFormat, + InventorySortField, + InventoryUploadFormat, + JobStatus, + ListInventoriesResponse, + PointProcess, + Resolution3D, + SortOrder, + TreeBand, + UpdateInventoryRequestBody, +) +from fastfuels_sdk.v2.client_library.types import UNSET + +# External imports +import attrs +import pandas as pd +import requests + +__all__ = [ + "Inventory", + "create_tree_inventory_from_pim_grid", + "create_tree_inventory_from_chm_grid", + "create_tree_inventory_from_file", + "list_inventories", + "get_inventory", +] + +_UPLOAD_FORMATS = { + ".csv": InventoryUploadFormat.CSV, + ".geojson": InventoryUploadFormat.GEOJSON, + ".json": InventoryUploadFormat.GEOJSON, + ".gpkg": InventoryUploadFormat.GEOPACKAGE, +} + + +def _domain_id(domain) -> str: + """Resolve a Domain object or a domain-id string to the id string.""" + return getattr(domain, "id", domain) + + +def _enum_list(values, enum_cls): + """Coerce a list of strings/enum members to enum members, or UNSET.""" + if values is None: + return UNSET + return [v if isinstance(v, enum_cls) else enum_cls(v) for v in values] + + +def _opt(value): + """Map ``None`` to the generated UNSET sentinel, else pass through.""" + return value if value is not None else UNSET + + +def _put_upload(spec, path: str) -> None: + """Upload a local file to a signed upload URL with HTTP PUT.""" + headers = { + "Content-Type": spec.content_type, + # The signed URL covers this header; GCS rejects the PUT without it + "x-goog-content-length-range": f"0,{spec.max_size_bytes}", + } + with open(path, "rb") as file_obj: + response = requests.put(spec.url, data=file_obj, headers=headers) + response.raise_for_status() + + +class Inventory(InventoryModel): + """Inventory resource for the FastFuels v2 API. + + An inventory is a table of individual entities — trees — within a + domain, generated from a PIM (TreeMap) grid, derived from a canopy + height model, or uploaded from your own data. Inventories are + asynchronous job resources — creation starts a background job and + returns a *pending* record; call :meth:`wait` to block until it is + ready. + + The tree records themselves are retrieved through the data methods + (:meth:`get_data_metadata`, :meth:`get_data_partition`, + :meth:`to_dataframe`) once the inventory is completed. + + Attributes + ---------- + id : str + Unique identifier for the inventory. + domain_id : str + Identifier of the domain the inventory belongs to. + type_ : InventoryType + Type of entities in the inventory (currently always "tree"). + status : JobStatus + Job status: "pending", "running", "completed", or "failed". + source : InventorySource + Where the inventory data comes from (e.g. a PIM grid, a CHM + grid, or an upload). + name : str + Human-readable name for the inventory. + description : str + Detailed description of the inventory. + progress : JobProgress, optional + Progress info while the job is running. + checksum : str, optional + Version marker for the inventory's content; changes each time + the data is rebuilt, unaffected by metadata-only edits. + modifications : List[InventoryModification], optional + Cumulative modification rules applied to the inventory. + treatments : list, optional + Silvicultural treatments applied to the inventory. + columns : List[Column], optional + The columns of the inventory's tabular data. + georeference : InventoryGeoreference, optional + Spatial reference of the generated data; populated when the job + completes. + error : JobError, optional + Error details if the job failed. + tags : List[str], optional + User-defined tags for organization. + created_on : datetime + When the inventory was created. + modified_on : datetime + When the inventory was last modified. + + Examples + -------- + Create a tree inventory from a PIM grid and load it as a DataFrame: + >>> import fastfuels_sdk as ff + >>> pim = ff.grids.create_pim_grid_from_treemap(domain) + >>> inventory = ff.inventories.create_tree_inventory_from_pim_grid( + ... domain, pim.wait() + ... ) + >>> trees = inventory.wait().to_dataframe() + + Get an inventory by ID: + >>> inventory = ff.get_inventory(domain, "abc123") + + See Also + -------- + create_tree_inventory_from_pim_grid : Expand a PIM grid into trees. + create_tree_inventory_from_chm_grid : Derive trees from a canopy height model. + create_tree_inventory_from_file : Upload your own tree records. + list_inventories : List inventories in a domain or across all domains. + """ + + @classmethod + def _from_model(cls, model: InventoryModel) -> "Inventory": + """Build an Inventory from a generated Inventory model instance. + + Round-trips through the generated to_dict/from_dict — from_dict + constructs ``cls``, i.e. this subclass. + """ + return cls.from_dict(model.to_dict()) + + def _copy_fields_from(self, model: InventoryModel) -> "Inventory": + """Copy all generated-model fields from `model` onto self (in-place).""" + for field in attrs.fields(InventoryModel): + if field.init: + setattr(self, field.name, getattr(model, field.name)) + self.additional_properties = dict(model.additional_properties) + return self + + def _require_completed(self, action: str) -> None: + """Raise if the inventory is not completed, before deriving from it.""" + if self.status != JobStatus.COMPLETED: + raise ValueError( + f"Cannot {action} an inventory with status '{self.status}'. " + "Call .wait() until it completes first." + ) + + @classmethod + def from_id(cls, domain_id: str, inventory_id: str) -> "Inventory": + """Retrieve an existing Inventory resource by its ID. + + Parameters + ---------- + domain_id : str + The unique identifier of the domain the inventory belongs to. + inventory_id : str + The unique identifier of the inventory to retrieve. + + Returns + ------- + Inventory + The requested Inventory object. + + Raises + ------ + NotFoundException + If no inventory exists with the given IDs, or the user does not + have access to it. + """ + response = get_inventory_endpoint.sync_detailed( + domain_id, inventory_id, client=ensure_client() + ) + return cls._from_model(expect(response)) + + def refresh(self) -> "Inventory": + """Update this Inventory in place with the latest data from the API. + + Returns + ------- + Inventory + ``self``, updated with the latest data (so calls chain). + + Raises + ------ + NotFoundException + If the inventory no longer exists. + """ + response = get_inventory_endpoint.sync_detailed( + self.domain_id, self.id, client=ensure_client() + ) + return self._copy_fields_from(expect(response)) + + def wait( + self, timeout: Optional[float] = None, verbose: bool = False + ) -> "Inventory": + """Poll the inventory job until it reaches a terminal status. + + Parameters + ---------- + timeout : float, optional + Maximum seconds to wait. ``None`` (default) waits indefinitely; the + job runs server-side regardless, so a bounded wait is resumable. + verbose : bool, optional + If True, print the job status at each poll. + + Returns + ------- + Inventory + ``self``, updated to its terminal state (so calls chain). + + Raises + ------ + TimeoutError + If ``timeout`` is set and elapses before a terminal status. + JobFailedError + If the job finished with status "failed". + """ + return _wait(self, timeout=timeout, verbose=verbose) + + def update( + self, + name: Optional[str] = None, + description: Optional[str] = None, + tags: Optional[List[str]] = None, + ) -> "Inventory": + """Update the inventory's mutable metadata (name, description, tags) in place. + + Only provided fields are sent. If no fields are provided, no API call + is made. + + Parameters + ---------- + name : str, optional + New name for the inventory. + description : str, optional + New description for the inventory. + tags : List[str], optional + New tags for the inventory (replaces existing tags). + + Returns + ------- + Inventory + ``self``, updated (so calls chain). + + Raises + ------ + NotFoundException + If the inventory no longer exists. + """ + if name is None and description is None and tags is None: + return self + request_body = UpdateInventoryRequestBody( + name=_opt(name), description=_opt(description), tags=_opt(tags) + ) + response = update_inventory.sync_detailed( + self.domain_id, self.id, client=ensure_client(), body=request_body + ) + return self._copy_fields_from(expect(response)) + + def delete(self) -> None: + """Delete this inventory and its data. + + Raises + ------ + NotFoundException + If the inventory no longer exists. + """ + response = delete_inventory.sync_detailed( + self.domain_id, self.id, client=ensure_client() + ) + expect(response, HTTPStatus.NO_CONTENT) + + def duplicate( + self, + name: Optional[str] = None, + description: Optional[str] = None, + tags: Optional[List[str]] = None, + ) -> "Inventory": + """Create an independent copy of this inventory under a new ID. + + The copy is a true clone — the finished data is byte-copied, not + regenerated — carrying over the source's ``source``, + ``modifications``, ``treatments``, ``columns``, ``georeference``, + and ``checksum`` verbatim. Use this to branch a scenario: duplicate, + then modify the copy while the original stays untouched. + + Parameters + ---------- + name : str, optional + Name for the copy. Defaults to the source's name. + description : str, optional + Description for the copy. Defaults to the source's description. + tags : List[str], optional + Tags for the copy. Defaults to the source's tags. + + Returns + ------- + Inventory + The new Inventory object (job status "pending" while the data + is copied; call :meth:`wait` before using it). + + Raises + ------ + NotFoundException + If the inventory no longer exists. + UnprocessableEntityException + If the inventory is not in "completed" status. + """ + request_body = DuplicateInventoryRequest( + name=_opt(name), description=_opt(description), tags=_opt(tags) + ) + response = duplicate_inventory_endpoint.sync_detailed( + self.domain_id, self.id, client=ensure_client(), body=request_body + ) + return Inventory._from_model(expect(response, HTTPStatus.CREATED)) + + def apply_modifications(self, modifications: list) -> "Inventory": + """Apply modification rules to this inventory in place. + + The inventory keeps its ID; the submitted rules are appended to its + cumulative ``modifications`` list and the tree data is re-derived as + a background job — the inventory returns to "pending" status, so + call :meth:`wait` before using its data. To keep the original data, + :meth:`duplicate` first and modify the copy. + + Parameters + ---------- + modifications : list + Modification rules (``InventoryModification``). Each rule + filters trees by conditions (ANDed together) and applies + actions — remove, multiply, divide, add, subtract, or + replace — to the matching rows. + + Returns + ------- + Inventory + ``self``, updated (so calls chain). + + Raises + ------ + NotFoundException + If the inventory no longer exists. + """ + self._require_completed("apply modifications to") + request_body = ApplyModificationsRequest(modifications=list(modifications)) + response = apply_modifications_endpoint.sync_detailed( + self.domain_id, self.id, client=ensure_client(), body=request_body + ) + return self._copy_fields_from(expect(response)) + + def voxelize( + self, + horizontal_resolution_m: Optional[float] = None, + vertical_resolution_m: Optional[float] = None, + bands: Optional[list] = None, + crown_profile_model: Optional[str] = None, + biomass_source=None, + max_crown_radius_source=None, + moisture_model=None, + seed: Optional[int] = None, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, + ): + """Create a 3D voxelized tree fuel grid from this inventory. + + Distributes each tree's crown biomass into 3D cells, producing a + voxel grid of canopy fuel properties. + + Parameters + ---------- + horizontal_resolution_m : float, optional + Horizontal (x and y) cell size in meters. Must be given + together with ``vertical_resolution_m``; if both are omitted + the API default resolution is used. + vertical_resolution_m : float, optional + Vertical (z) cell size in meters. + bands : list, optional + Voxel bands to produce (``TreeBand`` members or their string + keys, e.g. "bulk_density.foliage.live", "fuel_moisture.dead", + "savr.foliage", "spcd", "tree_id", "volume_fraction"). + Defaults to the API's standard band set. + crown_profile_model : str, optional + Crown shape model used to distribute biomass: "beta" or + "purves". + biomass_source : AllometryBiomassSource or InventoryColumnsBiomassSource, optional + Where crown biomass values come from: allometry equations or + columns of this inventory. + max_crown_radius_source : optional + Where each tree's maximum crown radius comes from. + moisture_model : MoistureModel, optional + Fuel moisture values assigned to the voxelized fuels. + seed : int, optional + Random seed for reproducibility. Generated randomly if omitted. + name, description : str, optional + Metadata for the new grid. + tags : List[str], optional + Tags for the new grid. + + Returns + ------- + Grid + The new (pending) 3D voxel Grid. + + Raises + ------ + ValueError + If only one of ``horizontal_resolution_m`` / + ``vertical_resolution_m`` is given, or the inventory is not + completed. + + Examples + -------- + >>> inventory.wait() + >>> voxels = inventory.voxelize( + ... horizontal_resolution_m=2.0, vertical_resolution_m=1.0 + ... ) + >>> voxels.wait() + """ + # Lazy import so the modules stay decoupled (grids never imports + # inventories). + from fastfuels_sdk.v2.client_library.api.grids import ( + create_tree_inventory_grid, + ) + from fastfuels_sdk.v2.grids import Grid + + self._require_completed("voxelize") + if (horizontal_resolution_m is None) != (vertical_resolution_m is None): + raise ValueError( + "horizontal_resolution_m and vertical_resolution_m must be " + "given together." + ) + if horizontal_resolution_m is not None: + resolution = Resolution3D( + horizontal=horizontal_resolution_m, vertical=vertical_resolution_m + ) + else: + resolution = UNSET + request_body = CreateTreeInventoryRequest( + source_inventory_id=self.id, + resolution=resolution, + bands=_enum_list(bands, TreeBand), + crown_profile_model=( + CrownProfileModel(crown_profile_model) + if crown_profile_model is not None + else UNSET + ), + biomass_source=_opt(biomass_source), + max_crown_radius_source=_opt(max_crown_radius_source), + moisture_model=_opt(moisture_model), + seed=_opt(seed), + name=name, + description=description, + tags=_opt(tags), + ) + response = create_tree_inventory_grid.sync_detailed( + self.domain_id, client=ensure_client(), body=request_body + ) + return Grid._from_model(expect(response, HTTPStatus.CREATED)) + + def export( + self, + format: str = "parquet", + columns: Optional[List[str]] = None, + expiration_days: int = 7, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, + ): + """Export this inventory to a downloadable file format. + + Parameters + ---------- + format : str, optional + Export format: "parquet" (zipped, default), "csv", "geojson", + or "geopackage". + columns : List[str], optional + Columns to include (default: all columns). + expiration_days : int, optional + Days until the signed download URL expires (max 7, default 7). + name, description : str, optional + Metadata for the export. + tags : List[str], optional + Tags for the export. + + Returns + ------- + Export + The generated Export resource (job status "pending"). Poll it until + completed to obtain the signed download URL. + + Raises + ------ + NotFoundException + If the inventory no longer exists. + UnprocessableEntityException + If the inventory is not in "completed" status. + """ + request_body = ExportInventoryRequest( + columns=_opt(columns), + expiration_days=expiration_days, + name=name, + description=description, + tags=_opt(tags), + ) + response = create_inventory_export.sync_detailed( + self.domain_id, + self.id, + InventoryExportFormat(format), + client=ensure_client(), + body=request_body, + ) + return expect(response, HTTPStatus.CREATED) + + def get_data_metadata(self) -> InventoryDataMetadata: + """Get the partition layout of the inventory's tree records. + + The tree records are served in fixed-size partitions. Use this to + discover how many partitions exist before retrieving them with + :meth:`get_data_partition` (or call :meth:`to_dataframe` to + retrieve everything at once). + + Returns + ------- + InventoryDataMetadata + With attributes ``num_partitions``, ``total_rows``, ``columns`` + (column names), and ``partitions`` (per-partition row counts). + An inventory with no data has ``num_partitions`` 0. + + Raises + ------ + NotFoundException + If the inventory no longer exists. + UnprocessableEntityException + If the inventory is not in "completed" status. + + Examples + -------- + >>> metadata = inventory.get_data_metadata() + >>> metadata.num_partitions + 1 + """ + response = get_inventory_data_metadata.sync_detailed( + self.domain_id, self.id, client=ensure_client() + ) + return expect(response) + + def get_data_partition( + self, partition_index: int, columns: Optional[List[str]] = None + ) -> InventoryDataResponse: + """Get one partition of the inventory's tree records. + + Parameters + ---------- + partition_index : int + Zero-indexed partition number. Must be less than the + ``num_partitions`` reported by :meth:`get_data_metadata`. + columns : List[str], optional + Column subset to retrieve (default: all columns). + + Returns + ------- + InventoryDataResponse + With attributes ``partition``, ``num_rows``, ``columns`` + (column names), and ``data`` (rows as lists of values, in + column order). + + Raises + ------ + NotFoundException + If the inventory no longer exists. + UnprocessableEntityException + If ``partition_index`` is past the last partition, or the + inventory is not in "completed" status. + + Examples + -------- + >>> partition = inventory.get_data_partition(0) + >>> partition.num_rows + 1392 + """ + response = get_inventory_data.sync_detailed( + self.domain_id, + self.id, + partition_index, + client=ensure_client(), + columns=",".join(columns) if columns is not None else UNSET, + ) + return expect(response) + + def to_dataframe(self, columns: Optional[List[str]] = None) -> pd.DataFrame: + """Retrieve the inventory's tree records as a pandas DataFrame. + + Retrieves every partition and concatenates them into a single + DataFrame with one row per tree, preserving source order. + + Parameters + ---------- + columns : List[str], optional + Column subset to retrieve (default: all columns). + + Returns + ------- + pd.DataFrame + DataFrame with one row per tree. Empty if the inventory + contains no trees. + + Raises + ------ + NotFoundException + If the inventory no longer exists. + UnprocessableEntityException + If the inventory is not in "completed" status. + + Examples + -------- + >>> inventory.wait() + >>> trees = inventory.to_dataframe() + """ + metadata = self.get_data_metadata() + frames = [] + for partition_index in range(metadata.num_partitions): + partition = self.get_data_partition(partition_index, columns=columns) + frames.append(pd.DataFrame(partition.data, columns=partition.columns)) + if not frames: + return pd.DataFrame(columns=columns or metadata.columns) + return pd.concat(frames, ignore_index=True) + + def to_json(self) -> str: + """Serialize the complete Inventory object to a JSON string. + + Returns + ------- + str + The Inventory as a pretty-printed JSON string. + """ + return json.dumps(self.to_dict(), default=str, indent=2) + + +# --------------------------------------------------------------------------- +# Create inventories (module-level functions) +# --------------------------------------------------------------------------- + + +def create_tree_inventory_from_pim_grid( + domain, + pim_grid, + seed: Optional[int] = None, + point_process: Optional[str] = None, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, + modifications: Optional[list] = None, + treatments: Optional[list] = None, +) -> Inventory: + """Create a tree inventory by expanding a PIM (TreeMap) grid. + + Each pixel of the completed PIM grid imputes an FIA plot; expansion + generates individual tree records from those plots and assigns each + tree a coordinate with a spatial point process. + + Parameters + ---------- + domain : Domain or str + The domain (or its id) to create the inventory in. + pim_grid : Grid or str + A completed PIM grid (or its id) to expand. See + :func:`fastfuels_sdk.v2.grids.create_pim_grid_from_treemap`. + seed : int, optional + Random seed for reproducibility. Generated randomly if omitted. + point_process : str, optional + Spatial point process for tree coordinate assignment + (``PointProcess`` member or its string key, e.g. + "inhomogeneous_poisson"). + name, description : str, optional + Metadata for the inventory. + tags : List[str], optional + Tags for the inventory. + modifications : list, optional + Modification rules (``InventoryModification``) applied after + expansion. + treatments : list, optional + Silvicultural treatments (``InventoryBasalAreaTreatment`` or + ``InventoryDiameterTreatment``) applied after modifications. + + Returns + ------- + Inventory + The created Inventory object (job status "pending" or "running"). + + Examples + -------- + >>> import fastfuels_sdk as ff + >>> pim = ff.grids.create_pim_grid_from_treemap(domain) + >>> inventory = ff.inventories.create_tree_inventory_from_pim_grid( + ... domain, pim.wait(), seed=42 + ... ) + >>> inventory.wait() + """ + request_body = CreatePimInventoryRequest( + source_pim_grid_id=_domain_id(pim_grid), + seed=_opt(seed), + point_process=( + PointProcess(point_process) if point_process is not None else UNSET + ), + name=name, + description=description, + tags=_opt(tags), + modifications=_opt(modifications), + treatments=_opt(treatments), + ) + response = create_pim_inventory.sync_detailed( + _domain_id(domain), client=ensure_client(), body=request_body + ) + return Inventory._from_model(expect(response, HTTPStatus.CREATED)) + + +def create_tree_inventory_from_chm_grid( + domain, + chm_grid, + algorithm=None, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, + modifications: Optional[list] = None, + treatments: Optional[list] = None, +) -> Inventory: + """Create a tree inventory by isolating stems in a canopy height model. + + Detects individual trees in a completed canopy height model (CHM) + grid and derives a tree record from each detected stem. + + Parameters + ---------- + domain : Domain or str + The domain (or its id) to create the inventory in. + chm_grid : Grid or str + A completed canopy height model grid (or its id). See + :func:`fastfuels_sdk.v2.grids.create_canopy_height_grid_from_meta` + and + :func:`fastfuels_sdk.v2.grids.create_canopy_height_grid_from_naip_chm`. + algorithm : StemIsolationLmf or StemIsolationVwf, optional + Stem isolation algorithm and its parameters: local maximum + filtering (``StemIsolationLmf``) or variable window filtering + (``StemIsolationVwf``). Defaults to the API's standard algorithm. + name, description : str, optional + Metadata for the inventory. + tags : List[str], optional + Tags for the inventory. + modifications : list, optional + Modification rules (``InventoryModification``) applied after stem + isolation. + treatments : list, optional + Silvicultural treatments (``InventoryBasalAreaTreatment`` or + ``InventoryDiameterTreatment``) applied after modifications. + + Returns + ------- + Inventory + The created Inventory object (job status "pending" or "running"). + + Examples + -------- + >>> import fastfuels_sdk as ff + >>> chm = ff.grids.create_canopy_height_grid_from_meta(domain) + >>> inventory = ff.inventories.create_tree_inventory_from_chm_grid( + ... domain, chm.wait() + ... ) + >>> inventory.wait() + """ + request_body = CreateChmInventoryRequest( + source_chm_grid_id=_domain_id(chm_grid), + algorithm=_opt(algorithm), + name=name, + description=description, + tags=_opt(tags), + modifications=_opt(modifications), + treatments=_opt(treatments), + ) + response = create_chm_inventory.sync_detailed( + _domain_id(domain), client=ensure_client(), body=request_body + ) + return Inventory._from_model(expect(response, HTTPStatus.CREATED)) + + +def create_tree_inventory_from_file( + domain, + path: str, + format: Optional[str] = None, + columns=None, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, +) -> Inventory: + """Create a tree inventory by uploading your own tree records. + + Creates the inventory resource, uploads the file to the returned + signed URL, and returns the (pending) Inventory. Coordinates must be + in the domain's CRS. + + Parameters + ---------- + domain : Domain or str + The domain (or its id) to create the inventory in. + path : str + Path to the local file: ``.csv``, ``.geojson``/``.json``, or + ``.gpkg``. + format : str, optional + Upload format: "csv", "geojson", or "geopackage". Inferred from + the file extension if omitted. + columns : dict or InventoryColumnMapping, optional + Mapping from the standard column roles to your file's column + names, e.g. ``{"x": "X_UTM", "y": "Y_UTM", "dbh": "DBH_CM"}``. + Roles: "x", "y", "height", "dbh", "crown_ratio", + "fia_species_code", "fia_status_code". Columns whose names + already match need no mapping. + name, description : str, optional + Metadata for the inventory. + tags : List[str], optional + Tags for the inventory. + + Returns + ------- + Inventory + The created Inventory object (job status "pending"). Call + :meth:`Inventory.wait` to block until the uploaded file is + processed. + + Raises + ------ + ValueError + If ``format`` is omitted and the file extension is not one of + ``.csv``, ``.geojson``, ``.json``, or ``.gpkg``. + + Examples + -------- + >>> import fastfuels_sdk as ff + >>> inventory = ff.inventories.create_tree_inventory_from_file( + ... domain, "plot_trees.csv", columns={"x": "X_UTM", "y": "Y_UTM"} + ... ) + >>> inventory.wait() + """ + if format is not None: + upload_format = InventoryUploadFormat(format) + else: + suffix = Path(path).suffix.lower() + if suffix not in _UPLOAD_FORMATS: + raise ValueError( + f"Cannot infer the upload format from {suffix!r}. Pass " + 'format="csv", "geojson", or "geopackage".' + ) + upload_format = _UPLOAD_FORMATS[suffix] + if isinstance(columns, dict): + columns = InventoryColumnMapping(**columns) + request_body = CreateInventoryUploadRequest( + format_=upload_format, + columns=_opt(columns), + name=name, + description=description, + tags=_opt(tags), + ) + response = create_inventory_upload.sync_detailed( + _domain_id(domain), client=ensure_client(), body=request_body + ) + created = expect(response, HTTPStatus.CREATED) + _put_upload(created.upload, path) + return Inventory._from_model(created.inventory) + + +# --------------------------------------------------------------------------- +# Top-level fetch / list helpers +# --------------------------------------------------------------------------- + + +def list_inventories( + domain=None, + page: int = 0, + size: int = 100, + sort_by: Optional[str] = None, + sort_order: Optional[str] = None, + source: Optional[str] = None, + tag: Optional[str] = None, +) -> List[Inventory]: + """List inventories in a domain, or across all domains (single page). + + Parameters + ---------- + domain : Domain or str, optional + The domain (or its id) to list inventories in. If omitted, + inventories from all the user's domains are listed. + page : int, optional + The page number to retrieve, zero-indexed (default 0). + size : int, optional + The number of inventories per page (default 100). + sort_by : str, optional + Field to sort by: "name", "created_on", or "modified_on". + sort_order : str, optional + Sort direction: "ascending" or "descending". + source : str, optional + Only return inventories from this source (e.g. "pim"). + tag : str, optional + Only return inventories carrying this tag. + + Returns + ------- + List[Inventory] + The requested page of Inventory objects. + """ + kwargs = dict( + client=ensure_client(), + page=page, + size=size, + sort_by=InventorySortField(sort_by) if sort_by else UNSET, + sort_order=SortOrder(sort_order) if sort_order else UNSET, + source=_opt(source), + tag=_opt(tag), + ) + if domain is None: + response = list_inventories_cross_domain.sync_detailed(**kwargs) + else: + response = list_inventories_endpoint.sync_detailed(_domain_id(domain), **kwargs) + list_response: ListInventoriesResponse = expect(response) + return [Inventory._from_model(i) for i in list_response.inventories] + + +def get_inventory(domain, inventory_id: str) -> Inventory: + """Retrieve a single inventory by its ID. + + Parameters + ---------- + domain : Domain or str + The domain (or its id) the inventory belongs to. + inventory_id : str + The unique identifier of the inventory. + + Returns + ------- + Inventory + The requested Inventory object. + + Raises + ------ + NotFoundException + If no inventory exists with the given IDs, or the user does not have + access. + """ + return Inventory.from_id(_domain_id(domain), inventory_id) diff --git a/tests/v2/conftest.py b/tests/v2/conftest.py index afcfccf..decaaef 100644 --- a/tests/v2/conftest.py +++ b/tests/v2/conftest.py @@ -22,8 +22,10 @@ ) from fastfuels_sdk.v2.grids import ( create_fuel_model_grid_from_landfire_fbfm40, + create_pim_grid_from_treemap, create_topography_grid_from_3dep, ) +from fastfuels_sdk.v2.inventories import create_tree_inventory_from_pim_grid from tests.v2.utils import ( create_default_domain, create_default_layerset_geojson, @@ -95,3 +97,36 @@ def completed_fbfm40_grid(test_domain): ) grid.wait() return grid + + +@pytest.fixture(scope="session") +def completed_pim_grid(test_domain): + """A completed TreeMap PIM grid. READ-ONLY: shared by every module.""" + grid = create_pim_grid_from_treemap( + test_domain, + output_resolution_m=30, + resampling="nearest", + name="test_pim", + description="PIM grid for testing v2 inventory operations", + tags=["test"], + ) + grid.wait() + return grid + + +@pytest.fixture(scope="session") +def completed_tree_inventory(test_domain, completed_pim_grid): + """A completed PIM-expanded tree inventory. READ-ONLY: shared by every + module. Tests that mutate (update, apply_modifications) work on a + duplicate or a throwaway instead. + """ + inventory = create_tree_inventory_from_pim_grid( + test_domain, + completed_pim_grid, + seed=42, + name="test_inventory", + description="Tree inventory for testing v2 inventory operations", + tags=["test"], + ) + inventory.wait() + return inventory diff --git a/tests/v2/test_inventories.py b/tests/v2/test_inventories.py new file mode 100644 index 0000000..97fe5b5 --- /dev/null +++ b/tests/v2/test_inventories.py @@ -0,0 +1,330 @@ +""" +tests/v2/test_inventories.py +""" + +# Core imports +import json +from uuid import uuid4 + +# Internal imports +from fastfuels_sdk.v2._jobs import JobFailedError +from fastfuels_sdk.v2.grids import Grid +from fastfuels_sdk.v2.inventories import ( + Inventory, + create_tree_inventory_from_file, + create_tree_inventory_from_pim_grid, + get_inventory, + list_inventories, +) +from fastfuels_sdk.v2.client_library.models import ( + InventoryAttribute, + InventoryModification, + InventoryModificationAction, + InventoryModificationCondition, + JobStatus, + Modifier, + Operator, +) +from fastfuels_sdk.v2.exceptions import NotFoundException + +# External imports +import pandas as pd +import pytest + +# The test_domain, completed_pim_grid, and completed_tree_inventory fixtures +# are session-scoped and shared across modules (tests/v2/conftest.py). They +# are READ-ONLY: tests that mutate or delete create throwaways (duplicates). + + +@pytest.fixture(scope="class") +def throwaway_inventory(completed_tree_inventory): + """A mutable copy of the shared inventory (the fixtures are read-only).""" + inventory = completed_tree_inventory.duplicate(name="throwaway_copy") + inventory.wait() + yield inventory + inventory.delete() + + +class TestCreateTreeInventoryFromPimGrid: + def test_create(self, test_domain, completed_pim_grid): + inventory = create_tree_inventory_from_pim_grid( + test_domain, + completed_pim_grid, + seed=42, + name="throwaway_pim_inventory", + ) + assert len(inventory.id) > 0 + assert inventory.domain_id == test_domain.id + assert inventory.status in (JobStatus.PENDING, JobStatus.RUNNING) + inventory.delete() + + def test_accepts_grid_id_string(self, test_domain, completed_pim_grid): + inventory = create_tree_inventory_from_pim_grid( + test_domain, completed_pim_grid.id, seed=42 + ) + assert inventory.domain_id == test_domain.id + inventory.delete() + + def test_completed_fixture(self, completed_tree_inventory): + assert completed_tree_inventory.status == JobStatus.COMPLETED + assert completed_tree_inventory.georeference is not None + assert completed_tree_inventory.checksum + + +class TestCreateTreeInventoryFromFile: + def test_unknown_extension_raises(self, test_domain): + with pytest.raises(ValueError, match="upload format"): + create_tree_inventory_from_file(test_domain, "trees.parquet") + + def test_create_from_csv(self, test_domain, tmp_path): + # Tree records at the center of the domain, in the domain CRS + x_center = (test_domain.bbox[0] + test_domain.bbox[2]) / 2 + y_center = (test_domain.bbox[1] + test_domain.bbox[3]) / 2 + trees = pd.DataFrame( + { + "x": [x_center, x_center + 10], + "y": [y_center, y_center + 10], + "height": [12.0, 8.5], + "dbh": [25.0, 18.0], + "crown_ratio": [0.4, 0.5], + "fia_species_code": [122, 122], + "fia_status_code": [1, 1], + } + ) + path = tmp_path / "trees.csv" + trees.to_csv(path, index=False) + + inventory = create_tree_inventory_from_file( + test_domain, str(path), name="throwaway_upload" + ) + assert len(inventory.id) > 0 + assert inventory.domain_id == test_domain.id + # Processing the uploaded file runs as a background job + inventory.wait() + assert inventory.status == JobStatus.COMPLETED + assert len(inventory.to_dataframe()) == 2 + inventory.delete() + + +class TestFromId: + def test_success(self, test_domain, completed_tree_inventory): + inventory = Inventory.from_id(test_domain.id, completed_tree_inventory.id) + assert inventory.id == completed_tree_inventory.id + assert inventory.domain_id == test_domain.id + + def test_not_found(self, test_domain): + with pytest.raises(NotFoundException): + Inventory.from_id(test_domain.id, uuid4().hex) + + +class TestGetInventory: + def test_get_inventory_returns_new_instance( + self, test_domain, completed_tree_inventory + ): + inventory = get_inventory(test_domain, completed_tree_inventory.id) + assert inventory.id == completed_tree_inventory.id + assert inventory is not completed_tree_inventory + + +class TestRefreshInventory: + def test_refresh_returns_self(self, completed_tree_inventory): + refreshed = completed_tree_inventory.refresh() + assert refreshed is completed_tree_inventory + assert refreshed.id == completed_tree_inventory.id + + +class TestUpdateInventory: + def test_update_name(self, test_domain, throwaway_inventory): + # update() mutates in place and returns self (chains) + updated = throwaway_inventory.update(name="updated_name") + assert updated is throwaway_inventory + assert throwaway_inventory.name == "updated_name" + assert get_inventory(test_domain, throwaway_inventory.id).name == "updated_name" + + def test_update_tags(self, throwaway_inventory): + throwaway_inventory.update(tags=["updated"]) + assert throwaway_inventory.tags == ["updated"] + + def test_update_no_fields_makes_no_api_call(self, throwaway_inventory): + assert throwaway_inventory.update() is throwaway_inventory + + +class TestDuplicate: + def test_duplicate_is_a_clone(self, completed_tree_inventory): + copy = completed_tree_inventory.duplicate(name="duplicate_test") + assert copy.id != completed_tree_inventory.id + assert copy.name == "duplicate_test" + # The copy job byte-copies the data rather than re-deriving it, so + # the finished copy carries the source's checksum verbatim + copy.wait() + assert copy.status == JobStatus.COMPLETED + assert copy.checksum == completed_tree_inventory.checksum + copy.delete() + + +class TestApplyModifications: + @pytest.mark.xfail( + reason="v2 API bug: the in-place modifications job fails at the save " + "step (module-level gcsfs client is not fork-safe in standgen) — " + "FastFuels-API-v2#333", + raises=JobFailedError, + strict=True, + ) + def test_apply_modifications_rederives_in_place(self, completed_tree_inventory): + # Mutating, so work on a duplicate — the shared fixture is read-only + copy = completed_tree_inventory.duplicate(name="modify_test") + copy.wait() + # Rules need at least one condition; height > 0 matches every tree + modification = InventoryModification( + conditions=[ + InventoryModificationCondition( + attribute=InventoryAttribute.HEIGHT, + operator=Operator.GT, + value=0, + ) + ], + actions=[ + InventoryModificationAction( + attribute=InventoryAttribute.HEIGHT, + modifier=Modifier.MULTIPLY, + value=0.9, + ) + ], + ) + original_checksum = copy.checksum + + modified = copy.apply_modifications([modification]) + + assert modified is copy # in place: same object, same id + assert len(copy.modifications) == 1 + copy.wait() + assert copy.status == JobStatus.COMPLETED + assert copy.checksum != original_checksum # data was re-derived + copy.delete() + + def test_requires_completed_source(self, test_domain, completed_pim_grid): + inventory = create_tree_inventory_from_pim_grid( + test_domain, completed_pim_grid, seed=42 + ) + if inventory.status == JobStatus.COMPLETED: + inventory.delete() + pytest.skip("inventory completed too quickly to test the guard") + with pytest.raises(ValueError, match="apply modifications"): + inventory.apply_modifications([]) + inventory.delete() + + +class TestVoxelize: + def test_voxelize_returns_new_pending_grid(self, completed_tree_inventory): + voxels = completed_tree_inventory.voxelize( + horizontal_resolution_m=2.0, + vertical_resolution_m=1.0, + name="throwaway_voxels", + ) + assert isinstance(voxels, Grid) + assert voxels.domain_id == completed_tree_inventory.domain_id + assert voxels.status in (JobStatus.PENDING, JobStatus.RUNNING) + voxels.delete() + + def test_resolution_args_must_be_given_together(self, completed_tree_inventory): + with pytest.raises(ValueError, match="together"): + completed_tree_inventory.voxelize(horizontal_resolution_m=2.0) + + def test_requires_completed_source(self, test_domain, completed_pim_grid): + inventory = create_tree_inventory_from_pim_grid( + test_domain, completed_pim_grid, seed=42 + ) + if inventory.status == JobStatus.COMPLETED: + inventory.delete() + pytest.skip("inventory completed too quickly to test the guard") + with pytest.raises(ValueError, match="voxelize"): + inventory.voxelize() + inventory.delete() + + +class TestExport: + def test_export_returns_pending_export(self, completed_tree_inventory): + export = completed_tree_inventory.export(format="csv") + assert len(export.id) > 0 + assert export.domain_id == completed_tree_inventory.domain_id + assert export.status in ( + JobStatus.PENDING, + JobStatus.RUNNING, + JobStatus.COMPLETED, + ) + + def test_invalid_format(self, completed_tree_inventory): + with pytest.raises(ValueError): + completed_tree_inventory.export(format="shapefile") + + +class TestInventoryData: + def test_get_data_metadata(self, completed_tree_inventory): + metadata = completed_tree_inventory.get_data_metadata() + assert metadata.inventory_id == completed_tree_inventory.id + assert metadata.num_partitions >= 1 + assert metadata.total_rows > 0 + assert len(metadata.columns) > 0 + + def test_get_data_partition(self, completed_tree_inventory): + # metadata.columns can carry a __null_dask_index__ backend artifact + # that the partition responses correctly omit, so compare as subset + metadata = completed_tree_inventory.get_data_metadata() + partition = completed_tree_inventory.get_data_partition(0) + assert partition.partition == 0 + assert partition.num_rows == metadata.partitions[0].num_rows + assert set(partition.columns) <= set(metadata.columns) + assert "height" in partition.columns + assert len(partition.data) == partition.num_rows + + def test_to_dataframe(self, completed_tree_inventory): + metadata = completed_tree_inventory.get_data_metadata() + trees = completed_tree_inventory.to_dataframe() + assert isinstance(trees, pd.DataFrame) + assert len(trees) == metadata.total_rows + assert set(trees.columns) <= set(metadata.columns) + assert {"x", "y", "height"} <= set(trees.columns) + + def test_to_dataframe_column_subset(self, completed_tree_inventory): + metadata = completed_tree_inventory.get_data_metadata() + subset = metadata.columns[:2] + trees = completed_tree_inventory.to_dataframe(columns=subset) + assert list(trees.columns) == subset + + +class TestListInventories: + def test_list_in_domain(self, test_domain, completed_tree_inventory): + inventory_ids = [i.id for i in list_inventories(test_domain)] + assert completed_tree_inventory.id in inventory_ids + + def test_list_cross_domain(self, completed_tree_inventory): + # No domain: list inventories across all the user's domains + inventory_ids = [i.id for i in list_inventories()] + assert completed_tree_inventory.id in inventory_ids + + def test_filter_by_tag(self, test_domain, completed_tree_inventory): + tagged = list_inventories(test_domain, tag="test") + assert completed_tree_inventory.id in [i.id for i in tagged] + + def test_invalid_sort_field(self): + with pytest.raises(ValueError): + list_inventories(sort_by="not_a_field") + + +class TestToJson: + def test_to_json(self, completed_tree_inventory): + inventory_dict = json.loads(completed_tree_inventory.to_json()) + assert inventory_dict["id"] == completed_tree_inventory.id + assert inventory_dict["domain_id"] == completed_tree_inventory.domain_id + + +class TestDeleteInventory: + def test_delete(self, test_domain, completed_tree_inventory): + inventory = completed_tree_inventory.duplicate(name="delete_test") + inventory.delete() + + with pytest.raises(NotFoundException): + Inventory.from_id(test_domain.id, inventory.id) + + with pytest.raises(NotFoundException): + inventory.delete() From 379624d386650be7e595b31f7264ac68c40a4607 Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Wed, 10 Jun 2026 07:30:15 -0600 Subject: [PATCH 27/71] Document v2 inventories and update the v2 SDK guides New inventories how-to guide with live-captured examples; the creating-grids voxelization section gains real inventory.voxelize code; the migration guide's Inventories section is filled in (v1 exposed tree data only through exports); inventories added to the Reference and nav. --- docs/v2/SUMMARY.md | 1 + docs/v2/guides/creating-grids.md | 43 ++-- docs/v2/guides/inventories.md | 391 +++++++++++++++++++++++++++++++ docs/v2/guides/migration.md | 74 +++++- docs/v2/reference.md | 2 + 5 files changed, 490 insertions(+), 21 deletions(-) create mode 100644 docs/v2/guides/inventories.md diff --git a/docs/v2/SUMMARY.md b/docs/v2/SUMMARY.md index 440a700..6e94921 100644 --- a/docs/v2/SUMMARY.md +++ b/docs/v2/SUMMARY.md @@ -6,4 +6,5 @@ * Grids * [Creating grids](guides/creating-grids.md) * [Working with grids](guides/working-with-grids.md) + * [Inventories](guides/inventories.md) * [Reference](reference.md) diff --git a/docs/v2/guides/creating-grids.md b/docs/v2/guides/creating-grids.md index ec28a09..c8cec5d 100644 --- a/docs/v2/guides/creating-grids.md +++ b/docs/v2/guides/creating-grids.md @@ -189,14 +189,8 @@ naip = ff.grids.create_canopy_height_grid_from_naip_chm(domain, output_resolutio ## 3D tree fuel grids (voxelization) -!!! note "Inventories module in development" - The PIM grid below is available today. Generating a tree inventory and - voxelizing it arrive with the v2 inventories module, which is still in - development. - -The 3D canopy fuel grid — per-voxel bulk density, moisture, and -surface-area-to-volume ratio, the inputs 3D fire models consume — is built -in three steps: +The 3D canopy fuel grid — per-voxel bulk density, the input 3D fire models +consume — is built in three steps: **1. Create a Plot Imputation Map (PIM) grid** that maps each cell to a TreeMap forest inventory plot: @@ -212,14 +206,35 @@ By default the grid carries the TreeMap id band (`tm_id`); request the plot control number as well with `bands=["tm_id", "plt_cn"]`. **2. Generate a tree inventory** from the PIM grid — a table of individual -trees imputed from the matched plots. *(Coming with the inventories -module.)* +trees imputed from the matched plots (see the +[Inventories guide](inventories.md)): + +```python +inventory = ff.inventories.create_tree_inventory_from_pim_grid( + domain, pim, seed=42 +) +inventory.wait() +``` + +**3. Voxelize the inventory**, discretizing each tree's crown onto a 3D +lattice and computing per-voxel fuel properties: + +```python +voxels = inventory.voxelize( + horizontal_resolution_m=2.0, vertical_resolution_m=1.0 +) +voxels.wait() +``` + +```python +>>> voxels.georeference.shape # [layers, rows, cols] +[37, 703, 863] +``` -**3. Voxelize the inventory** with `inventory.voxelize(...)`, discretizing -each tree's crown onto a 3D lattice and computing per-voxel fuel properties. Because it is a 3D product, a voxel grid supports neither resampling nor -post-hoc modifications — apply any tree modifications to the inventory -before voxelizing. *(Coming with the inventories module.)* +post-hoc modifications — apply any tree modifications on the inventory +before voxelizing (see +[Modify trees at creation](inventories.md#modify-trees-at-creation)). For the concepts behind plot imputation and voxelization, see the [FastFuels documentation](https://docs.fastfuels.silvxlabs.com). diff --git a/docs/v2/guides/inventories.md b/docs/v2/guides/inventories.md new file mode 100644 index 0000000..e2e28bf --- /dev/null +++ b/docs/v2/guides/inventories.md @@ -0,0 +1,391 @@ +# How to Work with Tree Inventories in FastFuels SDK + +!!! warning "Beta" + The v2 SDK targets the FastFuels v2 API and is under active + development. The v1 SDK remains the default — import v2 explicitly + from `fastfuels_sdk.v2`. + +An inventory is a table of individual trees within a domain — one row per +tree, with its coordinates, species, diameter, height, and crown ratio. +Inventories sit between grids: they are generated *from* a grid (a PIM grid +or a canopy height model) or from your own data, and they are voxelized +*into* the 3D canopy fuel grid that 3D fire models consume. This guide +covers working with inventories from Python; for what inventories *are*, +see the [FastFuels documentation](https://docs.fastfuels.silvxlabs.com). +Coming from the v1 SDK? Start with the +[migration guide](migration.md#inventories). + +The v2 surface is functional: you **create** an inventory by calling a +`create_tree_inventory_from_...` function on a domain, and everything you +do with an inventory you already hold is a **method** on it. + +```python +import fastfuels_sdk.v2 as ff + +inventory = ff.inventories.create_tree_inventory_from_pim_grid(domain, pim) +trees = inventory.wait().to_dataframe() +``` + +## Prerequisites + +- The FastFuels SDK installed: `pip install fastfuels-sdk` +- A FastFuels API key in the `FASTFUELS_API_KEY` environment variable +- An existing [domain](domains.md) — every creator's first argument is a + `Domain` (or a bare domain id string) + +## From TreeMap to trees + +The most common workflow generates a tree inventory for anywhere in the +conterminous US: create a [PIM grid](creating-grids.md#3d-tree-fuel-grids-voxelization) +that matches each cell to a TreeMap forest inventory plot, then expand the +matched plots into individual trees. Pass a `seed` to make the expansion +reproducible: + +```python +import fastfuels_sdk.v2 as ff + +pim = ff.grids.create_pim_grid_from_treemap( + domain, output_resolution_m=30, resampling="nearest" +) +pim.wait() + +inventory = ff.inventories.create_tree_inventory_from_pim_grid( + domain, pim, seed=42, name="Blue Mountain trees" +) +inventory.wait() +``` + +Expansion runs as a background job — the returned inventory starts in +`"pending"` status and `wait()` blocks until it is ready. Once completed, +load the trees as a pandas DataFrame: + +```python +>>> trees = inventory.to_dataframe() +>>> len(trees) +71619 +>>> trees.head() + x y fia_species_code ... dbh height crown_ratio +0 721167.447037 5.190627e+06 122 ... 10.160 6.7056 0.10 +1 721169.886263 5.190628e+06 122 ... 22.606 10.3632 0.80 +2 721172.889178 5.190630e+06 122 ... 10.160 6.7056 0.10 +3 721167.558322 5.190629e+06 122 ... 8.128 4.5720 0.08 +4 721177.067389 5.190639e+06 122 ... 8.128 4.5720 0.08 +``` + +Coordinates are in the domain CRS in meters, `dbh` is in centimeters, and +`height` is in meters; the inventory's `columns` attribute records each +column's type and unit. + +## Create an inventory from a canopy height model + +To detect individual trees in a completed +[canopy height model grid](creating-grids.md#canopy-grids) instead — useful +where you want trees derived from remotely sensed canopy structure: + +```python +chm = ff.grids.create_canopy_height_grid_from_meta(domain, output_resolution_m=1) +chm.wait() + +inventory = ff.inventories.create_tree_inventory_from_chm_grid(domain, chm) +inventory.wait() +``` + +Stem isolation defaults to local maximum filtering with a 2 m minimum +height. To tune the algorithm, pass a `StemIsolationLmf` or +`StemIsolationVwf` (variable window filtering) model as `algorithm=`. A +CHM-derived inventory carries only what the canopy surface reveals — `x`, +`y`, and `height` columns — so it cannot be voxelized directly (voxelization +needs the per-tree measurements a PIM expansion or an upload provides). + +## Upload your own tree records + +To create an inventory from your own measurements, upload a `.csv`, +`.geojson`, or `.gpkg` file. Coordinates must be in the domain's CRS: + +```python +inventory = ff.inventories.create_tree_inventory_from_file( + domain, "plot_trees.csv", name="Field plot" +) +inventory.wait() +``` + +The standard column roles are `x`, `y`, `height` (m), `dbh` (cm), +`crown_ratio`, `fia_species_code`, and `fia_status_code`. Columns whose +names already match need no mapping; otherwise map your file's column names +onto the roles: + +```python +inventory = ff.inventories.create_tree_inventory_from_file( + domain, + "plot_trees.csv", + columns={"x": "X_UTM", "y": "Y_UTM", "dbh": "DBH_CM", "height": "HT_M"}, +) +``` + +The upload itself is synchronous, but processing the file runs as a +background job — `wait()` before reading data back: + +```python +>>> inventory.wait().to_dataframe() + x y height ... crown_ratio fia_species_code fia_status_code +0 721055.0 5190149.0 12.0 ... 0.40 122 1 +1 721065.0 5190159.0 8.5 ... 0.50 122 1 +2 721075.0 5190169.0 15.2 ... 0.35 202 1 +``` + +## Wait for an inventory to finish + +`wait` polls until the job reaches a terminal status and returns the +inventory (so calls chain). By default it waits indefinitely; pass +`timeout` (seconds) to bound the wait, which raises `TimeoutError` if +exceeded. A failed job raises `JobFailedError`. To join several jobs at +once, use `ff.wait_all`: + +```python +ff.wait_all([pim, inventory], verbose=True) +``` + +```text +Inventory 7b02df6f583a418da3ec9929037d876d: completed (5s) +``` + +## Access the tree records + +For most workflows, `to_dataframe()` (shown above) is all you need — it +retrieves every partition and assembles one DataFrame. Pass `columns=` to +retrieve a subset. + +The records are served in fixed-size partitions; to control retrieval +partition by partition (useful for large inventories), read the partition +layout first: + +```python +>>> metadata = inventory.get_data_metadata() +>>> metadata.total_rows +71619 +>>> metadata.num_partitions +4 +>>> partition = inventory.get_data_partition(0) +>>> partition.num_rows +34831 +``` + +Data is only available once the inventory is `"completed"` — earlier calls +raise `UnprocessableEntityException`. + +## Modify trees at creation + +Every creator accepts `modifications=` — rules applied while the inventory +is derived. Each rule filters trees by conditions (ANDed together) and +applies actions to the matching rows. To thin from below by removing every +tree under 10 cm DBH: + +```python +from fastfuels_sdk.v2.client_library.models import ( + InventoryAttribute, + InventoryModification, + InventoryModificationCondition, + Operator, + RemoveAction, +) + +thinned = ff.inventories.create_tree_inventory_from_pim_grid( + domain, + pim, + seed=42, + modifications=[ + InventoryModification( + conditions=[ + InventoryModificationCondition( + attribute=InventoryAttribute.DBH, + operator=Operator.LT, + value=10.0, + ) + ], + actions=[RemoveAction()], + ) + ], + name="Thinned", +) +``` + +With `seed=42` the unthinned expansion above produced 71,619 trees; the +same expansion with this rule keeps 40,620. Besides `RemoveAction`, an +`InventoryModificationAction` can `replace`, `multiply`, `divide`, `add`, +or `subtract` an attribute's value. Creators also accept `treatments=` — +silvicultural prescriptions (`InventoryBasalAreaTreatment`, +`InventoryDiameterTreatment`) applied after modifications. + +## Branch a scenario + +To compare scenarios — say, a thinned stand against the original — keep +the original inventory untouched and work on copies. `duplicate` creates +an independent copy under a new ID by byte-copying the finished data, not +re-deriving it: + +```python +>>> copy = inventory.duplicate(name="Thinning scenario") +>>> copy.wait() +>>> copy.checksum == inventory.checksum +True +``` + +The `checksum` is a version marker for an inventory's content: it changes +each time the data is rebuilt and is unaffected by metadata-only edits, so +an identical checksum means identical trees. + +To modify an inventory you already hold — appending rules to its +cumulative `modifications` list and re-deriving the data in place — call +`apply_modifications`. The inventory keeps its ID and returns to +`"pending"` while it rebuilds: + +```python +copy.apply_modifications([modification]) +copy.wait() +``` + +## Voxelize into a 3D fuel grid + +A completed PIM-expanded or uploaded inventory voxelizes into the +[3D tree fuel grid](creating-grids.md#3d-tree-fuel-grids-voxelization) — +per-voxel canopy bulk density on a 3D lattice: + +```python +voxels = inventory.voxelize( + horizontal_resolution_m=2.0, vertical_resolution_m=1.0 +) +voxels.wait() +``` + +```python +>>> [b.key for b in voxels.bands] +['bulk_density.foliage.live'] +>>> voxels.georeference.crs +'EPSG:32611' +>>> voxels.georeference.shape # [layers, rows, cols] +[37, 703, 863] +``` + +By default each tree's foliage biomass is distributed with the Purves +crown profile model using NSVB allometry. Request more bands with +`bands=` (e.g. `"bulk_density.branchwood.live"`, `"fuel_moisture.dead"`, +`"spcd"`, `"tree_id"`), switch the crown shape with +`crown_profile_model="beta"`, and pass a `seed` for reproducibility. +Because the result is a 3D product, it supports neither resampling nor +grid modifications — modify the trees on the inventory instead, before +voxelizing. + +## Export an inventory + +To export the tree records to a downloadable file, call `export` with a +format — `"parquet"` (zipped, default), `"csv"`, `"geojson"`, or +`"geopackage"`: + +```python +>>> export = inventory.export(format="csv") +>>> export.status +pending +``` + +`export` returns an `Export` resource that processes as a background job; +its `signed_url` is populated when it completes. Polling and downloading +exports from the SDK arrives with the exports module, which is still in +development. + +## Retrieve an existing inventory + +To fetch an inventory using its domain and inventory IDs: + +```python +inventory = ff.get_inventory(domain, "7b02df6f583a418da3ec9929037d876d") +``` + +## Refresh and update + +To reload an inventory's latest state from the API in place: + +```python +inventory.refresh() +print(inventory.status) +``` + +To modify an inventory's name, description, or tags: + +```python +inventory.update(name="New Name", tags=["thinned"]) +``` + +Both update the inventory in place and return it. `update` sends only the +fields you pass; passing none makes no API call. + +## List inventories + +To list inventories in a domain: + +```python +inventories = ff.list_inventories(domain) +``` + +To list inventories across all your domains, omit the domain: + +```python +all_inventories = ff.list_inventories() +``` + +Narrow the results with filters: + +```python +pim_inventories = ff.list_inventories(domain, source="pim") +tagged = ff.list_inventories(domain, tag="thinned") +``` + +## Delete an inventory + +To permanently delete an inventory and its tree records: + +```python +inventory.delete() +``` + +Deleting a domain also deletes all of its inventories. + +## Error handling + +Wrapper functions and methods raise typed exceptions from +`fastfuels_sdk.v2.exceptions`: + +```python +import fastfuels_sdk.v2 as ff +from fastfuels_sdk.v2.exceptions import ( + NotFoundException, + UnprocessableEntityException, +) + +try: + inventory = ff.get_inventory(domain, "does-not-exist") +except NotFoundException: + print("No such inventory (or you don't have access to it)") + +try: + trees = running_inventory.to_dataframe() +except UnprocessableEntityException as exc: + print(exc.detail) + # inventories/a6840e0c7cd64de896954d95afc6aea0 status is 'running', + # expected 'completed'. +``` + +Deriving from an inventory that is not completed raises a `ValueError` +before any API call: + +```python +>>> pending_inventory.voxelize(horizontal_resolution_m=2.0, vertical_resolution_m=1.0) +ValueError: Cannot voxelize an inventory with status 'pending'. Call .wait() until it completes first. +``` + +## Next steps + +- Voxelize alongside aligned 2D grids — see + [Creating grids](creating-grids.md) +- Wait on, inspect, and manage the voxelized grid — see + [Working with grids](working-with-grids.md) +- Full signatures — see the [Reference](../reference.md) diff --git a/docs/v2/guides/migration.md b/docs/v2/guides/migration.md index 00f8d14..efca8d0 100644 --- a/docs/v2/guides/migration.md +++ b/docs/v2/guides/migration.md @@ -24,7 +24,7 @@ Both subpackages read the same `FASTFUELS_API_KEY` environment variable. | `Grids`, `SurfaceGrid`, `TreeGrid`, `TopographyGrid`, `FeatureGrid` + builders | unified `Grid` resource | One job-based resource per grid, distinguished by data source | | `Features`, `RoadFeature`, `WaterFeature` | unified `Feature` resource | One job-based resource for OSM roads, OSM water, and custom layersets | | `feature.get_data()`, `feature.get_all_data()` | `feature.get_data_metadata()`, `feature.get_data_partition()`, `feature.get_data()`, `feature.to_geodataframe()` | Page-based data retrieval becomes partition-based | -| `Inventories`, `TreeInventory` | `Inventory` resource *(SDK module in development)* | Job-based; tree inventories are generated from PIM grids | +| `Inventories`, `TreeInventory` | unified `Inventory` resource | One job-based resource, created from a PIM grid, a canopy height model, or an upload | ## Domains @@ -197,10 +197,70 @@ Available now — see the [Features guide](features.md) and the ## Inventories -!!! note "In development" - The v2 inventories module is under development and will be documented - here when it ships. +Available now — see the [Inventories guide](inventories.md) and the +[Reference](../reference.md). + +### What changed from v1 + +- **TreeMap generation is a two-resource workflow.** v1's + `create_tree_inventory_from_treemap()` did the plot matching and the + tree expansion in one call. v2 splits them: create a PIM grid + (`ff.grids.create_pim_grid_from_treemap`), wait for it, then expand it + with `ff.inventories.create_tree_inventory_from_pim_grid(domain, pim)`. + The intermediate PIM grid is reusable — expand it several times with + different seeds or modifications without re-matching plots. +- **Creation is a function per source.** The `Inventories.from_domain_id` + container is gone; call `create_tree_inventory_from_pim_grid`, + `create_tree_inventory_from_chm_grid` (new — stem isolation on a canopy + height model), or `create_tree_inventory_from_file` on the domain. +- **Upload columns changed.** v1 uploads required `TREE_ID`, `SPCD`, + `STATUSCD`, `DIA`, `HT` columns. v2 uses the roles `x`, `y`, `height`, + `dbh`, `crown_ratio`, `fia_species_code`, `fia_status_code`, and a + `columns={role: your_name}` mapping replaces renaming your file. +- **Modifications and treatments are typed models.** v1's dict syntax + (`{"attribute": "HT", ...}`) becomes the generated + `InventoryModification` / treatment models, passed to `modifications=` / + `treatments=` at creation or to `inventory.apply_modifications(...)` + in place. v1's `feature_masks=["road", "water"]` becomes a modification + whose condition is a feature spatial condition + (`InventoryFeatureSpatialCondition`) and whose action is `RemoveAction`. +- **Data access is direct.** v1 exposed tree records only through file + exports (`create_export` → `to_file`). v2 streams them from the API: + `get_data_metadata()` / `get_data_partition(index)` — plus + `to_dataframe()` for the common case of loading every tree into pandas. + File exports remain available via `inventory.export(...)`. +- **Scenario branching is first-class.** `inventory.duplicate()` clones a + completed inventory, and `checksum` marks the data version, so derived + resources can detect a stale source. +- **Voxelization is a method.** The v1 tree grid built from an inventory + becomes `inventory.voxelize(horizontal_resolution_m=..., + vertical_resolution_m=...)`, returning a 3D `Grid`. + +### Before and after + +=== "v1" + + ```python + from fastfuels_sdk import Inventories + + inventories = Inventories.from_domain_id(domain.id) + trees = inventories.create_tree_inventory_from_treemap(seed=42) + trees.wait_until_completed() -What to expect from the v2 API: tree inventories are generated from a -PIM (TreeMap) grid, so the workflow becomes: create a TreeMap grid, wait -for it to complete, then create an inventory from it. + export = trees.create_export("csv") + export.wait_until_completed().to_file("trees.csv") # read the file back + ``` + +=== "v2" + + ```python + import fastfuels_sdk.v2 as ff + + pim = ff.grids.create_pim_grid_from_treemap(domain, output_resolution_m=30) + pim.wait() + + trees = ff.inventories.create_tree_inventory_from_pim_grid(domain, pim, seed=42) + trees.wait() + + data = trees.to_dataframe() + ``` diff --git a/docs/v2/reference.md b/docs/v2/reference.md index fcae31a..0e414ca 100644 --- a/docs/v2/reference.md +++ b/docs/v2/reference.md @@ -4,6 +4,8 @@ ::: fastfuels_sdk.v2.grids +::: fastfuels_sdk.v2.inventories + ::: fastfuels_sdk.v2.modifications ::: fastfuels_sdk.v2._jobs From a417a34697c42635d5b9f60da3663667a9d77bf6 Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Wed, 10 Jun 2026 09:53:42 -0600 Subject: [PATCH 28/71] Add v2 exports module (#182) Export record with the uniform v2 lifecycle plus to_file (streams the signed URL; a directory target uses the export's default filename). create_quicfire_export bundles fuel grids into the QUIC-Fire archive, taking each role as a (grid, band) pair and the fire grid as horizontal/vertical resolution or align_to. list_exports/get_export address exports by id alone. grid.export and inventory.export now return the wrapped Export, so create -> wait -> to_file chains. Live tests: 21 passed, including the QUIC-Fire zip roundtrip. --- fastfuels_sdk/v2/__init__.py | 7 +- fastfuels_sdk/v2/exports.py | 549 ++++++++++++++++++++++++++++++++ fastfuels_sdk/v2/grids.py | 11 +- fastfuels_sdk/v2/inventories.py | 11 +- tests/v2/test_exports.py | 201 ++++++++++++ 5 files changed, 772 insertions(+), 7 deletions(-) create mode 100644 fastfuels_sdk/v2/exports.py create mode 100644 tests/v2/test_exports.py diff --git a/fastfuels_sdk/v2/__init__.py b/fastfuels_sdk/v2/__init__.py index 328c12b..138af46 100644 --- a/fastfuels_sdk/v2/__init__.py +++ b/fastfuels_sdk/v2/__init__.py @@ -18,10 +18,11 @@ ``ff.list_grids``, ``ff.wait_all``, ``ff.set_api_key``. """ -from fastfuels_sdk.v2 import features, grids, inventories +from fastfuels_sdk.v2 import exports, features, grids, inventories from fastfuels_sdk.v2._jobs import JobFailedError, wait_all from fastfuels_sdk.v2.api import set_api_key from fastfuels_sdk.v2.domains import Domain, list_domains, reproject_geojson +from fastfuels_sdk.v2.exports import Export, get_export, list_exports from fastfuels_sdk.v2.features import Feature, get_feature, list_features from fastfuels_sdk.v2.grids import Grid, get_grid, list_grids from fastfuels_sdk.v2.inventories import Inventory, get_inventory, list_inventories @@ -29,6 +30,7 @@ __all__ = [ # Submodules (resource creators live here: ff.grids.create_*, ff.features.create_*) + "exports", "features", "grids", "inventories", @@ -36,6 +38,7 @@ "set_api_key", # Records "Domain", + "Export", "Feature", "Grid", "Inventory", @@ -47,6 +50,8 @@ "get_grid", "list_inventories", "get_inventory", + "list_exports", + "get_export", "reproject_geojson", # Modifications "mask", diff --git a/fastfuels_sdk/v2/exports.py b/fastfuels_sdk/v2/exports.py new file mode 100644 index 0000000..ed8811b --- /dev/null +++ b/fastfuels_sdk/v2/exports.py @@ -0,0 +1,549 @@ +""" +fastfuels_sdk/v2/exports.py +""" + +# Core imports +import json +from http import HTTPStatus +from pathlib import Path +from typing import List, Optional, Tuple, Union +from urllib.parse import urlparse + +# Internal imports +from fastfuels_sdk.v2._jobs import wait as _wait +from fastfuels_sdk.v2.api import ensure_client +from fastfuels_sdk.v2.exceptions import expect +from fastfuels_sdk.v2.client_library.api.exports import ( + delete_export, + get_export as get_export_endpoint, + list_exports as list_exports_endpoint, + update_export, +) +from fastfuels_sdk.v2.client_library.api.grids import ( + create_quicfire_export as create_quicfire_export_endpoint, +) +from fastfuels_sdk.v2.client_library.models import ( + Export as ExportModel, + ExportSortField, + FieldSource, + JobStatus, + ListExportsResponse, + QuicfireExportRequest, + QuicfireExportRequestMoistMerge, + QUICFireExportAlignmentDomainTarget, + QUICFireExportAlignmentGridTarget, + SortOrder, + UpdateExportRequestBody, +) +from fastfuels_sdk.v2.client_library.types import UNSET + +# External imports +import attrs +import requests + +__all__ = [ + "Export", + "create_quicfire_export", + "list_exports", + "get_export", +] + +_DOWNLOAD_CHUNK_BYTES = 1024 * 1024 + + +def _domain_id(domain) -> str: + """Resolve a Domain object or a domain-id string to the id string.""" + return getattr(domain, "id", domain) + + +def _opt(value): + """Map ``None`` to the generated UNSET sentinel, else pass through.""" + return value if value is not None else UNSET + + +def _field_source(value, role: str) -> FieldSource: + """Build a FieldSource from a ``(grid, band)`` tuple or pass one through.""" + if isinstance(value, FieldSource): + return value + if isinstance(value, (tuple, list)) and len(value) == 2: + grid, band = value + return FieldSource(grid_id=_domain_id(grid), band=band) + raise ValueError( + f"{role} must be a (grid, band) tuple or a FieldSource, got {value!r}" + ) + + +class Export(ExportModel): + """Export resource for the FastFuels v2 API. + + An export packages a resource's data — a grid, an inventory, or a + multi-grid QUIC-Fire bundle — into a downloadable file. Exports are + asynchronous job resources: creation returns a *pending* record, and + the signed download URL is populated when the job completes. Call + :meth:`wait` and then :meth:`to_file` to download. + + Attributes + ---------- + id : str + Unique identifier for the export. + domain_id : str + Identifier of the domain the exported resource belongs to. + status : JobStatus + Job status: "pending", "running", "completed", or "failed". + source : ExportSource + What was exported and in which format. + name : str + Human-readable name for the export. + description : str + Detailed description of the export. + progress : JobProgress, optional + Progress info while the job is running. + signed_url : str, optional + Download URL; populated when the job completes. + expires_on : datetime, optional + When the signed URL stops working. + error : JobError, optional + Error details if the job failed. + tags : List[str], optional + User-defined tags for organization. + created_on : datetime + When the export was created. + modified_on : datetime + When the export was last modified. + + Examples + -------- + Export a grid and download the result: + >>> export = grid.export(format="geotiff") + >>> export.wait().to_file("elevation.tif") + + Get an export by ID: + >>> export = ff.get_export("abc123") + + See Also + -------- + create_quicfire_export : Bundle fuel grids into a QUIC-Fire archive. + list_exports : List your exports. + """ + + @classmethod + def _from_model(cls, model: ExportModel) -> "Export": + """Build an Export from a generated Export model instance. + + Round-trips through the generated to_dict/from_dict — from_dict + constructs ``cls``, i.e. this subclass. + """ + return cls.from_dict(model.to_dict()) + + def _copy_fields_from(self, model: ExportModel) -> "Export": + """Copy all generated-model fields from `model` onto self (in-place).""" + for field in attrs.fields(ExportModel): + if field.init: + setattr(self, field.name, getattr(model, field.name)) + self.additional_properties = dict(model.additional_properties) + return self + + @classmethod + def from_id(cls, export_id: str) -> "Export": + """Retrieve an existing Export resource by its ID. + + Exports are addressed by their ID alone (no domain in the path). + + Parameters + ---------- + export_id : str + The unique identifier of the export to retrieve. + + Returns + ------- + Export + The requested Export object. + + Raises + ------ + NotFoundException + If no export exists with the given ID, or the user does not + have access to it. + """ + response = get_export_endpoint.sync_detailed(export_id, client=ensure_client()) + return cls._from_model(expect(response)) + + def refresh(self) -> "Export": + """Update this Export in place with the latest data from the API. + + Returns + ------- + Export + ``self``, updated with the latest data (so calls chain). + + Raises + ------ + NotFoundException + If the export no longer exists. + """ + response = get_export_endpoint.sync_detailed(self.id, client=ensure_client()) + return self._copy_fields_from(expect(response)) + + def wait(self, timeout: Optional[float] = None, verbose: bool = False) -> "Export": + """Poll the export job until it reaches a terminal status. + + Parameters + ---------- + timeout : float, optional + Maximum seconds to wait. ``None`` (default) waits indefinitely; the + job runs server-side regardless, so a bounded wait is resumable. + verbose : bool, optional + If True, print the job status at each poll. + + Returns + ------- + Export + ``self``, updated to its terminal state (so calls chain). + + Raises + ------ + TimeoutError + If ``timeout`` is set and elapses before a terminal status. + JobFailedError + If the job finished with status "failed". + """ + return _wait(self, timeout=timeout, verbose=verbose) + + def update( + self, + name: Optional[str] = None, + description: Optional[str] = None, + tags: Optional[List[str]] = None, + ) -> "Export": + """Update the export's mutable metadata (name, description, tags) in place. + + Only provided fields are sent. If no fields are provided, no API call + is made. + + Parameters + ---------- + name : str, optional + New name for the export. + description : str, optional + New description for the export. + tags : List[str], optional + New tags for the export (replaces existing tags). + + Returns + ------- + Export + ``self``, updated (so calls chain). + + Raises + ------ + NotFoundException + If the export no longer exists. + """ + if name is None and description is None and tags is None: + return self + request_body = UpdateExportRequestBody( + name=_opt(name), description=_opt(description), tags=_opt(tags) + ) + response = update_export.sync_detailed( + self.id, client=ensure_client(), body=request_body + ) + return self._copy_fields_from(expect(response)) + + def delete(self) -> None: + """Delete this export and its packaged file. + + Raises + ------ + NotFoundException + If the export no longer exists. + """ + response = delete_export.sync_detailed(self.id, client=ensure_client()) + expect(response, HTTPStatus.NO_CONTENT) + + def to_file(self, path: Union[str, Path]) -> Path: + """Download the export's packaged file. + + Streams the signed URL to disk. The export must be completed — + call :meth:`wait` first. + + Parameters + ---------- + path : str or Path + Destination file path. If ``path`` is an existing directory, + the file is saved inside it under the export's default + filename. + + Returns + ------- + Path + The path of the written file. + + Raises + ------ + ValueError + If the export is not completed or carries no download URL. + + Examples + -------- + >>> export = grid.export(format="geotiff") + >>> export.wait().to_file("elevation.tif") + """ + if self.status != JobStatus.COMPLETED: + raise ValueError( + f"Cannot download an export with status '{self.status}'. " + "Call .wait() until it completes first." + ) + if not self.signed_url: + raise ValueError( + "Export carries no download URL. The signed URL may have " + "expired — re-create the export." + ) + destination = Path(path) + if destination.is_dir(): + filename = Path(urlparse(self.signed_url).path).name or f"{self.id}.zip" + destination = destination / filename + with requests.get(self.signed_url, stream=True) as response: + response.raise_for_status() + with open(destination, "wb") as file_obj: + for chunk in response.iter_content(chunk_size=_DOWNLOAD_CHUNK_BYTES): + file_obj.write(chunk) + return destination + + def to_json(self) -> str: + """Serialize the complete Export object to a JSON string. + + Returns + ------- + str + The Export as a pretty-printed JSON string. + """ + return json.dumps(self.to_dict(), default=str, indent=2) + + +# --------------------------------------------------------------------------- +# Create exports assembled from many resources (module-level functions) +# --------------------------------------------------------------------------- + + +def create_quicfire_export( + domain, + canopy_bulk_density: Tuple, + canopy_moisture: Tuple, + surface_fuel_load: Tuple, + surface_fuel_depth: Tuple, + surface_moisture: Tuple, + topography: Optional[Tuple] = None, + canopy_savr: Optional[Tuple] = None, + surface_savr: Optional[Tuple] = None, + horizontal_resolution_m: Optional[float] = None, + vertical_resolution_m: Optional[float] = None, + align_to=None, + moist_merge: Optional[str] = None, + expiration_days: int = 7, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, +) -> Export: + """Bundle fuel and topography grids into a QUIC-Fire export. + + Packages the grids into a QUIC-Fire-loadable zip archive containing + ``treesrhof.dat``, ``treesmoist.dat``, ``treesfueldepth.dat``, + ``metadata.json``, and ``domain.geojson`` — plus ``topo.dat`` when + ``topography`` is given, and ``treesss.dat`` when both SAVR roles are + given. + + Each grid role is a ``(grid, band)`` pair naming the grid (or its id) + and the band to read. Every role grid must be lattice-aligned with the + fire grid and cover its full extent; the exporter crops oversized + grids but never resamples. + + Parameters + ---------- + domain : Domain or str + The domain (or its id) the grids belong to. + canopy_bulk_density : tuple + ``(grid, band)`` for 3D canopy bulk density (kg/m³). + canopy_moisture : tuple + ``(grid, band)`` for 3D canopy fuel moisture. + surface_fuel_load : tuple + ``(grid, band)`` for 2D surface fuel load (kg/m²). + surface_fuel_depth : tuple + ``(grid, band)`` for 2D surface fuel depth (m). + surface_moisture : tuple + ``(grid, band)`` for 2D surface fuel moisture. + topography : tuple, optional + ``(grid, band)`` for elevation; produces ``topo.dat``. + canopy_savr, surface_savr : tuple, optional + ``(grid, band)`` for surface-area-to-volume ratio. Give both or + neither; together they produce ``treesss.dat``. + horizontal_resolution_m : float, optional + Fire-grid cell size (x and y) in meters when anchoring to the + domain bounding box (API default 2.0). Mutually exclusive with + ``align_to``. + vertical_resolution_m : float, optional + Fire-grid vertical cell size in meters (API default 1.0). + Mutually exclusive with ``align_to``. + align_to : Grid or str, optional + Define the fire grid by an existing grid's lattice instead. + moist_merge : str, optional + How surface and canopy moisture merge in shared cells: "max" + (API default) or "weighted_avg". + expiration_days : int, optional + Days until the signed download URL expires (max 7, default 7). + name, description : str, optional + Metadata for the export. + tags : List[str], optional + Tags for the export. + + Returns + ------- + Export + The created Export object (job status "pending"). Call + :meth:`Export.wait` and then :meth:`Export.to_file` to download + the archive. + + Raises + ------ + ValueError + If ``align_to`` is combined with a resolution argument. + + Examples + -------- + >>> export = ff.exports.create_quicfire_export( + ... domain, + ... canopy_bulk_density=(voxels, "bulk_density.foliage.live"), + ... canopy_moisture=(canopy_moist, "fuel_moisture"), + ... surface_fuel_load=(surface, "fuel_load.1hr"), + ... surface_fuel_depth=(surface, "fuel_depth"), + ... surface_moisture=(surface_moist, "fuel_moisture"), + ... ) + >>> export.wait().to_file("quicfire.zip") + """ + if align_to is not None: + if horizontal_resolution_m is not None or vertical_resolution_m is not None: + raise ValueError( + "Specify either align_to or the resolution arguments, not both." + ) + alignment = QUICFireExportAlignmentGridTarget( + target="grid", grid_id=_domain_id(align_to) + ) + elif horizontal_resolution_m is not None or vertical_resolution_m is not None: + alignment = QUICFireExportAlignmentDomainTarget( + dx=_opt(horizontal_resolution_m), + dy=_opt(horizontal_resolution_m), + dz=_opt(vertical_resolution_m), + ) + else: + alignment = UNSET + + request_body = QuicfireExportRequest( + canopy_bulk_density=_field_source(canopy_bulk_density, "canopy_bulk_density"), + canopy_moisture=_field_source(canopy_moisture, "canopy_moisture"), + surface_fuel_load=_field_source(surface_fuel_load, "surface_fuel_load"), + surface_fuel_depth=_field_source(surface_fuel_depth, "surface_fuel_depth"), + surface_moisture=_field_source(surface_moisture, "surface_moisture"), + topography=( + _field_source(topography, "topography") if topography is not None else UNSET + ), + canopy_savr=( + _field_source(canopy_savr, "canopy_savr") + if canopy_savr is not None + else UNSET + ), + surface_savr=( + _field_source(surface_savr, "surface_savr") + if surface_savr is not None + else UNSET + ), + alignment=alignment, + moist_merge=( + QuicfireExportRequestMoistMerge(moist_merge) + if moist_merge is not None + else UNSET + ), + expiration_days=expiration_days, + name=name, + description=description, + tags=_opt(tags), + ) + response = create_quicfire_export_endpoint.sync_detailed( + _domain_id(domain), client=ensure_client(), body=request_body + ) + return Export._from_model(expect(response, HTTPStatus.CREATED)) + + +# --------------------------------------------------------------------------- +# Top-level fetch / list helpers +# --------------------------------------------------------------------------- + + +def list_exports( + domain=None, + page: int = 0, + size: int = 100, + sort_by: Optional[str] = None, + sort_order: Optional[str] = None, + source: Optional[str] = None, + tag: Optional[str] = None, +) -> List[Export]: + """List your exports (single page), most filters optional. + + Parameters + ---------- + domain : Domain or str, optional + Only return exports of resources in this domain (or its id). If + omitted, exports from all your domains are listed. + page : int, optional + The page number to retrieve, zero-indexed (default 0). + size : int, optional + The number of exports per page (default 100). + sort_by : str, optional + Field to sort by: "name", "created_on", or "modified_on". + sort_order : str, optional + Sort direction: "ascending" or "descending". + source : str, optional + Only return exports with this source name — the format for + single-resource exports (e.g. "geotiff", "csv") or "quicfire" + for bundles. + tag : str, optional + Only return exports carrying this tag. + + Returns + ------- + List[Export] + The requested page of Export objects. + """ + response = list_exports_endpoint.sync_detailed( + client=ensure_client(), + page=page, + size=size, + sort_by=ExportSortField(sort_by) if sort_by else UNSET, + sort_order=SortOrder(sort_order) if sort_order else UNSET, + domain_id=_opt(_domain_id(domain) if domain is not None else None), + source_name=_opt(source), + tag=_opt(tag), + ) + list_response: ListExportsResponse = expect(response) + return [Export._from_model(e) for e in list_response.exports] + + +def get_export(export_id: str) -> Export: + """Retrieve a single export by its ID. + + Parameters + ---------- + export_id : str + The unique identifier of the export. + + Returns + ------- + Export + The requested Export object. + + Raises + ------ + NotFoundException + If no export exists with the given ID, or the user does not have + access. + """ + return Export.from_id(export_id) diff --git a/fastfuels_sdk/v2/grids.py b/fastfuels_sdk/v2/grids.py index 0349486..8137cfb 100644 --- a/fastfuels_sdk/v2/grids.py +++ b/fastfuels_sdk/v2/grids.py @@ -503,9 +503,14 @@ def export( Returns ------- Export - The generated Export resource (job status "pending"). Poll it until - completed to obtain the signed download URL. + The created Export object (job status "pending"). Call + :meth:`Export.wait` and then :meth:`Export.to_file` to download + the file. """ + # Lazy import so the modules stay decoupled (exports never imports + # grids). + from fastfuels_sdk.v2.exports import Export + request_body = ExportGridRequest( bands=_opt(bands), expiration_days=expiration_days, @@ -520,7 +525,7 @@ def export( client=ensure_client(), body=request_body, ) - return expect(response, HTTPStatus.CREATED) + return Export._from_model(expect(response, HTTPStatus.CREATED)) def to_json(self) -> str: """Serialize the complete Grid object to a JSON string. diff --git a/fastfuels_sdk/v2/inventories.py b/fastfuels_sdk/v2/inventories.py index adb40b5..305c0e4 100644 --- a/fastfuels_sdk/v2/inventories.py +++ b/fastfuels_sdk/v2/inventories.py @@ -545,8 +545,9 @@ def export( Returns ------- Export - The generated Export resource (job status "pending"). Poll it until - completed to obtain the signed download URL. + The created Export object (job status "pending"). Call + :meth:`Export.wait` and then :meth:`Export.to_file` to download + the file. Raises ------ @@ -555,6 +556,10 @@ def export( UnprocessableEntityException If the inventory is not in "completed" status. """ + # Lazy import so the modules stay decoupled (exports never imports + # inventories). + from fastfuels_sdk.v2.exports import Export + request_body = ExportInventoryRequest( columns=_opt(columns), expiration_days=expiration_days, @@ -569,7 +574,7 @@ def export( client=ensure_client(), body=request_body, ) - return expect(response, HTTPStatus.CREATED) + return Export._from_model(expect(response, HTTPStatus.CREATED)) def get_data_metadata(self) -> InventoryDataMetadata: """Get the partition layout of the inventory's tree records. diff --git a/tests/v2/test_exports.py b/tests/v2/test_exports.py new file mode 100644 index 0000000..a34308d --- /dev/null +++ b/tests/v2/test_exports.py @@ -0,0 +1,201 @@ +""" +tests/v2/test_exports.py +""" + +# Core imports +import json +import zipfile +from uuid import uuid4 + +# Internal imports +from fastfuels_sdk.v2.exports import ( + Export, + _field_source, + create_quicfire_export, + get_export, + list_exports, +) +from fastfuels_sdk.v2.grids import create_uniform_grid +from fastfuels_sdk.v2.client_library.models import FieldSource, JobStatus +from fastfuels_sdk.v2.exceptions import NotFoundException + +# External imports +import pytest + +# The test_domain, completed_topography_grid, and completed_tree_inventory +# fixtures are session-scoped and shared across modules (tests/v2/conftest.py). +# They are READ-ONLY: tests that mutate or delete create throwaway exports. + + +class TestFieldSource: + """Pure unit tests for the (grid, band) translator (no API).""" + + def test_tuple_with_grid_object(self, completed_topography_grid): + source = _field_source((completed_topography_grid, "elevation"), "topography") + assert source.grid_id == completed_topography_grid.id + assert source.band == "elevation" + + def test_tuple_with_grid_id(self): + source = _field_source(("abc123", "fuel_depth"), "surface_fuel_depth") + assert source.grid_id == "abc123" + + def test_field_source_passes_through(self): + source = FieldSource(grid_id="abc123", band="fuel_depth") + assert _field_source(source, "surface_fuel_depth") is source + + def test_invalid_value_raises(self): + with pytest.raises(ValueError, match="surface_moisture"): + _field_source("not-a-pair", "surface_moisture") + + +class TestGridExportLifecycle: + @pytest.fixture(scope="class") + def completed_export(self, completed_topography_grid): + """A completed GeoTIFF export of the shared topography grid.""" + export = completed_topography_grid.export(format="geotiff", tags=["test"]) + export.wait() + return export + + def test_export_returns_wrapped_record(self, completed_topography_grid): + export = completed_topography_grid.export(format="geotiff") + assert isinstance(export, Export) + assert export.domain_id == completed_topography_grid.domain_id + + def test_completed_export_carries_signed_url(self, completed_export): + assert completed_export.status == JobStatus.COMPLETED + assert completed_export.signed_url + assert completed_export.expires_on is not None + + def test_to_file_explicit_path(self, completed_export, tmp_path): + destination = completed_export.to_file(tmp_path / "elevation.tif") + assert destination == tmp_path / "elevation.tif" + assert destination.stat().st_size > 0 + + def test_to_file_directory_uses_default_filename(self, completed_export, tmp_path): + destination = completed_export.to_file(tmp_path) + assert destination.parent == tmp_path + assert destination.stat().st_size > 0 + + def test_to_file_requires_completed(self, completed_topography_grid): + export = completed_topography_grid.export(format="geotiff") + if export.status == JobStatus.COMPLETED: + pytest.skip("export completed too quickly to test the guard") + with pytest.raises(ValueError, match="download"): + export.to_file("nope.tif") + + def test_from_id_and_get_export(self, completed_export): + fetched = Export.from_id(completed_export.id) + assert fetched.id == completed_export.id + assert get_export(completed_export.id).id == completed_export.id + + def test_not_found(self): + with pytest.raises(NotFoundException): + Export.from_id(uuid4().hex) + + def test_refresh_returns_self(self, completed_export): + assert completed_export.refresh() is completed_export + + def test_update_name(self, completed_export): + updated = completed_export.update(name="updated_export") + assert updated is completed_export + assert get_export(completed_export.id).name == "updated_export" + + def test_update_no_fields_makes_no_api_call(self, completed_export): + assert completed_export.update() is completed_export + + def test_list_exports(self, test_domain, completed_export): + export_ids = [e.id for e in list_exports(test_domain)] + assert completed_export.id in export_ids + + def test_list_exports_tag_filter(self, completed_export): + export_ids = [e.id for e in list_exports(tag="test")] + assert completed_export.id in export_ids + + def test_to_json(self, completed_export): + export_dict = json.loads(completed_export.to_json()) + assert export_dict["id"] == completed_export.id + + def test_delete(self, completed_topography_grid): + export = completed_topography_grid.export(format="geotiff") + export.delete() + with pytest.raises(NotFoundException): + Export.from_id(export.id) + + +class TestInventoryExport: + def test_csv_roundtrip(self, completed_tree_inventory, tmp_path): + export = completed_tree_inventory.export(format="csv") + assert isinstance(export, Export) + export.wait() + destination = export.to_file(tmp_path / "trees.csv") + header = destination.read_text().splitlines()[0] + assert "height" in header + + +class TestQuicfireExport: + @pytest.fixture(scope="class") + def voxel_grid(self, completed_tree_inventory): + """A 3D canopy grid carrying bulk density + moisture bands.""" + voxels = completed_tree_inventory.voxelize( + horizontal_resolution_m=2.0, + vertical_resolution_m=1.0, + bands=["bulk_density.foliage.live", "fuel_moisture.live"], + name="qf_voxels", + ) + voxels.wait() + return voxels + + @pytest.fixture(scope="class") + def surface_grid(self, test_domain): + """A 2 m uniform surface grid carrying load, depth, and moisture.""" + grid = create_uniform_grid( + test_domain, + resolution_m=2.0, + bands={ + "fuel_load.1hr": 0.5, + "fuel_depth": 0.3, + "fuel_moisture.1hr": 10.0, + }, + name="qf_surface", + ) + grid.wait() + return grid + + def test_align_to_excludes_resolution(self, test_domain, voxel_grid): + with pytest.raises(ValueError, match="not both"): + create_quicfire_export( + test_domain, + canopy_bulk_density=(voxel_grid, "bulk_density.foliage.live"), + canopy_moisture=(voxel_grid, "fuel_moisture.live"), + surface_fuel_load=("g", "fuel_load.1hr"), + surface_fuel_depth=("g", "fuel_depth"), + surface_moisture=("g", "fuel_moisture.1hr"), + align_to=voxel_grid, + horizontal_resolution_m=2.0, + ) + + def test_bundle_roundtrip(self, test_domain, voxel_grid, surface_grid, tmp_path): + export = create_quicfire_export( + test_domain, + canopy_bulk_density=(voxel_grid, "bulk_density.foliage.live"), + canopy_moisture=(voxel_grid, "fuel_moisture.live"), + surface_fuel_load=(surface_grid, "fuel_load.1hr"), + surface_fuel_depth=(surface_grid, "fuel_depth"), + surface_moisture=(surface_grid, "fuel_moisture.1hr"), + name="qf_bundle", + ) + assert isinstance(export, Export) + assert export.status in (JobStatus.PENDING, JobStatus.RUNNING) + export.wait() + + destination = export.to_file(tmp_path) + with zipfile.ZipFile(destination) as archive: + names = set(archive.namelist()) + assert { + "treesrhof.dat", + "treesmoist.dat", + "treesfueldepth.dat", + "metadata.json", + "domain.geojson", + } <= names + export.delete() From 18151f983b73c240aef501dab2e69372565c8dcc Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Wed, 10 Jun 2026 09:53:51 -0600 Subject: [PATCH 29/71] Document v2 exports and update the v2 SDK guides New exports how-to guide with live-captured download and QUIC-Fire bundle examples; the grid and inventory export sections now show the working wait().to_file() flow; the migration guide gains an Exports section (export_roi becomes explicit creation plus the server-side bundle) and drops the stale grids-module note; exports added to the Reference and nav. --- docs/v2/SUMMARY.md | 1 + docs/v2/guides/exports.md | 174 +++++++++++++++++++++++++++ docs/v2/guides/inventories.md | 18 ++- docs/v2/guides/migration.md | 45 ++++++- docs/v2/guides/working-with-grids.md | 24 ++-- docs/v2/reference.md | 2 + 6 files changed, 235 insertions(+), 29 deletions(-) create mode 100644 docs/v2/guides/exports.md diff --git a/docs/v2/SUMMARY.md b/docs/v2/SUMMARY.md index 6e94921..6678caf 100644 --- a/docs/v2/SUMMARY.md +++ b/docs/v2/SUMMARY.md @@ -7,4 +7,5 @@ * [Creating grids](guides/creating-grids.md) * [Working with grids](guides/working-with-grids.md) * [Inventories](guides/inventories.md) + * [Exports](guides/exports.md) * [Reference](reference.md) diff --git a/docs/v2/guides/exports.md b/docs/v2/guides/exports.md new file mode 100644 index 0000000..f7825e1 --- /dev/null +++ b/docs/v2/guides/exports.md @@ -0,0 +1,174 @@ +# How to Export Data from FastFuels SDK + +!!! warning "Beta" + The v2 SDK targets the FastFuels v2 API and is under active + development. The v1 SDK remains the default — import v2 explicitly + from `fastfuels_sdk.v2`. + +An export packages a resource's data into a downloadable file: a +[grid](working-with-grids.md) as GeoTIFF/NetCDF/zarr, an +[inventory](inventories.md) as Parquet/CSV/GeoJSON/GeoPackage, or several +grids bundled into a QUIC-Fire-loadable archive. Exports run as background +jobs and expose a signed download URL on completion. + +The pattern is the same everywhere: exporting a resource you **hold** is a +method on it (`grid.export(...)`, `inventory.export(...)`); the QUIC-Fire +bundle is **assembled from many** grids, so it is a module-level function +(`ff.exports.create_quicfire_export(...)`). + +## Prerequisites + +- The FastFuels SDK installed: `pip install fastfuels-sdk` +- A FastFuels API key in the `FASTFUELS_API_KEY` environment variable +- A completed resource to export — a [grid](creating-grids.md) or an + [inventory](inventories.md) + +## Export and download a grid + +To export a completed grid and save the file, chain create → `wait` → +`to_file`: + +```python +import fastfuels_sdk.v2 as ff + +export = grid.export(format="geotiff") # or "netcdf", "zarr" +export.wait().to_file("elevation.tif") +``` + +`grid.export` returns a pending [`Export`](#work-with-an-export-you-hold); +`wait()` blocks until the file is packaged, and `to_file` streams it to +disk. GeoTIFF applies to 2D grids; export 3D voxel grids as `"netcdf"` or +`"zarr"`. Pass `bands=` to export a subset of the grid's bands. + +If `to_file` is given an existing directory, the file lands inside it +under the export's default filename: + +```python +>>> export.wait().to_file("outputs/") +PosixPath('outputs/export.tif') +``` + +## Export and download an inventory + +The same flow exports an inventory's tree records — `"parquet"` (zipped, +default), `"csv"`, `"geojson"`, or `"geopackage"`, with `columns=` for a +column subset: + +```python +export = inventory.export(format="csv") +path = export.wait().to_file("trees.csv") +``` + +```python +>>> path.read_text().splitlines()[0] +'x,y,fia_species_code,fia_status_code,dbh,height,crown_ratio' +``` + +## Bundle grids for QUIC-Fire + +`create_quicfire_export` packages surface fuel, canopy fuel, and +(optionally) topography grids into a zip archive that QUIC-Fire loads +directly. Each role is a `(grid, band)` pair; the five required roles +produce `treesrhof.dat`, `treesmoist.dat`, and `treesfueldepth.dat`: + +```python +export = ff.exports.create_quicfire_export( + domain, + canopy_bulk_density=(voxels, "bulk_density.foliage.live"), + canopy_moisture=(voxels, "fuel_moisture.live"), + surface_fuel_load=(surface, "fuel_load.1hr"), + surface_fuel_depth=(surface, "fuel_depth"), + surface_moisture=(surface, "fuel_moisture.1hr"), + name="QUIC-Fire bundle", +) +export.wait().to_file("outputs/") +``` + +```python +>>> import zipfile +>>> sorted(zipfile.ZipFile("outputs/QUIC-Fire_bundle.zip").namelist()) +['domain.geojson', 'metadata.json', 'treesfueldepth.dat', 'treesmoist.dat', 'treesrhof.dat'] +``` + +Here `voxels` is a [3D tree fuel grid](inventories.md#voxelize-into-a-3d-fuel-grid) +carrying bulk density and moisture bands, and `surface` is any 2D grid +carrying the surface roles (the example above pairs naturally with a +[uniform grid](creating-grids.md#uniform-grids) or an +[FBFM40 lookup grid](creating-grids.md#surface-fuel-model-grids)). + +Two optional roles extend the bundle: `topography=(grid, "elevation")` +adds `topo.dat`, and the SAVR pair (`canopy_savr=` + `surface_savr=`, +both or neither) adds `treesss.dat`. + +The fire grid — the lattice everything is sliced onto — defaults to the +domain bounding box at 2 m horizontal / 1 m vertical. Change it with +`horizontal_resolution_m=` / `vertical_resolution_m=`, or define it from +an existing grid's lattice with `align_to=`. Every role grid must be +lattice-aligned with the fire grid and cover its full extent — the +exporter crops oversized grids by integer slicing but never resamples, so +build the roles on the fire grid's lattice (see +[Align grids to each other](creating-grids.md#align-grids-to-each-other)). + +## Work with an export you hold + +An `Export` is a job resource like any other: `wait(timeout=, verbose=)` +polls it to a terminal status (raising `JobFailedError` on failure), +`refresh()` reloads it in place, `update(name=, description=, tags=)` +edits its metadata, and `delete()` removes it along with the packaged +file. + +The signed download URL fills in on completion and expires after +`expiration_days` (max 7, the default): + +```python +>>> export.signed_url[:50] +'https://storage.googleapis.com/silvx-fastfuels-exp' +>>> export.expires_on +datetime.datetime(2026, 6, 17, 15, 5, 46, 885537, tzinfo=datetime.timezone.utc) +``` + +Downloading before the job completes raises a `ValueError`: + +```python +>>> pending_export.to_file("outputs/") +ValueError: Cannot download an export with status 'pending'. Call .wait() until it completes first. +``` + +Exports are addressed by their ID alone — no domain in the path: + +```python +export = ff.get_export("4a56bae0cd5e481aa1617cb894a9a7f3") +``` + +## List exports + +To list your exports, optionally narrowed to a domain, a source name +(the format, or `"quicfire"` for bundles), or a tag: + +```python +exports = ff.list_exports(domain) +bundles = ff.list_exports(source="quicfire") +tagged = ff.list_exports(tag="run-42") +``` + +## Error handling + +Wrapper functions and methods raise typed exceptions from +`fastfuels_sdk.v2.exceptions`: + +```python +import fastfuels_sdk.v2 as ff +from fastfuels_sdk.v2.exceptions import NotFoundException + +try: + export = ff.get_export("does-not-exist") +except NotFoundException: + print("No such export (or you don't have access to it)") +``` + +## Next steps + +- Build the grids that feed an export — see + [Creating grids](creating-grids.md) and + [Inventories](inventories.md) +- Full signatures — see the [Reference](../reference.md) diff --git a/docs/v2/guides/inventories.md b/docs/v2/guides/inventories.md index e2e28bf..8d3dd22 100644 --- a/docs/v2/guides/inventories.md +++ b/docs/v2/guides/inventories.md @@ -277,20 +277,18 @@ voxelizing. ## Export an inventory -To export the tree records to a downloadable file, call `export` with a -format — `"parquet"` (zipped, default), `"csv"`, `"geojson"`, or -`"geopackage"`: +To export the tree records to a downloadable file and save it, call +`export` with a format — `"parquet"` (zipped, default), `"csv"`, +`"geojson"`, or `"geopackage"` — and chain the export job's `wait` into +`to_file`: ```python ->>> export = inventory.export(format="csv") ->>> export.status -pending +export = inventory.export(format="csv") +export.wait().to_file("trees.csv") ``` -`export` returns an `Export` resource that processes as a background job; -its `signed_url` is populated when it completes. Polling and downloading -exports from the SDK arrives with the exports module, which is still in -development. +Pass `columns=` to export a column subset. For managing exports, see the +[Exports guide](exports.md). ## Retrieve an existing inventory diff --git a/docs/v2/guides/migration.md b/docs/v2/guides/migration.md index efca8d0..e2e9109 100644 --- a/docs/v2/guides/migration.md +++ b/docs/v2/guides/migration.md @@ -44,8 +44,8 @@ Available now — see the [Domains guide](domains.md) and the GeoDataFrame's CRS to the API, so projected inputs (e.g. EPSG:5070) are interpreted correctly. The v1 SDK assumed EPSG:4326. - **No `Domain.export()`.** The v2 API has no domain export endpoint; - domain data is exported through grid exports instead (coming with the - v2 grids module). + data is exported through [grid and inventory exports](exports.md) + instead. - **New endpoints.** `Domain.preview` validates and projects a domain without creating it, `Domain.get_lattice` returns the pixel lattice for grid alignment, and `reproject_geojson` is a stateless @@ -236,6 +236,47 @@ Available now — see the [Inventories guide](inventories.md) and the becomes `inventory.voxelize(horizontal_resolution_m=..., vertical_resolution_m=...)`, returning a 3D `Grid`. +## Exports + +Available now — see the [Exports guide](exports.md) and the +[Reference](../reference.md). + +### What changed from v1 + +- **Exports hang off grids and inventories.** v1's `domain.export()` and + per-resource `create_export`/`get_export` pairs become + `grid.export(format=...)` and `inventory.export(format=...)`, each + returning a job-based `Export`; chain `export.wait().to_file(path)` to + download. The lifecycle renames match the other resources + (`wait_until_completed` → `wait`). +- **`export_roi` becomes explicit creation + the QUIC-Fire bundle.** The + v1 convenience built every resource from an ROI and exported in one + call. In v2 you create the domain, grids, and inventory explicitly + (see the other guides), then bundle them with + `ff.exports.create_quicfire_export(domain, ...)` — naming the exact + grid and band filling each QUIC-Fire role (`canopy_bulk_density`, + `surface_fuel_load`, ...). The API packages the `.dat` archive + server-side, replacing v1's client-side zarr-to-QUIC-Fire conversion. +- **Exports are cross-domain resources.** `ff.get_export(export_id)` and + `ff.list_exports(...)` address exports by ID alone, with domain, + source, and tag filters. + +### Before and after + +=== "v1" + + ```python + export = tree_inventory.create_export("csv") + export = export.wait_until_completed() + export.to_file("trees.csv") + ``` + +=== "v2" + + ```python + inventory.export(format="csv").wait().to_file("trees.csv") + ``` + ### Before and after === "v1" diff --git a/docs/v2/guides/working-with-grids.md b/docs/v2/guides/working-with-grids.md index 2a0caf3..113e995 100644 --- a/docs/v2/guides/working-with-grids.md +++ b/docs/v2/guides/working-with-grids.md @@ -135,28 +135,18 @@ The source grid must be `completed`. ## Export a grid -To start an export of a completed grid to a downloadable file format: +To export a completed grid to a downloadable file and save it, chain the +export job's `wait` into `to_file`: ```python export = grid.export(format="geotiff") # or "netcdf", "zarr" +export.wait().to_file("elevation.tif") ``` -The export runs as its own background job; `export` comes back pending, with -a signed download URL that fills in once it completes and stays valid for -seven days: - -```python ->>> print(export.id, export.status) -75d6403086484d519d0968e778d17ccf pending ->>> export.signed_url # None until the export job finishes ->>> export.expires_on -'2026-06-16T21:31:56.325358' -``` - -!!! note "Export polling and download in development" - `grid.export` returns the raw export record today. Convenience for - waiting on the export and saving the file lands with the v2 exports - module, which is still in development. +The export runs as its own background job; the signed download URL fills +in once it completes and stays valid for seven days. For exporting band +subsets, the multi-grid QUIC-Fire bundle, and managing exports, see the +[Exports guide](exports.md). ## List grids diff --git a/docs/v2/reference.md b/docs/v2/reference.md index 0e414ca..42fa80d 100644 --- a/docs/v2/reference.md +++ b/docs/v2/reference.md @@ -6,6 +6,8 @@ ::: fastfuels_sdk.v2.inventories +::: fastfuels_sdk.v2.exports + ::: fastfuels_sdk.v2.modifications ::: fastfuels_sdk.v2._jobs From 10524a8f7e65c0340e18dad30ad8b173b403fbbd Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Wed, 10 Jun 2026 11:05:55 -0600 Subject: [PATCH 30/71] Ship v2 in the wheel and split CI by API version Promote httpx/attrs/python-dateutil from the dev group to runtime dependencies and replace the whole-tree fastfuels_sdk/v2 build exclude with fine-grained excludes, so the v2 package ships in the wheel as a beta preview. Drop the httpx<0.29.0 upper cap (inherited from the client generator) to avoid resolution conflicts for installers. Split the test workflow into test-v1 and test-v2 jobs. v1 and v2 are separate deployments with separate keys, so each job authenticates its own deployment via a per-job secret mapped to the shared FASTFUELS_API_KEY variable. test-v2 is intended as a non-required check while v2 is beta. api.py records the settled decision that both versions read FASTFUELS_API_KEY. Part of #176. --- .github/workflows/tests_main.yml | 42 +++++++++++++++++++++++++++----- fastfuels_sdk/v2/api.py | 5 ++-- pyproject.toml | 16 ++++++------ uv.lock | 8 +++++- 4 files changed, 55 insertions(+), 16 deletions(-) diff --git a/.github/workflows/tests_main.yml b/.github/workflows/tests_main.yml index 4993cb3..18932e6 100644 --- a/.github/workflows/tests_main.yml +++ b/.github/workflows/tests_main.yml @@ -4,17 +4,47 @@ on: pull_request: branches: [ main ] -env: - FASTFUELS_API_KEY: ${{ secrets.FASTFUELS_API_KEY }} - +# v1 and v2 are separate live deployments with separate API keys, so each +# suite runs as its own job with its own secret mapped to FASTFUELS_API_KEY +# (the variable both SDKs read). While v2 is a beta preview, make only +# `test-v1` a required status check in branch protection — `test-v2` runs +# and reports on every PR but is intentionally NOT required, so v2-API +# flakiness does not block v1-only changes. jobs: - test: + test-v1: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + python-version: ["3.12"] + env: + FASTFUELS_API_KEY: ${{ secrets.FASTFUELS_API_KEY }} + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + python-version: ${{ matrix.python-version }} + enable-cache: true + - name: Install dependencies + run: uv sync + - name: Run v1 tests + env: + TEST_ENV: local + run: | + cd tests/ + uv run pytest v1 + + test-v2: runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [ubuntu-latest] python-version: ["3.12"] + env: + FASTFUELS_API_KEY: ${{ secrets.FASTFUELS_API_KEY_V2 }} steps: - uses: actions/checkout@v4 - name: Install uv @@ -24,9 +54,9 @@ jobs: enable-cache: true - name: Install dependencies run: uv sync - - name: Run pytest + - name: Run v2 tests env: TEST_ENV: local run: | cd tests/ - uv run pytest + uv run pytest v2 diff --git a/fastfuels_sdk/v2/api.py b/fastfuels_sdk/v2/api.py index 73ab375..1cbaedc 100644 --- a/fastfuels_sdk/v2/api.py +++ b/fastfuels_sdk/v2/api.py @@ -52,8 +52,9 @@ def get_client() -> Optional[AuthenticatedClient]: if _client is not None: return _client - # Whether v2 keeps sharing FASTFUELS_API_KEY with the v1 deployment or - # grows its own variable is an open decision tracked in #176. + # v1 and v2 are separate deployments with separate keys, but both read + # FASTFUELS_API_KEY: running both versions in one process with different + # keys is not a supported use case, so a single variable suffices. api_key = os.getenv("FASTFUELS_API_KEY") if not api_key: return None diff --git a/pyproject.toml b/pyproject.toml index 5f9f2f8..d9bcf2f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,10 +21,13 @@ classifiers = [ "Topic :: Scientific/Engineering", ] dependencies = [ + "attrs>=22.2.0", "geopandas", + "httpx>=0.23.0", "numpy", "pandas", "pydantic>=2", + "python-dateutil>=2.9.0.post0", "requests", "scipy", "urllib3>=2.1.0", @@ -40,7 +43,7 @@ dev = [ "pytest", "pre-commit", "ipykernel", - "httpx>=0.23.0,<0.29.0", + "httpx>=0.23.0", "attrs>=22.2.0", "python-dateutil>=2.9.0.post0", ] @@ -61,12 +64,11 @@ source = "vcs" [tool.hatch.build] exclude = [ - # v2 is under development and not yet distributable: its runtime deps - # (httpx, attrs, python-dateutil) live in the dev group until the v2 - # preview ships. The Phase 2 release swaps this for fine-grained - # excludes (v2/*.sh, v2/COMPARISON.md, v2/draft_*.py), mirroring v1's. - "fastfuels_sdk/v2", - # Client regeneration scaffolding — repo-only, not for distribution + # Client regeneration scaffolding and design notes — repo-only, not for + # distribution. v2 ships as a beta preview; only its non-runtime files + # are excluded (mirroring v1's client_library excludes below). + "fastfuels_sdk/v2/*.sh", + "fastfuels_sdk/v2/*.md", "fastfuels_sdk/v1/client_library/*.sh", "fastfuels_sdk/v1/client_library/README.md", "fastfuels_sdk/v1/client_library/api_spec.json", diff --git a/uv.lock b/uv.lock index f448fad..1c11b69 100644 --- a/uv.lock +++ b/uv.lock @@ -336,10 +336,13 @@ wheels = [ name = "fastfuels-sdk" source = { editable = "." } dependencies = [ + { name = "attrs" }, { name = "geopandas" }, + { name = "httpx" }, { name = "numpy" }, { name = "pandas" }, { name = "pydantic" }, + { name = "python-dateutil" }, { name = "requests" }, { name = "scipy" }, { name = "urllib3" }, @@ -366,10 +369,13 @@ docs = [ [package.metadata] requires-dist = [ + { name = "attrs", specifier = ">=22.2.0" }, { name = "geopandas" }, + { name = "httpx", specifier = ">=0.23.0" }, { name = "numpy" }, { name = "pandas" }, { name = "pydantic", specifier = ">=2" }, + { name = "python-dateutil", specifier = ">=2.9.0.post0" }, { name = "requests" }, { name = "scipy" }, { name = "urllib3", specifier = ">=2.1.0" }, @@ -379,7 +385,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "attrs", specifier = ">=22.2.0" }, - { name = "httpx", specifier = ">=0.23.0,<0.29.0" }, + { name = "httpx", specifier = ">=0.23.0" }, { name = "ipykernel" }, { name = "pre-commit" }, { name = "pytest" }, From c6a1e3350bb95843c2a30ef765d4c38697feb8ca Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Wed, 10 Jun 2026 11:06:10 -0600 Subject: [PATCH 31/71] Document the v1/v2 API key model in the migration guide v1 and v2 are separate deployments that issue different keys, but both subpackages read the single FASTFUELS_API_KEY variable. Note that a v1 key will not authenticate against v2, and that using both versions in one process with different keys is not supported. Reword the intro so it no longer implies simultaneous side-by-side use. Part of #176. --- docs/v2/guides/migration.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/v2/guides/migration.md b/docs/v2/guides/migration.md index e2e9109..068ff4a 100644 --- a/docs/v2/guides/migration.md +++ b/docs/v2/guides/migration.md @@ -3,8 +3,8 @@ The FastFuels v1 and v2 APIs are separate live services. Resources created in v1 are not visible to v2 (and vice versa) — migrating a workflow means creating new resources through v2. The SDK ships both interfaces as -versioned subpackages, so you can run them side by side and migrate one -workflow at a time: +versioned subpackages, so you can keep v1 working while you migrate to v2 +one workflow at a time: ```python from fastfuels_sdk import Domain # v1 (the default today) @@ -13,6 +13,15 @@ from fastfuels_sdk.v2 import Domain # v2 ``` Both subpackages read the same `FASTFUELS_API_KEY` environment variable. +v1 and v2 are separate deployments that issue **different keys**, though, so +a v1 key will not authenticate against v2 — set `FASTFUELS_API_KEY` to the +key for the version you are calling. + +!!! warning "One key per process" + Because both subpackages read the single `FASTFUELS_API_KEY` variable, + using v1 and v2 at the same time *in one process* with different keys is + not supported. Migrate one workflow at a time, pointing + `FASTFUELS_API_KEY` at the appropriate key for each run. ## At a glance From 51b7756dfa587a254a5e91068b63bbb80db9de94 Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Wed, 10 Jun 2026 16:03:00 -0600 Subject: [PATCH 32/71] Add Grid.to_numpy and Grid.to_xarray data-out methods (#183) Reconstruct grid data client-side from the binary chunk endpoint: to_numpy(band) returns one band as a NumPy array, to_xarray() returns all bands as an xarray.Dataset with coordinates from the georeference. Dense for 2D rasters, sparse for 3D voxel grids, with a 413 fallback to sparse. Bypasses the generated get_grid_data_binary parser, which JSON-decodes the octet-stream body. xarray becomes a runtime dependency (the base install already ships geopandas/scipy/zarr); rasterio is added to the dev group for the export ground-truth test. --- fastfuels_sdk/v2/grids.py | 215 +++++++++++++++++++++++++++++++++++++- pyproject.toml | 3 + uv.lock | 159 ++++++++++++++++++++++++++++ 3 files changed, 375 insertions(+), 2 deletions(-) diff --git a/fastfuels_sdk/v2/grids.py b/fastfuels_sdk/v2/grids.py index 8137cfb..35cb6e1 100644 --- a/fastfuels_sdk/v2/grids.py +++ b/fastfuels_sdk/v2/grids.py @@ -10,7 +10,7 @@ # Internal imports from fastfuels_sdk.v2._jobs import wait as _wait from fastfuels_sdk.v2.api import ensure_client -from fastfuels_sdk.v2.exceptions import expect +from fastfuels_sdk.v2.exceptions import expect, raise_for_response from fastfuels_sdk.v2.client_library.api.grids import ( check_3dep_coverage as check_3dep_coverage_endpoint, create_3dep_topography, @@ -29,6 +29,7 @@ create_uniform_grid as create_uniform_grid_endpoint, delete_grid, get_grid as get_grid_endpoint, + get_grid_data_binary, list_grids as list_grids_endpoint, list_grids_cross_domain, update_grid, @@ -53,6 +54,8 @@ GridAlignmentDomainTarget, GridAlignmentGridTarget, GridAlignmentNativeTarget, + GridDataArrayFormat, + GridDataOrder, GridExportFormat, GridSortField, JobStatus, @@ -74,10 +77,11 @@ UniformBandInput, UpdateGridRequestBody, ) -from fastfuels_sdk.v2.client_library.types import UNSET +from fastfuels_sdk.v2.client_library.types import UNSET, Response # External imports import attrs +import numpy as np import requests __all__ = [ @@ -169,6 +173,51 @@ def _put_upload(spec, path: str) -> None: response.raise_for_status() +def _fill_for(dtype, nodata=None): + """Pick the fill value for cells a chunk does not cover. + + Uses the band's ``nodata`` when defined, else NaN for floating dtypes and + 0 for integers (NaN is not representable in an integer array). + """ + if nodata is not None and nodata is not UNSET: + return nodata + return np.nan if np.issubdtype(dtype, np.floating) else 0 + + +def _decode_grid_chunk(content: bytes, headers): + """Decode one binary grid chunk into ``(offset, block)``. + + The chunk endpoint returns ``application/octet-stream`` and describes the + payload entirely in headers (see ``get_grid_data_binary``): ``X-Data-Shape``, + ``X-Data-Offset``, ``X-Data-Order``, ``X-Data-Format`` and the dtype headers. + ``offset`` is where the block lands in the full grid; ``block`` is an + ``np.ndarray`` of the chunk's own shape. + """ + shape = [int(s) for s in headers["X-Data-Shape"].split(",")] + offset = tuple(int(o) for o in headers["X-Data-Offset"].split(",")) + order = headers.get("X-Data-Order", "C") + + if headers["X-Data-Format"] == "dense": + dtype = np.dtype(headers["X-Data-Dtype"]) + block = np.frombuffer(content, dtype=dtype).reshape(shape, order=order) + return offset, block + + # Sparse: the body is the index array bytes immediately followed by the + # value array bytes; split at NNZ * sizeof(index_dtype). + nnz = int(headers["X-Data-NNZ"]) + index_dtype = np.dtype(headers["X-Data-Index-Dtype"]) + value_dtype = np.dtype(headers["X-Data-Value-Dtype"]) + split = nnz * index_dtype.itemsize + indices = np.frombuffer(content[:split], dtype=index_dtype) + values = np.frombuffer(content[split:], dtype=value_dtype) + + raw_fill = headers.get("X-Data-Fill-Value") + fill = _fill_for(value_dtype, float(raw_fill) if raw_fill else None) + flat = np.full(int(np.prod(shape)), fill, dtype=value_dtype) + flat[indices] = values + return offset, flat.reshape(shape, order=order) + + class Grid(GridModel): """Grid resource for the FastFuels v2 API. @@ -527,6 +576,168 @@ def export( ) return Export._from_model(expect(response, HTTPStatus.CREATED)) + def _chunk_count(self) -> int: + """Number of chunks the completed grid's data is split into.""" + count = self.chunks.count if self.chunks is not None else None + if count is UNSET or count is None: + raise ValueError( + "Grid chunk layout is unavailable. Call .refresh() once the " + "job has completed before reading data." + ) + return count + + def _request_chunk(self, band: str, chunk_index: int, array_format: str): + """GET one (band, chunk) as a raw octet-stream ``httpx.Response``. + + Bypasses ``get_grid_data_binary.sync_detailed()``, whose generated + parser JSON-decodes the binary body and raises (tracked in #184). We + reuse its ``_get_kwargs()`` for correct URL/param construction, then + issue the request through the shared client and read the bytes/headers + off the response directly. + """ + kwargs = get_grid_data_binary._get_kwargs( + self.domain_id, + self.id, + band, + chunk_index, + array_format=GridDataArrayFormat(array_format), + order=GridDataOrder.C, + ) + return ensure_client().get_httpx_client().request(**kwargs) + + def _read_chunk(self, band: str, chunk_index: int, array_format: str): + """Fetch and decode one chunk, retrying oversized dense chunks as sparse.""" + response = self._request_chunk(band, chunk_index, array_format) + if ( + response.status_code == HTTPStatus.REQUEST_ENTITY_TOO_LARGE + and array_format == "dense" + ): + # The API returns 413 for an oversized dense chunk and suggests + # the sparse encoding; honor that hint transparently. + response = self._request_chunk(band, chunk_index, "sparse") + if response.status_code != HTTPStatus.OK: + raise_for_response( + Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=None, + ) + ) + return _decode_grid_chunk(response.content, response.headers) + + def to_numpy(self, band: str) -> "np.ndarray": + """Read one band of this grid into an in-memory NumPy array. + + Fetches every chunk of ``band`` and reassembles them into a single + array shaped like the full grid: 2D ``(y, x)`` for rasters, 3D + ``(z, y, x)`` for voxel grids. Chunks are requested in the dense + encoding for 2D grids and the sparse encoding for 3D grids, matching + how the data is stored. + + Parameters + ---------- + band : str + The band key to read (see :attr:`bands` for available keys). + + Returns + ------- + numpy.ndarray + The band's values. Cells with no data carry the band's ``nodata`` + value, or NaN (floating dtypes) / 0 (integer dtypes) when the band + defines none. + + Raises + ------ + ValueError + If the grid is not completed, or ``band`` is not one of its bands. + + Examples + -------- + >>> grid = ff.get_grid(domain, "def456").wait() + >>> elevation = grid.to_numpy("elevation") + >>> elevation.shape + (1200, 1600) + """ + self._require_completed("read data from") + band_keys = [b.key for b in self.bands] + if band not in band_keys: + raise ValueError( + f"Grid has no band {band!r}. Available bands: {band_keys}." + ) + nodata = next(b.nodata for b in self.bands if b.key == band) + # 3D grids are stored sparsely; 2D rasters densely (413 falls back). + array_format = "sparse" if len(self.georeference.shape) == 3 else "dense" + + full = None + for chunk_index in range(self._chunk_count()): + offset, block = self._read_chunk(band, chunk_index, array_format) + if full is None: + full = np.full( + tuple(self.georeference.shape), + _fill_for(block.dtype, nodata), + dtype=block.dtype, + ) + slices = tuple(slice(o, o + s) for o, s in zip(offset, block.shape)) + full[slices] = block + return full + + def to_xarray(self): + """Read this grid's bands into an in-memory :class:`xarray.Dataset`. + + Each band becomes a data variable over ``(y, x)`` (2D) or + ``(z, y, x)`` (3D), with ``x``/``y`` (and ``z``) coordinate vectors + derived from the grid's affine transform and the CRS recorded on the + dataset's ``crs`` attribute. + + Returns + ------- + xarray.Dataset + All bands of the grid as aligned data variables. + + Raises + ------ + ValueError + If the grid is not completed. + + Examples + -------- + >>> grid = ff.get_grid(domain, "def456").wait() + >>> ds = grid.to_xarray() + >>> ds["elevation"].mean().item() + 2143.7 + """ + import xarray as xr + + self._require_completed("read data from") + is_3d = len(self.georeference.shape) == 3 + dims = ("z", "y", "x") if is_3d else ("y", "x") + data_vars = {b.key: (dims, self.to_numpy(b.key)) for b in self.bands} + return xr.Dataset( + data_vars=data_vars, + coords=self._coords(is_3d), + attrs={"crs": self.georeference.crs}, + ) + + def _coords(self, is_3d: bool) -> dict: + """Build x/y(/z) cell-center coordinate vectors from the georeference. + + Assumes a north-up affine (no rotation), the convention for FastFuels + grids: ``transform`` is the six-element ``(a, b, c, d, e, f)`` mapping + ``x = a*col + c`` and ``y = e*row + f``. + """ + geo = self.georeference + a, _b, c, _d, e, f = geo.transform[:6] + shape = geo.shape + ny, nx = (shape[1], shape[2]) if is_3d else (shape[0], shape[1]) + coords = { + "x": c + a * (np.arange(nx) + 0.5), + "y": f + e * (np.arange(ny) + 0.5), + } + if is_3d: + coords["z"] = geo.z_origin + geo.z_resolution * (np.arange(shape[0]) + 0.5) + return coords + def to_json(self) -> str: """Serialize the complete Grid object to a JSON string. diff --git a/pyproject.toml b/pyproject.toml index d9bcf2f..aec6826 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ dependencies = [ "requests", "scipy", "urllib3>=2.1.0", + "xarray", "zarr", ] @@ -46,6 +47,8 @@ dev = [ "httpx>=0.23.0", "attrs>=22.2.0", "python-dateutil>=2.9.0.post0", + # reads exported GeoTIFFs to ground-truth Grid.to_numpy against the server + "rasterio", ] docs = [ "mike>=2.2.0", diff --git a/uv.lock b/uv.lock index 1c11b69..1a5f94f 100644 --- a/uv.lock +++ b/uv.lock @@ -6,6 +6,15 @@ resolution-markers = [ "python_full_version < '3.12'", ] +[[package]] +name = "affine" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/98/d2f0bb06385069e799fc7d2870d9e078cfa0fa396dc8a2b81227d0da08b9/affine-2.4.0.tar.gz", hash = "sha256:a24d818d6a836c131976d22f8c27b8d3ca32d0af64c1d8d29deb7bafa4da1eea", size = 17132, upload-time = "2023-01-19T23:44:30.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/f7/85273299ab57117850cc0a936c64151171fac4da49bc6fba0dad984a7c5f/affine-2.4.0-py3-none-any.whl", hash = "sha256:8a3df80e2b2378aef598a83c1392efd47967afec4242021a0b06b4c7cbc61a92", size = 15662, upload-time = "2023-01-19T23:44:28.833Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -250,6 +259,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, ] +[[package]] +name = "click-plugins" +version = "1.1.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/a4/34847b59150da33690a36da3681d6bbc2ec14ee9a846bc30a6746e5984e4/click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261", size = 8343, upload-time = "2025-06-25T00:47:37.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/9a/2abecb28ae875e39c8cad711eb1186d8d14eab564705325e77e4e6ab9ae5/click_plugins-1.1.1.2-py2.py3-none-any.whl", hash = "sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6", size = 11051, upload-time = "2025-06-25T00:47:36.731Z" }, +] + +[[package]] +name = "cligj" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/0d/837dbd5d8430fd0f01ed72c4cfb2f548180f4c68c635df84ce87956cff32/cligj-0.7.2.tar.gz", hash = "sha256:a4bc13d623356b373c2c27c53dbd9c68cae5d526270bfa71f6c6fa69669c6b27", size = 9803, upload-time = "2021-05-28T21:23:27.935Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/86/43fa9f15c5b9fb6e82620428827cd3c284aa933431405d1bcf5231ae3d3e/cligj-0.7.2-py3-none-any.whl", hash = "sha256:c1ca117dbce1fe20a5809dc96f01e1c2840f6dcc939b3ddbb1111bf330ba82df", size = 7069, upload-time = "2021-05-28T21:23:26.877Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -346,6 +379,7 @@ dependencies = [ { name = "requests" }, { name = "scipy" }, { name = "urllib3" }, + { name = "xarray" }, { name = "zarr" }, ] @@ -357,6 +391,8 @@ dev = [ { name = "pre-commit" }, { name = "pytest" }, { name = "python-dateutil" }, + { name = "rasterio", version = "1.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "rasterio", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] docs = [ { name = "mike" }, @@ -379,6 +415,7 @@ requires-dist = [ { name = "requests" }, { name = "scipy" }, { name = "urllib3", specifier = ">=2.1.0" }, + { name = "xarray" }, { name = "zarr" }, ] @@ -390,6 +427,7 @@ dev = [ { name = "pre-commit" }, { name = "pytest" }, { name = "python-dateutil", specifier = ">=2.9.0.post0" }, + { name = "rasterio" }, ] docs = [ { name = "mike", specifier = ">=2.2.0" }, @@ -1660,6 +1698,113 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/01/1b/5dbe84eefc86f48473947e2f41711aded97eecef1231f4558f1f02713c12/pyzmq-27.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c9f7f6e13dff2e44a6afeaf2cf54cee5929ad64afaf4d40b50f93c58fc687355", size = 544862, upload-time = "2025-09-08T23:09:56.509Z" }, ] +[[package]] +name = "rasterio" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +dependencies = [ + { name = "affine", marker = "python_full_version < '3.12'" }, + { name = "attrs", marker = "python_full_version < '3.12'" }, + { name = "certifi", marker = "python_full_version < '3.12'" }, + { name = "click", marker = "python_full_version < '3.12'" }, + { name = "click-plugins", marker = "python_full_version < '3.12'" }, + { name = "cligj", marker = "python_full_version < '3.12'" }, + { name = "numpy", marker = "python_full_version < '3.12'" }, + { name = "pyparsing", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ec/fa/fce8dc9f09e5bc6520b6fc1b4ecfa510af9ca06eb42ad7bdff9c9b8989d0/rasterio-1.4.4.tar.gz", hash = "sha256:c95424e2c7f009b8f7df1095d645c52895cd332c0c2e1b4c2e073ea28b930320", size = 445004, upload-time = "2025-12-12T18:01:08.971Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/0d/d3859e49ab94464de2623fec82c6798d8d7c8bea2473cd2696fc5e09f717/rasterio-1.4.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:b8eea428b5f0c78a963f6003a19b60777df83a0aba8c28231d65431e32ac160e", size = 21144125, upload-time = "2025-12-12T17:58:59.511Z" }, + { url = "https://files.pythonhosted.org/packages/aa/3c/97ba4b146309cdc0e36f289b02ac69465b026a21afc828e4e4e1dc39466a/rasterio-1.4.4-cp311-cp311-macosx_15_0_x86_64.whl", hash = "sha256:1cc0ea5aa0d22f5f349aa221674481de689b7b3a99607ce6bb58a29e5be54d17", size = 25746406, upload-time = "2025-12-12T17:59:02.902Z" }, + { url = "https://files.pythonhosted.org/packages/ce/33/75f81bd837ac2336b24456fdb249597a4b9af2a212b7151f64d09022be36/rasterio-1.4.4-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:7eb25b23666b29dadfc49a59206cead62c99190584b61771bba0e95f7da06801", size = 34587242, upload-time = "2025-12-12T17:59:05.848Z" }, + { url = "https://files.pythonhosted.org/packages/f9/77/3869a426f6e752dde13f3868cdf16253ca0214f92107db79c1583c9aa07b/rasterio-1.4.4-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:e24b7b8c2df801dde2a1dffb44c58902bd76b5cab740dc11de4ff9963992a71a", size = 35881871, upload-time = "2025-12-12T17:59:09.779Z" }, + { url = "https://files.pythonhosted.org/packages/66/d0/3818859ddbd3750d0ef5a6580a3272e81764286d943c689dd41e49b8b786/rasterio-1.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:0718630f607be2f5742d8e4b34b434746fd788a192d77eefc9bb924399fea802", size = 25716477, upload-time = "2025-12-12T17:59:13.519Z" }, + { url = "https://files.pythonhosted.org/packages/4b/02/039eb4970c93aaef4c9eb1ee159abad18e6e7f932c2eed575c95f78d94f6/rasterio-1.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:0308ff4762ae9eb40a991f12d758626b59af4376b13675480391dd7295d17bbf", size = 24075993, upload-time = "2025-12-12T17:59:16.407Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fc/63d89ddfcb4643730553683ee322566b9b15fe56d026e4c21c4f4f5d9d26/rasterio-1.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:f3c4f0cbd188f893011f2a0a6dc2852b3892799b3a0d79eddf92f2b115ec7ed7", size = 21120715, upload-time = "2025-12-12T17:59:19.35Z" }, + { url = "https://files.pythonhosted.org/packages/43/70/2c003f76a23dbb078fdee35c8e2ec490d2ad8982f4dc956ba08b56027b87/rasterio-1.4.4-cp312-cp312-macosx_15_0_x86_64.whl", hash = "sha256:6fce26090b9f509eab337228420145947c491a13628965410f25bc3e6e05cf75", size = 25732944, upload-time = "2025-12-12T17:59:22.533Z" }, + { url = "https://files.pythonhosted.org/packages/f6/cc/4a8e92362c0ff496dd1007c3dcba66e9ededf1a45eca8ad1db302b071c49/rasterio-1.4.4-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c1c722da390dc264aeccdc0dc200ca37923875d910ca4cd5bec0fec351bb818e", size = 34295209, upload-time = "2025-12-12T17:59:26.035Z" }, + { url = "https://files.pythonhosted.org/packages/e6/6d/717d2dec47fbefad33ca0d27bd5f0d543b1d1bc9fcab5ef82a13adaaf38d/rasterio-1.4.4-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:98b6dfb8282b2a54b9d75c3dc8d2520a69bbc66916c7d43de8e0bbf6e0240ca1", size = 35661866, upload-time = "2025-12-12T17:59:29.928Z" }, + { url = "https://files.pythonhosted.org/packages/ed/60/ae3351fba2726ec0976974ce2eb030c159edd3363b8771e832b8db571c24/rasterio-1.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:9513f4c7a6d93b45098f8dff2421fa9516604e3bfbf35aa144484a88d36a321f", size = 25682853, upload-time = "2025-12-12T17:59:35.869Z" }, + { url = "https://files.pythonhosted.org/packages/38/ee/35387296bbacfc5cbbb4273228b1b959793d3ce38b0402a07f11a248420b/rasterio-1.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:60b49a482e0f12f12ce9d2cc3090add02f89f3d422e85f2cffaa9207adb83c04", size = 24043249, upload-time = "2025-12-12T17:59:39.915Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fe/e3e37041c49956f4f4cbe473c3fe290aaba96ed20e9c07da304e0cad2015/rasterio-1.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:df26c96aa81ffbd0b33189680859211eadf9950123c21579f84de73bb0f91d81", size = 21107336, upload-time = "2025-12-12T17:59:43.585Z" }, + { url = "https://files.pythonhosted.org/packages/f3/02/c217fdcc8e80a4b7d1b1bc4529d78f98452816e9add53ff8742049a77ae7/rasterio-1.4.4-cp313-cp313-macosx_15_0_x86_64.whl", hash = "sha256:b3af0ecc922a80f3755516629f7948e37bade9077b5f5c12a3869a5e7f01619b", size = 25719929, upload-time = "2025-12-12T17:59:47.64Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d0/7f177f37bc9595d809dabb0073abd0c42358469f6b10875192b46331c652/rasterio-1.4.4-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:7ce3b0f9a22e95a27790087908753973644d7c3877d495ec9bd6e04a25233ca4", size = 34198845, upload-time = "2025-12-12T17:59:52.405Z" }, + { url = "https://files.pythonhosted.org/packages/7b/84/66c0d9cca2a09074ec2ce6fffa87709ca51b0d197ae742d835e841bac660/rasterio-1.4.4-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:c072450caa96428b1218b030500bb908fd6f09bc013a88969ff81a124b6a112a", size = 35576074, upload-time = "2025-12-12T17:59:56.392Z" }, + { url = "https://files.pythonhosted.org/packages/32/68/f7df5478458ace2fa50be43e9fab1a39957a0e71afaa3e6147ec289e0fc8/rasterio-1.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:16ee92ef10c0ba89f45f9c2b40fca9f971f357385f04ee9b716fb09cbd9ce20c", size = 25680573, upload-time = "2025-12-12T18:00:00.45Z" }, + { url = "https://files.pythonhosted.org/packages/34/e5/1bdaccb658430dfd391ad4a63d206546f36639d7e4130bf31f125c6525b4/rasterio-1.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:65c10afe64b5e488185aaff0b659e08eda22c89285b54a3e433b80e6c6621770", size = 24040367, upload-time = "2025-12-12T18:00:04.443Z" }, + { url = "https://files.pythonhosted.org/packages/32/76/54643a7d1d650fd7f1acea9093c298603e4c01bba6f90be2254310b48507/rasterio-1.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:18c2c1130e789dc2771d0aa5ec4b56d5b8a0097c648ccb94882d5ff3ab55c928", size = 21247203, upload-time = "2025-12-12T18:00:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/76/ef/434b4849ccd6a3e03a0b1ac37c963c1771564945745613d15c5d96ce768d/rasterio-1.4.4-cp313-cp313t-macosx_15_0_x86_64.whl", hash = "sha256:2d1654b7ffa6f3dde42c5fd27159ae45148c11e352de26f12fe7313a3236aeed", size = 25822050, upload-time = "2025-12-12T18:00:11.081Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fa/fe9a478aa0cde246da58baeb0df3248c7ca174e4d9c9b27e81b504e40a76/rasterio-1.4.4-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c4022cbddb659856e120603b12233cec8913ae760fff220657ce888c3c6b9f9d", size = 34833783, upload-time = "2025-12-12T18:00:14.525Z" }, + { url = "https://files.pythonhosted.org/packages/04/cd/ed4716590dbcd4b8ae633417d758564e510bee4d6aaac5050a0f6d5179c5/rasterio-1.4.4-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:96b88880551a07b7a3b50439483cefbd9af91a09e19ff2b736815994e5671314", size = 35738114, upload-time = "2025-12-12T18:00:17.96Z" }, + { url = "https://files.pythonhosted.org/packages/7e/29/da7050d11ba1d041e0333ac14768e6e9ca1aa2b9fa8416f317d2650ed276/rasterio-1.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:def75d486d0ab8f306f918a913c425ed57159495518c54efe8e18d5164d37d90", size = 25896835, upload-time = "2025-12-12T18:00:21.411Z" }, + { url = "https://files.pythonhosted.org/packages/88/80/304dbe5434c4aa8dfaf90480c16d770161796a6a61fa88e72e8a402153df/rasterio-1.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:770b7e86f6c565e6f9cf30f6fa4479a5a2bab4e10ff44fe7acfd518ca4a71d1b", size = 24128074, upload-time = "2025-12-12T18:00:24.653Z" }, + { url = "https://files.pythonhosted.org/packages/03/01/d5a3dc51cd5fef62b76ecc77d33c1ca20de305fed7e16c71bcdf4858e466/rasterio-1.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:019693f14a83ae9225cb57c16e466901d0e6284962dcf13a9f4bb1175b979011", size = 21120237, upload-time = "2025-12-12T18:00:27.723Z" }, + { url = "https://files.pythonhosted.org/packages/50/da/db18362602b17327c0e00c9e9c0847c1c4ac657c1a289169ca06a26faccb/rasterio-1.4.4-cp314-cp314-macosx_15_0_x86_64.whl", hash = "sha256:87d7c3e97e3b40c9041d1602e2dcb4fc2d88abe6c645fccb4939dec297a91cf8", size = 25720506, upload-time = "2025-12-12T18:00:30.592Z" }, + { url = "https://files.pythonhosted.org/packages/5a/8f/a15d66c9c05bffb176c9707ef1f2bfcf9c0b835272937c80ac7207a20b5c/rasterio-1.4.4-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:a2401e4c43a31c7382154d4042b60a63b9bca5886802983c5c9362cdc5b09548", size = 34153931, upload-time = "2025-12-12T18:00:33.852Z" }, + { url = "https://files.pythonhosted.org/packages/05/2d/cd778286b910db7a3f0bc1743ca362173f1fbb7365137e4982ca857b6d26/rasterio-1.4.4-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:6c4287d8934d953f7870b8e2a1df1096fbf47eba39ad0f777a31ea500f4e5010", size = 35421139, upload-time = "2025-12-12T18:00:37.482Z" }, + { url = "https://files.pythonhosted.org/packages/70/97/13a2e33aede8d7a42178c696a6a93868d1f9560f73de05033a1675f0806a/rasterio-1.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:c3ba1871549221140661227dd4fa1f9a472ded4a6d2f2c2e367b0648bb15b99d", size = 26419132, upload-time = "2025-12-12T18:00:40.871Z" }, + { url = "https://files.pythonhosted.org/packages/27/d8/2dcfcb362d6a2fd07c14cfb803a345a7926d4d9fb6243e196df105671e97/rasterio-1.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:7c9d7dc824cb8d222808be153643cd4e65ea3e1f66019ada1ccd630221edfe30", size = 24800998, upload-time = "2025-12-12T18:00:45.332Z" }, + { url = "https://files.pythonhosted.org/packages/13/f8/16e9b648e7f16cadb41df7c0116dbab26b4a2ba02c85cbe3f744065bdf56/rasterio-1.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:98e17bded830a59992d9f8f8d9f227ce1c4be0694930afcc4360358f5cb1a5db", size = 21247046, upload-time = "2025-12-12T18:00:49.429Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ea/f3dc3a25d7591821d488f5c5eb89f6abcd1f5c8e2ef4bd2792f965cbc9c8/rasterio-1.4.4-cp314-cp314t-macosx_15_0_x86_64.whl", hash = "sha256:56134ca203f952855e60774b06672033cf65057eb9810fcc5c1a75f1921053a3", size = 25821677, upload-time = "2025-12-12T18:00:52.458Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d3/1e038350218e852f904c8dc4ab751aa023a2e82e68998767b7b42e33832c/rasterio-1.4.4-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:52edde65515b33fe4314c8a44a9ee2fc00b550deed6d56e1a8d085d42bbca3e6", size = 34829572, upload-time = "2025-12-12T18:00:56.294Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ce/28abf7a5f5d9cb014c2e14cc396bebe953b3deefbf604d49f4322e73fa35/rasterio-1.4.4-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d61d3f2c171c64050bd75e54a5d964ff7f165b3f5d2b92c9ee09b9716aa1b8bf", size = 35735171, upload-time = "2025-12-12T18:00:59.531Z" }, + { url = "https://files.pythonhosted.org/packages/54/91/1ce35cfda2d56dacd6395faf20a5290268bd9009c53393ac42b5f9bb2c4c/rasterio-1.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:40137fe512c0d6e96c0167a0ae4e56d82c488f244163c45494b7392e51c844de", size = 26700712, upload-time = "2025-12-12T18:01:03.023Z" }, + { url = "https://files.pythonhosted.org/packages/3b/33/4d13f48a8f01d782ffc1eece20821586518f3f515dca7cf152bca9fd22d4/rasterio-1.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:29ec3a794454b5bb255c9c0374cc380030a8a1e295c81eee7feb036802d2a9e3", size = 24875933, upload-time = "2025-12-12T18:01:06.134Z" }, +] + +[[package]] +name = "rasterio" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +dependencies = [ + { name = "affine", marker = "python_full_version >= '3.12'" }, + { name = "attrs", marker = "python_full_version >= '3.12'" }, + { name = "certifi", marker = "python_full_version >= '3.12'" }, + { name = "click", marker = "python_full_version >= '3.12'" }, + { name = "cligj", marker = "python_full_version >= '3.12'" }, + { name = "numpy", marker = "python_full_version >= '3.12'" }, + { name = "pyparsing", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/88/edb4b66b6cb2c13f123af5a3896bf70c0cbe73ab3cd4243cb4eb0212a0f6/rasterio-1.5.0.tar.gz", hash = "sha256:1e0ea56b02eea4989b36edf8e58a5a3ef40e1b7edcb04def2603accd5ab3ee7b", size = 452184, upload-time = "2026-01-05T16:06:47.169Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/de/ba1cd11d7d1182bfb26e758bf07016d04e5442f4f5fea35b0d7279b72399/rasterio-1.5.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:420656074897a460f5ef46f657b3061d2e004f9d99e613914b0671643e69d92c", size = 22787192, upload-time = "2026-01-05T16:05:19.779Z" }, + { url = "https://files.pythonhosted.org/packages/e6/42/efaeb6dc531dbcd02fec01c791a853bb5a139a5126ecec579ac0f735eeb9/rasterio-1.5.0-cp312-cp312-macosx_15_0_x86_64.whl", hash = "sha256:c5c3597a783857e760550e8f26365d928b0377ac5ffc3e12ba447ac65ca5406d", size = 24412221, upload-time = "2026-01-05T16:05:22.526Z" }, + { url = "https://files.pythonhosted.org/packages/a2/14/89645988424c40cbcb8334f94305ffe094dd28d85c643341d9690704c9f0/rasterio-1.5.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:e14d07a09833b6df6024ce7a57aee1e1977b3aec682e30b1e58ce773462f2382", size = 36128020, upload-time = "2026-01-05T16:05:25.556Z" }, + { url = "https://files.pythonhosted.org/packages/85/23/5a52319a98451ff910f42e5f7f4804bfb39f9327933a89daab685d1ce2dd/rasterio-1.5.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:26dbcffcf0d01fc121cbb92186bc1cb78e16efe62b17be45ad7494446b325cf8", size = 37634010, upload-time = "2026-01-05T16:05:28.673Z" }, + { url = "https://files.pythonhosted.org/packages/57/d6/fe8826f813c98b046d8d4c3bc83053c89c71f367f89257d211fe5dd0b0ba/rasterio-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:ac8d04eee66ca8060763ead607800e5611d857dd005905d920365e24a16ba20a", size = 30142328, upload-time = "2026-01-05T16:05:31.357Z" }, + { url = "https://files.pythonhosted.org/packages/af/62/6397379271d5628ed65ef781bf2d3a8f56094a86e6d8479c6ca506a1b960/rasterio-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:31f1edc45c781ebd087e60cc00a4fc37028dd3fe25cff4098e4139fc9d0565be", size = 28500710, upload-time = "2026-01-05T16:05:33.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/87/42865a77cebf2e524d27b6afc71db48984799ecd1dbe6a213d4713f42f5f/rasterio-1.5.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e7b25b0a19975ccd511e507e6de45b0a2d8fb6802abe49bb726cf48588e34833", size = 22776107, upload-time = "2026-01-05T16:05:36.967Z" }, + { url = "https://files.pythonhosted.org/packages/6a/53/e81683fbbfdf04e019e68b042d9cff8524b0571aa80e4f4d81c373c31a49/rasterio-1.5.0-cp313-cp313-macosx_15_0_x86_64.whl", hash = "sha256:1162c18eaece9f6d2aa1c2ff6b373b99651d93f113f24120a991eaebf28aa4f4", size = 24401477, upload-time = "2026-01-05T16:05:39.702Z" }, + { url = "https://files.pythonhosted.org/packages/bc/3c/6aa6e0690b18eea02a61739cb362a47c5df66138f0a02cc69e1181b964e5/rasterio-1.5.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:8eb87fd6f843eea109f3df9bef83f741b053b716b0465932276e2c0577dfb929", size = 36018214, upload-time = "2026-01-05T16:05:42.741Z" }, + { url = "https://files.pythonhosted.org/packages/48/4a/1af9aa9810fb30668568f2c4dd3eec2412c8e9762b69201d971c509b295e/rasterio-1.5.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:08a7580cbb9b3bd320bdf827e10c9b2424d0df066d8eef6f2feb37e154ce0c17", size = 37544972, upload-time = "2026-01-05T16:05:45.815Z" }, + { url = "https://files.pythonhosted.org/packages/01/62/bfe3408743c9837919ff232474a09ece9eaa88d4ee8c040711fa3dff6dad/rasterio-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:d7d6729c0739b5ec48c33686668a30e27f5bdb361093f180ee7818ff19665547", size = 30140141, upload-time = "2026-01-05T16:05:48.751Z" }, + { url = "https://files.pythonhosted.org/packages/63/ca/e90e19a6d065a718cc3d468a12b9f015289ad17017656dea8c76f7318d1f/rasterio-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:8af7c368c22f0a99d1259ccc5a5cd96c432c2bde6f132c1ac78508cd7445a745", size = 28498556, upload-time = "2026-01-05T16:05:51.334Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ba/e37462d8c33bbbd6c152a0390ec6911a3d9614ded3d2bc6f6a48e147e833/rasterio-1.5.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:b4ccfcc8ed9400e4f14efdf2005533fcf72048748b727f85ff89b9291ecdf98a", size = 22920107, upload-time = "2026-01-05T16:05:53.773Z" }, + { url = "https://files.pythonhosted.org/packages/66/dc/7bfa9cf96ac39b451b2f94dfc584c223ec584c52c148df2e4bab60c3341b/rasterio-1.5.0-cp313-cp313t-macosx_15_0_x86_64.whl", hash = "sha256:2f57c36ca4d3c896f7024226bd71eeb5cd10c8183c2a94508534d78cc05ff9e7", size = 24508993, upload-time = "2026-01-05T16:05:57.062Z" }, + { url = "https://files.pythonhosted.org/packages/e5/55/7293743f3b69de4b726c67b8dc9da01fc194070b6becc51add4ca8a20a27/rasterio-1.5.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:cc1395475e4bb7032cd81dda4d5558061c4c7d5a50b1b5e146bdf9716d0b9353", size = 36565784, upload-time = "2026-01-05T16:06:00.019Z" }, + { url = "https://files.pythonhosted.org/packages/cf/ef/5354c47de16c6e289728c3a3d6961ffcf7a9ad6313aef7e8db5d6a40c46e/rasterio-1.5.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:592a485e2057b1aaeab4f843c9897628e60e3ff45e2509325c3e1479116599cb", size = 37686456, upload-time = "2026-01-05T16:06:02.772Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fc/fe1f034b1acd1900d9fbd616826d001a3d5811f1d0c97c785f88f525853e/rasterio-1.5.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0c739e70a72fb080f039ee1570c5d02b974dde32ded1a3216e1f13fe38ac4844", size = 30355842, upload-time = "2026-01-05T16:06:06.359Z" }, + { url = "https://files.pythonhosted.org/packages/e0/cb/4dee9697891c9c6474b240d00e27688e03ecd882d3c83cc97eb25c2266ff/rasterio-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:a3539a2f401a7b4b2e94ff2db334878c0e15a2d1c9fe90bb0879c52f89367ae5", size = 28589538, upload-time = "2026-01-05T16:06:09.662Z" }, + { url = "https://files.pythonhosted.org/packages/77/9f/f84dfa54110c1c82f9f4fd929465d12519569b6f5d015273aa0957013b2e/rasterio-1.5.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:597be8df418d5ba7b6a927b6b9febfcb42b192882448a8d5b2e2e75a1296631f", size = 22788832, upload-time = "2026-01-05T16:06:12.247Z" }, + { url = "https://files.pythonhosted.org/packages/20/f1/de55255c918b17afd7292f793a3500c4aea7e9530b2b3f5b3a57836c7d49/rasterio-1.5.0-cp314-cp314-macosx_15_0_x86_64.whl", hash = "sha256:dd292030d39d685c0b35eddef233e7f1cb8b43052578a3ec97a2da57799693be", size = 24405917, upload-time = "2026-01-05T16:06:14.603Z" }, + { url = "https://files.pythonhosted.org/packages/a9/57/054087a9d5011ad5dfa799277ba8814e41775e1967d37a59ab7b8e2f1876/rasterio-1.5.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:62c3f97a3c72643c74f2d0f310621a09c35c0c412229c327ae6bcc1ee4b9c3bc", size = 35987536, upload-time = "2026-01-05T16:06:17.707Z" }, + { url = "https://files.pythonhosted.org/packages/c9/72/5fbe5f67ae75d7e89ffb718c500d5fecbaa84f6ba354db306de689faf961/rasterio-1.5.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:19577f0f0c5f1158af47b57f73356961cbd1782a5f6ae6f3adf6f2650f4eb369", size = 37408048, upload-time = "2026-01-05T16:06:20.82Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3e/0c4ef19980204bdcbc8f9e084056adebc97916ff4edcc718750ef34e5bf9/rasterio-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:015c1ab6e5453312c5e29692752e7ad73568fe4d13567cbd448d7893128cbd2d", size = 30949590, upload-time = "2026-01-05T16:06:23.425Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d8/2e6b81505408926c00e629d7d3d73fd0454213201bd9907450e0fe82f3dd/rasterio-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:ff677c0a9d3ba667c067227ef2b76872488b37ff29b061bc3e576fad9baa3286", size = 29337287, upload-time = "2026-01-05T16:06:26.599Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/7b6e6afb28d4e3f69f2229f990ed87dfdc21a3e15ca63b96b2fd9ba17d89/rasterio-1.5.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:508251b9c746d8d008771a30c2160ff321bfc3b41f6a1aa8e8ef1dd4a00d97ba", size = 22926149, upload-time = "2026-01-05T16:06:29.617Z" }, + { url = "https://files.pythonhosted.org/packages/24/30/19345d8bc7d2b96c1172594026b9009702e9ab9f0baf07079d3612aaadae/rasterio-1.5.0-cp314-cp314t-macosx_15_0_x86_64.whl", hash = "sha256:742841ed48bc70f6ef517b8fa3521f231780bf408fde0aa6d73770337a36374e", size = 24516040, upload-time = "2026-01-05T16:06:32.964Z" }, + { url = "https://files.pythonhosted.org/packages/9e/43/dc7a4518fa78904bc41952cbf346c3c2a88a20e61b479154058392914c0b/rasterio-1.5.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c9a9eee49ce9410c2f352b34c370bb3a96bb518b6a7f97b3a72ee4c835fd4b5c", size = 36589519, upload-time = "2026-01-05T16:06:35.922Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f2/8f706083c6c163054d12c7ed6d5ac4e4ed02252b761288d74e6158871b34/rasterio-1.5.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:b9fd87a0b63ab5c6267dfb0bc96f54fdf49d000651b9ee85ed37798141cff046", size = 37714599, upload-time = "2026-01-05T16:06:38.818Z" }, + { url = "https://files.pythonhosted.org/packages/a6/d5/bbca726d5fea5864f7e4bcf3ee893095369e93ad51120495e8c40e2aa1a0/rasterio-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f459db8953ba30ca04fcef2b5e1260eeeff0eae8158bd9c3d6adbe56289765cc", size = 31233931, upload-time = "2026-01-05T16:06:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d1/8b017856e63ccaff3cbd0e82490dbb01363a42f3a462a41b1d8a391e1443/rasterio-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f4b9c2c3b5f10469eb9588f105086e68f0279e62cc9095c4edd245e3f9b88c8a", size = 29418321, upload-time = "2026-01-05T16:06:44.758Z" }, +] + [[package]] name = "requests" version = "2.32.5" @@ -1955,6 +2100,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" }, ] +[[package]] +name = "xarray" +version = "2026.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "packaging" }, + { name = "pandas" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/a6/6fe936a798a3a38a79c7422d1a31afd2e9a14690fcb0ccff96bc01f04bf2/xarray-2026.4.0.tar.gz", hash = "sha256:c4ac9a01a945d90d5b1628e2af045099a9d4943536d4f2ee3ae963c3b222d15b", size = 3132311, upload-time = "2026-04-13T19:45:36.688Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/83/6d810a8a9ebc9c307989b418840c20e46907c74d707beb67ab566773e6fc/xarray-2026.4.0-py3-none-any.whl", hash = "sha256:d43751d9fb4a90f9249c30431684f00c41bc874f1edccd862631a40cbc0edf08", size = 1414326, upload-time = "2026-04-13T19:45:34.659Z" }, +] + [[package]] name = "zarr" version = "3.1.5" From 47597b03c767b4e7087a37676b0d48c461ccd04f Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Wed, 10 Jun 2026 16:03:13 -0600 Subject: [PATCH 33/71] Test grid and inventory data-out against server exports Offline decode unit tests plus live tests for Grid.to_numpy/to_xarray and Inventory.to_dataframe. Ground-truth each reconstruction against an independent server export read by a standard library: grids vs a GeoTIFF read with rasterio (which also validates absolute orientation), inventories vs a CSV read with pandas. The binary chunk decode is also cross-checked against the typed JSON chunk endpoint. --- tests/v2/test_exports.py | 22 ++++ tests/v2/test_grids.py | 188 ++++++++++++++++++++++++++++++++++- tests/v2/test_inventories.py | 37 +++++++ 3 files changed, 246 insertions(+), 1 deletion(-) diff --git a/tests/v2/test_exports.py b/tests/v2/test_exports.py index a34308d..53c6ec7 100644 --- a/tests/v2/test_exports.py +++ b/tests/v2/test_exports.py @@ -20,7 +20,9 @@ from fastfuels_sdk.v2.exceptions import NotFoundException # External imports +import numpy as np import pytest +import rasterio # The test_domain, completed_topography_grid, and completed_tree_inventory # fixtures are session-scoped and shared across modules (tests/v2/conftest.py). @@ -76,6 +78,26 @@ def test_to_file_directory_uses_default_filename(self, completed_export, tmp_pat assert destination.parent == tmp_path assert destination.stat().st_size > 0 + def test_geotiff_matches_grid_to_numpy( + self, completed_export, completed_topography_grid, tmp_path + ): + # Ground-truth check for Grid.to_numpy: the array it reconstructs from + # the binary chunk endpoint must match the same grid rendered to a + # GeoTIFF by the server and read back with rasterio -- an independent + # reader that applies the georeference, validating absolute orientation + # too. Reuses the export fixture, so it adds no export job. + path = completed_export.to_file(tmp_path / "topography.tif") + expected = completed_topography_grid.to_numpy("elevation") + + with rasterio.open(path) as src: + band_number = src.descriptions.index("elevation") + 1 + actual = src.read(band_number).astype(float) + actual[actual == src.nodata] = np.nan + + valid = np.isfinite(actual) & np.isfinite(expected) + assert valid.any() + assert np.allclose(actual[valid], expected[valid], rtol=1e-4, atol=1e-2) + def test_to_file_requires_completed(self, completed_topography_grid): export = completed_topography_grid.export(format="geotiff") if export.status == JobStatus.COMPLETED: diff --git a/tests/v2/test_grids.py b/tests/v2/test_grids.py index 93f33a8..2485560 100644 --- a/tests/v2/test_grids.py +++ b/tests/v2/test_grids.py @@ -13,8 +13,10 @@ from fastfuels_sdk.v2.grids import ( Grid, _build_alignment, + _decode_grid_chunk, _domain_id, _enum_list, + _fill_for, _opt, check_3dep_coverage, create_canopy_fuel_grid_from_landfire, @@ -29,19 +31,24 @@ get_grid, list_grids, ) +from fastfuels_sdk.v2.api import ensure_client +from fastfuels_sdk.v2.client_library.api.grids import get_grid_data_json from fastfuels_sdk.v2.client_library.models import ( GridAlignmentDomainTarget, GridAlignmentGridTarget, GridAlignmentNativeTarget, + GridDataArrayFormat, + GridDataOrder, JobStatus, ResamplingMethod, TopographyBand, ) from fastfuels_sdk.v2.client_library.types import UNSET -from fastfuels_sdk.v2.exceptions import NotFoundException +from fastfuels_sdk.v2.exceptions import NotFoundException, expect from fastfuels_sdk.v2.modifications import mask # External imports +import numpy as np import pytest # The test_domain and completed_topography_grid fixtures are session-scoped @@ -519,3 +526,182 @@ def test_delete(self, test_domain): with pytest.raises(NotFoundException): grid.delete() + + +class TestDecodeGridChunk: + """Offline unit tests for binary chunk decoding (no API required).""" + + def test_dense_c_order(self): + values = np.arange(6, dtype=np.float32) + offset, block = _decode_grid_chunk( + values.tobytes(), + { + "X-Data-Shape": "2,3", + "X-Data-Offset": "0,0", + "X-Data-Order": "C", + "X-Data-Format": "dense", + "X-Data-Dtype": "float32", + }, + ) + assert offset == (0, 0) + assert block.dtype == np.float32 + assert np.array_equal(block, np.arange(6).reshape(2, 3)) + + def test_dense_f_order_with_offset(self): + values = np.arange(6, dtype=np.float32) + offset, block = _decode_grid_chunk( + values.tobytes(), + { + "X-Data-Shape": "2,3", + "X-Data-Offset": "4,8", + "X-Data-Order": "F", + "X-Data-Format": "dense", + "X-Data-Dtype": "float32", + }, + ) + assert offset == (4, 8) + assert np.array_equal(block, np.arange(6).reshape(2, 3, order="F")) + + def test_sparse_with_fill_value(self): + content = ( + np.array([1], dtype=np.int32).tobytes() + + np.array([9.0], dtype=np.float32).tobytes() + ) + _, block = _decode_grid_chunk( + content, + { + "X-Data-Shape": "2,2", + "X-Data-Offset": "0,0", + "X-Data-Order": "C", + "X-Data-Format": "sparse", + "X-Data-NNZ": "1", + "X-Data-Index-Dtype": "int32", + "X-Data-Value-Dtype": "float32", + "X-Data-Fill-Value": "0", + }, + ) + assert np.array_equal(block, np.array([[0, 9], [0, 0]], dtype=np.float32)) + + def test_sparse_without_fill_uses_nan(self): + content = ( + np.array([0, 3], dtype=np.int32).tobytes() + + np.array([5.0, 7.0], dtype=np.float32).tobytes() + ) + _, block = _decode_grid_chunk( + content, + { + "X-Data-Shape": "2,2", + "X-Data-Offset": "0,0", + "X-Data-Order": "C", + "X-Data-Format": "sparse", + "X-Data-NNZ": "2", + "X-Data-Index-Dtype": "int32", + "X-Data-Value-Dtype": "float32", + }, + ) + assert block[0, 0] == 5 and block[1, 1] == 7 + assert np.isnan(block[0, 1]) and np.isnan(block[1, 0]) + + def test_fill_for(self): + assert np.isnan(_fill_for(np.dtype("float32"))) + assert _fill_for(np.dtype("int32")) == 0 + assert _fill_for(np.dtype("float32"), -9999) == -9999 + assert _fill_for(np.dtype("int32"), UNSET) == 0 + + +def _reassemble_band_via_json(grid, band): + """Reassemble one band from the JSON chunk endpoint, independently of + ``Grid.to_numpy``. + + This shares no code with the binary path: it uses the generated client's + fully-typed JSON parser (``get_grid_data_json``) rather than hand-decoding + raw bytes. Comparing the two arrays validates the binary decode end to end + — dtype, byte order, sparse split, reshape order, and chunk placement. + """ + is_3d = len(grid.georeference.shape) == 3 + array_format = GridDataArrayFormat.SPARSE if is_3d else GridDataArrayFormat.DENSE + full = np.full(tuple(grid.georeference.shape), np.nan) + for chunk_index in range(grid.chunks.count): + response = expect( + get_grid_data_json.sync_detailed( + grid.domain_id, + grid.id, + band, + chunk_index, + client=ensure_client(), + array_format=array_format, + order=GridDataOrder.C, + ) + ) + shape = response.shape + order = response.order.value + if response.data.format_ == "dense": + block = np.array(response.data.values, dtype=float).reshape( + shape, order=order + ) + else: + fill = response.data.fill_value + flat = np.full(int(np.prod(shape)), np.nan if fill is None else float(fill)) + flat[np.array(response.data.indices, dtype=int)] = response.data.values + block = flat.reshape(shape, order=order) + slices = tuple(slice(o, o + s) for o, s in zip(response.metadata.offset, shape)) + full[slices] = block + return full + + +class TestDataOut: + """Live tests for reading grid data into memory.""" + + def test_to_numpy_topography(self, completed_topography_grid): + grid = completed_topography_grid + array = grid.to_numpy(grid.bands[0].key) + assert array.shape == tuple(grid.georeference.shape) + assert array.ndim == len(grid.georeference.shape) + assert np.isfinite(array).any() + + def test_to_numpy_matches_json_transport(self, completed_topography_grid): + # The binary reconstruction must agree, value for value, with an + # independent reassembly over the JSON chunk endpoint. + grid = completed_topography_grid + band = grid.bands[0].key + binary = grid.to_numpy(band) + reference = _reassemble_band_via_json(grid, band) + assert binary.shape == reference.shape + assert np.allclose(binary, reference, equal_nan=True) + + def test_topography_values_are_plausible(self, completed_topography_grid): + # Guards against all-zero / constant-fill / garbage decodes that a + # shape-only check would miss: real terrain varies and sits within + # Earth's elevation range (meters). + grid = completed_topography_grid + elevation = grid.to_numpy("elevation") + finite = elevation[np.isfinite(elevation)] + assert finite.size > 0 + assert finite.std() > 0 + assert -500 < finite.min() and finite.max() < 9000 + + def test_to_numpy_pim(self, completed_pim_grid): + # Exercises whichever encoding the PIM grid uses (3D -> sparse). + grid = completed_pim_grid + array = grid.to_numpy(grid.bands[0].key) + assert array.shape == tuple(grid.georeference.shape) + + def test_to_numpy_unknown_band(self, completed_topography_grid): + with pytest.raises(ValueError): + completed_topography_grid.to_numpy("not_a_band") + + def test_to_numpy_requires_completed(self, test_domain): + grid = create_topography_grid_from_3dep(test_domain, output_resolution_m=10) + try: + with pytest.raises(ValueError): + grid.to_numpy("elevation") + finally: + grid.delete() + + def test_to_xarray(self, completed_topography_grid): + grid = completed_topography_grid + dataset = grid.to_xarray() + assert set(dataset.data_vars) == {band.key for band in grid.bands} + assert dataset.sizes["x"] == grid.georeference.shape[-1] + assert dataset.sizes["y"] == grid.georeference.shape[-2] + assert dataset.attrs["crs"] == grid.georeference.crs diff --git a/tests/v2/test_inventories.py b/tests/v2/test_inventories.py index 97fe5b5..2f2f3ad 100644 --- a/tests/v2/test_inventories.py +++ b/tests/v2/test_inventories.py @@ -28,6 +28,7 @@ from fastfuels_sdk.v2.exceptions import NotFoundException # External imports +import numpy as np import pandas as pd import pytest @@ -291,6 +292,42 @@ def test_to_dataframe_column_subset(self, completed_tree_inventory): trees = completed_tree_inventory.to_dataframe(columns=subset) assert list(trees.columns) == subset + @pytest.fixture(scope="class") + def completed_csv_export(self, completed_tree_inventory): + """A completed CSV export of the shared tree inventory.""" + export = completed_tree_inventory.export(format="csv", tags=["test"]) + export.wait() + return export + + def test_to_dataframe_matches_csv_export( + self, completed_tree_inventory, completed_csv_export, tmp_path + ): + # Ground-truth check for to_dataframe: the records it loads from the + # data partitions must match the same inventory the server renders to + # a CSV and we read back with pandas. Row order is not guaranteed and + # the CSV round-trips floats through text, so compare order- and + # precision-independently: equal row count, shared columns, and the + # same multiset of values per column. Reuses the export fixture. + path = completed_csv_export.to_file(tmp_path / "trees.csv") + from_csv = pd.read_csv(path) + from_api = completed_tree_inventory.to_dataframe() + + shared = sorted(set(from_api.columns) & set(from_csv.columns)) + assert {"x", "y", "height"} <= set(shared) + assert len(from_api) == len(from_csv) + + for column in shared: + api_values = from_api[column].to_numpy() + if not np.issubdtype(api_values.dtype, np.number): + continue # presence is already asserted via `shared` + assert np.allclose( + np.sort(api_values.astype(float)), + np.sort(from_csv[column].to_numpy(dtype=float)), + rtol=1e-4, + atol=1e-4, + equal_nan=True, + ) + class TestListInventories: def test_list_in_domain(self, test_domain, completed_tree_inventory): From 2122ca633d6d92b7013048ff33bda3839b673c0e Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Wed, 10 Jun 2026 16:03:14 -0600 Subject: [PATCH 34/71] Document reading grid data into Python Add a 'Read grid data into Python' how-to section covering Grid.to_numpy and Grid.to_xarray, cross-linked with the export section. Example output captured from a live run. --- docs/v2/guides/working-with-grids.md | 49 ++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/docs/v2/guides/working-with-grids.md b/docs/v2/guides/working-with-grids.md index 113e995..4790ffb 100644 --- a/docs/v2/guides/working-with-grids.md +++ b/docs/v2/guides/working-with-grids.md @@ -133,6 +133,49 @@ coarser = grid.resample(output_resolution_m=90, resampling="average") [Align grids to each other](creating-grids.md#align-grids-to-each-other)). The source grid must be `completed`. +## Read grid data into Python + +To pull a single band's values into a NumPy array, pass the band key to +`to_numpy`: + +```python +elevation = topography.to_numpy("elevation") +``` + +```python +>>> elevation.shape # matches grid.georeference.shape +(47, 58) +>>> elevation.dtype +dtype('float32') +``` + +The array is shaped like the grid — `(rows, cols)` for a 2D raster, or +`(z, rows, cols)` for a 3D voxel grid. Cells with no data hold the band's +`nodata` value, or `NaN` when the band defines none. + +To read every band at once into an `xarray.Dataset` — one variable per band, +with `x`/`y` (and `z`) coordinates derived from the grid's affine transform +and the CRS on `.attrs` — call `to_xarray`: + +```python +>>> topography.to_xarray() + Size: 34kB +Dimensions: (y: 47, x: 58) +Coordinates: + * y (y) float64 376B 5.191e+06 5.191e+06 ... 5.189e+06 5.189e+06 + * x (x) float64 464B 7.202e+05 7.202e+05 ... 7.219e+05 7.219e+05 +Data variables: + elevation (y, x) float32 11kB 1.015e+03 1.013e+03 ... 1.02e+03 1.019e+03 + slope (y, x) float32 11kB 7.587 8.345 8.092 7.41 ... 3.42 3.008 3.094 + aspect (y, x) float32 11kB 25.29 28.79 40.35 51.47 ... 126.0 135.2 138.1 +Attributes: + crs: EPSG:32611 +``` + +Both methods download the grid's data and require a `completed` grid. To +write the data to a file on disk instead of loading it into memory, see +[Export a grid](#export-a-grid). + ## Export a grid To export a completed grid to a downloadable file and save it, chain the @@ -144,8 +187,10 @@ export.wait().to_file("elevation.tif") ``` The export runs as its own background job; the signed download URL fills -in once it completes and stays valid for seven days. For exporting band -subsets, the multi-grid QUIC-Fire bundle, and managing exports, see the +in once it completes and stays valid for seven days. To load a grid's +values into memory instead of a file, see +[Read grid data into Python](#read-grid-data-into-python). For exporting +band subsets, the multi-grid QUIC-Fire bundle, and managing exports, see the [Exports guide](exports.md). ## List grids From 1d42850179a2547de9a7296dd019599f80f1057c Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Thu, 11 Jun 2026 10:02:19 -0600 Subject: [PATCH 35/71] Add v2 QUIC-Fire export tutorial Mirror the v1 export_to_quicfire tutorial on the v2 functional surface: domain, OSM features, 3DEP topography, FBFM40-lookup surface fuels, uniform moisture, and a TreeMap tree inventory voxelized into a 3D canopy grid, bundled with create_quicfire_export. All examples and outputs are captured from a live run against the v2 API. Add the Tutorials nav section in SUMMARY.md and a reciprocal link from the exports guide's QUIC-Fire section. --- docs/v2/SUMMARY.md | 2 + docs/v2/guides/exports.md | 5 +- docs/v2/tutorials/export_to_quicfire.md | 338 ++++++++++++++++++++++++ 3 files changed, 344 insertions(+), 1 deletion(-) create mode 100644 docs/v2/tutorials/export_to_quicfire.md diff --git a/docs/v2/SUMMARY.md b/docs/v2/SUMMARY.md index 6678caf..3594668 100644 --- a/docs/v2/SUMMARY.md +++ b/docs/v2/SUMMARY.md @@ -1,5 +1,7 @@ * [Home](index.md) * [Migrating from v1](guides/migration.md) +* Tutorials + * [Export QUIC-Fire inputs](tutorials/export_to_quicfire.md) * How-To Guides * [Domains](guides/domains.md) * [Features](guides/features.md) diff --git a/docs/v2/guides/exports.md b/docs/v2/guides/exports.md index f7825e1..e698b46 100644 --- a/docs/v2/guides/exports.md +++ b/docs/v2/guides/exports.md @@ -94,7 +94,10 @@ Here `voxels` is a [3D tree fuel grid](inventories.md#voxelize-into-a-3d-fuel-gr carrying bulk density and moisture bands, and `surface` is any 2D grid carrying the surface roles (the example above pairs naturally with a [uniform grid](creating-grids.md#uniform-grids) or an -[FBFM40 lookup grid](creating-grids.md#surface-fuel-model-grids)). +[FBFM40 lookup grid](creating-grids.md#surface-fuel-model-grids)). For an +end-to-end walkthrough that builds all of these grids from real data sources +and bundles them, follow the +[Export QUIC-Fire inputs tutorial](../tutorials/export_to_quicfire.md). Two optional roles extend the bundle: `topography=(grid, "elevation")` adds `topo.dat`, and the SAVR pair (`canopy_savr=` + `surface_savr=`, diff --git a/docs/v2/tutorials/export_to_quicfire.md b/docs/v2/tutorials/export_to_quicfire.md new file mode 100644 index 0000000..0a0079a --- /dev/null +++ b/docs/v2/tutorials/export_to_quicfire.md @@ -0,0 +1,338 @@ +# Tutorial: Export QUIC-Fire Inputs with the v2 SDK + +!!! warning "Beta" + The v2 SDK targets the FastFuels v2 API and is under active + development. The v1 SDK remains the default — import v2 explicitly + from `fastfuels_sdk.v2`. + +In this tutorial we'll build a complete set of QUIC-Fire fuel inputs for a +small region in the Blue Mountain Recreation Area and package them into a +QUIC-Fire-loadable archive. Along the way we'll touch +every kind of v2 resource — a domain, OpenStreetMap features, raster grids, a +tree inventory, and a 3D voxel grid — and assemble them with a single +QUIC-Fire export. + +We'll follow the v2 SDK's functional style throughout: we **create** a +resource by calling a `create_…` function on a domain, and everything we do +with a resource we already hold (waiting, looking up values, voxelizing, +exporting) is a **method** on it. Because every creation runs as a background +job, we'll create work in batches and join each batch with `ff.wait_all`, +letting the server do the jobs in parallel. + +## What we'll build + +A QUIC-Fire export reads five required fuel fields — plus optional +topography, which we'll include. We'll build each from a real data source: + +| Field | Source we'll use | v2 resource | +| --- | --- | --- | +| Canopy bulk density | TreeMap → tree inventory → voxels | 3D voxel grid | +| Canopy fuel moisture | voxelization moisture model | 3D voxel grid | +| Surface fuel load | LANDFIRE FBFM40 lookup | 2D grid | +| Surface fuel depth | LANDFIRE FBFM40 lookup | 2D grid | +| Surface fuel moisture | a uniform value | 2D grid | +| Topography (elevation) | USGS 3DEP | 2D grid | + +The exporter slices these into the QUIC-Fire `.dat` arrays we'll inspect in +[Step 9](#step-9-inspect-the-export). + +## Prerequisites + +- The FastFuels SDK installed: `pip install fastfuels-sdk` +- A FastFuels API key in the `FASTFUELS_API_KEY` environment variable +- Basic familiarity with Python and GeoPandas + +For background on what each of these resources *is*, see the +[FastFuels documentation](https://docs.fastfuels.silvxlabs.com). Coming from +the v1 SDK? The [migration guide](../guides/migration.md) maps the v1 builder +classes to the v2 functions used here. + +## Step 1: Authenticate + +The v2 SDK reads your API key from the `FASTFUELS_API_KEY` environment +variable: + +```bash +export FASTFUELS_API_KEY="your-api-key" +``` + +Then import the package under the conventional `ff` alias — everything in this +tutorial hangs off it: + +```python +import fastfuels_sdk.v2 as ff +``` + +If you'd rather set the key in code, call `ff.set_api_key("your-api-key")` +before anything else. + +## Step 2: Define a region of interest + +We'll describe our area as a GeoDataFrame holding a single polygon in WGS 84 +(EPSG:4326): + +```python +import geopandas as gpd +from shapely.geometry import Polygon + +coordinates = [ + [-114.09957018646286, 46.82933208815811], + [-114.10141707482919, 46.828370407248826], + [-114.10010954324228, 46.82690548814563], + [-114.09560673134018, 46.8271123684554], + [-114.09592544216444, 46.829058122675065], + [-114.09957018646286, 46.82933208815811], +] + +roi = gpd.GeoDataFrame(geometry=[Polygon(coordinates)], crs="EPSG:4326") +``` + +## Step 3: Create a domain + +A domain is the spatial container every other resource lives in. We'll create +one from the GeoDataFrame, padding its bounding box out to a whole number of +2 m cells so the grids we build later tile it exactly: + +```python +domain = ff.Domain.from_geodataframe( + geodataframe=roi, + name="Blue Mountain QUIC-Fire", + description="Tutorial region in the Blue Mountain Recreation Area", + pad_to_resolution=2.0, +) +``` + +```python +>>> domain.id +'0d77a5b1525b497c829fceeadba5f958' +``` + +The domain reprojects our lat/lon polygon into a local metric CRS. We can see +the 2 m pixel lattice every grid will share: + +```python +>>> lattice = domain.get_lattice(resolution=2.0) +>>> lattice.crs +'EPSG:32611' +>>> lattice.shape # [rows, cols] +[136, 225] +``` + +## Step 4: Add roads and water from OpenStreetMap + +Roads and open water aren't fuel — we'll pull them from OpenStreetMap now so +we can carve them out of the fuels later. Both extractions are background +jobs, so we create them together and join with `ff.wait_all`: + +```python +roads = ff.features.create_road_feature_from_osm(domain, name="Roads") +water = ff.features.create_water_feature_from_osm(domain, name="Water") + +ff.wait_all([roads, water], verbose=True) +``` + +```text +Feature 07e6407aedd44d7e8a09fbc5e48c3f3e: pending (5s) +Feature 07e6407aedd44d7e8a09fbc5e48c3f3e: pending (15s) +Feature 07e6407aedd44d7e8a09fbc5e48c3f3e: completed (20s) +``` + +(Only one feature prints here — the other finished before `wait_all` +reached it. Small regions extract fast.) + +!!! note "No separate feature grid in v2" + In the v1 SDK you created a standalone *feature grid* to mask trees and + fuels. In v2 there's no such resource: you mask a feature directly into + each grid by passing `ff.mask(feature, …)` in that creator's + `modifications=` list, as we do for the surface fuels in + [Step 6](#step-6-build-the-surface-fuel-grids). See + [Mask out features](../guides/creating-grids.md#mask-out-features) for the + full masking model. + +## Step 5: Build the topography grid + +Elevation comes from the USGS 3D Elevation Program (3DEP). We resample its +10 m source onto our 2 m domain lattice: + +```python +topography = ff.grids.create_topography_grid_from_3dep( + domain, + source_resolution_m=10, + output_resolution_m=2, + bands=["elevation"], +) +``` + +We won't wait on it yet — it's a pending job we'll join with the other raster +grids in the next step. + +## Step 6: Build the surface fuel grids + +QUIC-Fire's surface needs fuel load, fuel depth, and fuel moisture. Load and +depth come from LANDFIRE's 40 Scott & Burgan fire behavior fuel models +(FBFM40). First we build the FBFM40 grid, masking the road network to a +non-burnable code (FBFM 91) as we go — this is the v2 replacement for v1's +`feature_masks`: + +```python +fbfm = ff.grids.create_fuel_model_grid_from_landfire_fbfm40( + domain, + output_resolution_m=2, + modifications=[ff.mask(roads, "fbfm", 91, buffer_m=5)], +) +``` + +The FBFM40 grid holds categorical fuel-model *codes*. We also start the +PIM grid we'll need for trees in [Step 7](#step-7-build-the-canopy-fuel-grid), +then join all three pending raster jobs: + +```python +pim = ff.grids.create_pim_grid_from_treemap( + domain, output_resolution_m=2, resampling="nearest" +) + +ff.wait_all([topography, fbfm, pim], verbose=True) +``` + +With the FBFM40 codes in hand, we look up the actual fuel quantities QUIC-Fire +consumes — 1-hour fuel load and fuel-bed depth — which produces a new grid: + +```python +surface = fbfm.lookup_fuel_model_values(bands=["fuel_load.1hr", "fuel_depth"]) +``` + +```python +>>> [(b.key, b.unit) for b in surface.bands] +[('fuel_load.1hr', 'kg/m**2'), ('fuel_depth', 'm')] +``` + +LANDFIRE doesn't carry fuel moisture (it's a weather-driven scenario input), +so we set a uniform 15% surface moisture on a matching 2 m grid: + +```python +moisture = ff.grids.create_uniform_grid( + domain, resolution_m=2.0, bands={"fuel_moisture.1hr": 15.0} +) +``` + +## Step 7: Build the canopy fuel grid + +Canopy fuel takes three moves: match each cell to a TreeMap forest plot (the +PIM grid we already started), expand those plots into individual trees, then +voxelize the trees into a 3D bulk-density grid. We expand the trees from the +completed PIM grid, using a fixed `seed` so the result is reproducible: + +```python +inventory = ff.inventories.create_tree_inventory_from_pim_grid( + domain, pim, seed=42 +) + +ff.wait_all([surface, moisture, inventory], verbose=True) +``` + +```python +>>> len(inventory.to_dataframe()) +2068 +``` + +Now we voxelize the inventory onto the same 2 m horizontal lattice, with 1 m +vertical layers, asking for both the live foliage bulk density and the live +fuel moisture QUIC-Fire needs: + +```python +voxels = inventory.voxelize( + horizontal_resolution_m=2.0, + vertical_resolution_m=1.0, + bands=["bulk_density.foliage.live", "fuel_moisture.live"], +) +voxels.wait(verbose=True) +``` + +```python +>>> [b.key for b in voxels.bands] +['bulk_density.foliage.live', 'fuel_moisture.live'] +>>> voxels.georeference.shape # [layers, rows, cols] +[27, 136, 225] +``` + +## Step 8: Export to QUIC-Fire + +Everything is now on the same 2 m lattice. `create_quicfire_export` bundles +the fields into a QUIC-Fire archive — each role is a `(grid, band)` pair +naming the grid and the band to read from it: + +```python +export = ff.exports.create_quicfire_export( + domain, + canopy_bulk_density=(voxels, "bulk_density.foliage.live"), + canopy_moisture=(voxels, "fuel_moisture.live"), + surface_fuel_load=(surface, "fuel_load.1hr"), + surface_fuel_depth=(surface, "fuel_depth"), + surface_moisture=(moisture, "fuel_moisture.1hr"), + topography=(topography, "elevation"), + name="Blue Mountain QUIC-Fire", +) + +export.wait(verbose=True) +``` + +```text +Export 67af943ef13e49a1b71c97c90c3fa4d9: pending (5s) +Export 67af943ef13e49a1b71c97c90c3fa4d9: running (20s) +Export 67af943ef13e49a1b71c97c90c3fa4d9: completed (25s) +``` + +The export is its own background job; once it completes, a signed download URL +is filled in. We stream the archive to a local directory: + +```python +path = export.to_file("quicfire_export/") +``` + +```python +>>> path +PosixPath('quicfire_export/Blue_Mountain_QUIC-Fire.zip') +``` + +## Step 9: Inspect the export + +The archive is a zip that QUIC-Fire loads directly. Let's confirm the inputs +are all there: + +```python +>>> import zipfile +>>> sorted(zipfile.ZipFile(path).namelist()) +['domain.geojson', 'metadata.json', 'topo.dat', 'treesfueldepth.dat', 'treesmoist.dat', 'treesrhof.dat'] +``` + +The `.dat` files are the QUIC-Fire fuel arrays: + +- `treesrhof.dat` — canopy bulk density +- `treesmoist.dat` — canopy fuel moisture +- `treesfueldepth.dat` — surface fuel-bed depth +- `topo.dat` — elevation + +alongside `metadata.json` (the grid geometry and band provenance) and +`domain.geojson` (the domain footprint). + +## Recap + +In one pass we created a domain, pulled road and water features, built +topography from 3DEP and surface fuels from LANDFIRE FBFM40, expanded a +TreeMap tree inventory into a 3D canopy fuel grid, and bundled it all into a +QUIC-Fire archive — masking roads out of the fuels along the way. + +## Next steps + +- Swap in your own region by changing the polygon in + [Step 2](#step-2-define-a-region-of-interest). +- Mask water (and other features) into the fuels the same way we masked + roads — see [Mask out features](../guides/creating-grids.md#mask-out-features). +- Tune the tree inventory before voxelizing — thinning, treatments, and + modifications — see [Modify trees at creation](../guides/inventories.md#modify-trees-at-creation). +- Define the fire grid from an existing grid's lattice, or change its + resolution, with the export's alignment options — see + [Bundle grids for QUIC-Fire](../guides/exports.md#bundle-grids-for-quic-fire). +- Full signatures for every function used here are in the + [Reference](../reference.md). +``` From b136ae056b53363fa564235225774f7a234c1204 Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Thu, 11 Jun 2026 10:02:34 -0600 Subject: [PATCH 36/71] Test the realistic QUIC-Fire bundle (lookup surface + topography) Cover the workflow test_bundle_roundtrip omits: surface load and depth come from a data-derived FBFM40-lookup grid rather than a uniform grid, and a 3DEP topography grid is supplied so the bundle gains topo.dat. Every role grid is built on the 2 m fire-grid lattice, so the test also guards that an FBFM40-lookup grid and a 3DEP grid align cell-for-cell with the voxel grid (the exporter crops but never resamples). --- tests/v2/test_exports.py | 81 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/tests/v2/test_exports.py b/tests/v2/test_exports.py index 53c6ec7..4fc2f5b 100644 --- a/tests/v2/test_exports.py +++ b/tests/v2/test_exports.py @@ -15,7 +15,11 @@ get_export, list_exports, ) -from fastfuels_sdk.v2.grids import create_uniform_grid +from fastfuels_sdk.v2.grids import ( + create_fuel_model_grid_from_landfire_fbfm40, + create_topography_grid_from_3dep, + create_uniform_grid, +) from fastfuels_sdk.v2.client_library.models import FieldSource, JobStatus from fastfuels_sdk.v2.exceptions import NotFoundException @@ -183,6 +187,39 @@ def surface_grid(self, test_domain): grid.wait() return grid + @pytest.fixture(scope="class") + def lookup_surface_grid(self, test_domain): + """Surface load + depth from a real FBFM40 lookup, on the 2 m lattice. + + Unlike the uniform ``surface_grid``, this is a genuine data-derived + grid: an FBFM40 grid built on the 2 m domain lattice, then looked up + into fuel-parameter bands. It must align cell-for-cell with the fire + grid (which defaults to the domain bbox at 2 m) for the export to + slice it without resampling. + """ + fbfm = create_fuel_model_grid_from_landfire_fbfm40( + test_domain, output_resolution_m=2.0, name="qf_fbfm" + ) + fbfm.wait() + surface = fbfm.lookup_fuel_model_values( + bands=["fuel_load.1hr", "fuel_depth"], name="qf_surface_lookup" + ) + surface.wait() + return surface + + @pytest.fixture(scope="class") + def aligned_topography_grid(self, test_domain): + """3DEP elevation on the 2 m lattice, so it aligns with the fire grid.""" + grid = create_topography_grid_from_3dep( + test_domain, + source_resolution_m=10, + output_resolution_m=2.0, + bands=["elevation"], + name="qf_topo", + ) + grid.wait() + return grid + def test_align_to_excludes_resolution(self, test_domain, voxel_grid): with pytest.raises(ValueError, match="not both"): create_quicfire_export( @@ -221,3 +258,45 @@ def test_bundle_roundtrip(self, test_domain, voxel_grid, surface_grid, tmp_path) "domain.geojson", } <= names export.delete() + + def test_bundle_with_lookup_surface_and_topography( + self, + test_domain, + voxel_grid, + lookup_surface_grid, + surface_grid, + aligned_topography_grid, + tmp_path, + ): + # The realistic QUIC-Fire workflow (and the export tutorial): surface + # load/depth come from a data-derived FBFM40 lookup grid rather than a + # uniform grid, and a 3DEP topography grid is supplied -- so the bundle + # must additionally contain topo.dat. Every role grid sits on the 2 m + # fire-grid lattice; the exporter crops but never resamples, so this + # also guards that an FBFM40-lookup grid and a 3DEP grid align with the + # voxel grid cell-for-cell. + export = create_quicfire_export( + test_domain, + canopy_bulk_density=(voxel_grid, "bulk_density.foliage.live"), + canopy_moisture=(voxel_grid, "fuel_moisture.live"), + surface_fuel_load=(lookup_surface_grid, "fuel_load.1hr"), + surface_fuel_depth=(lookup_surface_grid, "fuel_depth"), + surface_moisture=(surface_grid, "fuel_moisture.1hr"), + topography=(aligned_topography_grid, "elevation"), + name="qf_realistic", + ) + assert isinstance(export, Export) + export.wait() + + destination = export.to_file(tmp_path) + with zipfile.ZipFile(destination) as archive: + names = set(archive.namelist()) + assert { + "treesrhof.dat", + "treesmoist.dat", + "treesfueldepth.dat", + "topo.dat", + "metadata.json", + "domain.geojson", + } <= names + export.delete() From a70295b1789ac39e6e0406cf1f8076e9188f18ea Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Fri, 12 Jun 2026 09:36:10 -0600 Subject: [PATCH 37/71] Make FBFM40 lookup a function, off the generic Grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lookup_fuel_model_values was a method on every Grid, but it only works on FBFM40 fuel-model grids (it reads an `fbfm`-coded band via create_fbfm40_lookup). It was the only type-specific method on Grid — resample, export, and to_numpy/ to_xarray are all universal. Move it to a module-level function, create_fuel_grid_from_fbfm40_lookup( source_grid, bands=...), and add a runtime guard that raises a clear ValueError (naming the missing band and the right creator) when handed a grid with no `fbfm` band. This codifies the rule: universal transforms stay methods; type-specific transforms are functions, so a transform never appears on a grid that cannot perform it. Also add create_pim_grid_from_treemap to grids.__all__ (it was defined and used but not exported). Tests + docs (creating-grids, migration, export tutorial) and the working design note updated to match. Verified live: TestFbfm40Lookup passes (returns a pending grid, guards a pending source, rejects a non-FBFM grid). --- docs/v2/guides/creating-grids.md | 10 +- docs/v2/guides/migration.md | 8 +- docs/v2/tutorials/export_to_quicfire.md | 4 +- fastfuels_sdk/v2/grids.py | 126 +++++++++++++++--------- fastfuels_sdk/v2/v2_api_design.md | 25 +++-- tests/v2/conftest.py | 2 +- tests/v2/test_exports.py | 5 +- tests/v2/test_grids.py | 19 +++- 8 files changed, 129 insertions(+), 70 deletions(-) diff --git a/docs/v2/guides/creating-grids.md b/docs/v2/guides/creating-grids.md index c8cec5d..7671ae9 100644 --- a/docs/v2/guides/creating-grids.md +++ b/docs/v2/guides/creating-grids.md @@ -124,15 +124,15 @@ grid = ff.grids.create_fuel_model_grid_from_landfire_fbfm40( The `fbfm` band holds codes, not the quantities a fire model consumes. To turn the codes into fuel parameters — loadings by size class, fuel-bed depth, -surface-area-to-volume ratios — call `lookup_fuel_model_values` on the -completed grid. It returns a new grid whose bands are the requested -parameters: +surface-area-to-volume ratios — pass the completed grid to +`ff.grids.create_fuel_grid_from_fbfm40_lookup`. It returns a new grid whose +bands are the requested parameters: ```python grid.wait() -fuels = grid.lookup_fuel_model_values( - bands=["fuel_load.1hr", "fuel_load.10hr", "fuel_depth"] +fuels = ff.grids.create_fuel_grid_from_fbfm40_lookup( + grid, bands=["fuel_load.1hr", "fuel_load.10hr", "fuel_depth"] ) fuels.wait() ``` diff --git a/docs/v2/guides/migration.md b/docs/v2/guides/migration.md index 068ff4a..82a9218 100644 --- a/docs/v2/guides/migration.md +++ b/docs/v2/guides/migration.md @@ -115,9 +115,11 @@ Available now — see [Creating grids](creating-grids.md), `feature_masks=["road", "water"]` becomes `modifications=[ff.mask(feature, band, value)]`, which overwrites the cells a feature covers. -- **Transforms are methods.** `grid.resample(...)`, - `grid.lookup_fuel_model_values(...)`, and `grid.export(...)` act on a - grid you already hold. +- **Universal transforms are methods; type-specific ones are functions.** + Transforms that apply to any grid you hold are methods — + `grid.resample(...)`, `grid.export(...)`. Deriving a fuel-parameter grid + from FBFM40 codes only applies to FBFM40 grids, so it is a function: + `ff.grids.create_fuel_grid_from_fbfm40_lookup(fbfm_grid, ...)`. ### Before and after diff --git a/docs/v2/tutorials/export_to_quicfire.md b/docs/v2/tutorials/export_to_quicfire.md index 0a0079a..0f1dec1 100644 --- a/docs/v2/tutorials/export_to_quicfire.md +++ b/docs/v2/tutorials/export_to_quicfire.md @@ -198,7 +198,9 @@ With the FBFM40 codes in hand, we look up the actual fuel quantities QUIC-Fire consumes — 1-hour fuel load and fuel-bed depth — which produces a new grid: ```python -surface = fbfm.lookup_fuel_model_values(bands=["fuel_load.1hr", "fuel_depth"]) +surface = ff.grids.create_fuel_grid_from_fbfm40_lookup( + fbfm, bands=["fuel_load.1hr", "fuel_depth"] +) ``` ```python diff --git a/fastfuels_sdk/v2/grids.py b/fastfuels_sdk/v2/grids.py index 35cb6e1..2034441 100644 --- a/fastfuels_sdk/v2/grids.py +++ b/fastfuels_sdk/v2/grids.py @@ -93,9 +93,11 @@ "create_canopy_height_grid_from_naip_chm", "create_fuel_model_grid_from_landfire_fbfm40", "create_fuel_model_grid_from_landfire_fccs", + "create_pim_grid_from_treemap", "create_grid_from_geotiff", "create_grid_from_netcdf", "create_uniform_grid", + "create_fuel_grid_from_fbfm40_lookup", "list_grids", "get_grid", "check_3dep_coverage", @@ -479,51 +481,6 @@ def resample( ) return Grid._from_model(expect(response, HTTPStatus.CREATED)) - def lookup_fuel_model_values( - self, - bands: list, - source_band: str = "fbfm", - name: str = "", - description: str = "", - tags: Optional[List[str]] = None, - modifications: Optional[list] = None, - ) -> "Grid": - """Create a fuel-parameter grid by looking up FBFM40 codes in this grid. - - Parameters - ---------- - bands : list - The FBFM40 lookup bands to produce (``Fbfm40LookupBand`` members or - their string keys, e.g. "fuel_load_1hr"). - source_band : str, optional - The band in this grid that holds FBFM40 codes (default "fbfm"). - name, description : str, optional - Metadata for the new grid. - tags : List[str], optional - Tags for the new grid. - modifications : list, optional - Modification rules applied after the grid is built. - - Returns - ------- - Grid - The new (pending) fuel-parameter Grid. - """ - self._require_completed("look up fuel parameters from") - request_body = CreateFbfm40LookupRequest( - source_grid_id=self.id, - bands=_enum_list(bands, Fbfm40LookupBand), - source_band=source_band, - name=name, - description=description, - tags=_opt(tags), - modifications=_opt(modifications), - ) - response = create_fbfm40_lookup.sync_detailed( - self.domain_id, client=ensure_client(), body=request_body - ) - return Grid._from_model(expect(response, HTTPStatus.CREATED)) - def export( self, format: str = "geotiff", @@ -1426,6 +1383,85 @@ def create_uniform_grid( return Grid._from_model(expect(response, HTTPStatus.CREATED)) +# --------------------------------------------------------------------------- +# Derive a grid from a grid you hold (source-specific transforms) +# --------------------------------------------------------------------------- +# +# Universal transforms that apply to *any* grid are methods on ``Grid`` +# (``resample``, ``export``). Transforms that only make sense for a particular +# kind of grid are functions here instead — keeping them off ``Grid`` so they +# never appear on a grid that cannot perform them. + + +def create_fuel_grid_from_fbfm40_lookup( + source_grid: "Grid", + bands: list, + source_band: str = "fbfm", + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, + modifications: Optional[list] = None, +) -> Grid: + """Create a fuel-parameter grid by looking up FBFM40 codes in a grid. + + The `fbfm` band of an FBFM40 fuel model grid holds categorical fuel-model + codes, not the quantities a fire model consumes. This translates those + codes into fuel parameters (loadings by size class, fuel-bed depth, + surface-area-to-volume ratios), returning a new grid whose bands are the + requested parameters. + + Parameters + ---------- + source_grid : Grid + A completed grid carrying FBFM40 codes (produced by + :func:`create_fuel_model_grid_from_landfire_fbfm40`). + bands : list + The FBFM40 lookup bands to produce (``Fbfm40LookupBand`` members or + their string keys, e.g. "fuel_load.1hr", "fuel_depth"). + source_band : str, optional + The band in ``source_grid`` that holds FBFM40 codes (default "fbfm"). + name, description : str, optional + Metadata for the new grid. + tags : List[str], optional + Tags for the new grid. + modifications : list, optional + Modification rules applied after the grid is built. + + Returns + ------- + Grid + The new (pending) fuel-parameter Grid. + + Raises + ------ + ValueError + If ``source_grid`` is not completed, or carries no ``source_band`` band + to look up (i.e. it is not an FBFM40 fuel model grid). + """ + source_grid._require_completed("look up fuel parameters from") + band_keys = [b.key for b in source_grid.bands] + if source_band not in band_keys: + raise ValueError( + f"Grid {source_grid.id} has no {source_band!r} band to look up; pass " + "an FBFM40 fuel model grid (see " + f"create_fuel_model_grid_from_landfire_fbfm40). Available bands: " + f"{band_keys}." + ) + request_body = CreateFbfm40LookupRequest( + source_grid_id=source_grid.id, + bands=_enum_list(bands, Fbfm40LookupBand), + source_band=source_band, + name=name, + description=description, + tags=_opt(tags), + modifications=_opt(modifications), + ) + response = create_fbfm40_lookup.sync_detailed( + source_grid.domain_id, client=ensure_client(), body=request_body + ) + return Grid._from_model(expect(response, HTTPStatus.CREATED)) + + # --------------------------------------------------------------------------- # Listing, fetching, and utilities # --------------------------------------------------------------------------- diff --git a/fastfuels_sdk/v2/v2_api_design.md b/fastfuels_sdk/v2/v2_api_design.md index ed7cb33..9b4b3bb 100644 --- a/fastfuels_sdk/v2/v2_api_design.md +++ b/fastfuels_sdk/v2/v2_api_design.md @@ -101,7 +101,7 @@ Per-creator: - **`voxelize`** (3D): direct `resolution: Resolution3D = {horizontal, vertical}` (horizontal isotropic x/y, vertical independent), **no alignment** → `horizontal_resolution_m=` + `vertical_resolution_m=`. -- **`lookup_fuel_model_values`, geotiff/netcdf upload**: no resolution/alignment — inherit +- **`create_fuel_grid_from_fbfm40_lookup`, geotiff/netcdf upload**: no resolution/alignment — inherit the source grid's lattice (lookup) or the file (uploads). - ⚠️ **`landfire_fccs` carries neither `alignment` nor `resolution`** — an asymmetry vs the other LANDFIRE creators. Confirm with the API team before @@ -111,7 +111,7 @@ Per-creator: - Every creator also accepts `name=`, `description=`, `tags=`, `modifications=[...]` (applied server-side after build; `modifications` is a - real field on grid creators *and* `lookup_fuel_model_values`/`rasterize`). + real field on grid creators *and* the FBFM40 lookup / `rasterize`). - **Data out (methods, hide chunk/partition plumbing + signed-URL handshake):** `grid.to_xarray()`, `grid.to_numpy(band)`, `feature.to_geodataframe()`. - **Generic methods on any record:** `.wait()`, `.refresh()`, `.update(...)`, @@ -124,8 +124,8 @@ fastfuels_sdk/ __init__.py set_api_key, Domain, wait_all, mask, list_*, get_*, ... domains.py Domain (record + from_* + methods), list_domains features.py create_*_feature_from_* (fns); Feature record + methods [rewrite creation only] - grids.py create_*_grid_from_* (fns); Grid record + methods - (wait/to_xarray/resample/lookup_fuel_model_values/export/...) [GREENFIELD #178] + grids.py create_*_grid_from_* (fns; incl. create_fuel_grid_from_fbfm40_lookup); + Grid record + methods (wait/to_xarray/resample/export/...) [GREENFIELD #178] inventories.py create_tree_inventory_from_* (fns); Inventory + methods [later] exports.py create_quicfire_export (fn); Export record + methods [later] ``` @@ -168,12 +168,19 @@ alignment-translation helper (plain function, not a base class). | `create_netcdf_upload` | `ff.grids.create_grid_from_netcdf(domain, path)` | | `create_uniform_grid` | `ff.grids.create_uniform_grid(domain, resolution_m=…, bands={…})` | -**Transform a resource you hold (methods on the source)** +**Transform a resource you hold** -| `create_fbfm40_lookup` | `fbfm_grid.lookup_fuel_model_values(bands=[…])` | -| `create_tree_inventory_grid` | `inventory.voxelize(horizontal_resolution_m=…, vertical_resolution_m=…, bands=…)` | -| `create_resample` | `grid.resample(output_resolution_m=… / align_to=…, resampling=…)` | -| `create_layerset_rasterize` | `layerset.rasterize(output_resolution_m=…, overlap_method=…)` | +A transform is a *method* when it applies to any instance of the resource +(every grid can resample/export; every inventory can voxelize). A transform +that only makes sense for a *particular kind* of grid is a **function** instead, +so it never appears on a grid that cannot perform it (the alternative — a method +that raises for the wrong grid type — is the wart this avoids). The FBFM40 +lookup is the only such case today: it needs a grid carrying `fbfm` codes. + +| `create_fbfm40_lookup` | `ff.grids.create_fuel_grid_from_fbfm40_lookup(fbfm_grid, bands=[…])` (fn — FBFM40-only) | +| `create_tree_inventory_grid` | `inventory.voxelize(horizontal_resolution_m=…, vertical_resolution_m=…, bands=…)` (method) | +| `create_resample` | `grid.resample(output_resolution_m=… / align_to=…, resampling=…)` (method) | +| `create_layerset_rasterize` | `layerset.rasterize(output_resolution_m=…, overlap_method=…)` (method) | **Export** diff --git a/tests/v2/conftest.py b/tests/v2/conftest.py index decaaef..74efb9d 100644 --- a/tests/v2/conftest.py +++ b/tests/v2/conftest.py @@ -86,7 +86,7 @@ def completed_topography_grid(test_domain): def completed_fbfm40_grid(test_domain): """A completed LANDFIRE FBFM40 fuel model grid. READ-ONLY: shared by every module. Provides the FBFM40 codes that - ``Grid.lookup_fuel_model_values`` reads. + ``create_fuel_grid_from_fbfm40_lookup`` reads. """ grid = create_fuel_model_grid_from_landfire_fbfm40( test_domain, diff --git a/tests/v2/test_exports.py b/tests/v2/test_exports.py index 4fc2f5b..9e3f4d0 100644 --- a/tests/v2/test_exports.py +++ b/tests/v2/test_exports.py @@ -16,6 +16,7 @@ list_exports, ) from fastfuels_sdk.v2.grids import ( + create_fuel_grid_from_fbfm40_lookup, create_fuel_model_grid_from_landfire_fbfm40, create_topography_grid_from_3dep, create_uniform_grid, @@ -201,8 +202,8 @@ def lookup_surface_grid(self, test_domain): test_domain, output_resolution_m=2.0, name="qf_fbfm" ) fbfm.wait() - surface = fbfm.lookup_fuel_model_values( - bands=["fuel_load.1hr", "fuel_depth"], name="qf_surface_lookup" + surface = create_fuel_grid_from_fbfm40_lookup( + fbfm, bands=["fuel_load.1hr", "fuel_depth"], name="qf_surface_lookup" ) surface.wait() return surface diff --git a/tests/v2/test_grids.py b/tests/v2/test_grids.py index 2485560..4235c3c 100644 --- a/tests/v2/test_grids.py +++ b/tests/v2/test_grids.py @@ -22,6 +22,7 @@ create_canopy_fuel_grid_from_landfire, create_canopy_height_grid_from_meta, create_canopy_height_grid_from_naip_chm, + create_fuel_grid_from_fbfm40_lookup, create_fuel_model_grid_from_landfire_fbfm40, create_fuel_model_grid_from_landfire_fccs, create_pim_grid_from_treemap, @@ -444,10 +445,12 @@ def test_resample_requires_completed_source(self, test_domain): grid.delete() -class TestLookupFuelModelValues: +class TestFbfm40Lookup: def test_lookup_returns_new_pending_grid(self, completed_fbfm40_grid): - fuel_grid = completed_fbfm40_grid.lookup_fuel_model_values( - bands=["fuel_load.1hr", "fuel_depth"], name="throwaway_lookup" + fuel_grid = create_fuel_grid_from_fbfm40_lookup( + completed_fbfm40_grid, + bands=["fuel_load.1hr", "fuel_depth"], + name="throwaway_lookup", ) assert isinstance(fuel_grid, Grid) assert fuel_grid.id != completed_fbfm40_grid.id @@ -463,9 +466,17 @@ def test_lookup_requires_completed_source(self, test_domain): grid.delete() pytest.skip("grid completed too quickly to test the guard") with pytest.raises(ValueError, match="look up fuel"): - grid.lookup_fuel_model_values(bands=["fuel_load.1hr"]) + create_fuel_grid_from_fbfm40_lookup(grid, bands=["fuel_load.1hr"]) grid.delete() + def test_lookup_rejects_non_fbfm_grid(self, completed_topography_grid): + # A topography grid has no `fbfm` band; the guard rejects it before any + # API call, naming the missing band and the right creator to use. + with pytest.raises(ValueError, match="fbfm"): + create_fuel_grid_from_fbfm40_lookup( + completed_topography_grid, bands=["fuel_load.1hr"] + ) + class TestExport: def test_export_returns_pending_export(self, completed_topography_grid): From 2ee74ab80c1ae9407c8803df82dd7fdd9ba8815b Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Fri, 12 Jun 2026 11:17:40 -0600 Subject: [PATCH 38/71] Regenerate v2 client library from live API spec Wholesale regen via generate_client.sh (openapi-python-client 0.29.0, pinned). Syncs the committed client to the current prod spec after it had drifted. What came in: - #358: CreateLandfireFccsRequest gains `alignment` + `extent_buffer_cells`, bringing FCCS create to parity with the other LANDFIRE/source grids. - New point_clouds resource (api/point_clouds/ + point_cloud_* models). - apply_treatments inventory endpoint (+ ApplyTreatmentsRequest). - duplicate_grid endpoint (+ DuplicateGridRequest). - categorical/continuous band summaries on Band. - Upload-spec `headers` fields (grid/inventory/point-cloud upload specs). This is generated surface only. SDK wrappers (grids/inventories/exports/...) for the new resources and fields are follow-up work tracked separately; this commit changes no hand-written SDK behavior. --- .../api/grids/create_geotiff_upload.py | 44 +- .../api/grids/create_netcdf_upload.py | 44 +- .../api/grids/duplicate_grid.py | 347 ++++++++++++++ .../api/inventories/apply_treatments.py | 448 ++++++++++++++++++ .../inventories/create_inventory_upload.py | 52 +- .../api/point_clouds/__init__.py | 1 + .../point_clouds/create_point_cloud_upload.py | 344 ++++++++++++++ .../api/point_clouds/delete_point_cloud.py | 245 ++++++++++ .../api/point_clouds/get_point_cloud.py | 243 ++++++++++ .../api/point_clouds/list_point_clouds.py | 406 ++++++++++++++++ .../list_point_clouds_cross_domain.py | 374 +++++++++++++++ .../api/point_clouds/update_point_cloud.py | 360 ++++++++++++++ .../v2/client_library/models/__init__.py | 36 ++ .../models/apply_treatments_request.py | 107 +++++ .../v2/client_library/models/band.py | 57 ++- .../models/categorical_band_summary.py | 92 ++++ .../models/continuous_band_summary.py | 140 ++++++ .../models/create_landfire_fccs_request.py | 79 +++ .../create_point_cloud_upload_request.py | 106 +++++ .../models/duplicate_grid_request.py | 128 +++++ .../client_library/models/grid_upload_spec.py | 15 + .../models/grid_upload_spec_headers.py | 47 ++ .../models/inventory_upload_spec.py | 15 + .../models/inventory_upload_spec_headers.py | 47 ++ .../models/list_point_clouds_response.py | 100 ++++ .../v2/client_library/models/point_cloud.py | 379 +++++++++++++++ .../models/point_cloud_georeference.py | 89 ++++ .../models/point_cloud_sort_field.py | 10 + .../models/point_cloud_source.py | 51 ++ .../models/point_cloud_summary.py | 83 ++++ .../client_library/models/point_cloud_type.py | 9 + .../point_cloud_upload_created_response.py | 93 ++++ .../models/point_cloud_upload_spec.py | 125 +++++ .../models/point_cloud_upload_spec_headers.py | 50 ++ .../models/update_point_cloud_request_body.py | 129 +++++ 35 files changed, 4866 insertions(+), 29 deletions(-) create mode 100644 fastfuels_sdk/v2/client_library/api/grids/duplicate_grid.py create mode 100644 fastfuels_sdk/v2/client_library/api/inventories/apply_treatments.py create mode 100644 fastfuels_sdk/v2/client_library/api/point_clouds/__init__.py create mode 100644 fastfuels_sdk/v2/client_library/api/point_clouds/create_point_cloud_upload.py create mode 100644 fastfuels_sdk/v2/client_library/api/point_clouds/delete_point_cloud.py create mode 100644 fastfuels_sdk/v2/client_library/api/point_clouds/get_point_cloud.py create mode 100644 fastfuels_sdk/v2/client_library/api/point_clouds/list_point_clouds.py create mode 100644 fastfuels_sdk/v2/client_library/api/point_clouds/list_point_clouds_cross_domain.py create mode 100644 fastfuels_sdk/v2/client_library/api/point_clouds/update_point_cloud.py create mode 100644 fastfuels_sdk/v2/client_library/models/apply_treatments_request.py create mode 100644 fastfuels_sdk/v2/client_library/models/categorical_band_summary.py create mode 100644 fastfuels_sdk/v2/client_library/models/continuous_band_summary.py create mode 100644 fastfuels_sdk/v2/client_library/models/create_point_cloud_upload_request.py create mode 100644 fastfuels_sdk/v2/client_library/models/duplicate_grid_request.py create mode 100644 fastfuels_sdk/v2/client_library/models/grid_upload_spec_headers.py create mode 100644 fastfuels_sdk/v2/client_library/models/inventory_upload_spec_headers.py create mode 100644 fastfuels_sdk/v2/client_library/models/list_point_clouds_response.py create mode 100644 fastfuels_sdk/v2/client_library/models/point_cloud.py create mode 100644 fastfuels_sdk/v2/client_library/models/point_cloud_georeference.py create mode 100644 fastfuels_sdk/v2/client_library/models/point_cloud_sort_field.py create mode 100644 fastfuels_sdk/v2/client_library/models/point_cloud_source.py create mode 100644 fastfuels_sdk/v2/client_library/models/point_cloud_summary.py create mode 100644 fastfuels_sdk/v2/client_library/models/point_cloud_type.py create mode 100644 fastfuels_sdk/v2/client_library/models/point_cloud_upload_created_response.py create mode 100644 fastfuels_sdk/v2/client_library/models/point_cloud_upload_spec.py create mode 100644 fastfuels_sdk/v2/client_library/models/point_cloud_upload_spec_headers.py create mode 100644 fastfuels_sdk/v2/client_library/models/update_point_cloud_request_body.py diff --git a/fastfuels_sdk/v2/client_library/api/grids/create_geotiff_upload.py b/fastfuels_sdk/v2/client_library/api/grids/create_geotiff_upload.py index 591bd2b..6c615fa 100644 --- a/fastfuels_sdk/v2/client_library/api/grids/create_geotiff_upload.py +++ b/fastfuels_sdk/v2/client_library/api/grids/create_geotiff_upload.py @@ -70,12 +70,19 @@ def sync_detailed( client: AuthenticatedClient, body: CreateGeoTIFFUploadRequest, ) -> Response[GridUploadCreatedResponse | HTTPValidationError]: - """Create a grid from a direct GeoTIFF upload + r"""Create a grid from a direct GeoTIFF upload # Create Upload Grid (GeoTIFF) Creates a grid resource and returns a signed URL for uploading a GeoTIFF - directly to GCS. The upload must use HTTP PUT with Content-Type: image/tiff. + directly to GCS. Upload with HTTP PUT, sending **every header in the + response's `upload.headers`** exactly as given — the signed URL commits to + them, and the upload is rejected if any is missing or altered. For example: + + ```bash + curl -X PUT --upload-file grid.tif -H \"Content-Type: image/tiff\" -H \"x-goog-content- + length-range: 0,1073741824\" \"\" + ``` When the upload completes, the uploader service processes the file automatically via Eventarc and updates the grid status to `completed` @@ -134,12 +141,19 @@ def sync( client: AuthenticatedClient, body: CreateGeoTIFFUploadRequest, ) -> GridUploadCreatedResponse | HTTPValidationError | None: - """Create a grid from a direct GeoTIFF upload + r"""Create a grid from a direct GeoTIFF upload # Create Upload Grid (GeoTIFF) Creates a grid resource and returns a signed URL for uploading a GeoTIFF - directly to GCS. The upload must use HTTP PUT with Content-Type: image/tiff. + directly to GCS. Upload with HTTP PUT, sending **every header in the + response's `upload.headers`** exactly as given — the signed URL commits to + them, and the upload is rejected if any is missing or altered. For example: + + ```bash + curl -X PUT --upload-file grid.tif -H \"Content-Type: image/tiff\" -H \"x-goog-content- + length-range: 0,1073741824\" \"\" + ``` When the upload completes, the uploader service processes the file automatically via Eventarc and updates the grid status to `completed` @@ -193,12 +207,19 @@ async def asyncio_detailed( client: AuthenticatedClient, body: CreateGeoTIFFUploadRequest, ) -> Response[GridUploadCreatedResponse | HTTPValidationError]: - """Create a grid from a direct GeoTIFF upload + r"""Create a grid from a direct GeoTIFF upload # Create Upload Grid (GeoTIFF) Creates a grid resource and returns a signed URL for uploading a GeoTIFF - directly to GCS. The upload must use HTTP PUT with Content-Type: image/tiff. + directly to GCS. Upload with HTTP PUT, sending **every header in the + response's `upload.headers`** exactly as given — the signed URL commits to + them, and the upload is rejected if any is missing or altered. For example: + + ```bash + curl -X PUT --upload-file grid.tif -H \"Content-Type: image/tiff\" -H \"x-goog-content- + length-range: 0,1073741824\" \"\" + ``` When the upload completes, the uploader service processes the file automatically via Eventarc and updates the grid status to `completed` @@ -255,12 +276,19 @@ async def asyncio( client: AuthenticatedClient, body: CreateGeoTIFFUploadRequest, ) -> GridUploadCreatedResponse | HTTPValidationError | None: - """Create a grid from a direct GeoTIFF upload + r"""Create a grid from a direct GeoTIFF upload # Create Upload Grid (GeoTIFF) Creates a grid resource and returns a signed URL for uploading a GeoTIFF - directly to GCS. The upload must use HTTP PUT with Content-Type: image/tiff. + directly to GCS. Upload with HTTP PUT, sending **every header in the + response's `upload.headers`** exactly as given — the signed URL commits to + them, and the upload is rejected if any is missing or altered. For example: + + ```bash + curl -X PUT --upload-file grid.tif -H \"Content-Type: image/tiff\" -H \"x-goog-content- + length-range: 0,1073741824\" \"\" + ``` When the upload completes, the uploader service processes the file automatically via Eventarc and updates the grid status to `completed` diff --git a/fastfuels_sdk/v2/client_library/api/grids/create_netcdf_upload.py b/fastfuels_sdk/v2/client_library/api/grids/create_netcdf_upload.py index 335cbfb..a2f24d7 100644 --- a/fastfuels_sdk/v2/client_library/api/grids/create_netcdf_upload.py +++ b/fastfuels_sdk/v2/client_library/api/grids/create_netcdf_upload.py @@ -75,8 +75,15 @@ def sync_detailed( # Create Upload Grid (netCDF) Creates a grid resource and returns a signed URL for uploading a - CF-conformant netCDF directly to GCS. The upload must use HTTP PUT - with `Content-Type: application/x-netcdf`. + CF-conformant netCDF directly to GCS. Upload with HTTP PUT, sending + **every header in the response's `upload.headers`** exactly as given — + the signed URL commits to them, and the upload is rejected if any is + missing or altered. For example: + + ```bash + curl -X PUT --upload-file grid.nc -H \"Content-Type: application/x-netcdf\" -H \"x-goog- + content-length-range: 0,1073741824\" \"\" + ``` When the upload completes, the uploader service processes the file automatically via Eventarc and updates the grid status to `completed` @@ -162,8 +169,15 @@ def sync( # Create Upload Grid (netCDF) Creates a grid resource and returns a signed URL for uploading a - CF-conformant netCDF directly to GCS. The upload must use HTTP PUT - with `Content-Type: application/x-netcdf`. + CF-conformant netCDF directly to GCS. Upload with HTTP PUT, sending + **every header in the response's `upload.headers`** exactly as given — + the signed URL commits to them, and the upload is rejected if any is + missing or altered. For example: + + ```bash + curl -X PUT --upload-file grid.nc -H \"Content-Type: application/x-netcdf\" -H \"x-goog- + content-length-range: 0,1073741824\" \"\" + ``` When the upload completes, the uploader service processes the file automatically via Eventarc and updates the grid status to `completed` @@ -244,8 +258,15 @@ async def asyncio_detailed( # Create Upload Grid (netCDF) Creates a grid resource and returns a signed URL for uploading a - CF-conformant netCDF directly to GCS. The upload must use HTTP PUT - with `Content-Type: application/x-netcdf`. + CF-conformant netCDF directly to GCS. Upload with HTTP PUT, sending + **every header in the response's `upload.headers`** exactly as given — + the signed URL commits to them, and the upload is rejected if any is + missing or altered. For example: + + ```bash + curl -X PUT --upload-file grid.nc -H \"Content-Type: application/x-netcdf\" -H \"x-goog- + content-length-range: 0,1073741824\" \"\" + ``` When the upload completes, the uploader service processes the file automatically via Eventarc and updates the grid status to `completed` @@ -329,8 +350,15 @@ async def asyncio( # Create Upload Grid (netCDF) Creates a grid resource and returns a signed URL for uploading a - CF-conformant netCDF directly to GCS. The upload must use HTTP PUT - with `Content-Type: application/x-netcdf`. + CF-conformant netCDF directly to GCS. Upload with HTTP PUT, sending + **every header in the response's `upload.headers`** exactly as given — + the signed URL commits to them, and the upload is rejected if any is + missing or altered. For example: + + ```bash + curl -X PUT --upload-file grid.nc -H \"Content-Type: application/x-netcdf\" -H \"x-goog- + content-length-range: 0,1073741824\" \"\" + ``` When the upload completes, the uploader service processes the file automatically via Eventarc and updates the grid status to `completed` diff --git a/fastfuels_sdk/v2/client_library/api/grids/duplicate_grid.py b/fastfuels_sdk/v2/client_library/api/grids/duplicate_grid.py new file mode 100644 index 0000000..5f64167 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/grids/duplicate_grid.py @@ -0,0 +1,347 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.duplicate_grid_request import DuplicateGridRequest +from ...models.grid import Grid +from ...models.http_validation_error import HTTPValidationError +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + domain_id: str, + grid_id: str, + *, + body: DuplicateGridRequest | None | Unset = UNSET, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/domains/{domain_id}/grids/{grid_id}/duplicate".format( + domain_id=quote(str(domain_id), safe=""), + grid_id=quote(str(grid_id), safe=""), + ), + } + + if isinstance(body, DuplicateGridRequest): + _kwargs["json"] = body.to_dict() + else: + _kwargs["json"] = body + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Grid | HTTPValidationError | None: + if response.status_code == 201: + response_201 = Grid.from_dict(response.json()) + + return response_201 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Grid | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + grid_id: str, + *, + client: AuthenticatedClient, + body: DuplicateGridRequest | None | Unset = UNSET, +) -> Response[Grid | HTTPValidationError]: + r"""Duplicate a grid + + # Duplicate a Grid + + Creates an independent **copy** of a completed grid under a new ID. Use + this to branch a scenario: duplicate, then edit the copy while the + original stays untouched. + + This is a true clone, not a re-derivation. The finished data is + byte-copied; no regeneration is performed and the upstream source is never + re-fetched, so the copy is exact even if the upstream product has been + updated since the original was built. The copy carries over the source's + `source`, `modifications`, `bands`, `georeference`, `chunks`, and + `checksum` verbatim — only its `id` and timestamps differ. + + ## Request Body (optional) + + All fields are optional. Any field omitted is carried over from the source. + + - **name**: Name for the copy. + - **description**: Description for the copy. + - **tags**: Tags for the copy. + + Send no body at all to copy the metadata unchanged. + + ## Response + + Returns the new Grid with status `\"pending\"`. The data is copied in the + background; the status transitions to `\"completed\"` once the copy finishes + (or `\"failed\"` if it does not). Data endpoints (`/chunks`, `/data`) become + available only after the copy completes. The source grid is unchanged. + + ## Error Responses + + - **404 Not Found**: The source grid does not exist, is not owned by the + caller, or is not in this domain. + - **422 Unprocessable Content**: The source grid exists but is not yet + `completed`, so there is no finished artifact to copy. + + Args: + domain_id (str): + grid_id (str): + body (DuplicateGridRequest | None | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + grid_id=grid_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + grid_id: str, + *, + client: AuthenticatedClient, + body: DuplicateGridRequest | None | Unset = UNSET, +) -> Grid | HTTPValidationError | None: + r"""Duplicate a grid + + # Duplicate a Grid + + Creates an independent **copy** of a completed grid under a new ID. Use + this to branch a scenario: duplicate, then edit the copy while the + original stays untouched. + + This is a true clone, not a re-derivation. The finished data is + byte-copied; no regeneration is performed and the upstream source is never + re-fetched, so the copy is exact even if the upstream product has been + updated since the original was built. The copy carries over the source's + `source`, `modifications`, `bands`, `georeference`, `chunks`, and + `checksum` verbatim — only its `id` and timestamps differ. + + ## Request Body (optional) + + All fields are optional. Any field omitted is carried over from the source. + + - **name**: Name for the copy. + - **description**: Description for the copy. + - **tags**: Tags for the copy. + + Send no body at all to copy the metadata unchanged. + + ## Response + + Returns the new Grid with status `\"pending\"`. The data is copied in the + background; the status transitions to `\"completed\"` once the copy finishes + (or `\"failed\"` if it does not). Data endpoints (`/chunks`, `/data`) become + available only after the copy completes. The source grid is unchanged. + + ## Error Responses + + - **404 Not Found**: The source grid does not exist, is not owned by the + caller, or is not in this domain. + - **422 Unprocessable Content**: The source grid exists but is not yet + `completed`, so there is no finished artifact to copy. + + Args: + domain_id (str): + grid_id (str): + body (DuplicateGridRequest | None | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError + """ + + return sync_detailed( + domain_id=domain_id, + grid_id=grid_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + grid_id: str, + *, + client: AuthenticatedClient, + body: DuplicateGridRequest | None | Unset = UNSET, +) -> Response[Grid | HTTPValidationError]: + r"""Duplicate a grid + + # Duplicate a Grid + + Creates an independent **copy** of a completed grid under a new ID. Use + this to branch a scenario: duplicate, then edit the copy while the + original stays untouched. + + This is a true clone, not a re-derivation. The finished data is + byte-copied; no regeneration is performed and the upstream source is never + re-fetched, so the copy is exact even if the upstream product has been + updated since the original was built. The copy carries over the source's + `source`, `modifications`, `bands`, `georeference`, `chunks`, and + `checksum` verbatim — only its `id` and timestamps differ. + + ## Request Body (optional) + + All fields are optional. Any field omitted is carried over from the source. + + - **name**: Name for the copy. + - **description**: Description for the copy. + - **tags**: Tags for the copy. + + Send no body at all to copy the metadata unchanged. + + ## Response + + Returns the new Grid with status `\"pending\"`. The data is copied in the + background; the status transitions to `\"completed\"` once the copy finishes + (or `\"failed\"` if it does not). Data endpoints (`/chunks`, `/data`) become + available only after the copy completes. The source grid is unchanged. + + ## Error Responses + + - **404 Not Found**: The source grid does not exist, is not owned by the + caller, or is not in this domain. + - **422 Unprocessable Content**: The source grid exists but is not yet + `completed`, so there is no finished artifact to copy. + + Args: + domain_id (str): + grid_id (str): + body (DuplicateGridRequest | None | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + grid_id=grid_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + grid_id: str, + *, + client: AuthenticatedClient, + body: DuplicateGridRequest | None | Unset = UNSET, +) -> Grid | HTTPValidationError | None: + r"""Duplicate a grid + + # Duplicate a Grid + + Creates an independent **copy** of a completed grid under a new ID. Use + this to branch a scenario: duplicate, then edit the copy while the + original stays untouched. + + This is a true clone, not a re-derivation. The finished data is + byte-copied; no regeneration is performed and the upstream source is never + re-fetched, so the copy is exact even if the upstream product has been + updated since the original was built. The copy carries over the source's + `source`, `modifications`, `bands`, `georeference`, `chunks`, and + `checksum` verbatim — only its `id` and timestamps differ. + + ## Request Body (optional) + + All fields are optional. Any field omitted is carried over from the source. + + - **name**: Name for the copy. + - **description**: Description for the copy. + - **tags**: Tags for the copy. + + Send no body at all to copy the metadata unchanged. + + ## Response + + Returns the new Grid with status `\"pending\"`. The data is copied in the + background; the status transitions to `\"completed\"` once the copy finishes + (or `\"failed\"` if it does not). Data endpoints (`/chunks`, `/data`) become + available only after the copy completes. The source grid is unchanged. + + ## Error Responses + + - **404 Not Found**: The source grid does not exist, is not owned by the + caller, or is not in this domain. + - **422 Unprocessable Content**: The source grid exists but is not yet + `completed`, so there is no finished artifact to copy. + + Args: + domain_id (str): + grid_id (str): + body (DuplicateGridRequest | None | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + grid_id=grid_id, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/inventories/apply_treatments.py b/fastfuels_sdk/v2/client_library/api/inventories/apply_treatments.py new file mode 100644 index 0000000..c8b6321 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/inventories/apply_treatments.py @@ -0,0 +1,448 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.apply_treatments_request import ApplyTreatmentsRequest +from ...models.http_validation_error import HTTPValidationError +from ...models.inventory import Inventory +from ...types import Response + + +def _get_kwargs( + domain_id: str, + inventory_id: str, + *, + body: ApplyTreatmentsRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/domains/{domain_id}/inventories/{inventory_id}/treatments".format( + domain_id=quote(str(domain_id), safe=""), + inventory_id=quote(str(inventory_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> HTTPValidationError | Inventory | None: + if response.status_code == 200: + response_200 = Inventory.from_dict(response.json()) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[HTTPValidationError | Inventory]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + inventory_id: str, + *, + client: AuthenticatedClient, + body: ApplyTreatmentsRequest, +) -> Response[HTTPValidationError | Inventory]: + r"""Apply treatments to an inventory in place + + # Apply Treatments to an Inventory (in place) + + Applies silvicultural treatments to **this** inventory in place — the + inventory keeps its ID and the submitted treatments are appended to its + cumulative `treatments` list, then the tree data is re-derived + asynchronously. To keep the original data instead, duplicate the inventory + first (`POST .../{inventory_id}/duplicate`) and treat the copy. + + A treatment thins the stand toward a target metric using a tree-selection + method. Treatments compose: each is applied to the result of the previous. + + ## Metrics + + Each treatment is discriminated by its `metric`: + + - `diameter` — thin to a diameter-at-breast-height limit (in cm unless + `unit` is set). `from_below` removes trees smaller than the limit; + `from_above` removes trees larger than it. + - `basal_area` — thin to a residual basal area (in `m**2/ha` unless `unit` + is set). `from_below`/`from_above` remove the smallest/largest trees first + until the target is reached; `proportional` removes across all diameter + classes, preserving the diameter distribution. + + `proportional` is only valid for a basal-area target — it is not an option + for a diameter limit. + + ## Units + + `value` uses the metric's native unit (`cm` for diameter, `m**2/ha` for + basal area) unless an optional `unit` is supplied. A supplied `unit` must be + canonical and dimensionally compatible with the native unit; it is converted + before the treatment is applied. + + ## Spatial scoping + + An optional `conditions` list restricts the treatment to a region + (`within`/`outside`/`intersects` a geometry or a referenced Feature, with an + optional `buffer_m`). An empty/omitted list treats the entire inventory. A + referenced Feature must belong to the same domain as this inventory and be + in `completed` status; cross-domain, missing, or unfinished references are + rejected with 422. + + Because a basal-area treatment holds its entire treated population in memory + at once, an inventory-wide basal-area treatment over a very large domain is + rejected with 422 — scope it with a spatial condition. + + ## Requirements + + Treatments thin against tree diameter, so the inventory must have a `dbh` + column. Inventories derived from a canopy height model (CHM) carry only + height and position, so treatments cannot be applied to them (422). + + ## Response + + Returns this inventory (same ID) with status `\"pending\"` and the submitted + treatments appended to `treatments`. Its `checksum` changes, so any resource + derived from it can detect that the source has changed. Poll the inventory + until status returns to `\"completed\"`. + + Args: + domain_id (str): + inventory_id (str): + body (ApplyTreatmentsRequest): Request body for applying treatments to an inventory in + place. + + Metadata (name, description, tags) is not accepted here — the inventory + keeps its identity; use PATCH to edit metadata. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | Inventory] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + inventory_id=inventory_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + inventory_id: str, + *, + client: AuthenticatedClient, + body: ApplyTreatmentsRequest, +) -> HTTPValidationError | Inventory | None: + r"""Apply treatments to an inventory in place + + # Apply Treatments to an Inventory (in place) + + Applies silvicultural treatments to **this** inventory in place — the + inventory keeps its ID and the submitted treatments are appended to its + cumulative `treatments` list, then the tree data is re-derived + asynchronously. To keep the original data instead, duplicate the inventory + first (`POST .../{inventory_id}/duplicate`) and treat the copy. + + A treatment thins the stand toward a target metric using a tree-selection + method. Treatments compose: each is applied to the result of the previous. + + ## Metrics + + Each treatment is discriminated by its `metric`: + + - `diameter` — thin to a diameter-at-breast-height limit (in cm unless + `unit` is set). `from_below` removes trees smaller than the limit; + `from_above` removes trees larger than it. + - `basal_area` — thin to a residual basal area (in `m**2/ha` unless `unit` + is set). `from_below`/`from_above` remove the smallest/largest trees first + until the target is reached; `proportional` removes across all diameter + classes, preserving the diameter distribution. + + `proportional` is only valid for a basal-area target — it is not an option + for a diameter limit. + + ## Units + + `value` uses the metric's native unit (`cm` for diameter, `m**2/ha` for + basal area) unless an optional `unit` is supplied. A supplied `unit` must be + canonical and dimensionally compatible with the native unit; it is converted + before the treatment is applied. + + ## Spatial scoping + + An optional `conditions` list restricts the treatment to a region + (`within`/`outside`/`intersects` a geometry or a referenced Feature, with an + optional `buffer_m`). An empty/omitted list treats the entire inventory. A + referenced Feature must belong to the same domain as this inventory and be + in `completed` status; cross-domain, missing, or unfinished references are + rejected with 422. + + Because a basal-area treatment holds its entire treated population in memory + at once, an inventory-wide basal-area treatment over a very large domain is + rejected with 422 — scope it with a spatial condition. + + ## Requirements + + Treatments thin against tree diameter, so the inventory must have a `dbh` + column. Inventories derived from a canopy height model (CHM) carry only + height and position, so treatments cannot be applied to them (422). + + ## Response + + Returns this inventory (same ID) with status `\"pending\"` and the submitted + treatments appended to `treatments`. Its `checksum` changes, so any resource + derived from it can detect that the source has changed. Poll the inventory + until status returns to `\"completed\"`. + + Args: + domain_id (str): + inventory_id (str): + body (ApplyTreatmentsRequest): Request body for applying treatments to an inventory in + place. + + Metadata (name, description, tags) is not accepted here — the inventory + keeps its identity; use PATCH to edit metadata. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | Inventory + """ + + return sync_detailed( + domain_id=domain_id, + inventory_id=inventory_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + inventory_id: str, + *, + client: AuthenticatedClient, + body: ApplyTreatmentsRequest, +) -> Response[HTTPValidationError | Inventory]: + r"""Apply treatments to an inventory in place + + # Apply Treatments to an Inventory (in place) + + Applies silvicultural treatments to **this** inventory in place — the + inventory keeps its ID and the submitted treatments are appended to its + cumulative `treatments` list, then the tree data is re-derived + asynchronously. To keep the original data instead, duplicate the inventory + first (`POST .../{inventory_id}/duplicate`) and treat the copy. + + A treatment thins the stand toward a target metric using a tree-selection + method. Treatments compose: each is applied to the result of the previous. + + ## Metrics + + Each treatment is discriminated by its `metric`: + + - `diameter` — thin to a diameter-at-breast-height limit (in cm unless + `unit` is set). `from_below` removes trees smaller than the limit; + `from_above` removes trees larger than it. + - `basal_area` — thin to a residual basal area (in `m**2/ha` unless `unit` + is set). `from_below`/`from_above` remove the smallest/largest trees first + until the target is reached; `proportional` removes across all diameter + classes, preserving the diameter distribution. + + `proportional` is only valid for a basal-area target — it is not an option + for a diameter limit. + + ## Units + + `value` uses the metric's native unit (`cm` for diameter, `m**2/ha` for + basal area) unless an optional `unit` is supplied. A supplied `unit` must be + canonical and dimensionally compatible with the native unit; it is converted + before the treatment is applied. + + ## Spatial scoping + + An optional `conditions` list restricts the treatment to a region + (`within`/`outside`/`intersects` a geometry or a referenced Feature, with an + optional `buffer_m`). An empty/omitted list treats the entire inventory. A + referenced Feature must belong to the same domain as this inventory and be + in `completed` status; cross-domain, missing, or unfinished references are + rejected with 422. + + Because a basal-area treatment holds its entire treated population in memory + at once, an inventory-wide basal-area treatment over a very large domain is + rejected with 422 — scope it with a spatial condition. + + ## Requirements + + Treatments thin against tree diameter, so the inventory must have a `dbh` + column. Inventories derived from a canopy height model (CHM) carry only + height and position, so treatments cannot be applied to them (422). + + ## Response + + Returns this inventory (same ID) with status `\"pending\"` and the submitted + treatments appended to `treatments`. Its `checksum` changes, so any resource + derived from it can detect that the source has changed. Poll the inventory + until status returns to `\"completed\"`. + + Args: + domain_id (str): + inventory_id (str): + body (ApplyTreatmentsRequest): Request body for applying treatments to an inventory in + place. + + Metadata (name, description, tags) is not accepted here — the inventory + keeps its identity; use PATCH to edit metadata. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | Inventory] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + inventory_id=inventory_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + inventory_id: str, + *, + client: AuthenticatedClient, + body: ApplyTreatmentsRequest, +) -> HTTPValidationError | Inventory | None: + r"""Apply treatments to an inventory in place + + # Apply Treatments to an Inventory (in place) + + Applies silvicultural treatments to **this** inventory in place — the + inventory keeps its ID and the submitted treatments are appended to its + cumulative `treatments` list, then the tree data is re-derived + asynchronously. To keep the original data instead, duplicate the inventory + first (`POST .../{inventory_id}/duplicate`) and treat the copy. + + A treatment thins the stand toward a target metric using a tree-selection + method. Treatments compose: each is applied to the result of the previous. + + ## Metrics + + Each treatment is discriminated by its `metric`: + + - `diameter` — thin to a diameter-at-breast-height limit (in cm unless + `unit` is set). `from_below` removes trees smaller than the limit; + `from_above` removes trees larger than it. + - `basal_area` — thin to a residual basal area (in `m**2/ha` unless `unit` + is set). `from_below`/`from_above` remove the smallest/largest trees first + until the target is reached; `proportional` removes across all diameter + classes, preserving the diameter distribution. + + `proportional` is only valid for a basal-area target — it is not an option + for a diameter limit. + + ## Units + + `value` uses the metric's native unit (`cm` for diameter, `m**2/ha` for + basal area) unless an optional `unit` is supplied. A supplied `unit` must be + canonical and dimensionally compatible with the native unit; it is converted + before the treatment is applied. + + ## Spatial scoping + + An optional `conditions` list restricts the treatment to a region + (`within`/`outside`/`intersects` a geometry or a referenced Feature, with an + optional `buffer_m`). An empty/omitted list treats the entire inventory. A + referenced Feature must belong to the same domain as this inventory and be + in `completed` status; cross-domain, missing, or unfinished references are + rejected with 422. + + Because a basal-area treatment holds its entire treated population in memory + at once, an inventory-wide basal-area treatment over a very large domain is + rejected with 422 — scope it with a spatial condition. + + ## Requirements + + Treatments thin against tree diameter, so the inventory must have a `dbh` + column. Inventories derived from a canopy height model (CHM) carry only + height and position, so treatments cannot be applied to them (422). + + ## Response + + Returns this inventory (same ID) with status `\"pending\"` and the submitted + treatments appended to `treatments`. Its `checksum` changes, so any resource + derived from it can detect that the source has changed. Poll the inventory + until status returns to `\"completed\"`. + + Args: + domain_id (str): + inventory_id (str): + body (ApplyTreatmentsRequest): Request body for applying treatments to an inventory in + place. + + Metadata (name, description, tags) is not accepted here — the inventory + keeps its identity; use PATCH to edit metadata. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | Inventory + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + inventory_id=inventory_id, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/inventories/create_inventory_upload.py b/fastfuels_sdk/v2/client_library/api/inventories/create_inventory_upload.py index dd46d70..f7705d2 100644 --- a/fastfuels_sdk/v2/client_library/api/inventories/create_inventory_upload.py +++ b/fastfuels_sdk/v2/client_library/api/inventories/create_inventory_upload.py @@ -70,13 +70,20 @@ def sync_detailed( client: AuthenticatedClient, body: CreateInventoryUploadRequest, ) -> Response[HTTPValidationError | InventoryUploadCreatedResponse]: - """Create an inventory from a direct file upload + r"""Create an inventory from a direct file upload # Create Upload Inventory Creates an inventory resource and returns a signed URL for uploading the - source file directly to GCS. The upload must use HTTP PUT with the - Content-Type header matching the value in the response. + source file directly to GCS. Upload with HTTP PUT, sending **every header + in the response's `upload.headers`** exactly as given — the signed URL + commits to them, and the upload is rejected if any is missing or altered. + For example: + + ```bash + curl -X PUT --upload-file trees.csv -H \"Content-Type: text/csv\" -H \"x-goog-content- + length-range: 0,524288000\" \"\" + ``` When the upload completes, the uploader service processes the file automatically via Eventarc and updates the inventory status to @@ -126,13 +133,20 @@ def sync( client: AuthenticatedClient, body: CreateInventoryUploadRequest, ) -> HTTPValidationError | InventoryUploadCreatedResponse | None: - """Create an inventory from a direct file upload + r"""Create an inventory from a direct file upload # Create Upload Inventory Creates an inventory resource and returns a signed URL for uploading the - source file directly to GCS. The upload must use HTTP PUT with the - Content-Type header matching the value in the response. + source file directly to GCS. Upload with HTTP PUT, sending **every header + in the response's `upload.headers`** exactly as given — the signed URL + commits to them, and the upload is rejected if any is missing or altered. + For example: + + ```bash + curl -X PUT --upload-file trees.csv -H \"Content-Type: text/csv\" -H \"x-goog-content- + length-range: 0,524288000\" \"\" + ``` When the upload completes, the uploader service processes the file automatically via Eventarc and updates the inventory status to @@ -177,13 +191,20 @@ async def asyncio_detailed( client: AuthenticatedClient, body: CreateInventoryUploadRequest, ) -> Response[HTTPValidationError | InventoryUploadCreatedResponse]: - """Create an inventory from a direct file upload + r"""Create an inventory from a direct file upload # Create Upload Inventory Creates an inventory resource and returns a signed URL for uploading the - source file directly to GCS. The upload must use HTTP PUT with the - Content-Type header matching the value in the response. + source file directly to GCS. Upload with HTTP PUT, sending **every header + in the response's `upload.headers`** exactly as given — the signed URL + commits to them, and the upload is rejected if any is missing or altered. + For example: + + ```bash + curl -X PUT --upload-file trees.csv -H \"Content-Type: text/csv\" -H \"x-goog-content- + length-range: 0,524288000\" \"\" + ``` When the upload completes, the uploader service processes the file automatically via Eventarc and updates the inventory status to @@ -231,13 +252,20 @@ async def asyncio( client: AuthenticatedClient, body: CreateInventoryUploadRequest, ) -> HTTPValidationError | InventoryUploadCreatedResponse | None: - """Create an inventory from a direct file upload + r"""Create an inventory from a direct file upload # Create Upload Inventory Creates an inventory resource and returns a signed URL for uploading the - source file directly to GCS. The upload must use HTTP PUT with the - Content-Type header matching the value in the response. + source file directly to GCS. Upload with HTTP PUT, sending **every header + in the response's `upload.headers`** exactly as given — the signed URL + commits to them, and the upload is rejected if any is missing or altered. + For example: + + ```bash + curl -X PUT --upload-file trees.csv -H \"Content-Type: text/csv\" -H \"x-goog-content- + length-range: 0,524288000\" \"\" + ``` When the upload completes, the uploader service processes the file automatically via Eventarc and updates the inventory status to diff --git a/fastfuels_sdk/v2/client_library/api/point_clouds/__init__.py b/fastfuels_sdk/v2/client_library/api/point_clouds/__init__.py new file mode 100644 index 0000000..2d7c0b2 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/point_clouds/__init__.py @@ -0,0 +1 @@ +"""Contains endpoint functions for accessing the API""" diff --git a/fastfuels_sdk/v2/client_library/api/point_clouds/create_point_cloud_upload.py b/fastfuels_sdk/v2/client_library/api/point_clouds/create_point_cloud_upload.py new file mode 100644 index 0000000..89a6be1 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/point_clouds/create_point_cloud_upload.py @@ -0,0 +1,344 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.create_point_cloud_upload_request import CreatePointCloudUploadRequest +from ...models.http_validation_error import HTTPValidationError +from ...models.point_cloud_upload_created_response import ( + PointCloudUploadCreatedResponse, +) +from ...types import Response + + +def _get_kwargs( + domain_id: str, + *, + body: CreatePointCloudUploadRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/domains/{domain_id}/pointclouds/upload".format( + domain_id=quote(str(domain_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> HTTPValidationError | PointCloudUploadCreatedResponse | None: + if response.status_code == 201: + response_201 = PointCloudUploadCreatedResponse.from_dict(response.json()) + + return response_201 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[HTTPValidationError | PointCloudUploadCreatedResponse]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreatePointCloudUploadRequest, +) -> Response[HTTPValidationError | PointCloudUploadCreatedResponse]: + r"""Create a point cloud from a direct file upload + + # Upload a Point Cloud + + Creates a point cloud resource and returns a signed URL for uploading the + source file directly to storage. Upload is a two-step flow: + + 1. **POST** this request to create the point cloud and receive an `upload` + spec containing a signed URL. + 2. **PUT** your file to `upload.url`, sending **every header in + `upload.headers`** exactly as given — the signed URL commits to them, + and the upload is rejected if any is missing or altered. The file must + not exceed `upload.max_size_bytes`, and the upload must complete before + `upload.expires_at`. For example: + + ```bash + curl -X PUT --upload-file cloud.laz -H \"Content-Type: application/octet-stream\" + -H \"x-goog-content-length-range: 0,1073741824\" \"\" + ``` + + The point cloud is returned immediately with `status` = `pending`. Once the + file finishes uploading it is ingested in the background: `status` becomes + `running`, then `completed` after the cloud is validated and its + `georeference` and `summary` are filled in — or `failed` if the file cannot + be read as a point cloud with a coordinate reference system. Poll + `GET /domains/{domain_id}/pointclouds/{id}` to follow progress. + + ## Supported formats + + Upload an uncompressed **LAS** or compressed **LAZ** file (including Cloud + Optimized Point Clouds, which are valid LAZ). The format is detected from + the file itself — there is nothing to declare. + + ## Coordinate reference system + + The file must carry a coordinate reference system; uploads without one are + rejected during ingestion. A cloud in a different CRS than its domain is + automatically reprojected to the domain CRS (horizontal coordinates only — + elevations are preserved as-is), so the stored cloud is always in the + domain CRS. + + Args: + domain_id (str): + body (CreatePointCloudUploadRequest): Request body for creating a point cloud from a + direct file upload. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | PointCloudUploadCreatedResponse] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreatePointCloudUploadRequest, +) -> HTTPValidationError | PointCloudUploadCreatedResponse | None: + r"""Create a point cloud from a direct file upload + + # Upload a Point Cloud + + Creates a point cloud resource and returns a signed URL for uploading the + source file directly to storage. Upload is a two-step flow: + + 1. **POST** this request to create the point cloud and receive an `upload` + spec containing a signed URL. + 2. **PUT** your file to `upload.url`, sending **every header in + `upload.headers`** exactly as given — the signed URL commits to them, + and the upload is rejected if any is missing or altered. The file must + not exceed `upload.max_size_bytes`, and the upload must complete before + `upload.expires_at`. For example: + + ```bash + curl -X PUT --upload-file cloud.laz -H \"Content-Type: application/octet-stream\" + -H \"x-goog-content-length-range: 0,1073741824\" \"\" + ``` + + The point cloud is returned immediately with `status` = `pending`. Once the + file finishes uploading it is ingested in the background: `status` becomes + `running`, then `completed` after the cloud is validated and its + `georeference` and `summary` are filled in — or `failed` if the file cannot + be read as a point cloud with a coordinate reference system. Poll + `GET /domains/{domain_id}/pointclouds/{id}` to follow progress. + + ## Supported formats + + Upload an uncompressed **LAS** or compressed **LAZ** file (including Cloud + Optimized Point Clouds, which are valid LAZ). The format is detected from + the file itself — there is nothing to declare. + + ## Coordinate reference system + + The file must carry a coordinate reference system; uploads without one are + rejected during ingestion. A cloud in a different CRS than its domain is + automatically reprojected to the domain CRS (horizontal coordinates only — + elevations are preserved as-is), so the stored cloud is always in the + domain CRS. + + Args: + domain_id (str): + body (CreatePointCloudUploadRequest): Request body for creating a point cloud from a + direct file upload. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | PointCloudUploadCreatedResponse + """ + + return sync_detailed( + domain_id=domain_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreatePointCloudUploadRequest, +) -> Response[HTTPValidationError | PointCloudUploadCreatedResponse]: + r"""Create a point cloud from a direct file upload + + # Upload a Point Cloud + + Creates a point cloud resource and returns a signed URL for uploading the + source file directly to storage. Upload is a two-step flow: + + 1. **POST** this request to create the point cloud and receive an `upload` + spec containing a signed URL. + 2. **PUT** your file to `upload.url`, sending **every header in + `upload.headers`** exactly as given — the signed URL commits to them, + and the upload is rejected if any is missing or altered. The file must + not exceed `upload.max_size_bytes`, and the upload must complete before + `upload.expires_at`. For example: + + ```bash + curl -X PUT --upload-file cloud.laz -H \"Content-Type: application/octet-stream\" + -H \"x-goog-content-length-range: 0,1073741824\" \"\" + ``` + + The point cloud is returned immediately with `status` = `pending`. Once the + file finishes uploading it is ingested in the background: `status` becomes + `running`, then `completed` after the cloud is validated and its + `georeference` and `summary` are filled in — or `failed` if the file cannot + be read as a point cloud with a coordinate reference system. Poll + `GET /domains/{domain_id}/pointclouds/{id}` to follow progress. + + ## Supported formats + + Upload an uncompressed **LAS** or compressed **LAZ** file (including Cloud + Optimized Point Clouds, which are valid LAZ). The format is detected from + the file itself — there is nothing to declare. + + ## Coordinate reference system + + The file must carry a coordinate reference system; uploads without one are + rejected during ingestion. A cloud in a different CRS than its domain is + automatically reprojected to the domain CRS (horizontal coordinates only — + elevations are preserved as-is), so the stored cloud is always in the + domain CRS. + + Args: + domain_id (str): + body (CreatePointCloudUploadRequest): Request body for creating a point cloud from a + direct file upload. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | PointCloudUploadCreatedResponse] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreatePointCloudUploadRequest, +) -> HTTPValidationError | PointCloudUploadCreatedResponse | None: + r"""Create a point cloud from a direct file upload + + # Upload a Point Cloud + + Creates a point cloud resource and returns a signed URL for uploading the + source file directly to storage. Upload is a two-step flow: + + 1. **POST** this request to create the point cloud and receive an `upload` + spec containing a signed URL. + 2. **PUT** your file to `upload.url`, sending **every header in + `upload.headers`** exactly as given — the signed URL commits to them, + and the upload is rejected if any is missing or altered. The file must + not exceed `upload.max_size_bytes`, and the upload must complete before + `upload.expires_at`. For example: + + ```bash + curl -X PUT --upload-file cloud.laz -H \"Content-Type: application/octet-stream\" + -H \"x-goog-content-length-range: 0,1073741824\" \"\" + ``` + + The point cloud is returned immediately with `status` = `pending`. Once the + file finishes uploading it is ingested in the background: `status` becomes + `running`, then `completed` after the cloud is validated and its + `georeference` and `summary` are filled in — or `failed` if the file cannot + be read as a point cloud with a coordinate reference system. Poll + `GET /domains/{domain_id}/pointclouds/{id}` to follow progress. + + ## Supported formats + + Upload an uncompressed **LAS** or compressed **LAZ** file (including Cloud + Optimized Point Clouds, which are valid LAZ). The format is detected from + the file itself — there is nothing to declare. + + ## Coordinate reference system + + The file must carry a coordinate reference system; uploads without one are + rejected during ingestion. A cloud in a different CRS than its domain is + automatically reprojected to the domain CRS (horizontal coordinates only — + elevations are preserved as-is), so the stored cloud is always in the + domain CRS. + + Args: + domain_id (str): + body (CreatePointCloudUploadRequest): Request body for creating a point cloud from a + direct file upload. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | PointCloudUploadCreatedResponse + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/point_clouds/delete_point_cloud.py b/fastfuels_sdk/v2/client_library/api/point_clouds/delete_point_cloud.py new file mode 100644 index 0000000..5837222 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/point_clouds/delete_point_cloud.py @@ -0,0 +1,245 @@ +from http import HTTPStatus +from typing import Any, cast +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.http_validation_error import HTTPValidationError +from ...types import Response + + +def _get_kwargs( + domain_id: str, + point_cloud_id: str, +) -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "delete", + "url": "/domains/{domain_id}/pointclouds/{point_cloud_id}".format( + domain_id=quote(str(domain_id), safe=""), + point_cloud_id=quote(str(point_cloud_id), safe=""), + ), + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Any | HTTPValidationError | None: + if response.status_code == 204: + response_204 = cast(Any, None) + return response_204 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Any | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + point_cloud_id: str, + *, + client: AuthenticatedClient, +) -> Response[Any | HTTPValidationError]: + """Delete a point cloud + + # Delete Point Cloud + + Permanently deletes a point cloud by its unique identifier, including the + stored point data in GCS. This action cannot be undone. + + ## Path Parameters + + - **domain_id**: (string) The domain the point cloud belongs to. + - **point_cloud_id**: (string) The unique identifier of the point cloud. + + ## Response + + Returns HTTP 204 No Content with an empty response body. + + ## Error Responses + + - **404 Not Found**: The point cloud does not exist or the user does not have access. + + Args: + domain_id (str): + point_cloud_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + point_cloud_id=point_cloud_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + point_cloud_id: str, + *, + client: AuthenticatedClient, +) -> Any | HTTPValidationError | None: + """Delete a point cloud + + # Delete Point Cloud + + Permanently deletes a point cloud by its unique identifier, including the + stored point data in GCS. This action cannot be undone. + + ## Path Parameters + + - **domain_id**: (string) The domain the point cloud belongs to. + - **point_cloud_id**: (string) The unique identifier of the point cloud. + + ## Response + + Returns HTTP 204 No Content with an empty response body. + + ## Error Responses + + - **404 Not Found**: The point cloud does not exist or the user does not have access. + + Args: + domain_id (str): + point_cloud_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Any | HTTPValidationError + """ + + return sync_detailed( + domain_id=domain_id, + point_cloud_id=point_cloud_id, + client=client, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + point_cloud_id: str, + *, + client: AuthenticatedClient, +) -> Response[Any | HTTPValidationError]: + """Delete a point cloud + + # Delete Point Cloud + + Permanently deletes a point cloud by its unique identifier, including the + stored point data in GCS. This action cannot be undone. + + ## Path Parameters + + - **domain_id**: (string) The domain the point cloud belongs to. + - **point_cloud_id**: (string) The unique identifier of the point cloud. + + ## Response + + Returns HTTP 204 No Content with an empty response body. + + ## Error Responses + + - **404 Not Found**: The point cloud does not exist or the user does not have access. + + Args: + domain_id (str): + point_cloud_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + point_cloud_id=point_cloud_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + point_cloud_id: str, + *, + client: AuthenticatedClient, +) -> Any | HTTPValidationError | None: + """Delete a point cloud + + # Delete Point Cloud + + Permanently deletes a point cloud by its unique identifier, including the + stored point data in GCS. This action cannot be undone. + + ## Path Parameters + + - **domain_id**: (string) The domain the point cloud belongs to. + - **point_cloud_id**: (string) The unique identifier of the point cloud. + + ## Response + + Returns HTTP 204 No Content with an empty response body. + + ## Error Responses + + - **404 Not Found**: The point cloud does not exist or the user does not have access. + + Args: + domain_id (str): + point_cloud_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Any | HTTPValidationError + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + point_cloud_id=point_cloud_id, + client=client, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/point_clouds/get_point_cloud.py b/fastfuels_sdk/v2/client_library/api/point_clouds/get_point_cloud.py new file mode 100644 index 0000000..25df4e8 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/point_clouds/get_point_cloud.py @@ -0,0 +1,243 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.http_validation_error import HTTPValidationError +from ...models.point_cloud import PointCloud +from ...types import Response + + +def _get_kwargs( + domain_id: str, + point_cloud_id: str, +) -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/domains/{domain_id}/pointclouds/{point_cloud_id}".format( + domain_id=quote(str(domain_id), safe=""), + point_cloud_id=quote(str(point_cloud_id), safe=""), + ), + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> HTTPValidationError | PointCloud | None: + if response.status_code == 200: + response_200 = PointCloud.from_dict(response.json()) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[HTTPValidationError | PointCloud]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + point_cloud_id: str, + *, + client: AuthenticatedClient, +) -> Response[HTTPValidationError | PointCloud]: + """Get a point cloud by ID + + # Get Point Cloud + + Retrieves a specific point cloud resource by its unique identifier. + + ## Path Parameters + + - **domain_id**: (string) The domain the point cloud belongs to. + - **point_cloud_id**: (string) The unique 32-character hex identifier. + + ## Response + + Returns the point cloud resource. + + ## Error Responses + + - **404 Not Found**: The point cloud does not exist or the user does not have access. + + Args: + domain_id (str): + point_cloud_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | PointCloud] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + point_cloud_id=point_cloud_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + point_cloud_id: str, + *, + client: AuthenticatedClient, +) -> HTTPValidationError | PointCloud | None: + """Get a point cloud by ID + + # Get Point Cloud + + Retrieves a specific point cloud resource by its unique identifier. + + ## Path Parameters + + - **domain_id**: (string) The domain the point cloud belongs to. + - **point_cloud_id**: (string) The unique 32-character hex identifier. + + ## Response + + Returns the point cloud resource. + + ## Error Responses + + - **404 Not Found**: The point cloud does not exist or the user does not have access. + + Args: + domain_id (str): + point_cloud_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | PointCloud + """ + + return sync_detailed( + domain_id=domain_id, + point_cloud_id=point_cloud_id, + client=client, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + point_cloud_id: str, + *, + client: AuthenticatedClient, +) -> Response[HTTPValidationError | PointCloud]: + """Get a point cloud by ID + + # Get Point Cloud + + Retrieves a specific point cloud resource by its unique identifier. + + ## Path Parameters + + - **domain_id**: (string) The domain the point cloud belongs to. + - **point_cloud_id**: (string) The unique 32-character hex identifier. + + ## Response + + Returns the point cloud resource. + + ## Error Responses + + - **404 Not Found**: The point cloud does not exist or the user does not have access. + + Args: + domain_id (str): + point_cloud_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | PointCloud] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + point_cloud_id=point_cloud_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + point_cloud_id: str, + *, + client: AuthenticatedClient, +) -> HTTPValidationError | PointCloud | None: + """Get a point cloud by ID + + # Get Point Cloud + + Retrieves a specific point cloud resource by its unique identifier. + + ## Path Parameters + + - **domain_id**: (string) The domain the point cloud belongs to. + - **point_cloud_id**: (string) The unique 32-character hex identifier. + + ## Response + + Returns the point cloud resource. + + ## Error Responses + + - **404 Not Found**: The point cloud does not exist or the user does not have access. + + Args: + domain_id (str): + point_cloud_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | PointCloud + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + point_cloud_id=point_cloud_id, + client=client, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/point_clouds/list_point_clouds.py b/fastfuels_sdk/v2/client_library/api/point_clouds/list_point_clouds.py new file mode 100644 index 0000000..39fe51a --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/point_clouds/list_point_clouds.py @@ -0,0 +1,406 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.http_validation_error import HTTPValidationError +from ...models.list_point_clouds_response import ListPointCloudsResponse +from ...models.point_cloud_sort_field import PointCloudSortField +from ...models.point_cloud_type import PointCloudType +from ...models.sort_order import SortOrder +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + domain_id: str, + *, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: None | PointCloudSortField | Unset = UNSET, + sort_order: None | SortOrder | Unset = UNSET, + type_: None | PointCloudType | Unset = UNSET, + source: None | str | Unset = UNSET, + tag: None | str | Unset = UNSET, +) -> dict[str, Any]: + + params: dict[str, Any] = {} + + params["page"] = page + + params["size"] = size + + json_sort_by: None | str | Unset + if isinstance(sort_by, Unset): + json_sort_by = UNSET + elif isinstance(sort_by, PointCloudSortField): + json_sort_by = sort_by.value + else: + json_sort_by = sort_by + params["sort_by"] = json_sort_by + + json_sort_order: None | str | Unset + if isinstance(sort_order, Unset): + json_sort_order = UNSET + elif isinstance(sort_order, SortOrder): + json_sort_order = sort_order.value + else: + json_sort_order = sort_order + params["sort_order"] = json_sort_order + + json_type_: None | str | Unset + if isinstance(type_, Unset): + json_type_ = UNSET + elif isinstance(type_, PointCloudType): + json_type_ = type_.value + else: + json_type_ = type_ + params["type"] = json_type_ + + json_source: None | str | Unset + if isinstance(source, Unset): + json_source = UNSET + else: + json_source = source + params["source"] = json_source + + json_tag: None | str | Unset + if isinstance(tag, Unset): + json_tag = UNSET + else: + json_tag = tag + params["tag"] = json_tag + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/domains/{domain_id}/pointclouds".format( + domain_id=quote(str(domain_id), safe=""), + ), + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> HTTPValidationError | ListPointCloudsResponse | None: + if response.status_code == 200: + response_200 = ListPointCloudsResponse.from_dict(response.json()) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[HTTPValidationError | ListPointCloudsResponse]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: None | PointCloudSortField | Unset = UNSET, + sort_order: None | SortOrder | Unset = UNSET, + type_: None | PointCloudType | Unset = UNSET, + source: None | str | Unset = UNSET, + tag: None | str | Unset = UNSET, +) -> Response[HTTPValidationError | ListPointCloudsResponse]: + """List point clouds in a domain + + # List Point Clouds (Domain) + + Retrieves a paginated list of the point clouds within a single domain + belonging to the authenticated user. + + ## Path Parameters + + - **domain_id**: (string) The domain to list point clouds for. + + ## Query Parameters + + - **page**: (integer, optional) Page number (zero-indexed). Default: 0. + - **size**: (integer, optional) Items per page (1-1000). Default: 100. + - **sort_by**: (string, optional) Field to sort by: `created_on`, `modified_on`, `name`. + - **sort_order**: (string, optional) Sort direction: `ascending`, `descending`. + - **type**: (string, optional) Filter by acquisition type: `als` or `tls`. + - **source**: (string, optional) Filter by source name (e.g., `3dep`, `upload`). + - **tag**: (string, optional) Filter point clouds that contain this tag. + + ## Response + + Returns a paginated list of point clouds with metadata. + + Args: + domain_id (str): + page (int | Unset): The page number to retrieve (zero-indexed). Default: 0. + size (int | Unset): The number of point clouds to retrieve per page. Default: 100. + sort_by (None | PointCloudSortField | Unset): The field to sort results by. + sort_order (None | SortOrder | Unset): The order to sort results (ascending or + descending). + type_ (None | PointCloudType | Unset): Filter point clouds by acquisition type (`als` or + `tls`). + source (None | str | Unset): Filter point clouds by source name (e.g., `3dep`, `upload`). + tag (None | str | Unset): Filter point clouds that contain this tag. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | ListPointCloudsResponse] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + page=page, + size=size, + sort_by=sort_by, + sort_order=sort_order, + type_=type_, + source=source, + tag=tag, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: None | PointCloudSortField | Unset = UNSET, + sort_order: None | SortOrder | Unset = UNSET, + type_: None | PointCloudType | Unset = UNSET, + source: None | str | Unset = UNSET, + tag: None | str | Unset = UNSET, +) -> HTTPValidationError | ListPointCloudsResponse | None: + """List point clouds in a domain + + # List Point Clouds (Domain) + + Retrieves a paginated list of the point clouds within a single domain + belonging to the authenticated user. + + ## Path Parameters + + - **domain_id**: (string) The domain to list point clouds for. + + ## Query Parameters + + - **page**: (integer, optional) Page number (zero-indexed). Default: 0. + - **size**: (integer, optional) Items per page (1-1000). Default: 100. + - **sort_by**: (string, optional) Field to sort by: `created_on`, `modified_on`, `name`. + - **sort_order**: (string, optional) Sort direction: `ascending`, `descending`. + - **type**: (string, optional) Filter by acquisition type: `als` or `tls`. + - **source**: (string, optional) Filter by source name (e.g., `3dep`, `upload`). + - **tag**: (string, optional) Filter point clouds that contain this tag. + + ## Response + + Returns a paginated list of point clouds with metadata. + + Args: + domain_id (str): + page (int | Unset): The page number to retrieve (zero-indexed). Default: 0. + size (int | Unset): The number of point clouds to retrieve per page. Default: 100. + sort_by (None | PointCloudSortField | Unset): The field to sort results by. + sort_order (None | SortOrder | Unset): The order to sort results (ascending or + descending). + type_ (None | PointCloudType | Unset): Filter point clouds by acquisition type (`als` or + `tls`). + source (None | str | Unset): Filter point clouds by source name (e.g., `3dep`, `upload`). + tag (None | str | Unset): Filter point clouds that contain this tag. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | ListPointCloudsResponse + """ + + return sync_detailed( + domain_id=domain_id, + client=client, + page=page, + size=size, + sort_by=sort_by, + sort_order=sort_order, + type_=type_, + source=source, + tag=tag, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: None | PointCloudSortField | Unset = UNSET, + sort_order: None | SortOrder | Unset = UNSET, + type_: None | PointCloudType | Unset = UNSET, + source: None | str | Unset = UNSET, + tag: None | str | Unset = UNSET, +) -> Response[HTTPValidationError | ListPointCloudsResponse]: + """List point clouds in a domain + + # List Point Clouds (Domain) + + Retrieves a paginated list of the point clouds within a single domain + belonging to the authenticated user. + + ## Path Parameters + + - **domain_id**: (string) The domain to list point clouds for. + + ## Query Parameters + + - **page**: (integer, optional) Page number (zero-indexed). Default: 0. + - **size**: (integer, optional) Items per page (1-1000). Default: 100. + - **sort_by**: (string, optional) Field to sort by: `created_on`, `modified_on`, `name`. + - **sort_order**: (string, optional) Sort direction: `ascending`, `descending`. + - **type**: (string, optional) Filter by acquisition type: `als` or `tls`. + - **source**: (string, optional) Filter by source name (e.g., `3dep`, `upload`). + - **tag**: (string, optional) Filter point clouds that contain this tag. + + ## Response + + Returns a paginated list of point clouds with metadata. + + Args: + domain_id (str): + page (int | Unset): The page number to retrieve (zero-indexed). Default: 0. + size (int | Unset): The number of point clouds to retrieve per page. Default: 100. + sort_by (None | PointCloudSortField | Unset): The field to sort results by. + sort_order (None | SortOrder | Unset): The order to sort results (ascending or + descending). + type_ (None | PointCloudType | Unset): Filter point clouds by acquisition type (`als` or + `tls`). + source (None | str | Unset): Filter point clouds by source name (e.g., `3dep`, `upload`). + tag (None | str | Unset): Filter point clouds that contain this tag. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | ListPointCloudsResponse] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + page=page, + size=size, + sort_by=sort_by, + sort_order=sort_order, + type_=type_, + source=source, + tag=tag, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: None | PointCloudSortField | Unset = UNSET, + sort_order: None | SortOrder | Unset = UNSET, + type_: None | PointCloudType | Unset = UNSET, + source: None | str | Unset = UNSET, + tag: None | str | Unset = UNSET, +) -> HTTPValidationError | ListPointCloudsResponse | None: + """List point clouds in a domain + + # List Point Clouds (Domain) + + Retrieves a paginated list of the point clouds within a single domain + belonging to the authenticated user. + + ## Path Parameters + + - **domain_id**: (string) The domain to list point clouds for. + + ## Query Parameters + + - **page**: (integer, optional) Page number (zero-indexed). Default: 0. + - **size**: (integer, optional) Items per page (1-1000). Default: 100. + - **sort_by**: (string, optional) Field to sort by: `created_on`, `modified_on`, `name`. + - **sort_order**: (string, optional) Sort direction: `ascending`, `descending`. + - **type**: (string, optional) Filter by acquisition type: `als` or `tls`. + - **source**: (string, optional) Filter by source name (e.g., `3dep`, `upload`). + - **tag**: (string, optional) Filter point clouds that contain this tag. + + ## Response + + Returns a paginated list of point clouds with metadata. + + Args: + domain_id (str): + page (int | Unset): The page number to retrieve (zero-indexed). Default: 0. + size (int | Unset): The number of point clouds to retrieve per page. Default: 100. + sort_by (None | PointCloudSortField | Unset): The field to sort results by. + sort_order (None | SortOrder | Unset): The order to sort results (ascending or + descending). + type_ (None | PointCloudType | Unset): Filter point clouds by acquisition type (`als` or + `tls`). + source (None | str | Unset): Filter point clouds by source name (e.g., `3dep`, `upload`). + tag (None | str | Unset): Filter point clouds that contain this tag. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | ListPointCloudsResponse + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + client=client, + page=page, + size=size, + sort_by=sort_by, + sort_order=sort_order, + type_=type_, + source=source, + tag=tag, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/point_clouds/list_point_clouds_cross_domain.py b/fastfuels_sdk/v2/client_library/api/point_clouds/list_point_clouds_cross_domain.py new file mode 100644 index 0000000..5315597 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/point_clouds/list_point_clouds_cross_domain.py @@ -0,0 +1,374 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.http_validation_error import HTTPValidationError +from ...models.list_point_clouds_response import ListPointCloudsResponse +from ...models.point_cloud_sort_field import PointCloudSortField +from ...models.point_cloud_type import PointCloudType +from ...models.sort_order import SortOrder +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + *, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: None | PointCloudSortField | Unset = UNSET, + sort_order: None | SortOrder | Unset = UNSET, + type_: None | PointCloudType | Unset = UNSET, + source: None | str | Unset = UNSET, + tag: None | str | Unset = UNSET, +) -> dict[str, Any]: + + params: dict[str, Any] = {} + + params["page"] = page + + params["size"] = size + + json_sort_by: None | str | Unset + if isinstance(sort_by, Unset): + json_sort_by = UNSET + elif isinstance(sort_by, PointCloudSortField): + json_sort_by = sort_by.value + else: + json_sort_by = sort_by + params["sort_by"] = json_sort_by + + json_sort_order: None | str | Unset + if isinstance(sort_order, Unset): + json_sort_order = UNSET + elif isinstance(sort_order, SortOrder): + json_sort_order = sort_order.value + else: + json_sort_order = sort_order + params["sort_order"] = json_sort_order + + json_type_: None | str | Unset + if isinstance(type_, Unset): + json_type_ = UNSET + elif isinstance(type_, PointCloudType): + json_type_ = type_.value + else: + json_type_ = type_ + params["type"] = json_type_ + + json_source: None | str | Unset + if isinstance(source, Unset): + json_source = UNSET + else: + json_source = source + params["source"] = json_source + + json_tag: None | str | Unset + if isinstance(tag, Unset): + json_tag = UNSET + else: + json_tag = tag + params["tag"] = json_tag + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/domains/-/pointclouds", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> HTTPValidationError | ListPointCloudsResponse | None: + if response.status_code == 200: + response_200 = ListPointCloudsResponse.from_dict(response.json()) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[HTTPValidationError | ListPointCloudsResponse]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: None | PointCloudSortField | Unset = UNSET, + sort_order: None | SortOrder | Unset = UNSET, + type_: None | PointCloudType | Unset = UNSET, + source: None | str | Unset = UNSET, + tag: None | str | Unset = UNSET, +) -> Response[HTTPValidationError | ListPointCloudsResponse]: + """List point clouds across all domains + + # List Point Clouds (All Domains) + + Retrieves a paginated list of every point cloud belonging to the + authenticated user, across all of their domains. + + ## Query Parameters + + - **page**: (integer, optional) Page number (zero-indexed). Default: 0. + - **size**: (integer, optional) Items per page (1-1000). Default: 100. + - **sort_by**: (string, optional) Field to sort by: `created_on`, `modified_on`, `name`. + - **sort_order**: (string, optional) Sort direction: `ascending`, `descending`. + - **type**: (string, optional) Filter by acquisition type: `als` or `tls`. + - **source**: (string, optional) Filter by source name (e.g., `3dep`, `upload`). + - **tag**: (string, optional) Filter point clouds that contain this tag. + + ## Response + + Returns a paginated list of point clouds with metadata. + + Args: + page (int | Unset): The page number to retrieve (zero-indexed). Default: 0. + size (int | Unset): The number of point clouds to retrieve per page. Default: 100. + sort_by (None | PointCloudSortField | Unset): The field to sort results by. + sort_order (None | SortOrder | Unset): The order to sort results (ascending or + descending). + type_ (None | PointCloudType | Unset): Filter point clouds by acquisition type (`als` or + `tls`). + source (None | str | Unset): Filter point clouds by source name (e.g., `3dep`, `upload`). + tag (None | str | Unset): Filter point clouds that contain this tag. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | ListPointCloudsResponse] + """ + + kwargs = _get_kwargs( + page=page, + size=size, + sort_by=sort_by, + sort_order=sort_order, + type_=type_, + source=source, + tag=tag, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: None | PointCloudSortField | Unset = UNSET, + sort_order: None | SortOrder | Unset = UNSET, + type_: None | PointCloudType | Unset = UNSET, + source: None | str | Unset = UNSET, + tag: None | str | Unset = UNSET, +) -> HTTPValidationError | ListPointCloudsResponse | None: + """List point clouds across all domains + + # List Point Clouds (All Domains) + + Retrieves a paginated list of every point cloud belonging to the + authenticated user, across all of their domains. + + ## Query Parameters + + - **page**: (integer, optional) Page number (zero-indexed). Default: 0. + - **size**: (integer, optional) Items per page (1-1000). Default: 100. + - **sort_by**: (string, optional) Field to sort by: `created_on`, `modified_on`, `name`. + - **sort_order**: (string, optional) Sort direction: `ascending`, `descending`. + - **type**: (string, optional) Filter by acquisition type: `als` or `tls`. + - **source**: (string, optional) Filter by source name (e.g., `3dep`, `upload`). + - **tag**: (string, optional) Filter point clouds that contain this tag. + + ## Response + + Returns a paginated list of point clouds with metadata. + + Args: + page (int | Unset): The page number to retrieve (zero-indexed). Default: 0. + size (int | Unset): The number of point clouds to retrieve per page. Default: 100. + sort_by (None | PointCloudSortField | Unset): The field to sort results by. + sort_order (None | SortOrder | Unset): The order to sort results (ascending or + descending). + type_ (None | PointCloudType | Unset): Filter point clouds by acquisition type (`als` or + `tls`). + source (None | str | Unset): Filter point clouds by source name (e.g., `3dep`, `upload`). + tag (None | str | Unset): Filter point clouds that contain this tag. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | ListPointCloudsResponse + """ + + return sync_detailed( + client=client, + page=page, + size=size, + sort_by=sort_by, + sort_order=sort_order, + type_=type_, + source=source, + tag=tag, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: None | PointCloudSortField | Unset = UNSET, + sort_order: None | SortOrder | Unset = UNSET, + type_: None | PointCloudType | Unset = UNSET, + source: None | str | Unset = UNSET, + tag: None | str | Unset = UNSET, +) -> Response[HTTPValidationError | ListPointCloudsResponse]: + """List point clouds across all domains + + # List Point Clouds (All Domains) + + Retrieves a paginated list of every point cloud belonging to the + authenticated user, across all of their domains. + + ## Query Parameters + + - **page**: (integer, optional) Page number (zero-indexed). Default: 0. + - **size**: (integer, optional) Items per page (1-1000). Default: 100. + - **sort_by**: (string, optional) Field to sort by: `created_on`, `modified_on`, `name`. + - **sort_order**: (string, optional) Sort direction: `ascending`, `descending`. + - **type**: (string, optional) Filter by acquisition type: `als` or `tls`. + - **source**: (string, optional) Filter by source name (e.g., `3dep`, `upload`). + - **tag**: (string, optional) Filter point clouds that contain this tag. + + ## Response + + Returns a paginated list of point clouds with metadata. + + Args: + page (int | Unset): The page number to retrieve (zero-indexed). Default: 0. + size (int | Unset): The number of point clouds to retrieve per page. Default: 100. + sort_by (None | PointCloudSortField | Unset): The field to sort results by. + sort_order (None | SortOrder | Unset): The order to sort results (ascending or + descending). + type_ (None | PointCloudType | Unset): Filter point clouds by acquisition type (`als` or + `tls`). + source (None | str | Unset): Filter point clouds by source name (e.g., `3dep`, `upload`). + tag (None | str | Unset): Filter point clouds that contain this tag. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | ListPointCloudsResponse] + """ + + kwargs = _get_kwargs( + page=page, + size=size, + sort_by=sort_by, + sort_order=sort_order, + type_=type_, + source=source, + tag=tag, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + page: int | Unset = 0, + size: int | Unset = 100, + sort_by: None | PointCloudSortField | Unset = UNSET, + sort_order: None | SortOrder | Unset = UNSET, + type_: None | PointCloudType | Unset = UNSET, + source: None | str | Unset = UNSET, + tag: None | str | Unset = UNSET, +) -> HTTPValidationError | ListPointCloudsResponse | None: + """List point clouds across all domains + + # List Point Clouds (All Domains) + + Retrieves a paginated list of every point cloud belonging to the + authenticated user, across all of their domains. + + ## Query Parameters + + - **page**: (integer, optional) Page number (zero-indexed). Default: 0. + - **size**: (integer, optional) Items per page (1-1000). Default: 100. + - **sort_by**: (string, optional) Field to sort by: `created_on`, `modified_on`, `name`. + - **sort_order**: (string, optional) Sort direction: `ascending`, `descending`. + - **type**: (string, optional) Filter by acquisition type: `als` or `tls`. + - **source**: (string, optional) Filter by source name (e.g., `3dep`, `upload`). + - **tag**: (string, optional) Filter point clouds that contain this tag. + + ## Response + + Returns a paginated list of point clouds with metadata. + + Args: + page (int | Unset): The page number to retrieve (zero-indexed). Default: 0. + size (int | Unset): The number of point clouds to retrieve per page. Default: 100. + sort_by (None | PointCloudSortField | Unset): The field to sort results by. + sort_order (None | SortOrder | Unset): The order to sort results (ascending or + descending). + type_ (None | PointCloudType | Unset): Filter point clouds by acquisition type (`als` or + `tls`). + source (None | str | Unset): Filter point clouds by source name (e.g., `3dep`, `upload`). + tag (None | str | Unset): Filter point clouds that contain this tag. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | ListPointCloudsResponse + """ + + return ( + await asyncio_detailed( + client=client, + page=page, + size=size, + sort_by=sort_by, + sort_order=sort_order, + type_=type_, + source=source, + tag=tag, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/point_clouds/update_point_cloud.py b/fastfuels_sdk/v2/client_library/api/point_clouds/update_point_cloud.py new file mode 100644 index 0000000..b18f008 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/point_clouds/update_point_cloud.py @@ -0,0 +1,360 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.http_validation_error import HTTPValidationError +from ...models.point_cloud import PointCloud +from ...models.update_point_cloud_request_body import UpdatePointCloudRequestBody +from ...types import Response + + +def _get_kwargs( + domain_id: str, + point_cloud_id: str, + *, + body: UpdatePointCloudRequestBody, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "patch", + "url": "/domains/{domain_id}/pointclouds/{point_cloud_id}".format( + domain_id=quote(str(domain_id), safe=""), + point_cloud_id=quote(str(point_cloud_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> HTTPValidationError | PointCloud | None: + if response.status_code == 200: + response_200 = PointCloud.from_dict(response.json()) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[HTTPValidationError | PointCloud]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + point_cloud_id: str, + *, + client: AuthenticatedClient, + body: UpdatePointCloudRequestBody, +) -> Response[HTTPValidationError | PointCloud]: + """Update a point cloud + + # Update Point Cloud + + Updates the metadata of an existing point cloud. Only the fields provided in + the request body are modified. + + ## Path Parameters + + - **domain_id**: (string) The domain the point cloud belongs to. + - **point_cloud_id**: (string) The unique identifier of the point cloud. + + ## Request Body + + All fields are optional: + + - **name**: (string) New name for the point cloud. + - **description**: (string) New description. + - **tags**: (array of strings) New tags (replaces existing). + + ## What Cannot Be Updated + + The following are immutable through this endpoint: + + - **id**, **domain_id**, **type**, **source**, **georeference** + - **created_on** (creation timestamp is permanent) + - **checksum** (changes only when the point cloud's content is rebuilt, never + via metadata updates) + + The **modified_on** field is updated automatically. + + ## Response + + Returns the updated point cloud resource. + + ## Error Responses + + - **404 Not Found**: The point cloud does not exist or the user does not have access. + + Args: + domain_id (str): + point_cloud_id (str): + body (UpdatePointCloudRequestBody): Request body for updating point cloud metadata. + + Only metadata is mutable. The point cloud's content, source, and derived + fields cannot be changed through this endpoint, so updates never alter the + `checksum`. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | PointCloud] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + point_cloud_id=point_cloud_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + point_cloud_id: str, + *, + client: AuthenticatedClient, + body: UpdatePointCloudRequestBody, +) -> HTTPValidationError | PointCloud | None: + """Update a point cloud + + # Update Point Cloud + + Updates the metadata of an existing point cloud. Only the fields provided in + the request body are modified. + + ## Path Parameters + + - **domain_id**: (string) The domain the point cloud belongs to. + - **point_cloud_id**: (string) The unique identifier of the point cloud. + + ## Request Body + + All fields are optional: + + - **name**: (string) New name for the point cloud. + - **description**: (string) New description. + - **tags**: (array of strings) New tags (replaces existing). + + ## What Cannot Be Updated + + The following are immutable through this endpoint: + + - **id**, **domain_id**, **type**, **source**, **georeference** + - **created_on** (creation timestamp is permanent) + - **checksum** (changes only when the point cloud's content is rebuilt, never + via metadata updates) + + The **modified_on** field is updated automatically. + + ## Response + + Returns the updated point cloud resource. + + ## Error Responses + + - **404 Not Found**: The point cloud does not exist or the user does not have access. + + Args: + domain_id (str): + point_cloud_id (str): + body (UpdatePointCloudRequestBody): Request body for updating point cloud metadata. + + Only metadata is mutable. The point cloud's content, source, and derived + fields cannot be changed through this endpoint, so updates never alter the + `checksum`. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | PointCloud + """ + + return sync_detailed( + domain_id=domain_id, + point_cloud_id=point_cloud_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + point_cloud_id: str, + *, + client: AuthenticatedClient, + body: UpdatePointCloudRequestBody, +) -> Response[HTTPValidationError | PointCloud]: + """Update a point cloud + + # Update Point Cloud + + Updates the metadata of an existing point cloud. Only the fields provided in + the request body are modified. + + ## Path Parameters + + - **domain_id**: (string) The domain the point cloud belongs to. + - **point_cloud_id**: (string) The unique identifier of the point cloud. + + ## Request Body + + All fields are optional: + + - **name**: (string) New name for the point cloud. + - **description**: (string) New description. + - **tags**: (array of strings) New tags (replaces existing). + + ## What Cannot Be Updated + + The following are immutable through this endpoint: + + - **id**, **domain_id**, **type**, **source**, **georeference** + - **created_on** (creation timestamp is permanent) + - **checksum** (changes only when the point cloud's content is rebuilt, never + via metadata updates) + + The **modified_on** field is updated automatically. + + ## Response + + Returns the updated point cloud resource. + + ## Error Responses + + - **404 Not Found**: The point cloud does not exist or the user does not have access. + + Args: + domain_id (str): + point_cloud_id (str): + body (UpdatePointCloudRequestBody): Request body for updating point cloud metadata. + + Only metadata is mutable. The point cloud's content, source, and derived + fields cannot be changed through this endpoint, so updates never alter the + `checksum`. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | PointCloud] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + point_cloud_id=point_cloud_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + point_cloud_id: str, + *, + client: AuthenticatedClient, + body: UpdatePointCloudRequestBody, +) -> HTTPValidationError | PointCloud | None: + """Update a point cloud + + # Update Point Cloud + + Updates the metadata of an existing point cloud. Only the fields provided in + the request body are modified. + + ## Path Parameters + + - **domain_id**: (string) The domain the point cloud belongs to. + - **point_cloud_id**: (string) The unique identifier of the point cloud. + + ## Request Body + + All fields are optional: + + - **name**: (string) New name for the point cloud. + - **description**: (string) New description. + - **tags**: (array of strings) New tags (replaces existing). + + ## What Cannot Be Updated + + The following are immutable through this endpoint: + + - **id**, **domain_id**, **type**, **source**, **georeference** + - **created_on** (creation timestamp is permanent) + - **checksum** (changes only when the point cloud's content is rebuilt, never + via metadata updates) + + The **modified_on** field is updated automatically. + + ## Response + + Returns the updated point cloud resource. + + ## Error Responses + + - **404 Not Found**: The point cloud does not exist or the user does not have access. + + Args: + domain_id (str): + point_cloud_id (str): + body (UpdatePointCloudRequestBody): Request body for updating point cloud metadata. + + Only metadata is mutable. The point cloud's content, source, and derived + fields cannot be changed through this endpoint, so updates never alter the + `checksum`. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | PointCloud + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + point_cloud_id=point_cloud_id, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/models/__init__.py b/fastfuels_sdk/v2/client_library/models/__init__.py index 3bdaf00..8562d86 100644 --- a/fastfuels_sdk/v2/client_library/models/__init__.py +++ b/fastfuels_sdk/v2/client_library/models/__init__.py @@ -8,6 +8,7 @@ from .allometry_max_crown_radius_source import AllometryMaxCrownRadiusSource from .application import Application from .apply_modifications_request import ApplyModificationsRequest +from .apply_treatments_request import ApplyTreatmentsRequest from .band import Band from .band_type import BandType from .base_model import BaseModel @@ -15,10 +16,12 @@ from .biomass_component_state import BiomassComponentState from .biomass_equations import BiomassEquations from .biomass_unit import BiomassUnit +from .categorical_band_summary import CategoricalBandSummary from .chunks import Chunks from .chunks_count_by_axis_type_0 import ChunksCountByAxisType0 from .column import Column from .column_type import ColumnType +from .continuous_band_summary import ContinuousBandSummary from .create_application_request import CreateApplicationRequest from .create_chm_inventory_request import CreateChmInventoryRequest from .create_domain_request_body import CreateDomainRequestBody @@ -39,6 +42,7 @@ from .create_osm_road_feature_request import CreateOsmRoadFeatureRequest from .create_osm_water_feature_request import CreateOsmWaterFeatureRequest from .create_pim_inventory_request import CreatePimInventoryRequest +from .create_point_cloud_upload_request import CreatePointCloudUploadRequest from .create_resample_request import CreateResampleRequest from .create_resample_request_method_overrides import ( CreateResampleRequestMethodOverrides, @@ -55,6 +59,7 @@ from .domain_sort_field import DomainSortField from .domain_sort_order import DomainSortOrder from .domain_style import DomainStyle +from .duplicate_grid_request import DuplicateGridRequest from .duplicate_inventory_request import DuplicateInventoryRequest from .export import Export from .export_grid_request import ExportGridRequest @@ -104,6 +109,7 @@ from .grid_spatial_target import GridSpatialTarget from .grid_upload_created_response import GridUploadCreatedResponse from .grid_upload_spec import GridUploadSpec +from .grid_upload_spec_headers import GridUploadSpecHeaders from .http_validation_error import HTTPValidationError from .inventory import Inventory from .inventory_attribute import InventoryAttribute @@ -149,6 +155,7 @@ from .inventory_upload_created_response import InventoryUploadCreatedResponse from .inventory_upload_format import InventoryUploadFormat from .inventory_upload_spec import InventoryUploadSpec +from .inventory_upload_spec_headers import InventoryUploadSpecHeaders from .job_error import JobError from .job_progress import JobProgress from .job_status import JobStatus @@ -170,6 +177,7 @@ from .list_grids_response import ListGridsResponse from .list_inventories_response import ListInventoriesResponse from .list_keys_response import ListKeysResponse +from .list_point_clouds_response import ListPointCloudsResponse from .max_crown_radius_unit import MaxCrownRadiusUnit from .meta_chm_version import MetaCHMVersion from .modifier import Modifier @@ -181,6 +189,15 @@ from .operator import Operator from .overlap_method import OverlapMethod from .point import Point +from .point_cloud import PointCloud +from .point_cloud_georeference import PointCloudGeoreference +from .point_cloud_sort_field import PointCloudSortField +from .point_cloud_source import PointCloudSource +from .point_cloud_summary import PointCloudSummary +from .point_cloud_type import PointCloudType +from .point_cloud_upload_created_response import PointCloudUploadCreatedResponse +from .point_cloud_upload_spec import PointCloudUploadSpec +from .point_cloud_upload_spec_headers import PointCloudUploadSpecHeaders from .point_process import PointProcess from .polygon import Polygon from .quic_fire_export_alignment_domain_target import ( @@ -213,6 +230,7 @@ from .update_feature_request_body import UpdateFeatureRequestBody from .update_grid_request_body import UpdateGridRequestBody from .update_inventory_request_body import UpdateInventoryRequestBody +from .update_point_cloud_request_body import UpdatePointCloudRequestBody from .upload_band_definition import UploadBandDefinition from .validation_error import ValidationError @@ -223,6 +241,7 @@ "AllometryMaxCrownRadiusSource", "Application", "ApplyModificationsRequest", + "ApplyTreatmentsRequest", "Band", "BandType", "BaseModel", @@ -230,10 +249,12 @@ "BiomassComponentState", "BiomassEquations", "BiomassUnit", + "CategoricalBandSummary", "Chunks", "ChunksCountByAxisType0", "Column", "ColumnType", + "ContinuousBandSummary", "CreateApplicationRequest", "CreateChmInventoryRequest", "CreateDomainRequestBody", @@ -254,6 +275,7 @@ "CreateOsmRoadFeatureRequest", "CreateOsmWaterFeatureRequest", "CreatePimInventoryRequest", + "CreatePointCloudUploadRequest", "CreateResampleRequest", "CreateResampleRequestMethodOverrides", "CreateThreeDepTopographyRequest", @@ -268,6 +290,7 @@ "DomainSortField", "DomainSortOrder", "DomainStyle", + "DuplicateGridRequest", "DuplicateInventoryRequest", "Export", "ExportGridRequest", @@ -313,6 +336,7 @@ "GridSpatialTarget", "GridUploadCreatedResponse", "GridUploadSpec", + "GridUploadSpecHeaders", "HTTPValidationError", "Inventory", "InventoryAttribute", @@ -348,6 +372,7 @@ "InventoryUploadCreatedResponse", "InventoryUploadFormat", "InventoryUploadSpec", + "InventoryUploadSpecHeaders", "JobError", "JobProgress", "JobStatus", @@ -369,6 +394,7 @@ "ListGridsResponse", "ListInventoriesResponse", "ListKeysResponse", + "ListPointCloudsResponse", "MaxCrownRadiusUnit", "MetaCHMVersion", "Modifier", @@ -380,6 +406,15 @@ "Operator", "OverlapMethod", "Point", + "PointCloud", + "PointCloudGeoreference", + "PointCloudSortField", + "PointCloudSource", + "PointCloudSummary", + "PointCloudType", + "PointCloudUploadCreatedResponse", + "PointCloudUploadSpec", + "PointCloudUploadSpecHeaders", "PointProcess", "Polygon", "QUICFireExportAlignmentDomainTarget", @@ -410,6 +445,7 @@ "UpdateFeatureRequestBody", "UpdateGridRequestBody", "UpdateInventoryRequestBody", + "UpdatePointCloudRequestBody", "UploadBandDefinition", "ValidationError", ) diff --git a/fastfuels_sdk/v2/client_library/models/apply_treatments_request.py b/fastfuels_sdk/v2/client_library/models/apply_treatments_request.py new file mode 100644 index 0000000..fd49724 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/apply_treatments_request.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.inventory_basal_area_treatment import InventoryBasalAreaTreatment + from ..models.inventory_diameter_treatment import InventoryDiameterTreatment + + +T = TypeVar("T", bound="ApplyTreatmentsRequest") + + +@_attrs_define +class ApplyTreatmentsRequest: + """Request body for applying treatments to an inventory in place. + + Metadata (name, description, tags) is not accepted here — the inventory + keeps its identity; use PATCH to edit metadata. + + Attributes: + treatments (list[InventoryBasalAreaTreatment | InventoryDiameterTreatment]): Treatments to append to this + inventory and apply to its data. + """ + + treatments: list[InventoryBasalAreaTreatment | InventoryDiameterTreatment] + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.inventory_diameter_treatment import InventoryDiameterTreatment + + treatments = [] + for treatments_item_data in self.treatments: + treatments_item: dict[str, Any] + if isinstance(treatments_item_data, InventoryDiameterTreatment): + treatments_item = treatments_item_data.to_dict() + else: + treatments_item = treatments_item_data.to_dict() + + treatments.append(treatments_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "treatments": treatments, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.inventory_basal_area_treatment import InventoryBasalAreaTreatment + from ..models.inventory_diameter_treatment import InventoryDiameterTreatment + + d = dict(src_dict) + treatments = [] + _treatments = d.pop("treatments") + for treatments_item_data in _treatments: + + def _parse_treatments_item( + data: object, + ) -> InventoryBasalAreaTreatment | InventoryDiameterTreatment: + try: + if not isinstance(data, dict): + raise TypeError() + treatments_item_type_0 = InventoryDiameterTreatment.from_dict(data) + + return treatments_item_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + if not isinstance(data, dict): + raise TypeError() + treatments_item_type_1 = InventoryBasalAreaTreatment.from_dict(data) + + return treatments_item_type_1 + + treatments_item = _parse_treatments_item(treatments_item_data) + + treatments.append(treatments_item) + + apply_treatments_request = cls( + treatments=treatments, + ) + + apply_treatments_request.additional_properties = d + return apply_treatments_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/band.py b/fastfuels_sdk/v2/client_library/models/band.py index 8be09d6..6e1a46f 100644 --- a/fastfuels_sdk/v2/client_library/models/band.py +++ b/fastfuels_sdk/v2/client_library/models/band.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -9,6 +9,11 @@ from ..models.band_type import BandType from ..types import UNSET, Unset +if TYPE_CHECKING: + from ..models.categorical_band_summary import CategoricalBandSummary + from ..models.continuous_band_summary import ContinuousBandSummary + + T = TypeVar("T", bound="Band") @@ -28,6 +33,7 @@ class Band: nodata (float | int | None | Unset): Value marking missing pixels in this band; pixels equal to it carry no data and should be excluded from analysis. `null` when the band has no missing pixels, or when they are represented as floating-point NaN. + summary (CategoricalBandSummary | ContinuousBandSummary | None | Unset): """ key: str @@ -37,9 +43,13 @@ class Band: description: None | str | Unset = UNSET unit: None | str | Unset = UNSET nodata: float | int | None | Unset = UNSET + summary: CategoricalBandSummary | ContinuousBandSummary | None | Unset = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: + from ..models.categorical_band_summary import CategoricalBandSummary + from ..models.continuous_band_summary import ContinuousBandSummary + key = self.key type_ = self.type_.value @@ -70,6 +80,16 @@ def to_dict(self) -> dict[str, Any]: else: nodata = self.nodata + summary: dict[str, Any] | None | Unset + if isinstance(self.summary, Unset): + summary = UNSET + elif isinstance(self.summary, ContinuousBandSummary): + summary = self.summary.to_dict() + elif isinstance(self.summary, CategoricalBandSummary): + summary = self.summary.to_dict() + else: + summary = self.summary + field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update( @@ -87,11 +107,16 @@ def to_dict(self) -> dict[str, Any]: field_dict["unit"] = unit if nodata is not UNSET: field_dict["nodata"] = nodata + if summary is not UNSET: + field_dict["summary"] = summary return field_dict @classmethod def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.categorical_band_summary import CategoricalBandSummary + from ..models.continuous_band_summary import ContinuousBandSummary + d = dict(src_dict) key = d.pop("key") @@ -135,6 +160,35 @@ def _parse_nodata(data: object) -> float | int | None | Unset: nodata = _parse_nodata(d.pop("nodata", UNSET)) + def _parse_summary( + data: object, + ) -> CategoricalBandSummary | ContinuousBandSummary | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + summary_type_0_type_0 = ContinuousBandSummary.from_dict(data) + + return summary_type_0_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + summary_type_0_type_1 = CategoricalBandSummary.from_dict(data) + + return summary_type_0_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast( + CategoricalBandSummary | ContinuousBandSummary | None | Unset, data + ) + + summary = _parse_summary(d.pop("summary", UNSET)) + band = cls( key=key, type_=type_, @@ -143,6 +197,7 @@ def _parse_nodata(data: object) -> float | int | None | Unset: description=description, unit=unit, nodata=nodata, + summary=summary, ) band.additional_properties = d diff --git a/fastfuels_sdk/v2/client_library/models/categorical_band_summary.py b/fastfuels_sdk/v2/client_library/models/categorical_band_summary.py new file mode 100644 index 0000000..9042c3b --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/categorical_band_summary.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + Any, + Literal, + TypeVar, + cast, +) + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="CategoricalBandSummary") + + +@_attrs_define +class CategoricalBandSummary: + """ + Attributes: + type_ (Literal['categorical']): + count (int): + nodata_count (int): + unique_count (int): + """ + + type_: Literal["categorical"] + count: int + nodata_count: int + unique_count: int + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + type_ = self.type_ + + count = self.count + + nodata_count = self.nodata_count + + unique_count = self.unique_count + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "type": type_, + "count": count, + "nodata_count": nodata_count, + "unique_count": unique_count, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + type_ = cast(Literal["categorical"], d.pop("type")) + if type_ != "categorical": + raise ValueError(f"type must match const 'categorical', got '{type_}'") + + count = d.pop("count") + + nodata_count = d.pop("nodata_count") + + unique_count = d.pop("unique_count") + + categorical_band_summary = cls( + type_=type_, + count=count, + nodata_count=nodata_count, + unique_count=unique_count, + ) + + categorical_band_summary.additional_properties = d + return categorical_band_summary + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/continuous_band_summary.py b/fastfuels_sdk/v2/client_library/models/continuous_band_summary.py new file mode 100644 index 0000000..781158e --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/continuous_band_summary.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + Any, + Literal, + TypeVar, + cast, +) + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="ContinuousBandSummary") + + +@_attrs_define +class ContinuousBandSummary: + """ + Attributes: + type_ (Literal['continuous']): + count (int): + nodata_count (int): + min_ (float | None): + max_ (float | None): + mean (float | None): + std (float | None): + """ + + type_: Literal["continuous"] + count: int + nodata_count: int + min_: float | None + max_: float | None + mean: float | None + std: float | None + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + type_ = self.type_ + + count = self.count + + nodata_count = self.nodata_count + + min_: float | None + min_ = self.min_ + + max_: float | None + max_ = self.max_ + + mean: float | None + mean = self.mean + + std: float | None + std = self.std + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "type": type_, + "count": count, + "nodata_count": nodata_count, + "min": min_, + "max": max_, + "mean": mean, + "std": std, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + type_ = cast(Literal["continuous"], d.pop("type")) + if type_ != "continuous": + raise ValueError(f"type must match const 'continuous', got '{type_}'") + + count = d.pop("count") + + nodata_count = d.pop("nodata_count") + + def _parse_min_(data: object) -> float | None: + if data is None: + return data + return cast(float | None, data) + + min_ = _parse_min_(d.pop("min")) + + def _parse_max_(data: object) -> float | None: + if data is None: + return data + return cast(float | None, data) + + max_ = _parse_max_(d.pop("max")) + + def _parse_mean(data: object) -> float | None: + if data is None: + return data + return cast(float | None, data) + + mean = _parse_mean(d.pop("mean")) + + def _parse_std(data: object) -> float | None: + if data is None: + return data + return cast(float | None, data) + + std = _parse_std(d.pop("std")) + + continuous_band_summary = cls( + type_=type_, + count=count, + nodata_count=nodata_count, + min_=min_, + max_=max_, + mean=mean, + std=std, + ) + + continuous_band_summary.additional_properties = d + return continuous_band_summary + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/create_landfire_fccs_request.py b/fastfuels_sdk/v2/client_library/models/create_landfire_fccs_request.py index bfc5b63..f25a8b7 100644 --- a/fastfuels_sdk/v2/client_library/models/create_landfire_fccs_request.py +++ b/fastfuels_sdk/v2/client_library/models/create_landfire_fccs_request.py @@ -10,6 +10,9 @@ from ..types import UNSET, Unset if TYPE_CHECKING: + from ..models.grid_alignment_domain_target import GridAlignmentDomainTarget + from ..models.grid_alignment_grid_target import GridAlignmentGridTarget + from ..models.grid_alignment_native_target import GridAlignmentNativeTarget from ..models.grid_modification import GridModification @@ -36,6 +39,15 @@ class CreateLandfireFccsRequest: and `target` (`centroid` or `cell`) to choose which part of the cell is tested. Actions modify band values via `replace`, `multiply`, `divide`, `add`, or `subtract`. See the `GridModification` schema for the full field reference and worked examples. + extent_buffer_cells (int | Unset): Number of result-grid cells included as a buffer around the domain extent in + the stored grid. The buffer is measured after the source raster is projected into the domain CRS, so a cell + means one cell in the returned grid rather than one source raster cell. Provides context for later operations + (resample, reproject, focal filters, derivative calculations) that are sensitive to edges. Default 0 adds no + buffer. Maximum: 10 cells. Default: 0. + alignment (GridAlignmentDomainTarget | GridAlignmentGridTarget | GridAlignmentNativeTarget | Unset): Per-fetch + alignment target. Default `target="domain"` anchors output cells to the domain origin so cross-source + composition works by construction. `target="native"` preserves the source pixel anchor. `target="grid"` aligns + to an existing grid by id. version (LandfireFccsVersion | Unset): Available LANDFIRE FCCS data versions. remove_bare_ground (bool | Unset): Default: False. """ @@ -44,11 +56,21 @@ class CreateLandfireFccsRequest: description: str | Unset = "" tags: list[str] | Unset = UNSET modifications: list[GridModification] | Unset = UNSET + extent_buffer_cells: int | Unset = 0 + alignment: ( + GridAlignmentDomainTarget + | GridAlignmentGridTarget + | GridAlignmentNativeTarget + | Unset + ) = UNSET version: LandfireFccsVersion | Unset = UNSET remove_bare_ground: bool | Unset = False additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: + from ..models.grid_alignment_domain_target import GridAlignmentDomainTarget + from ..models.grid_alignment_native_target import GridAlignmentNativeTarget + name = self.name description = self.description @@ -64,6 +86,18 @@ def to_dict(self) -> dict[str, Any]: modifications_item = modifications_item_data.to_dict() modifications.append(modifications_item) + extent_buffer_cells = self.extent_buffer_cells + + alignment: dict[str, Any] | Unset + if isinstance(self.alignment, Unset): + alignment = UNSET + elif isinstance(self.alignment, GridAlignmentDomainTarget): + alignment = self.alignment.to_dict() + elif isinstance(self.alignment, GridAlignmentNativeTarget): + alignment = self.alignment.to_dict() + else: + alignment = self.alignment.to_dict() + version: str | Unset = UNSET if not isinstance(self.version, Unset): version = self.version.value @@ -81,6 +115,10 @@ def to_dict(self) -> dict[str, Any]: field_dict["tags"] = tags if modifications is not UNSET: field_dict["modifications"] = modifications + if extent_buffer_cells is not UNSET: + field_dict["extent_buffer_cells"] = extent_buffer_cells + if alignment is not UNSET: + field_dict["alignment"] = alignment if version is not UNSET: field_dict["version"] = version if remove_bare_ground is not UNSET: @@ -90,6 +128,9 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.grid_alignment_domain_target import GridAlignmentDomainTarget + from ..models.grid_alignment_grid_target import GridAlignmentGridTarget + from ..models.grid_alignment_native_target import GridAlignmentNativeTarget from ..models.grid_modification import GridModification d = dict(src_dict) @@ -108,6 +149,42 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: modifications.append(modifications_item) + extent_buffer_cells = d.pop("extent_buffer_cells", UNSET) + + def _parse_alignment( + data: object, + ) -> ( + GridAlignmentDomainTarget + | GridAlignmentGridTarget + | GridAlignmentNativeTarget + | Unset + ): + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + alignment_type_0 = GridAlignmentDomainTarget.from_dict(data) + + return alignment_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + alignment_type_1 = GridAlignmentNativeTarget.from_dict(data) + + return alignment_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + if not isinstance(data, dict): + raise TypeError() + alignment_type_2 = GridAlignmentGridTarget.from_dict(data) + + return alignment_type_2 + + alignment = _parse_alignment(d.pop("alignment", UNSET)) + _version = d.pop("version", UNSET) version: LandfireFccsVersion | Unset if isinstance(_version, Unset): @@ -122,6 +199,8 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: description=description, tags=tags, modifications=modifications, + extent_buffer_cells=extent_buffer_cells, + alignment=alignment, version=version, remove_bare_ground=remove_bare_ground, ) diff --git a/fastfuels_sdk/v2/client_library/models/create_point_cloud_upload_request.py b/fastfuels_sdk/v2/client_library/models/create_point_cloud_upload_request.py new file mode 100644 index 0000000..e6d1184 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/create_point_cloud_upload_request.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.point_cloud_type import PointCloudType +from ..types import UNSET, Unset + +T = TypeVar("T", bound="CreatePointCloudUploadRequest") + + +@_attrs_define +class CreatePointCloudUploadRequest: + """Request body for creating a point cloud from a direct file upload. + + Attributes: + type_ (PointCloudType): How a point cloud was acquired. + + The acquisition platform determines the cloud's geometry and which downstream + products it can feed, so it is recorded as a first-class, filterable field. + + - ``als`` — **Airborne Laser Scanning.** Captured from an aircraft or drone + looking down. Covers large areas from above and is the basis for canopy + height models and individual-tree detection. Available from an upload or + from USGS 3DEP. + - ``tls`` — **Terrestrial Laser Scanning.** Captured from a tripod-mounted + scanner on the ground looking out and up. Resolves fine sub-canopy and + stem structure over a small plot. Available from an upload only (3DEP is + airborne and cannot produce terrestrial scans). + name (str | Unset): Human-readable name for the point cloud. Default: ''. + description (str | Unset): Longer free-text description of the point cloud. Default: ''. + tags (list[str] | Unset): Tags for organizing and filtering point clouds. + """ + + type_: PointCloudType + name: str | Unset = "" + description: str | Unset = "" + tags: list[str] | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + type_ = self.type_.value + + name = self.name + + description = self.description + + tags: list[str] | Unset = UNSET + if not isinstance(self.tags, Unset): + tags = self.tags + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "type": type_, + } + ) + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if tags is not UNSET: + field_dict["tags"] = tags + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + type_ = PointCloudType(d.pop("type")) + + name = d.pop("name", UNSET) + + description = d.pop("description", UNSET) + + tags = cast(list[str], d.pop("tags", UNSET)) + + create_point_cloud_upload_request = cls( + type_=type_, + name=name, + description=description, + tags=tags, + ) + + create_point_cloud_upload_request.additional_properties = d + return create_point_cloud_upload_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/duplicate_grid_request.py b/fastfuels_sdk/v2/client_library/models/duplicate_grid_request.py new file mode 100644 index 0000000..af58101 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/duplicate_grid_request.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="DuplicateGridRequest") + + +@_attrs_define +class DuplicateGridRequest: + """Optional metadata overrides for a duplicated grid. + + Every field is optional. Any field omitted is carried over verbatim from + the source grid. + + Attributes: + name (None | str | Unset): Name for the copy. Omit to reuse the source grid's name. + description (None | str | Unset): Description for the copy. Omit to reuse the source grid's description. + tags (list[str] | None | Unset): Tags for the copy. Omit to reuse the source grid's tags. + """ + + name: None | str | Unset = UNSET + description: None | str | Unset = UNSET + tags: list[str] | None | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + name: None | str | Unset + if isinstance(self.name, Unset): + name = UNSET + else: + name = self.name + + description: None | str | Unset + if isinstance(self.description, Unset): + description = UNSET + else: + description = self.description + + tags: list[str] | None | Unset + if isinstance(self.tags, Unset): + tags = UNSET + elif isinstance(self.tags, list): + tags = self.tags + + else: + tags = self.tags + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if tags is not UNSET: + field_dict["tags"] = tags + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + + def _parse_name(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + name = _parse_name(d.pop("name", UNSET)) + + def _parse_description(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + description = _parse_description(d.pop("description", UNSET)) + + def _parse_tags(data: object) -> list[str] | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, list): + raise TypeError() + tags_type_0 = cast(list[str], data) + + return tags_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(list[str] | None | Unset, data) + + tags = _parse_tags(d.pop("tags", UNSET)) + + duplicate_grid_request = cls( + name=name, + description=description, + tags=tags, + ) + + duplicate_grid_request.additional_properties = d + return duplicate_grid_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/grid_upload_spec.py b/fastfuels_sdk/v2/client_library/models/grid_upload_spec.py index 0a748dd..fddd5f1 100644 --- a/fastfuels_sdk/v2/client_library/models/grid_upload_spec.py +++ b/fastfuels_sdk/v2/client_library/models/grid_upload_spec.py @@ -3,6 +3,7 @@ import datetime from collections.abc import Mapping from typing import ( + TYPE_CHECKING, Any, Literal, TypeVar, @@ -14,6 +15,10 @@ from ..types import UNSET, Unset +if TYPE_CHECKING: + from ..models.grid_upload_spec_headers import GridUploadSpecHeaders + + T = TypeVar("T", bound="GridUploadSpec") @@ -22,6 +27,7 @@ class GridUploadSpec: """ Attributes: url (str): + headers (GridUploadSpecHeaders): content_type (str): expires_at (datetime.datetime): max_size_bytes (int): @@ -29,6 +35,7 @@ class GridUploadSpec: """ url: str + headers: GridUploadSpecHeaders content_type: str expires_at: datetime.datetime max_size_bytes: int @@ -38,6 +45,8 @@ class GridUploadSpec: def to_dict(self) -> dict[str, Any]: url = self.url + headers = self.headers.to_dict() + content_type = self.content_type expires_at = self.expires_at.isoformat() @@ -51,6 +60,7 @@ def to_dict(self) -> dict[str, Any]: field_dict.update( { "url": url, + "headers": headers, "content_type": content_type, "expires_at": expires_at, "max_size_bytes": max_size_bytes, @@ -63,9 +73,13 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.grid_upload_spec_headers import GridUploadSpecHeaders + d = dict(src_dict) url = d.pop("url") + headers = GridUploadSpecHeaders.from_dict(d.pop("headers")) + content_type = d.pop("content_type") expires_at = datetime.datetime.fromisoformat(d.pop("expires_at")) @@ -78,6 +92,7 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: grid_upload_spec = cls( url=url, + headers=headers, content_type=content_type, expires_at=expires_at, max_size_bytes=max_size_bytes, diff --git a/fastfuels_sdk/v2/client_library/models/grid_upload_spec_headers.py b/fastfuels_sdk/v2/client_library/models/grid_upload_spec_headers.py new file mode 100644 index 0000000..92d8b33 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/grid_upload_spec_headers.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="GridUploadSpecHeaders") + + +@_attrs_define +class GridUploadSpecHeaders: + """ """ + + additional_properties: dict[str, str] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + grid_upload_spec_headers = cls() + + grid_upload_spec_headers.additional_properties = d + return grid_upload_spec_headers + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> str: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: str) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/inventory_upload_spec.py b/fastfuels_sdk/v2/client_library/models/inventory_upload_spec.py index a642700..6c4998b 100644 --- a/fastfuels_sdk/v2/client_library/models/inventory_upload_spec.py +++ b/fastfuels_sdk/v2/client_library/models/inventory_upload_spec.py @@ -3,6 +3,7 @@ import datetime from collections.abc import Mapping from typing import ( + TYPE_CHECKING, Any, Literal, TypeVar, @@ -14,6 +15,10 @@ from ..types import UNSET, Unset +if TYPE_CHECKING: + from ..models.inventory_upload_spec_headers import InventoryUploadSpecHeaders + + T = TypeVar("T", bound="InventoryUploadSpec") @@ -22,6 +27,7 @@ class InventoryUploadSpec: """ Attributes: url (str): + headers (InventoryUploadSpecHeaders): content_type (str): expires_at (datetime.datetime): max_size_bytes (int): @@ -29,6 +35,7 @@ class InventoryUploadSpec: """ url: str + headers: InventoryUploadSpecHeaders content_type: str expires_at: datetime.datetime max_size_bytes: int @@ -38,6 +45,8 @@ class InventoryUploadSpec: def to_dict(self) -> dict[str, Any]: url = self.url + headers = self.headers.to_dict() + content_type = self.content_type expires_at = self.expires_at.isoformat() @@ -51,6 +60,7 @@ def to_dict(self) -> dict[str, Any]: field_dict.update( { "url": url, + "headers": headers, "content_type": content_type, "expires_at": expires_at, "max_size_bytes": max_size_bytes, @@ -63,9 +73,13 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.inventory_upload_spec_headers import InventoryUploadSpecHeaders + d = dict(src_dict) url = d.pop("url") + headers = InventoryUploadSpecHeaders.from_dict(d.pop("headers")) + content_type = d.pop("content_type") expires_at = datetime.datetime.fromisoformat(d.pop("expires_at")) @@ -78,6 +92,7 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: inventory_upload_spec = cls( url=url, + headers=headers, content_type=content_type, expires_at=expires_at, max_size_bytes=max_size_bytes, diff --git a/fastfuels_sdk/v2/client_library/models/inventory_upload_spec_headers.py b/fastfuels_sdk/v2/client_library/models/inventory_upload_spec_headers.py new file mode 100644 index 0000000..e13467a --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/inventory_upload_spec_headers.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="InventoryUploadSpecHeaders") + + +@_attrs_define +class InventoryUploadSpecHeaders: + """ """ + + additional_properties: dict[str, str] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + inventory_upload_spec_headers = cls() + + inventory_upload_spec_headers.additional_properties = d + return inventory_upload_spec_headers + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> str: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: str) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/list_point_clouds_response.py b/fastfuels_sdk/v2/client_library/models/list_point_clouds_response.py new file mode 100644 index 0000000..8e0db4c --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/list_point_clouds_response.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.point_cloud import PointCloud + + +T = TypeVar("T", bound="ListPointCloudsResponse") + + +@_attrs_define +class ListPointCloudsResponse: + """Paginated response for listing point clouds. + + Attributes: + current_page (int): The current page number (zero-indexed). + page_size (int): The number of items per page. + total_items (int): The total number of items across all pages. + point_clouds (list[PointCloud]): + """ + + current_page: int + page_size: int + total_items: int + point_clouds: list[PointCloud] + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + current_page = self.current_page + + page_size = self.page_size + + total_items = self.total_items + + point_clouds = [] + for point_clouds_item_data in self.point_clouds: + point_clouds_item = point_clouds_item_data.to_dict() + point_clouds.append(point_clouds_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "current_page": current_page, + "page_size": page_size, + "total_items": total_items, + "point_clouds": point_clouds, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.point_cloud import PointCloud + + d = dict(src_dict) + current_page = d.pop("current_page") + + page_size = d.pop("page_size") + + total_items = d.pop("total_items") + + point_clouds = [] + _point_clouds = d.pop("point_clouds") + for point_clouds_item_data in _point_clouds: + point_clouds_item = PointCloud.from_dict(point_clouds_item_data) + + point_clouds.append(point_clouds_item) + + list_point_clouds_response = cls( + current_page=current_page, + page_size=page_size, + total_items=total_items, + point_clouds=point_clouds, + ) + + list_point_clouds_response.additional_properties = d + return list_point_clouds_response + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/point_cloud.py b/fastfuels_sdk/v2/client_library/models/point_cloud.py new file mode 100644 index 0000000..3ea4ea1 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/point_cloud.py @@ -0,0 +1,379 @@ +from __future__ import annotations + +import datetime +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.job_status import JobStatus +from ..models.point_cloud_type import PointCloudType +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.job_error import JobError + from ..models.job_progress import JobProgress + from ..models.point_cloud_georeference import PointCloudGeoreference + from ..models.point_cloud_source import PointCloudSource + from ..models.point_cloud_summary import PointCloudSummary + + +T = TypeVar("T", bound="PointCloud") + + +@_attrs_define +class PointCloud: + """A laser-scanned point cloud scoped to a single domain. + + Point clouds are created asynchronously: a creation request returns + immediately with ``status="pending"`` and the file is ingested in the + background. While ``status`` is ``"pending"`` or ``"running"`` the + derived fields (`georeference`) are ``null``; the backend fills them in once + ingestion succeeds and flips ``status`` to ``"completed"``. If ingestion + fails, ``status`` becomes ``"failed"`` and `error` explains why. + + A completed point cloud is an input you compose with other resources — most + directly, an ALS cloud feeds a canopy-height-model grid, which feeds a tree + inventory. + + Attributes: + id (str): Unique 32-character hex identifier for this point cloud. + domain_id (str): Identifier of the domain this point cloud belongs to. + type_ (PointCloudType): How a point cloud was acquired. + + The acquisition platform determines the cloud's geometry and which downstream + products it can feed, so it is recorded as a first-class, filterable field. + + - ``als`` — **Airborne Laser Scanning.** Captured from an aircraft or drone + looking down. Covers large areas from above and is the basis for canopy + height models and individual-tree detection. Available from an upload or + from USGS 3DEP. + - ``tls`` — **Terrestrial Laser Scanning.** Captured from a tripod-mounted + scanner on the ground looking out and up. Resolves fine sub-canopy and + stem structure over a small plot. Available from an upload only (3DEP is + airborne and cannot produce terrestrial scans). + status (JobStatus): Status of an async job. + source (PointCloudSource): Provenance of the point cloud — where its points came from. Always carries a `name` + discriminator (`upload` for a user-supplied file, `3dep` for a USGS 3DEP fetch) alongside source-specific + parameters. Stored verbatim so the cloud can be reproduced from its origin. + name (str | Unset): Human-readable name for the point cloud. Default: ''. + description (str | Unset): Longer free-text description of the point cloud. Default: ''. + progress (JobProgress | None | Unset): Progress info while `status` is `running`. Null otherwise. + created_on (datetime.datetime | None | Unset): When the point cloud was created. + modified_on (datetime.datetime | None | Unset): When the point cloud was last modified. + checksum (None | str | Unset): Version marker for this point cloud's content. It changes each time the point + cloud is rebuilt and is unaffected by metadata-only edits (name, description, tags). A resource derived from + this point cloud stores the checksum it was built from; comparing that stored value against this field reveals + whether this point cloud has changed since. May be null for point clouds created before checksums were + introduced. + georeference (None | PointCloudGeoreference | Unset): Coordinate reference system and 3D extent of the points. + Null until the backend finishes ingesting the cloud. + summary (None | PointCloudSummary | Unset): Summary statistics — point count, classification codes present, and + density — describing the cloud's contents. Null until the backend finishes ingesting the cloud. + error (JobError | None | Unset): Details when `status` is `failed`. The traceback is stored but not exposed in + API responses. + tags (list[str] | Unset): User-assigned tags for organizing and filtering point clouds. + """ + + id: str + domain_id: str + type_: PointCloudType + status: JobStatus + source: PointCloudSource + name: str | Unset = "" + description: str | Unset = "" + progress: JobProgress | None | Unset = UNSET + created_on: datetime.datetime | None | Unset = UNSET + modified_on: datetime.datetime | None | Unset = UNSET + checksum: None | str | Unset = UNSET + georeference: None | PointCloudGeoreference | Unset = UNSET + summary: None | PointCloudSummary | Unset = UNSET + error: JobError | None | Unset = UNSET + tags: list[str] | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.job_error import JobError + from ..models.job_progress import JobProgress + from ..models.point_cloud_georeference import PointCloudGeoreference + from ..models.point_cloud_summary import PointCloudSummary + + id = self.id + + domain_id = self.domain_id + + type_ = self.type_.value + + status = self.status.value + + source = self.source.to_dict() + + name = self.name + + description = self.description + + progress: dict[str, Any] | None | Unset + if isinstance(self.progress, Unset): + progress = UNSET + elif isinstance(self.progress, JobProgress): + progress = self.progress.to_dict() + else: + progress = self.progress + + created_on: None | str | Unset + if isinstance(self.created_on, Unset): + created_on = UNSET + elif isinstance(self.created_on, datetime.datetime): + created_on = self.created_on.isoformat() + else: + created_on = self.created_on + + modified_on: None | str | Unset + if isinstance(self.modified_on, Unset): + modified_on = UNSET + elif isinstance(self.modified_on, datetime.datetime): + modified_on = self.modified_on.isoformat() + else: + modified_on = self.modified_on + + checksum: None | str | Unset + if isinstance(self.checksum, Unset): + checksum = UNSET + else: + checksum = self.checksum + + georeference: dict[str, Any] | None | Unset + if isinstance(self.georeference, Unset): + georeference = UNSET + elif isinstance(self.georeference, PointCloudGeoreference): + georeference = self.georeference.to_dict() + else: + georeference = self.georeference + + summary: dict[str, Any] | None | Unset + if isinstance(self.summary, Unset): + summary = UNSET + elif isinstance(self.summary, PointCloudSummary): + summary = self.summary.to_dict() + else: + summary = self.summary + + error: dict[str, Any] | None | Unset + if isinstance(self.error, Unset): + error = UNSET + elif isinstance(self.error, JobError): + error = self.error.to_dict() + else: + error = self.error + + tags: list[str] | Unset = UNSET + if not isinstance(self.tags, Unset): + tags = self.tags + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "id": id, + "domain_id": domain_id, + "type": type_, + "status": status, + "source": source, + } + ) + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if progress is not UNSET: + field_dict["progress"] = progress + if created_on is not UNSET: + field_dict["created_on"] = created_on + if modified_on is not UNSET: + field_dict["modified_on"] = modified_on + if checksum is not UNSET: + field_dict["checksum"] = checksum + if georeference is not UNSET: + field_dict["georeference"] = georeference + if summary is not UNSET: + field_dict["summary"] = summary + if error is not UNSET: + field_dict["error"] = error + if tags is not UNSET: + field_dict["tags"] = tags + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.job_error import JobError + from ..models.job_progress import JobProgress + from ..models.point_cloud_georeference import PointCloudGeoreference + from ..models.point_cloud_source import PointCloudSource + from ..models.point_cloud_summary import PointCloudSummary + + d = dict(src_dict) + id = d.pop("id") + + domain_id = d.pop("domain_id") + + type_ = PointCloudType(d.pop("type")) + + status = JobStatus(d.pop("status")) + + source = PointCloudSource.from_dict(d.pop("source")) + + name = d.pop("name", UNSET) + + description = d.pop("description", UNSET) + + def _parse_progress(data: object) -> JobProgress | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + progress_type_0 = JobProgress.from_dict(data) + + return progress_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(JobProgress | None | Unset, data) + + progress = _parse_progress(d.pop("progress", UNSET)) + + def _parse_created_on(data: object) -> datetime.datetime | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, str): + raise TypeError() + created_on_type_0 = datetime.datetime.fromisoformat(data) + + return created_on_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(datetime.datetime | None | Unset, data) + + created_on = _parse_created_on(d.pop("created_on", UNSET)) + + def _parse_modified_on(data: object) -> datetime.datetime | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, str): + raise TypeError() + modified_on_type_0 = datetime.datetime.fromisoformat(data) + + return modified_on_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(datetime.datetime | None | Unset, data) + + modified_on = _parse_modified_on(d.pop("modified_on", UNSET)) + + def _parse_checksum(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + checksum = _parse_checksum(d.pop("checksum", UNSET)) + + def _parse_georeference(data: object) -> None | PointCloudGeoreference | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + georeference_type_0 = PointCloudGeoreference.from_dict(data) + + return georeference_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(None | PointCloudGeoreference | Unset, data) + + georeference = _parse_georeference(d.pop("georeference", UNSET)) + + def _parse_summary(data: object) -> None | PointCloudSummary | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + summary_type_0 = PointCloudSummary.from_dict(data) + + return summary_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(None | PointCloudSummary | Unset, data) + + summary = _parse_summary(d.pop("summary", UNSET)) + + def _parse_error(data: object) -> JobError | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + error_type_0 = JobError.from_dict(data) + + return error_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(JobError | None | Unset, data) + + error = _parse_error(d.pop("error", UNSET)) + + tags = cast(list[str], d.pop("tags", UNSET)) + + point_cloud = cls( + id=id, + domain_id=domain_id, + type_=type_, + status=status, + source=source, + name=name, + description=description, + progress=progress, + created_on=created_on, + modified_on=modified_on, + checksum=checksum, + georeference=georeference, + summary=summary, + error=error, + tags=tags, + ) + + point_cloud.additional_properties = d + return point_cloud + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/point_cloud_georeference.py b/fastfuels_sdk/v2/client_library/models/point_cloud_georeference.py new file mode 100644 index 0000000..094b32b --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/point_cloud_georeference.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="PointCloudGeoreference") + + +@_attrs_define +class PointCloudGeoreference: + """The coordinate reference system and 3D extent of a point cloud. + + Populated by the backend after the cloud is ingested and inspected; it is + ``null`` while the point cloud is still ``pending`` or ``running``. + + Attributes: + crs (str): Coordinate reference system the points are stored in, as an authority code (e.g. `EPSG:32613`). This + is always the domain's CRS: uploads in a different CRS are reprojected during ingestion. + bounds (list[float]): Axis-aligned 3D bounding box of every point, given as `[min_x, min_y, min_z, max_x, max_y, + max_z]` in the units of `crs`. Point clouds are three-dimensional, so the box includes a vertical (z) extent. + Use it to check coverage against a domain before deriving products from the cloud. + """ + + crs: str + bounds: list[float] + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + crs = self.crs + + bounds = [] + for bounds_item_data in self.bounds: + bounds_item: float + bounds_item = bounds_item_data + bounds.append(bounds_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "crs": crs, + "bounds": bounds, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + crs = d.pop("crs") + + bounds = [] + _bounds = d.pop("bounds") + for bounds_item_data in _bounds: + + def _parse_bounds_item(data: object) -> float: + return cast(float, data) + + bounds_item = _parse_bounds_item(bounds_item_data) + + bounds.append(bounds_item) + + point_cloud_georeference = cls( + crs=crs, + bounds=bounds, + ) + + point_cloud_georeference.additional_properties = d + return point_cloud_georeference + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/point_cloud_sort_field.py b/fastfuels_sdk/v2/client_library/models/point_cloud_sort_field.py new file mode 100644 index 0000000..a89ed42 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/point_cloud_sort_field.py @@ -0,0 +1,10 @@ +from enum import Enum + + +class PointCloudSortField(str, Enum): + CREATED_ON = "created_on" + MODIFIED_ON = "modified_on" + NAME = "name" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/point_cloud_source.py b/fastfuels_sdk/v2/client_library/models/point_cloud_source.py new file mode 100644 index 0000000..7c367e9 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/point_cloud_source.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="PointCloudSource") + + +@_attrs_define +class PointCloudSource: + """Provenance of the point cloud — where its points came from. Always carries a `name` discriminator (`upload` for a + user-supplied file, `3dep` for a USGS 3DEP fetch) alongside source-specific parameters. Stored verbatim so the cloud + can be reproduced from its origin. + + """ + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + point_cloud_source = cls() + + point_cloud_source.additional_properties = d + return point_cloud_source + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/point_cloud_summary.py b/fastfuels_sdk/v2/client_library/models/point_cloud_summary.py new file mode 100644 index 0000000..82c3f59 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/point_cloud_summary.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="PointCloudSummary") + + +@_attrs_define +class PointCloudSummary: + """Summary statistics describing the contents of a point cloud. + + Populated by the backend after the cloud is ingested and inspected; it is + ``null`` while the point cloud is still ``pending`` or ``running``. Use it to + gauge a cloud's size, density, and composition without downloading it. + + Attributes: + point_count (int): Total number of points in the cloud. + point_classes (list[int]): ASPRS standard classification codes present in the cloud, sorted ascending. Common + codes include `1` (unclassified), `2` (ground), and `3`, `4`, `5` (low, medium, high vegetation). + density (float): Average point density over the cloud's horizontal extent, in points per square meter. + """ + + point_count: int + point_classes: list[int] + density: float + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + point_count = self.point_count + + point_classes = self.point_classes + + density = self.density + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "point_count": point_count, + "point_classes": point_classes, + "density": density, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + point_count = d.pop("point_count") + + point_classes = cast(list[int], d.pop("point_classes")) + + density = d.pop("density") + + point_cloud_summary = cls( + point_count=point_count, + point_classes=point_classes, + density=density, + ) + + point_cloud_summary.additional_properties = d + return point_cloud_summary + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/point_cloud_type.py b/fastfuels_sdk/v2/client_library/models/point_cloud_type.py new file mode 100644 index 0000000..89acace --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/point_cloud_type.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class PointCloudType(str, Enum): + ALS = "als" + TLS = "tls" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/point_cloud_upload_created_response.py b/fastfuels_sdk/v2/client_library/models/point_cloud_upload_created_response.py new file mode 100644 index 0000000..ba57d40 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/point_cloud_upload_created_response.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.point_cloud import PointCloud + from ..models.point_cloud_upload_spec import PointCloudUploadSpec + + +T = TypeVar("T", bound="PointCloudUploadCreatedResponse") + + +@_attrs_define +class PointCloudUploadCreatedResponse: + """Response returned when a point cloud upload is created. + + Attributes: + point_cloud (PointCloud): A laser-scanned point cloud scoped to a single domain. + + Point clouds are created asynchronously: a creation request returns + immediately with ``status="pending"`` and the file is ingested in the + background. While ``status`` is ``"pending"`` or ``"running"`` the + derived fields (`georeference`) are ``null``; the backend fills them in once + ingestion succeeds and flips ``status`` to ``"completed"``. If ingestion + fails, ``status`` becomes ``"failed"`` and `error` explains why. + + A completed point cloud is an input you compose with other resources — most + directly, an ALS cloud feeds a canopy-height-model grid, which feeds a tree + inventory. + upload (PointCloudUploadSpec): Where and how to upload the source file. + + PUT the file to `url`, sending every header in `headers` exactly as given. + The upload must complete before `expires_at` and must not exceed + `max_size_bytes`. + """ + + point_cloud: PointCloud + upload: PointCloudUploadSpec + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + point_cloud = self.point_cloud.to_dict() + + upload = self.upload.to_dict() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "point_cloud": point_cloud, + "upload": upload, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.point_cloud import PointCloud + from ..models.point_cloud_upload_spec import PointCloudUploadSpec + + d = dict(src_dict) + point_cloud = PointCloud.from_dict(d.pop("point_cloud")) + + upload = PointCloudUploadSpec.from_dict(d.pop("upload")) + + point_cloud_upload_created_response = cls( + point_cloud=point_cloud, + upload=upload, + ) + + point_cloud_upload_created_response.additional_properties = d + return point_cloud_upload_created_response + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/point_cloud_upload_spec.py b/fastfuels_sdk/v2/client_library/models/point_cloud_upload_spec.py new file mode 100644 index 0000000..7f01271 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/point_cloud_upload_spec.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import datetime +from collections.abc import Mapping +from typing import ( + TYPE_CHECKING, + Any, + Literal, + TypeVar, + cast, +) + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.point_cloud_upload_spec_headers import PointCloudUploadSpecHeaders + + +T = TypeVar("T", bound="PointCloudUploadSpec") + + +@_attrs_define +class PointCloudUploadSpec: + """Where and how to upload the source file. + + PUT the file to `url`, sending every header in `headers` exactly as given. + The upload must complete before `expires_at` and must not exceed + `max_size_bytes`. + + Attributes: + url (str): Signed URL to upload the source file to. + headers (PointCloudUploadSpecHeaders): HTTP headers that must be sent with the PUT request, exactly as given. + The signed URL commits to these headers; the upload is rejected if any is missing or altered. + content_type (str): Value the `Content-Type` header must use when uploading. + expires_at (datetime.datetime): When the signed URL expires. + max_size_bytes (int): Maximum allowed size of the uploaded file, in bytes. + method (Literal['PUT'] | Unset): Default: 'PUT'. + """ + + url: str + headers: PointCloudUploadSpecHeaders + content_type: str + expires_at: datetime.datetime + max_size_bytes: int + method: Literal["PUT"] | Unset = "PUT" + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + url = self.url + + headers = self.headers.to_dict() + + content_type = self.content_type + + expires_at = self.expires_at.isoformat() + + max_size_bytes = self.max_size_bytes + + method = self.method + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "url": url, + "headers": headers, + "content_type": content_type, + "expires_at": expires_at, + "max_size_bytes": max_size_bytes, + } + ) + if method is not UNSET: + field_dict["method"] = method + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.point_cloud_upload_spec_headers import PointCloudUploadSpecHeaders + + d = dict(src_dict) + url = d.pop("url") + + headers = PointCloudUploadSpecHeaders.from_dict(d.pop("headers")) + + content_type = d.pop("content_type") + + expires_at = datetime.datetime.fromisoformat(d.pop("expires_at")) + + max_size_bytes = d.pop("max_size_bytes") + + method = cast(Literal["PUT"] | Unset, d.pop("method", UNSET)) + if method != "PUT" and not isinstance(method, Unset): + raise ValueError(f"method must match const 'PUT', got '{method}'") + + point_cloud_upload_spec = cls( + url=url, + headers=headers, + content_type=content_type, + expires_at=expires_at, + max_size_bytes=max_size_bytes, + method=method, + ) + + point_cloud_upload_spec.additional_properties = d + return point_cloud_upload_spec + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/point_cloud_upload_spec_headers.py b/fastfuels_sdk/v2/client_library/models/point_cloud_upload_spec_headers.py new file mode 100644 index 0000000..6ba4143 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/point_cloud_upload_spec_headers.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="PointCloudUploadSpecHeaders") + + +@_attrs_define +class PointCloudUploadSpecHeaders: + """HTTP headers that must be sent with the PUT request, exactly as given. The signed URL commits to these headers; the + upload is rejected if any is missing or altered. + + """ + + additional_properties: dict[str, str] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + point_cloud_upload_spec_headers = cls() + + point_cloud_upload_spec_headers.additional_properties = d + return point_cloud_upload_spec_headers + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> str: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: str) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/update_point_cloud_request_body.py b/fastfuels_sdk/v2/client_library/models/update_point_cloud_request_body.py new file mode 100644 index 0000000..cce3f14 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/update_point_cloud_request_body.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="UpdatePointCloudRequestBody") + + +@_attrs_define +class UpdatePointCloudRequestBody: + """Request body for updating point cloud metadata. + + Only metadata is mutable. The point cloud's content, source, and derived + fields cannot be changed through this endpoint, so updates never alter the + `checksum`. + + Attributes: + name (None | str | Unset): New name. Omit to leave unchanged. + description (None | str | Unset): New description. Omit to leave unchanged. + tags (list[str] | None | Unset): New tags (replaces the existing list). Omit to leave unchanged. + """ + + name: None | str | Unset = UNSET + description: None | str | Unset = UNSET + tags: list[str] | None | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + name: None | str | Unset + if isinstance(self.name, Unset): + name = UNSET + else: + name = self.name + + description: None | str | Unset + if isinstance(self.description, Unset): + description = UNSET + else: + description = self.description + + tags: list[str] | None | Unset + if isinstance(self.tags, Unset): + tags = UNSET + elif isinstance(self.tags, list): + tags = self.tags + + else: + tags = self.tags + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if tags is not UNSET: + field_dict["tags"] = tags + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + + def _parse_name(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + name = _parse_name(d.pop("name", UNSET)) + + def _parse_description(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + description = _parse_description(d.pop("description", UNSET)) + + def _parse_tags(data: object) -> list[str] | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, list): + raise TypeError() + tags_type_0 = cast(list[str], data) + + return tags_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(list[str] | None | Unset, data) + + tags = _parse_tags(d.pop("tags", UNSET)) + + update_point_cloud_request_body = cls( + name=name, + description=description, + tags=tags, + ) + + update_point_cloud_request_body.additional_properties = d + return update_point_cloud_request_body + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties From cd8084ce6b7114ba19d01f663205012d3c14ee75 Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Fri, 12 Jun 2026 11:21:33 -0600 Subject: [PATCH 39/71] Expose alignment on the FCCS grid creator FastFuels-API-v2 #358 brought FCCS create to alignment parity with the other LANDFIRE/source grids (CreateLandfireFccsRequest now carries `alignment` + `extent_buffer_cells`), resolving the asymmetry we'd flagged. create_fuel_model_grid_from_landfire_fccs now accepts the same output_resolution_m / align_to / align / resampling / extent_buffer_cells kwargs as the FBFM40 and topography creators, via the shared _build_alignment helper. Dropped the "FCCS is alignment-free" comment and flipped the signature test to assert the kwargs are present; added a live create-with-alignment test. Docs and the design note updated to match. --- docs/v2/guides/creating-grids.md | 9 ++++----- fastfuels_sdk/v2/grids.py | 21 +++++++++++++++++---- fastfuels_sdk/v2/v2_api_design.md | 11 ++++++----- tests/v2/test_grids.py | 21 +++++++++++++++------ 4 files changed, 42 insertions(+), 20 deletions(-) diff --git a/docs/v2/guides/creating-grids.md b/docs/v2/guides/creating-grids.md index 7671ae9..d04e755 100644 --- a/docs/v2/guides/creating-grids.md +++ b/docs/v2/guides/creating-grids.md @@ -149,14 +149,13 @@ Characteristic Classification System (FCCS), skipping the lookup step: ```python grid = ff.grids.create_fuel_model_grid_from_landfire_fccs( - domain, remove_bare_ground=True + domain, remove_bare_ground=True, output_resolution_m=30 ) ``` -FCCS grids currently come back on their native lattice, so this creator -takes no alignment arguments; -[resample](working-with-grids.md#resample-a-grid) the result if you need a -different resolution. +FCCS takes the same alignment arguments as the other source grids +(`output_resolution_m`, `align_to`, `align`, `resampling`) — see +[Align grids to each other](#align-grids-to-each-other). ## Canopy grids diff --git a/fastfuels_sdk/v2/grids.py b/fastfuels_sdk/v2/grids.py index 2034441..163e170 100644 --- a/fastfuels_sdk/v2/grids.py +++ b/fastfuels_sdk/v2/grids.py @@ -1109,6 +1109,11 @@ def create_fuel_model_grid_from_landfire_fccs( domain, version: Optional[str] = None, remove_bare_ground: bool = False, + output_resolution_m: Optional[float] = None, + align_to=None, + align: Optional[str] = None, + resampling: Optional[str] = None, + extent_buffer_cells: int = 0, name: str = "", description: str = "", tags: Optional[List[str]] = None, @@ -1125,6 +1130,16 @@ def create_fuel_model_grid_from_landfire_fccs( current version. remove_bare_ground : bool, optional Drop bare-ground fuelbeds (default False). + output_resolution_m : float, optional + Output cell size in meters, anchored to the domain origin. + align_to : Grid or str, optional + Match the lattice of an existing grid (or its id). + align : str, optional + Pass ``"native"`` to keep the source raster's pixel anchor. + resampling : str, optional + Resampling method (e.g. "nearest" for categorical fuelbeds). + extent_buffer_cells : int, optional + Result-grid cells to buffer around the domain extent (0-10, default 0). name, description : str, optional Metadata for the grid. tags : List[str], optional @@ -1137,13 +1152,11 @@ def create_fuel_model_grid_from_landfire_fccs( Grid The created Grid object (job status "pending" or "running"). """ - # FCCS is alignment-free: CreateLandfireFccsRequest has no `alignment` or - # `extent_buffer_cells` field, unlike the other LANDFIRE creators, so there - # are no output_resolution_m/align_to/align/resampling kwargs here. - # See fastfuels_sdk/v2/v2_api_design.md. request_body = CreateLandfireFccsRequest( version=LandfireFccsVersion(version) if version is not None else UNSET, remove_bare_ground=remove_bare_ground, + alignment=_build_alignment(output_resolution_m, align_to, align, resampling), + extent_buffer_cells=extent_buffer_cells, name=name, description=description, tags=_opt(tags), diff --git a/fastfuels_sdk/v2/v2_api_design.md b/fastfuels_sdk/v2/v2_api_design.md index 9b4b3bb..511046d 100644 --- a/fastfuels_sdk/v2/v2_api_design.md +++ b/fastfuels_sdk/v2/v2_api_design.md @@ -103,9 +103,9 @@ Per-creator: `horizontal_resolution_m=` + `vertical_resolution_m=`. - **`create_fuel_grid_from_fbfm40_lookup`, geotiff/netcdf upload**: no resolution/alignment — inherit the source grid's lattice (lookup) or the file (uploads). -- ⚠️ **`landfire_fccs` carries neither `alignment` nor `resolution`** — an - asymmetry vs the other LANDFIRE creators. Confirm with the API team before - building (fixed resolution? inherits a default?). +- **`landfire_fccs`** reached alignment parity with the other LANDFIRE creators + in FastFuels-API-v2 #358 (it now carries `alignment` + `extent_buffer_cells`), + resolving the earlier asymmetry. ## Conventions @@ -159,7 +159,7 @@ alignment-translation helper (plain function, not a base class). | `create_meta_chm` | `ff.grids.create_canopy_height_grid_from_meta(domain, version=…)` | | `create_naip_chm` | `ff.grids.create_canopy_height_grid_from_naip_chm(domain)` | | `create_landfire_fbfm40` | `ff.grids.create_fuel_model_grid_from_landfire_fbfm40(domain, version=…, remove_non_burnable=…)` | -| `create_landfire_fccs` | `ff.grids.create_fuel_model_grid_from_landfire_fccs(domain, version=…, remove_bare_ground=…)` (⚠ no alignment — confirm) | +| `create_landfire_fccs` | `ff.grids.create_fuel_model_grid_from_landfire_fccs(domain, version=…, remove_bare_ground=…, output_resolution_m=…)` | | `create_treemap` | `ff.grids.create_pim_grid_from_treemap(domain, version=…, bands=[…])` (PIM = Plot Imputation Map; bands tm_id/plt_cn) | **From your file / generated (functions)** @@ -209,7 +209,8 @@ lookup is the only such case today: it needs a grid carrying `fbfm` codes. ## Open / flagged -- ⚠ `landfire_fccs` alignment/resolution asymmetry — confirm with API team. +- ~~`landfire_fccs` alignment/resolution asymmetry~~ — resolved (FastFuels-API-v2 + #358 added `alignment` + `extent_buffer_cells`; the SDK creator now exposes them). - Single-grid export is a method (`grid.export`) but the QUIC-Fire bundle is a function (`ff.exports.create_quicfire_export`) — consistent with the rule (one held resource → method; assembled-from-many → function), but worth a look. diff --git a/tests/v2/test_grids.py b/tests/v2/test_grids.py index 4235c3c..be46b39 100644 --- a/tests/v2/test_grids.py +++ b/tests/v2/test_grids.py @@ -139,21 +139,21 @@ def test_enum_list_invalid_value_raises(self): class TestFccsSignature: - """The FCCS request model has no alignment field, so the SDK creator - exposes no alignment kwargs (unlike the other LANDFIRE creators).""" + """FCCS create reached alignment parity with the other LANDFIRE creators + (FastFuels-API-v2 #358), so the SDK creator exposes the same kwargs.""" - def test_fccs_has_no_alignment_kwargs(self): + def test_fccs_exposes_alignment_kwargs(self): params = inspect.signature( grids.create_fuel_model_grid_from_landfire_fccs ).parameters - for forbidden in ( + for expected in ( "output_resolution_m", "align_to", "align", "resampling", "extent_buffer_cells", ): - assert forbidden not in params + assert expected in params def test_fbfm40_has_alignment_kwargs(self): params = inspect.signature( @@ -295,7 +295,6 @@ def test_mask_applied_to_grid_creation(self, test_domain, completed_road_feature class TestCreateFuelModelGridFromLandfireFccs: def test_create(self, test_domain): - # FCCS is alignment-free; only metadata and remove_bare_ground apply grid = create_fuel_model_grid_from_landfire_fccs( test_domain, remove_bare_ground=True, name="throwaway_fccs" ) @@ -304,6 +303,16 @@ def test_create(self, test_domain): assert grid.status in (JobStatus.PENDING, JobStatus.RUNNING) grid.delete() + def test_create_with_alignment(self, test_domain): + # #358 brought FCCS to alignment parity; output_resolution_m anchors + # output cells to the domain origin, like the other LANDFIRE creators. + grid = create_fuel_model_grid_from_landfire_fccs( + test_domain, output_resolution_m=30, name="throwaway_fccs_aligned" + ) + assert len(grid.id) > 0 + assert grid.status in (JobStatus.PENDING, JobStatus.RUNNING) + grid.delete() + class TestCreatePimGridFromTreemap: def test_create(self, test_domain): From 2911c6574dcf045001b334a3b18d1bfef16203ef Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Sat, 13 Jun 2026 09:00:20 -0600 Subject: [PATCH 40/71] Regenerate v2 client library: apply_grid_modifications + GDAM inventory Incremental regen picking up spec changes since the last sync: - apply_grid_modifications endpoint (POST /domains/{id}/grids/{id}/modifications, ApplyGridModificationsRequest{modifications}) -> Grid. Applies modifications to an existing grid, not just at create time. - create_gdam_inventory endpoint (CreateGdamInventoryRequest{ source_tree_inventory_id, impute_columns: crown_ratio/dbh/fia_species_code}). A new inventory source that imputes columns from a source tree inventory. - grid_modification.py: docstring-only clarification of AND-within-rule / OR-across-rules semantics (no structural change). Generated surface only; SDK wrappers are follow-up. --- .../api/grids/apply_grid_modifications.py | 560 ++++++++++++++++++ .../api/inventories/create_gdam_inventory.py | 386 ++++++++++++ .../v2/client_library/models/__init__.py | 8 + .../apply_grid_modifications_request.py | 79 +++ .../models/create_gdam_inventory_request.py | 142 +++++ ...m_inventory_request_impute_columns_item.py | 10 + .../models/grid_modification.py | 16 +- 7 files changed, 1197 insertions(+), 4 deletions(-) create mode 100644 fastfuels_sdk/v2/client_library/api/grids/apply_grid_modifications.py create mode 100644 fastfuels_sdk/v2/client_library/api/inventories/create_gdam_inventory.py create mode 100644 fastfuels_sdk/v2/client_library/models/apply_grid_modifications_request.py create mode 100644 fastfuels_sdk/v2/client_library/models/create_gdam_inventory_request.py create mode 100644 fastfuels_sdk/v2/client_library/models/create_gdam_inventory_request_impute_columns_item.py diff --git a/fastfuels_sdk/v2/client_library/api/grids/apply_grid_modifications.py b/fastfuels_sdk/v2/client_library/api/grids/apply_grid_modifications.py new file mode 100644 index 0000000..cc53659 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/grids/apply_grid_modifications.py @@ -0,0 +1,560 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.apply_grid_modifications_request import ApplyGridModificationsRequest +from ...models.grid import Grid +from ...models.http_validation_error import HTTPValidationError +from ...types import Response + + +def _get_kwargs( + domain_id: str, + grid_id: str, + *, + body: ApplyGridModificationsRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/domains/{domain_id}/grids/{grid_id}/modifications".format( + domain_id=quote(str(domain_id), safe=""), + grid_id=quote(str(grid_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Grid | HTTPValidationError | None: + if response.status_code == 200: + response_200 = Grid.from_dict(response.json()) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Grid | HTTPValidationError]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + grid_id: str, + *, + client: AuthenticatedClient, + body: ApplyGridModificationsRequest, +) -> Response[Grid | HTTPValidationError]: + r"""Apply modifications to a grid in place + + # Apply Modifications to a Grid (in place) + + Applies modification rules to **this** grid in place — the grid keeps its + ID and the submitted rules are applied on top of its current data + asynchronously. To keep the original data instead, duplicate the grid + first (`POST .../{grid_id}/duplicate`) and modify the copy. + + The grid's stored data is updated directly; the upstream source (LANDFIRE, + 3DEP, ...) is **not** re-fetched, so cells your rules don't touch are + byte-for-byte unchanged — even if the upstream product has been updated + since the grid was built. + + Modifications select cells by conditions and apply actions to the matching + cells. + + ## Combining conditions: AND within a rule, OR across rules + + Each rule's `conditions` are **ANDed** — a cell is selected only when it + satisfies *every* condition in that rule. Adding a condition to a rule + therefore **narrows** the selection (the intersection). Example: a feature + condition plus an attribute condition matches cells inside the feature + **and** above a value threshold. + + There is **no OR within a rule**. To act on a **union** — \"roads *or* + water bodies\", \"GR1 *or* GR2 cells\" — use **multiple rules**. Rules are + applied independently and in order, so a cell matched by *any* rule is + affected. Adding a rule therefore **widens** the overall selection. + + Putting two mutually exclusive conditions in one rule (e.g. a road feature + AND a water feature) is the classic mistake: it selects cells that are + both at once — usually none. Split them into one rule per feature instead. + + ## Conditions + + **Attribute conditions** compare a band's cell values against a value: + - `band`: dot-notation band key (e.g., `fbfm`, `fuel_load.1hr`) + - `operator`: `eq`, `ne`, `gt`, `lt`, `ge`, `le` + (`eq`/`ne` also accept a list of values) + - `value`: number, string, or list for `eq`/`ne` + + **Spatial conditions** test each cell's location against a geometry. Two + variants discriminated by the required `source` field: + + - `source: \"geometry\"` — supply GeoJSON directly via `geometry` (plus + optional `crs`; defaults to the domain CRS). + - `source: \"feature\"` — reference a persisted Feature resource by + `feature_id` (road, water, layerset). The Feature must belong to the + same domain as this grid and be in `completed` status; cross-domain, + missing, or unfinished references are rejected with 422. + + Both spatial variants accept: + - `operator`: `within`, `outside`, or `intersects` + - `buffer_m`: (optional, meters) expands the geometry outward in the + domain's projected CRS before testing. + - `target`: `centroid` (default) tests the cell center; `cell` tests the + cell's full footprint — use it with linestring features (e.g. roads) + so every crossed cell matches. + + ## Actions + + - `{\"band\": \"...\", \"modifier\": \"replace|multiply|divide|add|subtract\", \"value\": ...}` + - Non-`replace` results are clamped at zero (grid bands are physical + quantities). + + ## Response + + Returns this grid (same ID) with status `\"pending\"`. Its `checksum` + changes immediately, so any resource derived from it (resample, lookup, + exports) can detect that the source has changed. The submitted rules + appear in the grid's `modifications` list once processing completes — + poll the grid until status returns to `\"completed\"`. + + If processing fails, the grid's status becomes `\"failed\"` with error + details, the stored data is unchanged, and the queued rules are retained — + submit another POST to retry (the new rules are applied together with the + retained ones). + + ## Error Responses + + - **404 Not Found**: The grid does not exist, is not owned by the caller, + or is not in this domain. + - **422 Unprocessable Content**: The grid is not in `completed` status + (and is not a retryable failed modification); the grid is a 3D voxel + grid (apply modifications to the source tree inventory and re-voxelize + instead); a referenced `feature_id` is missing, cross-domain, or not + completed; or a referenced band does not exist on this grid. + + Args: + domain_id (str): + grid_id (str): + body (ApplyGridModificationsRequest): Request body for applying modifications to a grid in + place. + + Metadata (name, description, tags) is not accepted here — the grid keeps + its identity; use PATCH to edit metadata. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + grid_id=grid_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + grid_id: str, + *, + client: AuthenticatedClient, + body: ApplyGridModificationsRequest, +) -> Grid | HTTPValidationError | None: + r"""Apply modifications to a grid in place + + # Apply Modifications to a Grid (in place) + + Applies modification rules to **this** grid in place — the grid keeps its + ID and the submitted rules are applied on top of its current data + asynchronously. To keep the original data instead, duplicate the grid + first (`POST .../{grid_id}/duplicate`) and modify the copy. + + The grid's stored data is updated directly; the upstream source (LANDFIRE, + 3DEP, ...) is **not** re-fetched, so cells your rules don't touch are + byte-for-byte unchanged — even if the upstream product has been updated + since the grid was built. + + Modifications select cells by conditions and apply actions to the matching + cells. + + ## Combining conditions: AND within a rule, OR across rules + + Each rule's `conditions` are **ANDed** — a cell is selected only when it + satisfies *every* condition in that rule. Adding a condition to a rule + therefore **narrows** the selection (the intersection). Example: a feature + condition plus an attribute condition matches cells inside the feature + **and** above a value threshold. + + There is **no OR within a rule**. To act on a **union** — \"roads *or* + water bodies\", \"GR1 *or* GR2 cells\" — use **multiple rules**. Rules are + applied independently and in order, so a cell matched by *any* rule is + affected. Adding a rule therefore **widens** the overall selection. + + Putting two mutually exclusive conditions in one rule (e.g. a road feature + AND a water feature) is the classic mistake: it selects cells that are + both at once — usually none. Split them into one rule per feature instead. + + ## Conditions + + **Attribute conditions** compare a band's cell values against a value: + - `band`: dot-notation band key (e.g., `fbfm`, `fuel_load.1hr`) + - `operator`: `eq`, `ne`, `gt`, `lt`, `ge`, `le` + (`eq`/`ne` also accept a list of values) + - `value`: number, string, or list for `eq`/`ne` + + **Spatial conditions** test each cell's location against a geometry. Two + variants discriminated by the required `source` field: + + - `source: \"geometry\"` — supply GeoJSON directly via `geometry` (plus + optional `crs`; defaults to the domain CRS). + - `source: \"feature\"` — reference a persisted Feature resource by + `feature_id` (road, water, layerset). The Feature must belong to the + same domain as this grid and be in `completed` status; cross-domain, + missing, or unfinished references are rejected with 422. + + Both spatial variants accept: + - `operator`: `within`, `outside`, or `intersects` + - `buffer_m`: (optional, meters) expands the geometry outward in the + domain's projected CRS before testing. + - `target`: `centroid` (default) tests the cell center; `cell` tests the + cell's full footprint — use it with linestring features (e.g. roads) + so every crossed cell matches. + + ## Actions + + - `{\"band\": \"...\", \"modifier\": \"replace|multiply|divide|add|subtract\", \"value\": ...}` + - Non-`replace` results are clamped at zero (grid bands are physical + quantities). + + ## Response + + Returns this grid (same ID) with status `\"pending\"`. Its `checksum` + changes immediately, so any resource derived from it (resample, lookup, + exports) can detect that the source has changed. The submitted rules + appear in the grid's `modifications` list once processing completes — + poll the grid until status returns to `\"completed\"`. + + If processing fails, the grid's status becomes `\"failed\"` with error + details, the stored data is unchanged, and the queued rules are retained — + submit another POST to retry (the new rules are applied together with the + retained ones). + + ## Error Responses + + - **404 Not Found**: The grid does not exist, is not owned by the caller, + or is not in this domain. + - **422 Unprocessable Content**: The grid is not in `completed` status + (and is not a retryable failed modification); the grid is a 3D voxel + grid (apply modifications to the source tree inventory and re-voxelize + instead); a referenced `feature_id` is missing, cross-domain, or not + completed; or a referenced band does not exist on this grid. + + Args: + domain_id (str): + grid_id (str): + body (ApplyGridModificationsRequest): Request body for applying modifications to a grid in + place. + + Metadata (name, description, tags) is not accepted here — the grid keeps + its identity; use PATCH to edit metadata. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError + """ + + return sync_detailed( + domain_id=domain_id, + grid_id=grid_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + grid_id: str, + *, + client: AuthenticatedClient, + body: ApplyGridModificationsRequest, +) -> Response[Grid | HTTPValidationError]: + r"""Apply modifications to a grid in place + + # Apply Modifications to a Grid (in place) + + Applies modification rules to **this** grid in place — the grid keeps its + ID and the submitted rules are applied on top of its current data + asynchronously. To keep the original data instead, duplicate the grid + first (`POST .../{grid_id}/duplicate`) and modify the copy. + + The grid's stored data is updated directly; the upstream source (LANDFIRE, + 3DEP, ...) is **not** re-fetched, so cells your rules don't touch are + byte-for-byte unchanged — even if the upstream product has been updated + since the grid was built. + + Modifications select cells by conditions and apply actions to the matching + cells. + + ## Combining conditions: AND within a rule, OR across rules + + Each rule's `conditions` are **ANDed** — a cell is selected only when it + satisfies *every* condition in that rule. Adding a condition to a rule + therefore **narrows** the selection (the intersection). Example: a feature + condition plus an attribute condition matches cells inside the feature + **and** above a value threshold. + + There is **no OR within a rule**. To act on a **union** — \"roads *or* + water bodies\", \"GR1 *or* GR2 cells\" — use **multiple rules**. Rules are + applied independently and in order, so a cell matched by *any* rule is + affected. Adding a rule therefore **widens** the overall selection. + + Putting two mutually exclusive conditions in one rule (e.g. a road feature + AND a water feature) is the classic mistake: it selects cells that are + both at once — usually none. Split them into one rule per feature instead. + + ## Conditions + + **Attribute conditions** compare a band's cell values against a value: + - `band`: dot-notation band key (e.g., `fbfm`, `fuel_load.1hr`) + - `operator`: `eq`, `ne`, `gt`, `lt`, `ge`, `le` + (`eq`/`ne` also accept a list of values) + - `value`: number, string, or list for `eq`/`ne` + + **Spatial conditions** test each cell's location against a geometry. Two + variants discriminated by the required `source` field: + + - `source: \"geometry\"` — supply GeoJSON directly via `geometry` (plus + optional `crs`; defaults to the domain CRS). + - `source: \"feature\"` — reference a persisted Feature resource by + `feature_id` (road, water, layerset). The Feature must belong to the + same domain as this grid and be in `completed` status; cross-domain, + missing, or unfinished references are rejected with 422. + + Both spatial variants accept: + - `operator`: `within`, `outside`, or `intersects` + - `buffer_m`: (optional, meters) expands the geometry outward in the + domain's projected CRS before testing. + - `target`: `centroid` (default) tests the cell center; `cell` tests the + cell's full footprint — use it with linestring features (e.g. roads) + so every crossed cell matches. + + ## Actions + + - `{\"band\": \"...\", \"modifier\": \"replace|multiply|divide|add|subtract\", \"value\": ...}` + - Non-`replace` results are clamped at zero (grid bands are physical + quantities). + + ## Response + + Returns this grid (same ID) with status `\"pending\"`. Its `checksum` + changes immediately, so any resource derived from it (resample, lookup, + exports) can detect that the source has changed. The submitted rules + appear in the grid's `modifications` list once processing completes — + poll the grid until status returns to `\"completed\"`. + + If processing fails, the grid's status becomes `\"failed\"` with error + details, the stored data is unchanged, and the queued rules are retained — + submit another POST to retry (the new rules are applied together with the + retained ones). + + ## Error Responses + + - **404 Not Found**: The grid does not exist, is not owned by the caller, + or is not in this domain. + - **422 Unprocessable Content**: The grid is not in `completed` status + (and is not a retryable failed modification); the grid is a 3D voxel + grid (apply modifications to the source tree inventory and re-voxelize + instead); a referenced `feature_id` is missing, cross-domain, or not + completed; or a referenced band does not exist on this grid. + + Args: + domain_id (str): + grid_id (str): + body (ApplyGridModificationsRequest): Request body for applying modifications to a grid in + place. + + Metadata (name, description, tags) is not accepted here — the grid keeps + its identity; use PATCH to edit metadata. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + grid_id=grid_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + grid_id: str, + *, + client: AuthenticatedClient, + body: ApplyGridModificationsRequest, +) -> Grid | HTTPValidationError | None: + r"""Apply modifications to a grid in place + + # Apply Modifications to a Grid (in place) + + Applies modification rules to **this** grid in place — the grid keeps its + ID and the submitted rules are applied on top of its current data + asynchronously. To keep the original data instead, duplicate the grid + first (`POST .../{grid_id}/duplicate`) and modify the copy. + + The grid's stored data is updated directly; the upstream source (LANDFIRE, + 3DEP, ...) is **not** re-fetched, so cells your rules don't touch are + byte-for-byte unchanged — even if the upstream product has been updated + since the grid was built. + + Modifications select cells by conditions and apply actions to the matching + cells. + + ## Combining conditions: AND within a rule, OR across rules + + Each rule's `conditions` are **ANDed** — a cell is selected only when it + satisfies *every* condition in that rule. Adding a condition to a rule + therefore **narrows** the selection (the intersection). Example: a feature + condition plus an attribute condition matches cells inside the feature + **and** above a value threshold. + + There is **no OR within a rule**. To act on a **union** — \"roads *or* + water bodies\", \"GR1 *or* GR2 cells\" — use **multiple rules**. Rules are + applied independently and in order, so a cell matched by *any* rule is + affected. Adding a rule therefore **widens** the overall selection. + + Putting two mutually exclusive conditions in one rule (e.g. a road feature + AND a water feature) is the classic mistake: it selects cells that are + both at once — usually none. Split them into one rule per feature instead. + + ## Conditions + + **Attribute conditions** compare a band's cell values against a value: + - `band`: dot-notation band key (e.g., `fbfm`, `fuel_load.1hr`) + - `operator`: `eq`, `ne`, `gt`, `lt`, `ge`, `le` + (`eq`/`ne` also accept a list of values) + - `value`: number, string, or list for `eq`/`ne` + + **Spatial conditions** test each cell's location against a geometry. Two + variants discriminated by the required `source` field: + + - `source: \"geometry\"` — supply GeoJSON directly via `geometry` (plus + optional `crs`; defaults to the domain CRS). + - `source: \"feature\"` — reference a persisted Feature resource by + `feature_id` (road, water, layerset). The Feature must belong to the + same domain as this grid and be in `completed` status; cross-domain, + missing, or unfinished references are rejected with 422. + + Both spatial variants accept: + - `operator`: `within`, `outside`, or `intersects` + - `buffer_m`: (optional, meters) expands the geometry outward in the + domain's projected CRS before testing. + - `target`: `centroid` (default) tests the cell center; `cell` tests the + cell's full footprint — use it with linestring features (e.g. roads) + so every crossed cell matches. + + ## Actions + + - `{\"band\": \"...\", \"modifier\": \"replace|multiply|divide|add|subtract\", \"value\": ...}` + - Non-`replace` results are clamped at zero (grid bands are physical + quantities). + + ## Response + + Returns this grid (same ID) with status `\"pending\"`. Its `checksum` + changes immediately, so any resource derived from it (resample, lookup, + exports) can detect that the source has changed. The submitted rules + appear in the grid's `modifications` list once processing completes — + poll the grid until status returns to `\"completed\"`. + + If processing fails, the grid's status becomes `\"failed\"` with error + details, the stored data is unchanged, and the queued rules are retained — + submit another POST to retry (the new rules are applied together with the + retained ones). + + ## Error Responses + + - **404 Not Found**: The grid does not exist, is not owned by the caller, + or is not in this domain. + - **422 Unprocessable Content**: The grid is not in `completed` status + (and is not a retryable failed modification); the grid is a 3D voxel + grid (apply modifications to the source tree inventory and re-voxelize + instead); a referenced `feature_id` is missing, cross-domain, or not + completed; or a referenced band does not exist on this grid. + + Args: + domain_id (str): + grid_id (str): + body (ApplyGridModificationsRequest): Request body for applying modifications to a grid in + place. + + Metadata (name, description, tags) is not accepted here — the grid keeps + its identity; use PATCH to edit metadata. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + grid_id=grid_id, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/inventories/create_gdam_inventory.py b/fastfuels_sdk/v2/client_library/api/inventories/create_gdam_inventory.py new file mode 100644 index 0000000..7b47dcf --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/inventories/create_gdam_inventory.py @@ -0,0 +1,386 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.create_gdam_inventory_request import CreateGdamInventoryRequest +from ...models.http_validation_error import HTTPValidationError +from ...models.inventory import Inventory +from ...types import Response + + +def _get_kwargs( + domain_id: str, + *, + body: CreateGdamInventoryRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/domains/{domain_id}/inventories/tree/allometry/gdam".format( + domain_id=quote(str(domain_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> HTTPValidationError | Inventory | None: + if response.status_code == 201: + response_201 = Inventory.from_dict(response.json()) + + return response_201 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[HTTPValidationError | Inventory]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateGdamInventoryRequest, +) -> Response[HTTPValidationError | Inventory]: + r"""Create an inventory by filling in another via GDAM + + # Create GDAM Allometry Inventory + + **GDAM (Generalized Dendro Allometric Model)** is a machine-learning model + that predicts tree morphology — diameter at breast height, live crown ratio, + and species — from simple stem metrics (position and height). It replaces + legacy region-specific allometric equations with a single generative model: + each tree is routed to a region-specific model by geography, and a masked + tabular autoencoder fills in every missing field in one pass while preserving + any values you already supply. + + This endpoint creates a new tree inventory by calling the GDAM API to fill in + the missing morphology columns (diameter, crown ratio, species) of an existing + tree inventory. + + The typical input is an uploaded **position + height** inventory (`x`, `y`, + `height`). GDAM predicts the missing fields; any values already present are + preserved and passed to GDAM as conditioning inputs. + + ## Request Body + + - **source_tree_inventory_id**: (required) ID of a completed tree inventory to + fill in. + - **impute_columns**: (optional) Which morphology columns to impute. Defaults + to all of ``dbh``, ``crown_ratio``, ``fia_species_code``. Narrow it (e.g. + ``[\"fia_species_code\"]``) to impute fewer columns and write less to disk; + columns left out are not imputed. Must be non-empty with no duplicates. + - **name**: (optional) Name for the inventory. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing inventories. + + ## Columns + + **Required on the source inventory** (the typical uploaded position+height set): + + - **x**, **y**: tree position, in the domain CRS. + - **height**: tree height, in metres (``m``). + + **Imputable by GDAM** (select via ``impute_columns``) — filled only where + missing; existing values are preserved: + + - **dbh**: diameter at breast height, in centimetres (``cm``). + - **crown_ratio**: live crown ratio, as a 0–1 fraction. + - **fia_species_code**: FIA species code. + + ## Response + + Returns the created Inventory resource with status ``\"pending\"``. The backend + (Standgen) calls GDAM asynchronously and updates status to ``\"completed\"`` when + ready. + + Args: + domain_id (str): + body (CreateGdamInventoryRequest): Request body for creating an inventory via GDAM + allometry imputation. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | Inventory] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateGdamInventoryRequest, +) -> HTTPValidationError | Inventory | None: + r"""Create an inventory by filling in another via GDAM + + # Create GDAM Allometry Inventory + + **GDAM (Generalized Dendro Allometric Model)** is a machine-learning model + that predicts tree morphology — diameter at breast height, live crown ratio, + and species — from simple stem metrics (position and height). It replaces + legacy region-specific allometric equations with a single generative model: + each tree is routed to a region-specific model by geography, and a masked + tabular autoencoder fills in every missing field in one pass while preserving + any values you already supply. + + This endpoint creates a new tree inventory by calling the GDAM API to fill in + the missing morphology columns (diameter, crown ratio, species) of an existing + tree inventory. + + The typical input is an uploaded **position + height** inventory (`x`, `y`, + `height`). GDAM predicts the missing fields; any values already present are + preserved and passed to GDAM as conditioning inputs. + + ## Request Body + + - **source_tree_inventory_id**: (required) ID of a completed tree inventory to + fill in. + - **impute_columns**: (optional) Which morphology columns to impute. Defaults + to all of ``dbh``, ``crown_ratio``, ``fia_species_code``. Narrow it (e.g. + ``[\"fia_species_code\"]``) to impute fewer columns and write less to disk; + columns left out are not imputed. Must be non-empty with no duplicates. + - **name**: (optional) Name for the inventory. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing inventories. + + ## Columns + + **Required on the source inventory** (the typical uploaded position+height set): + + - **x**, **y**: tree position, in the domain CRS. + - **height**: tree height, in metres (``m``). + + **Imputable by GDAM** (select via ``impute_columns``) — filled only where + missing; existing values are preserved: + + - **dbh**: diameter at breast height, in centimetres (``cm``). + - **crown_ratio**: live crown ratio, as a 0–1 fraction. + - **fia_species_code**: FIA species code. + + ## Response + + Returns the created Inventory resource with status ``\"pending\"``. The backend + (Standgen) calls GDAM asynchronously and updates status to ``\"completed\"`` when + ready. + + Args: + domain_id (str): + body (CreateGdamInventoryRequest): Request body for creating an inventory via GDAM + allometry imputation. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | Inventory + """ + + return sync_detailed( + domain_id=domain_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateGdamInventoryRequest, +) -> Response[HTTPValidationError | Inventory]: + r"""Create an inventory by filling in another via GDAM + + # Create GDAM Allometry Inventory + + **GDAM (Generalized Dendro Allometric Model)** is a machine-learning model + that predicts tree morphology — diameter at breast height, live crown ratio, + and species — from simple stem metrics (position and height). It replaces + legacy region-specific allometric equations with a single generative model: + each tree is routed to a region-specific model by geography, and a masked + tabular autoencoder fills in every missing field in one pass while preserving + any values you already supply. + + This endpoint creates a new tree inventory by calling the GDAM API to fill in + the missing morphology columns (diameter, crown ratio, species) of an existing + tree inventory. + + The typical input is an uploaded **position + height** inventory (`x`, `y`, + `height`). GDAM predicts the missing fields; any values already present are + preserved and passed to GDAM as conditioning inputs. + + ## Request Body + + - **source_tree_inventory_id**: (required) ID of a completed tree inventory to + fill in. + - **impute_columns**: (optional) Which morphology columns to impute. Defaults + to all of ``dbh``, ``crown_ratio``, ``fia_species_code``. Narrow it (e.g. + ``[\"fia_species_code\"]``) to impute fewer columns and write less to disk; + columns left out are not imputed. Must be non-empty with no duplicates. + - **name**: (optional) Name for the inventory. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing inventories. + + ## Columns + + **Required on the source inventory** (the typical uploaded position+height set): + + - **x**, **y**: tree position, in the domain CRS. + - **height**: tree height, in metres (``m``). + + **Imputable by GDAM** (select via ``impute_columns``) — filled only where + missing; existing values are preserved: + + - **dbh**: diameter at breast height, in centimetres (``cm``). + - **crown_ratio**: live crown ratio, as a 0–1 fraction. + - **fia_species_code**: FIA species code. + + ## Response + + Returns the created Inventory resource with status ``\"pending\"``. The backend + (Standgen) calls GDAM asynchronously and updates status to ``\"completed\"`` when + ready. + + Args: + domain_id (str): + body (CreateGdamInventoryRequest): Request body for creating an inventory via GDAM + allometry imputation. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | Inventory] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateGdamInventoryRequest, +) -> HTTPValidationError | Inventory | None: + r"""Create an inventory by filling in another via GDAM + + # Create GDAM Allometry Inventory + + **GDAM (Generalized Dendro Allometric Model)** is a machine-learning model + that predicts tree morphology — diameter at breast height, live crown ratio, + and species — from simple stem metrics (position and height). It replaces + legacy region-specific allometric equations with a single generative model: + each tree is routed to a region-specific model by geography, and a masked + tabular autoencoder fills in every missing field in one pass while preserving + any values you already supply. + + This endpoint creates a new tree inventory by calling the GDAM API to fill in + the missing morphology columns (diameter, crown ratio, species) of an existing + tree inventory. + + The typical input is an uploaded **position + height** inventory (`x`, `y`, + `height`). GDAM predicts the missing fields; any values already present are + preserved and passed to GDAM as conditioning inputs. + + ## Request Body + + - **source_tree_inventory_id**: (required) ID of a completed tree inventory to + fill in. + - **impute_columns**: (optional) Which morphology columns to impute. Defaults + to all of ``dbh``, ``crown_ratio``, ``fia_species_code``. Narrow it (e.g. + ``[\"fia_species_code\"]``) to impute fewer columns and write less to disk; + columns left out are not imputed. Must be non-empty with no duplicates. + - **name**: (optional) Name for the inventory. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing inventories. + + ## Columns + + **Required on the source inventory** (the typical uploaded position+height set): + + - **x**, **y**: tree position, in the domain CRS. + - **height**: tree height, in metres (``m``). + + **Imputable by GDAM** (select via ``impute_columns``) — filled only where + missing; existing values are preserved: + + - **dbh**: diameter at breast height, in centimetres (``cm``). + - **crown_ratio**: live crown ratio, as a 0–1 fraction. + - **fia_species_code**: FIA species code. + + ## Response + + Returns the created Inventory resource with status ``\"pending\"``. The backend + (Standgen) calls GDAM asynchronously and updates status to ``\"completed\"`` when + ready. + + Args: + domain_id (str): + body (CreateGdamInventoryRequest): Request body for creating an inventory via GDAM + allometry imputation. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | Inventory + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/models/__init__.py b/fastfuels_sdk/v2/client_library/models/__init__.py index 8562d86..997bde2 100644 --- a/fastfuels_sdk/v2/client_library/models/__init__.py +++ b/fastfuels_sdk/v2/client_library/models/__init__.py @@ -7,6 +7,7 @@ ) from .allometry_max_crown_radius_source import AllometryMaxCrownRadiusSource from .application import Application +from .apply_grid_modifications_request import ApplyGridModificationsRequest from .apply_modifications_request import ApplyModificationsRequest from .apply_treatments_request import ApplyTreatmentsRequest from .band import Band @@ -26,6 +27,10 @@ from .create_chm_inventory_request import CreateChmInventoryRequest from .create_domain_request_body import CreateDomainRequestBody from .create_fbfm_40_lookup_request import CreateFbfm40LookupRequest +from .create_gdam_inventory_request import CreateGdamInventoryRequest +from .create_gdam_inventory_request_impute_columns_item import ( + CreateGdamInventoryRequestImputeColumnsItem, +) from .create_geo_tiff_upload_request import CreateGeoTIFFUploadRequest from .create_inventory_upload_request import CreateInventoryUploadRequest from .create_key_request import CreateKeyRequest @@ -240,6 +245,7 @@ "AllometryBiomassSourceComponentStates", "AllometryMaxCrownRadiusSource", "Application", + "ApplyGridModificationsRequest", "ApplyModificationsRequest", "ApplyTreatmentsRequest", "Band", @@ -259,6 +265,8 @@ "CreateChmInventoryRequest", "CreateDomainRequestBody", "CreateFbfm40LookupRequest", + "CreateGdamInventoryRequest", + "CreateGdamInventoryRequestImputeColumnsItem", "CreateGeoTIFFUploadRequest", "CreateInventoryUploadRequest", "CreateKeyRequest", diff --git a/fastfuels_sdk/v2/client_library/models/apply_grid_modifications_request.py b/fastfuels_sdk/v2/client_library/models/apply_grid_modifications_request.py new file mode 100644 index 0000000..9478c2d --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/apply_grid_modifications_request.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.grid_modification import GridModification + + +T = TypeVar("T", bound="ApplyGridModificationsRequest") + + +@_attrs_define +class ApplyGridModificationsRequest: + """Request body for applying modifications to a grid in place. + + Metadata (name, description, tags) is not accepted here — the grid keeps + its identity; use PATCH to edit metadata. + + Attributes: + modifications (list[GridModification]): Modifications to append to this grid and apply to its data. + """ + + modifications: list[GridModification] + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + modifications = [] + for modifications_item_data in self.modifications: + modifications_item = modifications_item_data.to_dict() + modifications.append(modifications_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "modifications": modifications, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.grid_modification import GridModification + + d = dict(src_dict) + modifications = [] + _modifications = d.pop("modifications") + for modifications_item_data in _modifications: + modifications_item = GridModification.from_dict(modifications_item_data) + + modifications.append(modifications_item) + + apply_grid_modifications_request = cls( + modifications=modifications, + ) + + apply_grid_modifications_request.additional_properties = d + return apply_grid_modifications_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/create_gdam_inventory_request.py b/fastfuels_sdk/v2/client_library/models/create_gdam_inventory_request.py new file mode 100644 index 0000000..b83e14f --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/create_gdam_inventory_request.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.create_gdam_inventory_request_impute_columns_item import ( + CreateGdamInventoryRequestImputeColumnsItem, +) +from ..models.inventory_type import InventoryType +from ..types import UNSET, Unset + +T = TypeVar("T", bound="CreateGdamInventoryRequest") + + +@_attrs_define +class CreateGdamInventoryRequest: + """Request body for creating an inventory via GDAM allometry imputation. + + Attributes: + source_tree_inventory_id (str): ID of a completed tree inventory whose missing morphology columns (dbh, crown + ratio, species) GDAM will fill in. Existing values are preserved; only missing cells are imputed. + type_ (InventoryType | Unset): Type of entities in the inventory. + name (str | Unset): Default: ''. + description (str | Unset): Default: ''. + tags (list[str] | Unset): + impute_columns (list[CreateGdamInventoryRequestImputeColumnsItem] | Unset): Which morphology columns GDAM should + impute. Defaults to all of `dbh`, `crown_ratio`, `fia_species_code`. Narrow it (e.g. `['fia_species_code']`) to + impute fewer columns and write less to disk; columns left out are not imputed (they stay as the source had + them). Must contain at least one column, with no duplicates. + """ + + source_tree_inventory_id: str + type_: InventoryType | Unset = UNSET + name: str | Unset = "" + description: str | Unset = "" + tags: list[str] | Unset = UNSET + impute_columns: list[CreateGdamInventoryRequestImputeColumnsItem] | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + source_tree_inventory_id = self.source_tree_inventory_id + + type_: str | Unset = UNSET + if not isinstance(self.type_, Unset): + type_ = self.type_.value + + name = self.name + + description = self.description + + tags: list[str] | Unset = UNSET + if not isinstance(self.tags, Unset): + tags = self.tags + + impute_columns: list[str] | Unset = UNSET + if not isinstance(self.impute_columns, Unset): + impute_columns = [] + for impute_columns_item_data in self.impute_columns: + impute_columns_item = impute_columns_item_data.value + impute_columns.append(impute_columns_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "source_tree_inventory_id": source_tree_inventory_id, + } + ) + if type_ is not UNSET: + field_dict["type"] = type_ + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if tags is not UNSET: + field_dict["tags"] = tags + if impute_columns is not UNSET: + field_dict["impute_columns"] = impute_columns + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + source_tree_inventory_id = d.pop("source_tree_inventory_id") + + _type_ = d.pop("type", UNSET) + type_: InventoryType | Unset + if isinstance(_type_, Unset): + type_ = UNSET + else: + type_ = InventoryType(_type_) + + name = d.pop("name", UNSET) + + description = d.pop("description", UNSET) + + tags = cast(list[str], d.pop("tags", UNSET)) + + _impute_columns = d.pop("impute_columns", UNSET) + impute_columns: list[CreateGdamInventoryRequestImputeColumnsItem] | Unset = ( + UNSET + ) + if _impute_columns is not UNSET: + impute_columns = [] + for impute_columns_item_data in _impute_columns: + impute_columns_item = CreateGdamInventoryRequestImputeColumnsItem( + impute_columns_item_data + ) + + impute_columns.append(impute_columns_item) + + create_gdam_inventory_request = cls( + source_tree_inventory_id=source_tree_inventory_id, + type_=type_, + name=name, + description=description, + tags=tags, + impute_columns=impute_columns, + ) + + create_gdam_inventory_request.additional_properties = d + return create_gdam_inventory_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/create_gdam_inventory_request_impute_columns_item.py b/fastfuels_sdk/v2/client_library/models/create_gdam_inventory_request_impute_columns_item.py new file mode 100644 index 0000000..7af142c --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/create_gdam_inventory_request_impute_columns_item.py @@ -0,0 +1,10 @@ +from enum import Enum + + +class CreateGdamInventoryRequestImputeColumnsItem(str, Enum): + CROWN_RATIO = "crown_ratio" + DBH = "dbh" + FIA_SPECIES_CODE = "fia_species_code" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/grid_modification.py b/fastfuels_sdk/v2/client_library/models/grid_modification.py index 451fe13..0eb443f 100644 --- a/fastfuels_sdk/v2/client_library/models/grid_modification.py +++ b/fastfuels_sdk/v2/client_library/models/grid_modification.py @@ -18,10 +18,18 @@ @_attrs_define class GridModification: - """A grid modification rule with conditions and actions. - - All conditions are ANDed together. When all match, actions are applied. - Conditions can be band-based (checking values) or spatial (checking location). + """A grid modification rule: a list of conditions and a list of actions. + + Conditions can be band-based (checking values) or spatial (checking + location). All conditions in a rule are **ANDed** — the actions apply only + to cells that satisfy *every* condition, so adding a condition narrows the + selection (the intersection). + + There is no OR within a rule. To act on a **union** of selections (e.g. + roads *or* water bodies), supply multiple rules: each rule is applied + independently, so adding a rule widens the overall selection. Putting two + mutually exclusive conditions (a road feature AND a water feature) in one + rule selects cells that are both at once — usually none. Attributes: conditions (list[GridFeatureSpatialCondition | GridGeometrySpatialCondition | GridModificationCondition]): From 3c9485868c07a8ab4a176b856e3ddab687855cf3 Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Sat, 13 Jun 2026 09:13:43 -0600 Subject: [PATCH 41/71] Echo server-provided headers on signed uploads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The signed upload URL is signed against a specific header set — Content-Type plus a GCS x-goog-content-length-range — returned by the create endpoints as spec.headers. The PUT must echo those headers exactly or GCS rejects it with 403. grids.py and inventories.py each had their own _put_upload and had drifted: inventories sent both required headers (hand-built), but grids sent only Content-Type, so geotiff/netcdf uploads were missing x-goog-content-length-range (the grid upload tests were skipped, so this went unnoticed). The regen now exposes spec.headers, which is exactly the set to send. Extract a single fastfuels_sdk/v2/_uploads.put_upload(spec, path) that sends spec.headers verbatim, and route both grids and inventories through it (deletes the two divergent copies; point clouds will reuse it). Rule-of-three shared helper, so the header contract can't drift again. Tests (TDD): tests/v2/test_uploads.py unit-checks that put_upload echoes the full server header set and raises on non-2xx; replaced the skipped GeoTIFF stub with a live roundtrip that builds an in-CRS GeoTIFF with rasterio and uploads it to completion. Verified live: unit + GeoTIFF upload + inventory CSV upload pass. --- fastfuels_sdk/v2/_uploads.py | 38 +++++++++++++++++ fastfuels_sdk/v2/grids.py | 15 ++----- fastfuels_sdk/v2/inventories.py | 16 +------ tests/v2/test_grids.py | 55 ++++++++++++++++++++---- tests/v2/test_uploads.py | 75 +++++++++++++++++++++++++++++++++ 5 files changed, 165 insertions(+), 34 deletions(-) create mode 100644 fastfuels_sdk/v2/_uploads.py create mode 100644 tests/v2/test_uploads.py diff --git a/fastfuels_sdk/v2/_uploads.py b/fastfuels_sdk/v2/_uploads.py new file mode 100644 index 0000000..1dc4e6a --- /dev/null +++ b/fastfuels_sdk/v2/_uploads.py @@ -0,0 +1,38 @@ +""" +fastfuels_sdk/v2/_uploads.py + +Shared helper for uploading a local file to a signed upload URL. Used by every +upload creator (grids, inventories, point clouds) so the signed-header contract +lives in exactly one place. +""" + +# External imports +import requests + + +def put_upload(spec, path: str) -> None: + """Upload a local file to a signed upload URL with HTTP PUT. + + The API signs the upload URL against a specific set of headers -- the + ``Content-Type`` and a GCS ``x-goog-content-length-range`` -- and returns + them in ``spec.headers``. The PUT must echo those headers *exactly* (no + more, no less) or GCS rejects it with 403, so this sends the server-provided + set verbatim rather than reconstructing it. + + Parameters + ---------- + spec : upload spec + An upload spec carrying ``url`` and ``headers`` (a + ``*UploadSpecHeaders`` model), as returned by the create endpoints. + path : str + Path to the local file to upload. + + Raises + ------ + requests.HTTPError + If the signed PUT returns a non-2xx status. + """ + headers = dict(spec.headers.to_dict()) + with open(path, "rb") as file_obj: + response = requests.put(spec.url, data=file_obj, headers=headers) + response.raise_for_status() diff --git a/fastfuels_sdk/v2/grids.py b/fastfuels_sdk/v2/grids.py index 163e170..148d00d 100644 --- a/fastfuels_sdk/v2/grids.py +++ b/fastfuels_sdk/v2/grids.py @@ -9,6 +9,7 @@ # Internal imports from fastfuels_sdk.v2._jobs import wait as _wait +from fastfuels_sdk.v2._uploads import put_upload from fastfuels_sdk.v2.api import ensure_client from fastfuels_sdk.v2.exceptions import expect, raise_for_response from fastfuels_sdk.v2.client_library.api.grids import ( @@ -82,7 +83,6 @@ # External imports import attrs import numpy as np -import requests __all__ = [ "Grid", @@ -166,15 +166,6 @@ def _build_alignment( return UNSET -def _put_upload(spec, path: str) -> None: - """Upload a local file to a signed upload URL with HTTP PUT.""" - with open(path, "rb") as file_obj: - response = requests.put( - spec.url, data=file_obj, headers={"Content-Type": spec.content_type} - ) - response.raise_for_status() - - def _fill_for(dtype, nodata=None): """Pick the fill value for cells a chunk does not cover. @@ -1291,7 +1282,7 @@ def create_grid_from_geotiff( _domain_id(domain), client=ensure_client(), body=request_body ) created = expect(response, HTTPStatus.CREATED) - _put_upload(created.upload, path) + put_upload(created.upload, path) return Grid._from_model(created.grid) @@ -1337,7 +1328,7 @@ def create_grid_from_netcdf( _domain_id(domain), client=ensure_client(), body=request_body ) created = expect(response, HTTPStatus.CREATED) - _put_upload(created.upload, path) + put_upload(created.upload, path) return Grid._from_model(created.grid) diff --git a/fastfuels_sdk/v2/inventories.py b/fastfuels_sdk/v2/inventories.py index 305c0e4..f4babcc 100644 --- a/fastfuels_sdk/v2/inventories.py +++ b/fastfuels_sdk/v2/inventories.py @@ -10,6 +10,7 @@ # Internal imports from fastfuels_sdk.v2._jobs import wait as _wait +from fastfuels_sdk.v2._uploads import put_upload from fastfuels_sdk.v2.api import ensure_client from fastfuels_sdk.v2.exceptions import expect from fastfuels_sdk.v2.client_library.api.inventories import ( @@ -56,7 +57,6 @@ # External imports import attrs import pandas as pd -import requests __all__ = [ "Inventory", @@ -92,18 +92,6 @@ def _opt(value): return value if value is not None else UNSET -def _put_upload(spec, path: str) -> None: - """Upload a local file to a signed upload URL with HTTP PUT.""" - headers = { - "Content-Type": spec.content_type, - # The signed URL covers this header; GCS rejects the PUT without it - "x-goog-content-length-range": f"0,{spec.max_size_bytes}", - } - with open(path, "rb") as file_obj: - response = requests.put(spec.url, data=file_obj, headers=headers) - response.raise_for_status() - - class Inventory(InventoryModel): """Inventory resource for the FastFuels v2 API. @@ -927,7 +915,7 @@ def create_tree_inventory_from_file( _domain_id(domain), client=ensure_client(), body=request_body ) created = expect(response, HTTPStatus.CREATED) - _put_upload(created.upload, path) + put_upload(created.upload, path) return Inventory._from_model(created.inventory) diff --git a/tests/v2/test_grids.py b/tests/v2/test_grids.py index be46b39..47b1f62 100644 --- a/tests/v2/test_grids.py +++ b/tests/v2/test_grids.py @@ -5,6 +5,7 @@ # Core imports import inspect import json +import math from types import SimpleNamespace from uuid import uuid4 @@ -25,6 +26,7 @@ create_fuel_grid_from_fbfm40_lookup, create_fuel_model_grid_from_landfire_fbfm40, create_fuel_model_grid_from_landfire_fccs, + create_grid_from_geotiff, create_pim_grid_from_treemap, create_topography_grid_from_3dep, create_topography_grid_from_landfire, @@ -35,6 +37,7 @@ from fastfuels_sdk.v2.api import ensure_client from fastfuels_sdk.v2.client_library.api.grids import get_grid_data_json from fastfuels_sdk.v2.client_library.models import ( + BandType, GridAlignmentDomainTarget, GridAlignmentGridTarget, GridAlignmentNativeTarget, @@ -43,6 +46,7 @@ JobStatus, ResamplingMethod, TopographyBand, + UploadBandDefinition, ) from fastfuels_sdk.v2.client_library.types import UNSET from fastfuels_sdk.v2.exceptions import NotFoundException, expect @@ -51,6 +55,8 @@ # External imports import numpy as np import pytest +import rasterio +from rasterio.transform import from_origin # The test_domain and completed_topography_grid fixtures are session-scoped # and shared across modules (tests/v2/conftest.py). They are READ-ONLY: @@ -330,18 +336,51 @@ def test_create(self, test_domain): class TestUploadCreators: """The GeoTIFF/NetCDF upload creators need a binary raster whose CRS - matches the domain CRS. There is no committed raster fixture and no - raster dependency (rasterio/xarray) in the test environment to build - one on the fly, so these paths are deferred rather than mocked. + matches the domain CRS. The GeoTIFF path builds one on the fly with + rasterio; the NetCDF path is deferred (no on-the-fly NetCDF writer here). """ + def test_create_grid_from_geotiff(self, test_domain, tmp_path): + # Build a single-band float GeoTIFF on the domain's lattice and CRS, + # upload it, and confirm it processes to completion. This exercises the + # signed-upload header contract end-to-end (the unit-level check lives + # in tests/v2/test_uploads.py); the grid upload path was missing the + # GCS x-goog-content-length-range header until that was centralized. + minx, miny, maxx, maxy = test_domain.bbox + crs = test_domain.get_lattice(resolution=30.0).crs + res = 30.0 + width = max(1, math.ceil((maxx - minx) / res)) + height = max(1, math.ceil((maxy - miny) / res)) + data = np.arange(width * height, dtype="float32").reshape(height, width) + path = tmp_path / "elevation.tif" + with rasterio.open( + path, + "w", + driver="GTiff", + height=height, + width=width, + count=1, + dtype="float32", + crs=crs, + transform=from_origin(minx, maxy, res, res), + ) as dst: + dst.write(data, 1) + + grid = create_grid_from_geotiff( + test_domain, + str(path), + bands=[UploadBandDefinition(key="elevation", type_=BandType.CONTINUOUS)], + name="throwaway_geotiff", + ) + assert grid.domain_id == test_domain.id + grid.wait() + assert grid.status == JobStatus.COMPLETED + assert [b.key for b in grid.bands] == ["elevation"] + grid.delete() + @pytest.mark.skip( - reason="needs a committed GeoTIFF fixture matching the domain CRS" + reason="needs an on-the-fly NetCDF writer matching the domain CRS" ) - def test_create_grid_from_geotiff(self, test_domain): - pass - - @pytest.mark.skip(reason="needs a committed NetCDF fixture matching the domain CRS") def test_create_grid_from_netcdf(self, test_domain): pass diff --git a/tests/v2/test_uploads.py b/tests/v2/test_uploads.py new file mode 100644 index 0000000..7dc5388 --- /dev/null +++ b/tests/v2/test_uploads.py @@ -0,0 +1,75 @@ +""" +tests/v2/test_uploads.py + +Unit tests for the shared signed-upload helper (no API). +""" + +# Core imports +from types import SimpleNamespace + +# Internal imports +from fastfuels_sdk.v2._uploads import put_upload +from fastfuels_sdk.v2.client_library.models import GridUploadSpecHeaders + +# External imports +import pytest +import requests + + +def _spec(url, header_dict): + """A minimal upload spec: a signed URL plus the server-provided headers.""" + headers = GridUploadSpecHeaders() + for key, value in header_dict.items(): + headers[key] = value + return SimpleNamespace( + url=url, headers=headers, content_type=header_dict.get("Content-Type") + ) + + +class _FakeResponse: + def __init__(self, status_code=200): + self.status_code = status_code + + def raise_for_status(self): + if self.status_code >= 400: + raise requests.HTTPError(str(self.status_code)) + + +def test_put_upload_echoes_server_headers(tmp_path, monkeypatch): + # The signed URL covers BOTH Content-Type and the GCS content-length-range; + # the PUT must send exactly the server-provided header set -- no more, no + # less -- or GCS rejects it with 403. + server_headers = { + "Content-Type": "image/tiff", + "x-goog-content-length-range": "0,1073741824", + } + spec = _spec("https://upload.example/signed", server_headers) + path = tmp_path / "raster.tif" + path.write_bytes(b"\x00\x01\x02data") + + captured = {} + + def fake_put(url, data=None, headers=None): + captured["url"] = url + captured["headers"] = headers + captured["data"] = data.read() + return _FakeResponse(200) + + monkeypatch.setattr(requests, "put", fake_put) + + put_upload(spec, str(path)) + + assert captured["url"] == "https://upload.example/signed" + assert captured["headers"] == server_headers # incl. x-goog-content-length-range + assert captured["data"] == b"\x00\x01\x02data" # file streamed to the PUT + + +def test_put_upload_raises_on_http_error(tmp_path, monkeypatch): + spec = _spec("https://upload.example/signed", {"Content-Type": "text/csv"}) + path = tmp_path / "trees.csv" + path.write_text("x,y\n") + + monkeypatch.setattr(requests, "put", lambda *a, **k: _FakeResponse(403)) + + with pytest.raises(requests.HTTPError): + put_upload(spec, str(path)) From 04cd030d08f4182a43d9645726cac8d8a1ebcb9b Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Sat, 13 Jun 2026 09:25:59 -0600 Subject: [PATCH 42/71] Add point_clouds SDK module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps the point_clouds resource the regen brought in: upload your own ALS/TLS LiDAR file to a domain and manage it. Point clouds are an upload-only job resource today — no transforms and no data-out endpoint — so the record carries just the job lifecycle. - PointCloud(PointCloudModel) record: from_id / refresh / wait / update / delete / to_json, mirroring Grid and Inventory. - create_point_cloud_from_file(domain, path, point_cloud_type=...) two-step upload, reusing the shared _uploads.put_upload helper (so it inherits the signed-header contract for free). - list_point_clouds(domain=None, ..., point_cloud_type=, source=, tag=) with cross-domain routing, and get_point_cloud(domain, id). - Wired ff.point_clouds + flat exports (PointCloud, list_point_clouds, get_point_cloud) into __init__.py. - Docs: a How-To guide (guides/point-clouds.md), SUMMARY nav entry, and a reference.md mkdocstrings block. No grid/inventory creator sources from a point cloud yet, so there is no downstream wiring to add. Tests (tests/v2/test_point_clouds.py): unit-test the als/tls coercion; live-test create -> signed PUT -> from_id/get/refresh/update/list/delete. No laspy in the test env to author a valid LAS, so the upload uses a placeholder file (create and PUT succeed, record comes back pending) and the tests never wait() on server-side processing. 13 passed live. --- docs/v2/SUMMARY.md | 1 + docs/v2/guides/point-clouds.md | 108 ++++++++ docs/v2/reference.md | 2 + fastfuels_sdk/v2/__init__.py | 11 +- fastfuels_sdk/v2/point_clouds.py | 408 +++++++++++++++++++++++++++++++ tests/v2/test_point_clouds.py | 128 ++++++++++ 6 files changed, 657 insertions(+), 1 deletion(-) create mode 100644 docs/v2/guides/point-clouds.md create mode 100644 fastfuels_sdk/v2/point_clouds.py create mode 100644 tests/v2/test_point_clouds.py diff --git a/docs/v2/SUMMARY.md b/docs/v2/SUMMARY.md index 3594668..2ed4620 100644 --- a/docs/v2/SUMMARY.md +++ b/docs/v2/SUMMARY.md @@ -9,5 +9,6 @@ * [Creating grids](guides/creating-grids.md) * [Working with grids](guides/working-with-grids.md) * [Inventories](guides/inventories.md) + * [Point clouds](guides/point-clouds.md) * [Exports](guides/exports.md) * [Reference](reference.md) diff --git a/docs/v2/guides/point-clouds.md b/docs/v2/guides/point-clouds.md new file mode 100644 index 0000000..a8b4420 --- /dev/null +++ b/docs/v2/guides/point-clouds.md @@ -0,0 +1,108 @@ +# How to Work with Point Clouds in FastFuels SDK + +!!! warning "Beta" + The v2 SDK targets the FastFuels v2 API and is under active + development. The v1 SDK remains the default — import v2 explicitly + from `fastfuels_sdk.v2`. + +A point cloud is a 3D LiDAR dataset within a domain, uploaded from your own +airborne (ALS) or terrestrial (TLS) scan. For what point clouds *are* and how +they fit the platform, see the +[FastFuels documentation](https://docs.fastfuels.silvxlabs.com); this guide +covers uploading and managing them from Python. + +The v2 surface is functional: you **create** a point cloud by uploading a file +to a domain, and everything you do with one you already hold is a **method** on +it. + +```python +import fastfuels_sdk.v2 as ff + +pc = ff.point_clouds.create_point_cloud_from_file( + domain, "scan.laz", point_cloud_type="als" +) +pc.wait() +``` + +## Prerequisites + +- The FastFuels SDK installed: `pip install fastfuels-sdk` +- A FastFuels API key in the `FASTFUELS_API_KEY` environment variable +- An existing [domain](domains.md) — the first argument is a `Domain` (or a + bare domain id string) +- A local LiDAR file (`.las`/`.laz`) + +## Upload a point cloud + +`create_point_cloud_from_file` creates the resource, uploads the file to a +signed URL, and returns the (pending) point cloud. Set `point_cloud_type` to +`"als"` for an airborne scan or `"tls"` for a terrestrial one: + +```python +pc = ff.point_clouds.create_point_cloud_from_file( + domain, + "scan.laz", + point_cloud_type="als", + name="North stand ALS", +) +``` + +Processing runs as a background job — the returned point cloud starts in +`"pending"` status and `wait()` blocks until it is ready: + +```python +pc.wait(verbose=True) +``` + +Once completed, the point cloud's `georeference` (CRS and bounds) and +`summary` (point statistics) are populated. + +## Work with a point cloud you hold + +A point cloud is a job resource like any other: `wait(timeout=, verbose=)` +polls it to a terminal status (raising `JobFailedError` on failure), +`refresh()` reloads it in place, `update(name=, description=, tags=)` edits its +metadata, and `delete()` removes it along with its data. + +```python +pc.update(name="North stand (2024)", tags=["als", "2024"]) +pc.delete() +``` + +Point clouds are addressed by their domain and id: + +```python +pc = ff.get_point_cloud(domain, "4a56bae0cd5e481aa1617cb894a9a7f3") +``` + +## List point clouds + +To list your point clouds, optionally narrowed to a domain, a scan type, a +source, or a tag: + +```python +clouds = ff.list_point_clouds(domain) +als_only = ff.list_point_clouds(domain, point_cloud_type="als") +tagged = ff.list_point_clouds(tag="2024") +``` + +Omit `domain` to list point clouds across all of your domains. + +## Error handling + +Wrapper functions and methods raise typed exceptions from +`fastfuels_sdk.v2.exceptions`: + +```python +import fastfuels_sdk.v2 as ff +from fastfuels_sdk.v2.exceptions import NotFoundException + +try: + pc = ff.get_point_cloud(domain, "does-not-exist") +except NotFoundException: + print("No such point cloud (or you don't have access to it)") +``` + +## Next steps + +- Full signatures — see the [Reference](../reference.md) diff --git a/docs/v2/reference.md b/docs/v2/reference.md index 42fa80d..b910059 100644 --- a/docs/v2/reference.md +++ b/docs/v2/reference.md @@ -6,6 +6,8 @@ ::: fastfuels_sdk.v2.inventories +::: fastfuels_sdk.v2.point_clouds + ::: fastfuels_sdk.v2.exports ::: fastfuels_sdk.v2.modifications diff --git a/fastfuels_sdk/v2/__init__.py b/fastfuels_sdk/v2/__init__.py index 138af46..b397d41 100644 --- a/fastfuels_sdk/v2/__init__.py +++ b/fastfuels_sdk/v2/__init__.py @@ -18,7 +18,7 @@ ``ff.list_grids``, ``ff.wait_all``, ``ff.set_api_key``. """ -from fastfuels_sdk.v2 import exports, features, grids, inventories +from fastfuels_sdk.v2 import exports, features, grids, inventories, point_clouds from fastfuels_sdk.v2._jobs import JobFailedError, wait_all from fastfuels_sdk.v2.api import set_api_key from fastfuels_sdk.v2.domains import Domain, list_domains, reproject_geojson @@ -27,6 +27,11 @@ from fastfuels_sdk.v2.grids import Grid, get_grid, list_grids from fastfuels_sdk.v2.inventories import Inventory, get_inventory, list_inventories from fastfuels_sdk.v2.modifications import mask +from fastfuels_sdk.v2.point_clouds import ( + PointCloud, + get_point_cloud, + list_point_clouds, +) __all__ = [ # Submodules (resource creators live here: ff.grids.create_*, ff.features.create_*) @@ -34,6 +39,7 @@ "features", "grids", "inventories", + "point_clouds", # Configuration "set_api_key", # Records @@ -42,6 +48,7 @@ "Feature", "Grid", "Inventory", + "PointCloud", # Fetch / list helpers "list_domains", "list_features", @@ -52,6 +59,8 @@ "get_inventory", "list_exports", "get_export", + "list_point_clouds", + "get_point_cloud", "reproject_geojson", # Modifications "mask", diff --git a/fastfuels_sdk/v2/point_clouds.py b/fastfuels_sdk/v2/point_clouds.py new file mode 100644 index 0000000..7891f07 --- /dev/null +++ b/fastfuels_sdk/v2/point_clouds.py @@ -0,0 +1,408 @@ +""" +fastfuels_sdk/v2/point_clouds.py +""" + +# Core imports +import json +from http import HTTPStatus +from typing import List, Optional + +# Internal imports +from fastfuels_sdk.v2._jobs import wait as _wait +from fastfuels_sdk.v2._uploads import put_upload +from fastfuels_sdk.v2.api import ensure_client +from fastfuels_sdk.v2.exceptions import expect +from fastfuels_sdk.v2.client_library.api.point_clouds import ( + create_point_cloud_upload, + delete_point_cloud, + get_point_cloud as get_point_cloud_endpoint, + list_point_clouds as list_point_clouds_endpoint, + list_point_clouds_cross_domain, + update_point_cloud, +) +from fastfuels_sdk.v2.client_library.models import ( + PointCloud as PointCloudModel, + CreatePointCloudUploadRequest, + ListPointCloudsResponse, + PointCloudSortField, + PointCloudType, + SortOrder, + UpdatePointCloudRequestBody, +) +from fastfuels_sdk.v2.client_library.types import UNSET + +# External imports +import attrs + +__all__ = [ + "PointCloud", + "create_point_cloud_from_file", + "list_point_clouds", + "get_point_cloud", +] + + +def _domain_id(domain) -> str: + """Resolve a Domain object or a domain-id string to the id string.""" + return getattr(domain, "id", domain) + + +def _opt(value): + """Map ``None`` to the generated UNSET sentinel, else pass through.""" + return value if value is not None else UNSET + + +def _point_cloud_type(value) -> PointCloudType: + """Coerce a string or enum member to a ``PointCloudType``.""" + return value if isinstance(value, PointCloudType) else PointCloudType(value) + + +class PointCloud(PointCloudModel): + """Point cloud resource for the FastFuels v2 API. + + A point cloud is a 3D LiDAR dataset within a domain, uploaded from your own + ALS (airborne) or TLS (terrestrial) scan. Point clouds are asynchronous job + resources — creation starts a background job and returns a *pending* record; + call :meth:`wait` to block until the uploaded file is processed. + + Attributes + ---------- + id : str + Unique identifier for the point cloud. + domain_id : str + Identifier of the domain the point cloud belongs to. + type_ : PointCloudType + Scan type: "als" (airborne) or "tls" (terrestrial). + status : JobStatus + Job status: "pending", "running", "completed", or "failed". + source : PointCloudSource + Where the point cloud data comes from. + name : str + Human-readable name for the point cloud. + description : str + Detailed description of the point cloud. + progress : JobProgress, optional + Progress info while the job is running. + checksum : str, optional + Version marker for the point cloud's content; changes each time the + data is rebuilt, unaffected by metadata-only edits. + georeference : PointCloudGeoreference, optional + Spatial reference (CRS and bounds); populated when the job completes. + summary : PointCloudSummary, optional + Summary statistics of the points; populated when the job completes. + error : JobError, optional + Error details if the job failed. + tags : List[str], optional + User-defined tags for organization. + created_on : datetime + When the point cloud was created. + modified_on : datetime + When the point cloud was last modified. + + Examples + -------- + Upload a point cloud and wait for it to process: + >>> import fastfuels_sdk as ff + >>> pc = ff.point_clouds.create_point_cloud_from_file( + ... domain, "scan.laz", point_cloud_type="als" + ... ) + >>> pc.wait() + + Get a point cloud by ID: + >>> pc = ff.get_point_cloud(domain, "abc123") + + See Also + -------- + create_point_cloud_from_file : Upload a local LiDAR file. + list_point_clouds : List point clouds in a domain or across all domains. + """ + + @classmethod + def _from_model(cls, model: PointCloudModel) -> "PointCloud": + """Build a PointCloud from a generated PointCloud model instance. + + Round-trips through the generated to_dict/from_dict — from_dict + constructs ``cls``, i.e. this subclass. + """ + return cls.from_dict(model.to_dict()) + + def _copy_fields_from(self, model: PointCloudModel) -> "PointCloud": + """Copy all generated-model fields from `model` onto self (in-place).""" + for field in attrs.fields(PointCloudModel): + if field.init: + setattr(self, field.name, getattr(model, field.name)) + self.additional_properties = dict(model.additional_properties) + return self + + @classmethod + def from_id(cls, domain_id: str, point_cloud_id: str) -> "PointCloud": + """Retrieve an existing PointCloud resource by its ID. + + Parameters + ---------- + domain_id : str + The unique identifier of the domain the point cloud belongs to. + point_cloud_id : str + The unique identifier of the point cloud to retrieve. + + Returns + ------- + PointCloud + The requested PointCloud object. + + Raises + ------ + NotFoundException + If no point cloud exists with the given IDs, or the user does not + have access to it. + """ + response = get_point_cloud_endpoint.sync_detailed( + domain_id, point_cloud_id, client=ensure_client() + ) + return cls._from_model(expect(response)) + + def refresh(self) -> "PointCloud": + """Update this PointCloud in place with the latest data from the API. + + Returns + ------- + PointCloud + ``self``, updated with the latest data (so calls chain). + + Raises + ------ + NotFoundException + If the point cloud no longer exists. + """ + response = get_point_cloud_endpoint.sync_detailed( + self.domain_id, self.id, client=ensure_client() + ) + return self._copy_fields_from(expect(response)) + + def wait( + self, timeout: Optional[float] = None, verbose: bool = False + ) -> "PointCloud": + """Poll the point cloud job until it reaches a terminal status. + + Parameters + ---------- + timeout : float, optional + Maximum seconds to wait. ``None`` (default) waits indefinitely; the + job runs server-side regardless, so a bounded wait is resumable. + verbose : bool, optional + If True, print the job status at each poll. + + Returns + ------- + PointCloud + ``self``, updated to its terminal state (so calls chain). + + Raises + ------ + TimeoutError + If ``timeout`` is set and elapses before a terminal status. + JobFailedError + If the job finished with status "failed". + """ + return _wait(self, timeout=timeout, verbose=verbose) + + def update( + self, + name: Optional[str] = None, + description: Optional[str] = None, + tags: Optional[List[str]] = None, + ) -> "PointCloud": + """Update the point cloud's metadata (name, description, tags) in place. + + Only provided fields are sent. If no fields are provided, no API call + is made. + + Parameters + ---------- + name : str, optional + New name for the point cloud. + description : str, optional + New description for the point cloud. + tags : List[str], optional + New tags for the point cloud (replaces existing tags). + + Returns + ------- + PointCloud + ``self``, updated (so calls chain). + + Raises + ------ + NotFoundException + If the point cloud no longer exists. + """ + if name is None and description is None and tags is None: + return self + request_body = UpdatePointCloudRequestBody( + name=_opt(name), description=_opt(description), tags=_opt(tags) + ) + response = update_point_cloud.sync_detailed( + self.domain_id, self.id, client=ensure_client(), body=request_body + ) + return self._copy_fields_from(expect(response)) + + def delete(self) -> None: + """Delete this point cloud and its data. + + Raises + ------ + NotFoundException + If the point cloud no longer exists. + """ + response = delete_point_cloud.sync_detailed( + self.domain_id, self.id, client=ensure_client() + ) + expect(response, HTTPStatus.NO_CONTENT) + + def to_json(self) -> str: + """Serialize the complete PointCloud object to a JSON string. + + Returns + ------- + str + The PointCloud as a pretty-printed JSON string. + """ + return json.dumps(self.to_dict(), default=str, indent=2) + + +# --------------------------------------------------------------------------- +# Create a point cloud from your own file +# --------------------------------------------------------------------------- + + +def create_point_cloud_from_file( + domain, + path: str, + point_cloud_type: str, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, +) -> PointCloud: + """Create a point cloud by uploading a local LiDAR file. + + Creates the point cloud resource, uploads the file to the returned signed + URL, and returns the (pending) PointCloud. + + Parameters + ---------- + domain : Domain or str + The domain (or its id) to create the point cloud in. + path : str + Path to the local LiDAR file (e.g. ``.las``/``.laz``). + point_cloud_type : str + Scan type: "als" (airborne) or "tls" (terrestrial). A + ``PointCloudType`` member is also accepted. + name, description : str, optional + Metadata for the point cloud. + tags : List[str], optional + Tags for the point cloud. + + Returns + ------- + PointCloud + The created PointCloud object (job status "pending"). Call + :meth:`PointCloud.wait` to block until the uploaded file is processed. + """ + request_body = CreatePointCloudUploadRequest( + type_=_point_cloud_type(point_cloud_type), + name=name, + description=description, + tags=_opt(tags), + ) + response = create_point_cloud_upload.sync_detailed( + _domain_id(domain), client=ensure_client(), body=request_body + ) + created = expect(response, HTTPStatus.CREATED) + put_upload(created.upload, path) + return PointCloud._from_model(created.point_cloud) + + +# --------------------------------------------------------------------------- +# Top-level fetch / list helpers +# --------------------------------------------------------------------------- + + +def list_point_clouds( + domain=None, + page: int = 0, + size: int = 100, + sort_by: Optional[str] = None, + sort_order: Optional[str] = None, + point_cloud_type: Optional[str] = None, + source: Optional[str] = None, + tag: Optional[str] = None, +) -> List[PointCloud]: + """List point clouds in a domain, or across all domains (single page). + + Parameters + ---------- + domain : Domain or str, optional + The domain (or its id) to list point clouds in. If omitted, point + clouds from all the user's domains are listed. + page : int, optional + The page number to retrieve, zero-indexed (default 0). + size : int, optional + The number of point clouds per page (default 100). + sort_by : str, optional + Field to sort by: "name", "created_on", or "modified_on". + sort_order : str, optional + Sort direction: "ascending" or "descending". + point_cloud_type : str, optional + Only return point clouds of this scan type: "als" or "tls". + source : str, optional + Only return point clouds from this source. + tag : str, optional + Only return point clouds carrying this tag. + + Returns + ------- + List[PointCloud] + The requested page of PointCloud objects. + """ + kwargs = dict( + client=ensure_client(), + page=page, + size=size, + sort_by=PointCloudSortField(sort_by) if sort_by else UNSET, + sort_order=SortOrder(sort_order) if sort_order else UNSET, + type_=_point_cloud_type(point_cloud_type) if point_cloud_type else UNSET, + source=_opt(source), + tag=_opt(tag), + ) + if domain is None: + response = list_point_clouds_cross_domain.sync_detailed(**kwargs) + else: + response = list_point_clouds_endpoint.sync_detailed( + _domain_id(domain), **kwargs + ) + list_response: ListPointCloudsResponse = expect(response) + return [PointCloud._from_model(pc) for pc in list_response.point_clouds] + + +def get_point_cloud(domain, point_cloud_id: str) -> PointCloud: + """Retrieve a single point cloud by its ID. + + Parameters + ---------- + domain : Domain or str + The domain (or its id) the point cloud belongs to. + point_cloud_id : str + The unique identifier of the point cloud. + + Returns + ------- + PointCloud + The requested PointCloud object. + + Raises + ------ + NotFoundException + If no point cloud exists with the given IDs, or the user does not have + access. + """ + return PointCloud.from_id(_domain_id(domain), point_cloud_id) diff --git a/tests/v2/test_point_clouds.py b/tests/v2/test_point_clouds.py new file mode 100644 index 0000000..52f045e --- /dev/null +++ b/tests/v2/test_point_clouds.py @@ -0,0 +1,128 @@ +""" +tests/v2/test_point_clouds.py +""" + +# Core imports +import json +from uuid import uuid4 + +# Internal imports +from fastfuels_sdk.v2.point_clouds import ( + PointCloud, + _point_cloud_type, + create_point_cloud_from_file, + get_point_cloud, + list_point_clouds, +) +from fastfuels_sdk.v2.client_library.models import JobStatus, PointCloudType +from fastfuels_sdk.v2.exceptions import NotFoundException + +# External imports +import pytest + +# The test_domain fixture is session-scoped and shared (tests/v2/conftest.py). +# +# There is no laspy in the test environment to author a valid LAS/LAZ, so the +# upload tests send a tiny placeholder file: the create call and the signed PUT +# both succeed and the point cloud comes back pending. These tests exercise the +# SDK surface (create + lifecycle), not the server-side LiDAR processing, so +# they never call wait() -- the placeholder would fail parsing. + + +def _placeholder_laz(directory) -> str: + path = directory / "scan.laz" + path.write_bytes(b"\x00" * 64) + return str(path) + + +class TestPointCloudType: + """Pure unit tests for the scan-type coercion (no API).""" + + def test_string_coerces_to_enum(self): + assert _point_cloud_type("als") == PointCloudType.ALS + assert _point_cloud_type("tls") == PointCloudType.TLS + + def test_enum_passes_through(self): + assert _point_cloud_type(PointCloudType.ALS) is PointCloudType.ALS + + def test_invalid_raises(self): + with pytest.raises(ValueError): + _point_cloud_type("mls") + + +class TestCreateAndLifecycle: + @pytest.fixture(scope="class") + def uploaded_point_cloud(self, test_domain, tmp_path_factory): + path = _placeholder_laz(tmp_path_factory.mktemp("pc")) + pc = create_point_cloud_from_file( + test_domain, path, point_cloud_type="als", name="throwaway_pc" + ) + yield pc + try: + pc.delete() + except NotFoundException: + pass + + def test_create_returns_pending_record(self, uploaded_point_cloud, test_domain): + pc = uploaded_point_cloud + assert isinstance(pc, PointCloud) + assert len(pc.id) > 0 + assert pc.domain_id == test_domain.id + assert pc.type_ == PointCloudType.ALS + # Pending at create; the placeholder may later fail processing -- either + # is fine, we only assert the record was created as a real job. + assert pc.status in ( + JobStatus.PENDING, + JobStatus.RUNNING, + JobStatus.FAILED, + ) + + def test_from_id_and_get_point_cloud(self, uploaded_point_cloud, test_domain): + fetched = PointCloud.from_id(test_domain.id, uploaded_point_cloud.id) + assert fetched.id == uploaded_point_cloud.id + assert ( + get_point_cloud(test_domain, uploaded_point_cloud.id).id + == uploaded_point_cloud.id + ) + + def test_refresh_returns_self(self, uploaded_point_cloud): + assert uploaded_point_cloud.refresh() is uploaded_point_cloud + + def test_update_name(self, uploaded_point_cloud, test_domain): + updated = uploaded_point_cloud.update(name="renamed_pc") + assert updated is uploaded_point_cloud + assert ( + get_point_cloud(test_domain, uploaded_point_cloud.id).name == "renamed_pc" + ) + + def test_update_no_fields_makes_no_api_call(self, uploaded_point_cloud): + assert uploaded_point_cloud.update() is uploaded_point_cloud + + def test_list_membership(self, uploaded_point_cloud, test_domain): + ids = [pc.id for pc in list_point_clouds(test_domain)] + assert uploaded_point_cloud.id in ids + + def test_list_cross_domain_membership(self, uploaded_point_cloud): + ids = [pc.id for pc in list_point_clouds()] + assert uploaded_point_cloud.id in ids + + def test_to_json(self, uploaded_point_cloud): + assert ( + json.loads(uploaded_point_cloud.to_json())["id"] == uploaded_point_cloud.id + ) + + +class TestNotFound: + def test_from_id_not_found(self, test_domain): + with pytest.raises(NotFoundException): + PointCloud.from_id(test_domain.id, uuid4().hex) + + +class TestDelete: + def test_delete_then_not_found(self, test_domain, tmp_path): + pc = create_point_cloud_from_file( + test_domain, _placeholder_laz(tmp_path), point_cloud_type="tls" + ) + pc.delete() + with pytest.raises(NotFoundException): + PointCloud.from_id(test_domain.id, pc.id) From 25f546d16f06b7b7e79e2e4c0556ae517e2f4f47 Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Sat, 13 Jun 2026 12:44:43 -0600 Subject: [PATCH 43/71] Wire apply_treatments and treatment builders for inventories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The regen added the apply_treatments inventory endpoint (POST .../inventories/{id}/treatments -> Inventory, in place) and the InventoryBasalAreaTreatment / InventoryDiameterTreatment models. - Inventory.apply_treatments(treatments) re-derives the inventory in place (mirrors apply_modifications): appends to the cumulative treatments list, returns to pending. Unlike apply_modifications (xfail for #333), the treatments re-derive completes cleanly. - New treatments.py with ergonomic builders so callers don't hand-build generated models: basal_area_treatment(method, value, unit=, conditions=) and diameter_treatment(...), exported top-level as ff.basal_area_treatment / ff.diameter_treatment (parallel to ff.mask). Spatial conditions are an optional pass-through — no condition-builder vocab yet. - Docs: an "apply treatments" example in the inventories guide and a reference.md mkdocstrings block. Tests (TDD): tests/v2/test_treatments.py unit-checks the builders (enum coercion, value/unit/conditions, metric, invalid method) — request serializes to the openapi example shape. Live: TestApplyTreatments covers the in-place re-derive (duplicate -> treat -> wait -> completed, checksum changes) and the not-completed guard. 6 unit + 2 live passed. --- docs/v2/guides/inventories.md | 15 +++- docs/v2/reference.md | 2 + fastfuels_sdk/v2/__init__.py | 4 + fastfuels_sdk/v2/inventories.py | 44 +++++++++++ fastfuels_sdk/v2/treatments.py | 130 ++++++++++++++++++++++++++++++++ tests/v2/test_inventories.py | 29 +++++++ tests/v2/test_treatments.py | 59 +++++++++++++++ 7 files changed, 281 insertions(+), 2 deletions(-) create mode 100644 fastfuels_sdk/v2/treatments.py create mode 100644 tests/v2/test_treatments.py diff --git a/docs/v2/guides/inventories.md b/docs/v2/guides/inventories.md index 8d3dd22..0262e8a 100644 --- a/docs/v2/guides/inventories.md +++ b/docs/v2/guides/inventories.md @@ -213,8 +213,9 @@ With `seed=42` the unthinned expansion above produced 71,619 trees; the same expansion with this rule keeps 40,620. Besides `RemoveAction`, an `InventoryModificationAction` can `replace`, `multiply`, `divide`, `add`, or `subtract` an attribute's value. Creators also accept `treatments=` — -silvicultural prescriptions (`InventoryBasalAreaTreatment`, -`InventoryDiameterTreatment`) applied after modifications. +silvicultural thinning prescriptions built with `ff.basal_area_treatment(...)` +(thin to a residual basal area) or `ff.diameter_treatment(...)` (thin to a +diameter limit), applied after modifications. ## Branch a scenario @@ -244,6 +245,16 @@ copy.apply_modifications([modification]) copy.wait() ``` +`apply_treatments` works the same way for silvicultural thinning: it appends +prescriptions to the inventory's cumulative `treatments` list and re-derives in +place. Build them with `ff.basal_area_treatment` (thin to a residual basal +area) or `ff.diameter_treatment` (thin to a diameter limit): + +```python +copy.apply_treatments([ff.basal_area_treatment("from_below", 25.0)]) +copy.wait() +``` + ## Voxelize into a 3D fuel grid A completed PIM-expanded or uploaded inventory voxelizes into the diff --git a/docs/v2/reference.md b/docs/v2/reference.md index b910059..2bf86c4 100644 --- a/docs/v2/reference.md +++ b/docs/v2/reference.md @@ -12,6 +12,8 @@ ::: fastfuels_sdk.v2.modifications +::: fastfuels_sdk.v2.treatments + ::: fastfuels_sdk.v2._jobs ::: fastfuels_sdk.v2.api diff --git a/fastfuels_sdk/v2/__init__.py b/fastfuels_sdk/v2/__init__.py index b397d41..d221063 100644 --- a/fastfuels_sdk/v2/__init__.py +++ b/fastfuels_sdk/v2/__init__.py @@ -32,6 +32,7 @@ get_point_cloud, list_point_clouds, ) +from fastfuels_sdk.v2.treatments import basal_area_treatment, diameter_treatment __all__ = [ # Submodules (resource creators live here: ff.grids.create_*, ff.features.create_*) @@ -64,6 +65,9 @@ "reproject_geojson", # Modifications "mask", + # Treatments + "basal_area_treatment", + "diameter_treatment", # Jobs "wait_all", "JobFailedError", diff --git a/fastfuels_sdk/v2/inventories.py b/fastfuels_sdk/v2/inventories.py index f4babcc..90ffd26 100644 --- a/fastfuels_sdk/v2/inventories.py +++ b/fastfuels_sdk/v2/inventories.py @@ -15,6 +15,7 @@ from fastfuels_sdk.v2.exceptions import expect from fastfuels_sdk.v2.client_library.api.inventories import ( apply_modifications as apply_modifications_endpoint, + apply_treatments as apply_treatments_endpoint, create_chm_inventory, create_inventory_export, create_inventory_upload, @@ -31,6 +32,7 @@ from fastfuels_sdk.v2.client_library.models import ( Inventory as InventoryModel, ApplyModificationsRequest, + ApplyTreatmentsRequest, CreateChmInventoryRequest, CreateInventoryUploadRequest, CreatePimInventoryRequest, @@ -395,6 +397,48 @@ def apply_modifications(self, modifications: list) -> "Inventory": ) return self._copy_fields_from(expect(response)) + def apply_treatments(self, treatments: list) -> "Inventory": + """Apply silvicultural treatments to this inventory in place. + + The inventory keeps its ID; the submitted treatments are appended to + its cumulative ``treatments`` list and the tree data is re-derived as a + background job — the inventory returns to "pending" status, so call + :meth:`wait` before using its data. To keep the original data, + :meth:`duplicate` first and treat the copy. + + Parameters + ---------- + treatments : list + Treatments thinning the stand to a target. Build them with + :func:`fastfuels_sdk.v2.treatments.basal_area_treatment` (residual + basal area) or + :func:`fastfuels_sdk.v2.treatments.diameter_treatment` (diameter + limit), available as ``ff.basal_area_treatment`` / + ``ff.diameter_treatment``. + + Returns + ------- + Inventory + ``self``, updated (so calls chain). + + Raises + ------ + NotFoundException + If the inventory no longer exists. + + Examples + -------- + >>> import fastfuels_sdk.v2 as ff + >>> inventory.apply_treatments([ff.basal_area_treatment("from_below", 25.0)]) + >>> inventory.wait() + """ + self._require_completed("apply treatments to") + request_body = ApplyTreatmentsRequest(treatments=list(treatments)) + response = apply_treatments_endpoint.sync_detailed( + self.domain_id, self.id, client=ensure_client(), body=request_body + ) + return self._copy_fields_from(expect(response)) + def voxelize( self, horizontal_resolution_m: Optional[float] = None, diff --git a/fastfuels_sdk/v2/treatments.py b/fastfuels_sdk/v2/treatments.py new file mode 100644 index 0000000..a3744b1 --- /dev/null +++ b/fastfuels_sdk/v2/treatments.py @@ -0,0 +1,130 @@ +""" +fastfuels_sdk/v2/treatments.py + +Treatment primitives for tree inventories. + +A treatment thins a tree inventory by removing stems until the stand reaches a +target — either a residual basal area or a diameter limit. Pass treatments to a +tree-inventory creator's ``treatments=`` argument, or to +:meth:`fastfuels_sdk.v2.inventories.Inventory.apply_treatments` on an inventory +you already hold. These builders construct the generated treatment models so +callers don't have to. + +The optional ``conditions`` argument restricts a treatment to a subarea +(``InventoryFeatureSpatialCondition`` / ``InventoryGeometrySpatialCondition``); +it is passed through as-is — there is no condition-builder vocabulary yet. +""" + +from typing import Optional + +from fastfuels_sdk.v2.client_library.models import ( + InventoryBasalAreaTreatment, + InventoryDiameterTreatment, + InventoryDiameterTreatmentMethod, + InventoryTreatmentMethod, +) +from fastfuels_sdk.v2.client_library.types import UNSET + +__all__ = ["basal_area_treatment", "diameter_treatment"] + + +def basal_area_treatment( + method: str, + value: float, + *, + unit: Optional[str] = None, + conditions: Optional[list] = None, +) -> InventoryBasalAreaTreatment: + """Build a treatment that thins an inventory to a residual basal area. + + Parameters + ---------- + method : {"from_below", "from_above", "proportional"} + How stems are removed: "from_below" removes the smallest trees first, + "from_above" the largest first, "proportional" removes across all size + classes. An ``InventoryTreatmentMethod`` member is also accepted. + value : float + The residual basal area to thin to (default unit m**2/ha). + unit : str, optional + Unit of ``value`` if not the default. + conditions : list, optional + Spatial conditions restricting the treatment to a subarea + (``InventoryFeatureSpatialCondition`` / ``InventoryGeometrySpatialCondition``). + + Returns + ------- + InventoryBasalAreaTreatment + A treatment for a creator's ``treatments=`` list or + :meth:`Inventory.apply_treatments`. + + Examples + -------- + Thin from below to a residual basal area of 25 m**2/ha: + + >>> import fastfuels_sdk.v2 as ff + >>> inventory.apply_treatments([ff.basal_area_treatment("from_below", 25.0)]) + """ + return InventoryBasalAreaTreatment( + method=_basal_area_method(method), + value=value, + unit=unit if unit is not None else UNSET, + conditions=list(conditions) if conditions is not None else UNSET, + ) + + +def diameter_treatment( + method: str, + value: float, + *, + unit: Optional[str] = None, + conditions: Optional[list] = None, +) -> InventoryDiameterTreatment: + """Build a treatment that thins an inventory to a diameter limit. + + Parameters + ---------- + method : {"from_below", "from_above"} + "from_below" removes trees smaller than ``value`` (clears suppressed + understory stems); "from_above" removes those larger than ``value``. An + ``InventoryDiameterTreatmentMethod`` member is also accepted. + value : float + The diameter limit (default unit cm dbh). + unit : str, optional + Unit of ``value`` if not the default. + conditions : list, optional + Spatial conditions restricting the treatment to a subarea + (``InventoryFeatureSpatialCondition`` / ``InventoryGeometrySpatialCondition``). + + Returns + ------- + InventoryDiameterTreatment + A treatment for a creator's ``treatments=`` list or + :meth:`Inventory.apply_treatments`. + + Examples + -------- + Remove trees smaller than 10 cm dbh: + + >>> import fastfuels_sdk.v2 as ff + >>> inventory.apply_treatments([ff.diameter_treatment("from_below", 10.0)]) + """ + return InventoryDiameterTreatment( + method=_diameter_method(method), + value=value, + unit=unit if unit is not None else UNSET, + conditions=list(conditions) if conditions is not None else UNSET, + ) + + +def _basal_area_method(method) -> InventoryTreatmentMethod: + """Coerce a string or enum member to an ``InventoryTreatmentMethod``.""" + if isinstance(method, InventoryTreatmentMethod): + return method + return InventoryTreatmentMethod(method) + + +def _diameter_method(method) -> InventoryDiameterTreatmentMethod: + """Coerce a string or enum member to an ``InventoryDiameterTreatmentMethod``.""" + if isinstance(method, InventoryDiameterTreatmentMethod): + return method + return InventoryDiameterTreatmentMethod(method) diff --git a/tests/v2/test_inventories.py b/tests/v2/test_inventories.py index 2f2f3ad..b0c0bbd 100644 --- a/tests/v2/test_inventories.py +++ b/tests/v2/test_inventories.py @@ -16,6 +16,7 @@ get_inventory, list_inventories, ) +from fastfuels_sdk.v2.treatments import basal_area_treatment, diameter_treatment from fastfuels_sdk.v2.client_library.models import ( InventoryAttribute, InventoryModification, @@ -215,6 +216,34 @@ def test_requires_completed_source(self, test_domain, completed_pim_grid): inventory.delete() +class TestApplyTreatments: + def test_apply_treatments_rederives_in_place(self, completed_tree_inventory): + # Mutating, so work on a duplicate — the shared fixture is read-only + copy = completed_tree_inventory.duplicate(name="treat_test") + copy.wait() + original_checksum = copy.checksum + + treated = copy.apply_treatments([basal_area_treatment("from_below", 25.0)]) + + assert treated is copy # in place: same object, same id + assert len(copy.treatments) == 1 + copy.wait() + assert copy.status == JobStatus.COMPLETED + assert copy.checksum != original_checksum # data was re-derived + copy.delete() + + def test_requires_completed_source(self, test_domain, completed_pim_grid): + inventory = create_tree_inventory_from_pim_grid( + test_domain, completed_pim_grid, seed=42 + ) + if inventory.status == JobStatus.COMPLETED: + inventory.delete() + pytest.skip("inventory completed too quickly to test the guard") + with pytest.raises(ValueError, match="apply treatments"): + inventory.apply_treatments([diameter_treatment("from_below", 10.0)]) + inventory.delete() + + class TestVoxelize: def test_voxelize_returns_new_pending_grid(self, completed_tree_inventory): voxels = completed_tree_inventory.voxelize( diff --git a/tests/v2/test_treatments.py b/tests/v2/test_treatments.py new file mode 100644 index 0000000..d6c1af8 --- /dev/null +++ b/tests/v2/test_treatments.py @@ -0,0 +1,59 @@ +""" +tests/v2/test_treatments.py + +Unit tests for the inventory treatment builders (no API). +""" + +# Internal imports +from fastfuels_sdk.v2.treatments import basal_area_treatment, diameter_treatment +from fastfuels_sdk.v2.client_library.models import ( + InventoryBasalAreaTreatment, + InventoryDiameterTreatment, + InventoryDiameterTreatmentMethod, + InventoryTreatmentMethod, +) +from fastfuels_sdk.v2.client_library.types import UNSET + +# External imports +import pytest + + +class TestBasalAreaTreatment: + def test_builds_model(self): + t = basal_area_treatment("from_below", 25.0) + assert isinstance(t, InventoryBasalAreaTreatment) + assert t.method == InventoryTreatmentMethod.FROM_BELOW + assert t.value == 25.0 + assert t.metric == "basal_area" + assert t.unit is UNSET + assert t.conditions is UNSET + + def test_method_enum_passes_through(self): + t = basal_area_treatment(InventoryTreatmentMethod.PROPORTIONAL, 0.5) + assert t.method is InventoryTreatmentMethod.PROPORTIONAL + + def test_unit_and_conditions_pass_through(self): + sentinel = object() + t = basal_area_treatment( + "from_below", 25.0, unit="m**2/ha", conditions=[sentinel] + ) + assert t.unit == "m**2/ha" + assert t.conditions == [sentinel] + + def test_invalid_method_raises(self): + with pytest.raises(ValueError): + basal_area_treatment("sideways", 25.0) + + +class TestDiameterTreatment: + def test_builds_model(self): + t = diameter_treatment("from_below", 10.0) + assert isinstance(t, InventoryDiameterTreatment) + assert t.method == InventoryDiameterTreatmentMethod.FROM_BELOW + assert t.value == 10.0 + assert t.metric == "diameter" + + def test_proportional_not_valid_for_diameter(self): + # Diameter treatments support only from_below / from_above. + with pytest.raises(ValueError): + diameter_treatment("proportional", 10.0) From fc718d844cbcc77cc6ef61b0134852f84a72ad6d Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Sat, 13 Jun 2026 12:52:46 -0600 Subject: [PATCH 44/71] Add Grid.apply_modifications for post-hoc grid modifications The regen added the apply_grid_modifications endpoint (POST .../grids/{id}/modifications -> Grid, in place). Grid.apply_modifications( modifications) re-derives the grid in place (mirrors Inventory.apply_modifications and the creators' modifications= argument): appends the rules to the grid's cumulative modifications and returns to pending. Accepts the same ff.mask(...) descriptors. Unlike inventory apply_modifications (xfail for #333), the grid re-derive completes cleanly. One behavioral difference surfaced and documented in the test: the grid's `modifications` list is not echoed in the immediate pending response (it stays []), so the test verifies the re-derive via the pending -> completed transition and a changed checksum instead. Docs: an "Apply modifications to a grid" section in working-with-grids.md and a cross-link from the creating-grids "Mask out features" section. Tests (TDD): a pure unit guard (non-completed grid raises before any API call, red -> green) plus a live in-place re-derive on a throwaway uniform grid with a value-based modification (no feature needed). 2 passed. --- docs/v2/guides/creating-grids.md | 4 ++ docs/v2/guides/working-with-grids.md | 17 ++++++++ fastfuels_sdk/v2/grids.py | 45 +++++++++++++++++++++ tests/v2/test_grids.py | 58 ++++++++++++++++++++++++++++ 4 files changed, 124 insertions(+) diff --git a/docs/v2/guides/creating-grids.md b/docs/v2/guides/creating-grids.md index d04e755..5056712 100644 --- a/docs/v2/guides/creating-grids.md +++ b/docs/v2/guides/creating-grids.md @@ -324,6 +324,10 @@ thin geometry covers whole cells. See `ff.mask` in the [Reference](../reference.md) for masking multiple bands and the `operator` and `target` options. +The same masks apply to a grid you already hold via +[`grid.apply_modifications`](working-with-grids.md#apply-modifications-to-a-grid), +which re-derives the grid in place. + ## Error handling Creators raise typed exceptions from `fastfuels_sdk.v2.exceptions` on invalid diff --git a/docs/v2/guides/working-with-grids.md b/docs/v2/guides/working-with-grids.md index 4790ffb..febfa58 100644 --- a/docs/v2/guides/working-with-grids.md +++ b/docs/v2/guides/working-with-grids.md @@ -133,6 +133,23 @@ coarser = grid.resample(output_resolution_m=90, resampling="average") [Align grids to each other](creating-grids.md#align-grids-to-each-other)). The source grid must be `completed`. +## Apply modifications to a grid + +To modify a grid you already hold — rather than at creation — call +`apply_modifications` with the same `ff.mask(...)` rules a creator's +`modifications=` argument accepts. The rules are appended to the grid's +cumulative modifications and the data is re-derived **in place**: the grid +keeps its ID and returns to `"pending"` while it rebuilds. + +```python +grid.apply_modifications([ff.mask(roads, "fbfm", 91, buffer_m=5)]) +grid.wait() +``` + +The grid must be `completed` first. Re-deriving overwrites the grid's data, so +to keep the original, build a fresh grid instead. See +[Mask out features](creating-grids.md#mask-out-features) for the masking model. + ## Read grid data into Python To pull a single band's values into a NumPy array, pass the band key to diff --git a/fastfuels_sdk/v2/grids.py b/fastfuels_sdk/v2/grids.py index 148d00d..09b934e 100644 --- a/fastfuels_sdk/v2/grids.py +++ b/fastfuels_sdk/v2/grids.py @@ -13,6 +13,7 @@ from fastfuels_sdk.v2.api import ensure_client from fastfuels_sdk.v2.exceptions import expect, raise_for_response from fastfuels_sdk.v2.client_library.api.grids import ( + apply_grid_modifications as apply_grid_modifications_endpoint, check_3dep_coverage as check_3dep_coverage_endpoint, create_3dep_topography, create_fbfm40_lookup, @@ -37,6 +38,7 @@ ) from fastfuels_sdk.v2.client_library.models import ( Grid as GridModel, + ApplyGridModificationsRequest, CreateFbfm40LookupRequest, CreateGeoTIFFUploadRequest, CreateLandfireCanopyRequest, @@ -472,6 +474,49 @@ def resample( ) return Grid._from_model(expect(response, HTTPStatus.CREATED)) + def apply_modifications(self, modifications: list) -> "Grid": + """Apply modification rules to this grid in place. + + The grid keeps its ID; the submitted rules are appended to its + cumulative ``modifications`` list and the data is re-derived as a + background job — the grid returns to "pending" status, so call + :meth:`wait` before using its data. Unlike a creator's + ``modifications=`` argument (applied while the grid is first built), + this modifies a grid you already hold. + + Parameters + ---------- + modifications : list + Modification rules (``GridModification``). Build feature masks with + :func:`fastfuels_sdk.v2.modifications.mask` (``ff.mask``); each rule + pairs conditions (spatial or band-value tests, ANDed) with actions + that rewrite band values for the matching cells. + + Returns + ------- + Grid + ``self``, updated (so calls chain). + + Raises + ------ + ValueError + If the grid is not completed. + NotFoundException + If the grid no longer exists. + + Examples + -------- + >>> import fastfuels_sdk.v2 as ff + >>> grid.apply_modifications([ff.mask(roads, "fbfm", 91, buffer_m=5)]) + >>> grid.wait() + """ + self._require_completed("apply modifications to") + request_body = ApplyGridModificationsRequest(modifications=list(modifications)) + response = apply_grid_modifications_endpoint.sync_detailed( + self.domain_id, self.id, client=ensure_client(), body=request_body + ) + return self._copy_fields_from(expect(response)) + def export( self, format: str = "geotiff", diff --git a/tests/v2/test_grids.py b/tests/v2/test_grids.py index 47b1f62..880e233 100644 --- a/tests/v2/test_grids.py +++ b/tests/v2/test_grids.py @@ -43,7 +43,13 @@ GridAlignmentNativeTarget, GridDataArrayFormat, GridDataOrder, + GridModification, + GridModificationAction, + GridModificationCondition, + GridSource, JobStatus, + Modifier, + Operator, ResamplingMethod, TopographyBand, UploadBandDefinition, @@ -493,6 +499,58 @@ def test_resample_requires_completed_source(self, test_domain): grid.delete() +class TestApplyModifications: + def test_apply_modifications_requires_completed(self): + # Pure guard: a non-completed grid raises before any API call. + grid = Grid( + id="g", + domain_id="d", + status=JobStatus.PENDING, + source=GridSource(), + bands=[], + ) + with pytest.raises(ValueError, match="apply modifications"): + grid.apply_modifications([]) + + def test_apply_modifications_rederives_in_place(self, test_domain): + # A throwaway uniform grid -- grids have no duplicate yet (#16) and the + # shared fixture is read-only. A value-based modification (band > 0, + # matching every cell) avoids needing a feature. + grid = create_uniform_grid( + test_domain, + resolution_m=30, + bands={"fuel_load.1hr": 0.5}, + name="grid_modify_test", + ) + grid.wait() + original_checksum = grid.checksum + + modification = GridModification( + conditions=[ + GridModificationCondition( + band="fuel_load.1hr", operator=Operator.GT, value=0 + ) + ], + actions=[ + GridModificationAction( + band="fuel_load.1hr", modifier=Modifier.MULTIPLY, value=0.9 + ) + ], + ) + modified = grid.apply_modifications([modification]) + + assert modified is grid # in place: same object, same id + assert modified.id == grid.id + # The grid re-derives in place; once it settles its content has + # changed (the multiply-by-0.9 action), so the checksum differs. (The + # `modifications` list is not echoed in the immediate pending response, + # unlike inventories.) + grid.wait() + assert grid.status == JobStatus.COMPLETED + assert grid.checksum != original_checksum + grid.delete() + + class TestFbfm40Lookup: def test_lookup_returns_new_pending_grid(self, completed_fbfm40_grid): fuel_grid = create_fuel_grid_from_fbfm40_lookup( From f500b46a6af6465ac5374ccd40e9c9b4ecb38722 Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Sat, 13 Jun 2026 13:28:30 -0600 Subject: [PATCH 45/71] Add GDAM inventory creator The regen added the create_gdam_inventory endpoint (POST .../inventories/tree/allometry/gdam -> 201 new pending Inventory). GDAM fills in a completed tree inventory's missing morphology columns (dbh, crown_ratio, fia_species_code) via allometry imputation, producing a new inventory; the source is left unchanged. create_tree_inventory_from_gdam(domain, source_inventory, impute_columns=None, ...) joins the create_tree_inventory_from_* family with the same domain-first shape and id-or-object extraction. It's a create (new resource) -> a function, consistent with the family and the create_fuel_grid_from_fbfm40_lookup precedent; not a method. impute_columns accepts strings/enum members and defaults to all three columns. Docs: an "Impute missing morphology with GDAM" section in the inventories guide. Tests (TDD): import-driven red -> green, then live tests (create returns a new pending Inventory with a distinct id in the same domain; accepts an id string). 2 passed live. --- docs/v2/guides/inventories.md | 18 +++++++++ fastfuels_sdk/v2/inventories.py | 69 +++++++++++++++++++++++++++++++++ tests/v2/test_inventories.py | 25 ++++++++++++ 3 files changed, 112 insertions(+) diff --git a/docs/v2/guides/inventories.md b/docs/v2/guides/inventories.md index 0262e8a..0dc2fa6 100644 --- a/docs/v2/guides/inventories.md +++ b/docs/v2/guides/inventories.md @@ -133,6 +133,24 @@ background job — `wait()` before reading data back: 2 721075.0 5190169.0 15.2 ... 0.35 202 1 ``` +## Impute missing morphology with GDAM + +An uploaded inventory may carry only some columns — coordinates and heights, +say, without diameters or species. `create_tree_inventory_from_gdam` fills in +the missing morphology (`dbh`, `crown_ratio`, `fia_species_code`) for a +completed inventory using GDAM allometry, producing a new inventory: + +```python +sparse = ff.inventories.create_tree_inventory_from_file(domain, "stems.csv") +sparse.wait() + +full = ff.inventories.create_tree_inventory_from_gdam(domain, sparse) +full.wait() +``` + +Existing values are preserved — only missing cells are imputed. Pass +`impute_columns=["fia_species_code"]` to impute just a subset. + ## Wait for an inventory to finish `wait` polls until the job reaches a terminal status and returns the diff --git a/fastfuels_sdk/v2/inventories.py b/fastfuels_sdk/v2/inventories.py index 90ffd26..132ed69 100644 --- a/fastfuels_sdk/v2/inventories.py +++ b/fastfuels_sdk/v2/inventories.py @@ -17,6 +17,7 @@ apply_modifications as apply_modifications_endpoint, apply_treatments as apply_treatments_endpoint, create_chm_inventory, + create_gdam_inventory, create_inventory_export, create_inventory_upload, create_pim_inventory, @@ -34,6 +35,8 @@ ApplyModificationsRequest, ApplyTreatmentsRequest, CreateChmInventoryRequest, + CreateGdamInventoryRequest, + CreateGdamInventoryRequestImputeColumnsItem, CreateInventoryUploadRequest, CreatePimInventoryRequest, CreateTreeInventoryRequest, @@ -65,6 +68,7 @@ "create_tree_inventory_from_pim_grid", "create_tree_inventory_from_chm_grid", "create_tree_inventory_from_file", + "create_tree_inventory_from_gdam", "list_inventories", "get_inventory", ] @@ -963,6 +967,71 @@ def create_tree_inventory_from_file( return Inventory._from_model(created.inventory) +def create_tree_inventory_from_gdam( + domain, + source_inventory, + impute_columns: Optional[list] = None, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, +) -> Inventory: + """Create a tree inventory by imputing morphology with GDAM allometry. + + GDAM fills in missing per-tree morphology — diameter at breast height, + crown ratio, and FIA species code — for a completed source tree inventory + (for example one uploaded with coordinates and heights only). Existing + values are preserved; only missing cells are imputed. The result is a new + inventory; the source is left unchanged. + + Parameters + ---------- + domain : Domain or str + The domain (or its id) to create the inventory in. + source_inventory : Inventory or str + A completed tree inventory (or its id) whose missing morphology + columns GDAM will fill in. + impute_columns : list, optional + Which morphology columns to impute + (``CreateGdamInventoryRequestImputeColumnsItem`` members or their + string keys: "dbh", "crown_ratio", "fia_species_code"). Defaults to all + three. Narrow it to impute fewer columns; columns left out keep the + source's values. + name, description : str, optional + Metadata for the inventory. + tags : List[str], optional + Tags for the inventory. + + Returns + ------- + Inventory + The created Inventory object (job status "pending"). Call + :meth:`Inventory.wait` to block until imputation finishes. + + Examples + -------- + >>> import fastfuels_sdk as ff + >>> sparse = ff.inventories.create_tree_inventory_from_file( + ... domain, "stems.csv" # x, y, height only + ... ) + >>> sparse.wait() + >>> full = ff.inventories.create_tree_inventory_from_gdam(domain, sparse) + >>> full.wait() + """ + request_body = CreateGdamInventoryRequest( + source_tree_inventory_id=_domain_id(source_inventory), + impute_columns=_enum_list( + impute_columns, CreateGdamInventoryRequestImputeColumnsItem + ), + name=name, + description=description, + tags=_opt(tags), + ) + response = create_gdam_inventory.sync_detailed( + _domain_id(domain), client=ensure_client(), body=request_body + ) + return Inventory._from_model(expect(response, HTTPStatus.CREATED)) + + # --------------------------------------------------------------------------- # Top-level fetch / list helpers # --------------------------------------------------------------------------- diff --git a/tests/v2/test_inventories.py b/tests/v2/test_inventories.py index b0c0bbd..30188c5 100644 --- a/tests/v2/test_inventories.py +++ b/tests/v2/test_inventories.py @@ -12,6 +12,7 @@ from fastfuels_sdk.v2.inventories import ( Inventory, create_tree_inventory_from_file, + create_tree_inventory_from_gdam, create_tree_inventory_from_pim_grid, get_inventory, list_inventories, @@ -108,6 +109,30 @@ def test_create_from_csv(self, test_domain, tmp_path): inventory.delete() +class TestCreateTreeInventoryFromGdam: + def test_create_returns_new_pending_inventory( + self, test_domain, completed_tree_inventory + ): + imputed = create_tree_inventory_from_gdam( + test_domain, + completed_tree_inventory, + impute_columns=["dbh", "crown_ratio"], + name="throwaway_gdam", + ) + assert isinstance(imputed, Inventory) + assert imputed.id != completed_tree_inventory.id + assert imputed.domain_id == test_domain.id + assert imputed.status in (JobStatus.PENDING, JobStatus.RUNNING) + imputed.delete() + + def test_accepts_inventory_id_string(self, test_domain, completed_tree_inventory): + imputed = create_tree_inventory_from_gdam( + test_domain, completed_tree_inventory.id + ) + assert imputed.domain_id == test_domain.id + imputed.delete() + + class TestFromId: def test_success(self, test_domain, completed_tree_inventory): inventory = Inventory.from_id(test_domain.id, completed_tree_inventory.id) From 38c61d45a7e42c5e537a36813db6e5167d58d98b Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Sat, 13 Jun 2026 13:51:01 -0600 Subject: [PATCH 46/71] Add Grid.duplicate for the duplicate_grid endpoint The regen added the duplicate_grid endpoint (POST .../grids/{id}/duplicate -> 201 new pending Grid). Grid.duplicate(name=, description=, tags=) makes an independent copy under a new ID, mirroring Inventory.duplicate: the finished data is byte-copied (not re-derived), so the copy carries the source's checksum verbatim and only its id/timestamps differ. Docs: a "Duplicate a grid" section in working-with-grids.md, and the apply_modifications note now points at duplicate (instead of "build a fresh grid") for keeping the original. Test: live TestDuplicateGrid clones the shared completed topography grid (new id, same domain, waits to completed, checksum matches the source) and deletes the copy. 1 passed. (No red-green cycle here -- a pure endpoint mirror of the already-tested Inventory.duplicate, verified live.) --- docs/v2/guides/working-with-grids.md | 16 ++++++++-- fastfuels_sdk/v2/grids.py | 44 ++++++++++++++++++++++++++++ tests/v2/test_grids.py | 14 +++++++++ 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/docs/v2/guides/working-with-grids.md b/docs/v2/guides/working-with-grids.md index febfa58..5e06a30 100644 --- a/docs/v2/guides/working-with-grids.md +++ b/docs/v2/guides/working-with-grids.md @@ -133,6 +133,17 @@ coarser = grid.resample(output_resolution_m=90, resampling="average") [Align grids to each other](creating-grids.md#align-grids-to-each-other)). The source grid must be `completed`. +## Duplicate a grid + +To branch from a completed grid, `duplicate` makes an independent copy under a +new ID. The finished data is byte-copied rather than re-derived, so the copy +carries the source's `checksum` verbatim — only its `id` and timestamps differ: + +```python +copy = grid.duplicate(name="Scenario A") +copy.wait() +``` + ## Apply modifications to a grid To modify a grid you already hold — rather than at creation — call @@ -147,8 +158,9 @@ grid.wait() ``` The grid must be `completed` first. Re-deriving overwrites the grid's data, so -to keep the original, build a fresh grid instead. See -[Mask out features](creating-grids.md#mask-out-features) for the masking model. +to keep the original, [`duplicate`](#duplicate-a-grid) it first and modify the +copy. See [Mask out features](creating-grids.md#mask-out-features) for the +masking model. ## Read grid data into Python diff --git a/fastfuels_sdk/v2/grids.py b/fastfuels_sdk/v2/grids.py index 09b934e..19eb35e 100644 --- a/fastfuels_sdk/v2/grids.py +++ b/fastfuels_sdk/v2/grids.py @@ -30,6 +30,7 @@ create_treemap, create_uniform_grid as create_uniform_grid_endpoint, delete_grid, + duplicate_grid as duplicate_grid_endpoint, get_grid as get_grid_endpoint, get_grid_data_binary, list_grids as list_grids_endpoint, @@ -52,6 +53,7 @@ CreateThreeDepTopographyRequest, CreateTreeMapRequest, CreateUniformRequest, + DuplicateGridRequest, ExportGridRequest, Fbfm40LookupBand, GridAlignmentDomainTarget, @@ -419,6 +421,48 @@ def delete(self) -> None: ) expect(response, HTTPStatus.NO_CONTENT) + def duplicate( + self, + name: Optional[str] = None, + description: Optional[str] = None, + tags: Optional[List[str]] = None, + ) -> "Grid": + """Create an independent copy of this grid under a new ID. + + The copy is a true clone — the finished data is byte-copied, not + re-derived — carrying over the source's ``source``, ``modifications``, + ``georeference``, and ``checksum`` verbatim; only its ``id`` and + timestamps differ. Use this to branch from a grid before modifying the + copy while the original stays untouched. + + Parameters + ---------- + name : str, optional + Name for the copy. Defaults to the source's name. + description : str, optional + Description for the copy. Defaults to the source's description. + tags : List[str], optional + Tags for the copy. Defaults to the source's tags. + + Returns + ------- + Grid + The new Grid object (job status "pending" while the data is + copied; call :meth:`wait` before using it). + + Raises + ------ + NotFoundException + If the grid no longer exists. + """ + request_body = DuplicateGridRequest( + name=_opt(name), description=_opt(description), tags=_opt(tags) + ) + response = duplicate_grid_endpoint.sync_detailed( + self.domain_id, self.id, client=ensure_client(), body=request_body + ) + return Grid._from_model(expect(response, HTTPStatus.CREATED)) + def resample( self, output_resolution_m: Optional[float] = None, diff --git a/tests/v2/test_grids.py b/tests/v2/test_grids.py index 880e233..e200cae 100644 --- a/tests/v2/test_grids.py +++ b/tests/v2/test_grids.py @@ -645,6 +645,20 @@ def test_delete(self, test_domain): grid.delete() +class TestDuplicateGrid: + def test_duplicate_is_a_clone(self, completed_topography_grid): + # Duplicating creates a new grid (the shared fixture is not mutated), + # byte-copying the data so the copy carries the same checksum. + copy = completed_topography_grid.duplicate(name="grid_duplicate_test") + assert copy.id != completed_topography_grid.id + assert copy.name == "grid_duplicate_test" + assert copy.domain_id == completed_topography_grid.domain_id + copy.wait() + assert copy.status == JobStatus.COMPLETED + assert copy.checksum == completed_topography_grid.checksum + copy.delete() + + class TestDecodeGridChunk: """Offline unit tests for binary chunk decoding (no API required).""" From 026158c992d3f0eea4408d9686a03ddf12858fde Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Sat, 13 Jun 2026 13:57:25 -0600 Subject: [PATCH 47/71] Surface per-band summaries via Grid.band_summary The regen added Band.summary, a continuous/categorical union the server populates per band when a grid completes. It was reachable through the raw grid.bands[i].summary, but looking a band up by key was clunky. Grid.band_summary(band) returns the summary for a named band (validating the key like to_numpy), normalizing the not-yet-computed case to None. The returned object is the generated ContinuousBandSummary (count/nodata_count/min_/max_/ mean/std) or CategoricalBandSummary (count/nodata_count/unique_count), discriminated by type_. Factored the band-key lookup + "no band" error out of to_numpy into a shared Grid._band helper (used by both), so the validation can't drift. Docs: a "Summarize a band without downloading it" section in working-with-grids. Tests (TDD): pure unit tests for the accessor (returns the summary, None when unset, raises on unknown band) plus a live continuous-summary check; the existing to_numpy tests guard the _band refactor. Verified the field is None while pending and populated after wait(). 3 unit + 3 live passed. --- docs/v2/guides/working-with-grids.md | 17 ++++++++++ fastfuels_sdk/v2/grids.py | 50 +++++++++++++++++++++++---- tests/v2/test_grids.py | 51 ++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 6 deletions(-) diff --git a/docs/v2/guides/working-with-grids.md b/docs/v2/guides/working-with-grids.md index 5e06a30..34a858f 100644 --- a/docs/v2/guides/working-with-grids.md +++ b/docs/v2/guides/working-with-grids.md @@ -93,6 +93,23 @@ output: Two grids built on the same domain at the same resolution share this georeference, which is what lets them overlay cell-for-cell. +## Summarize a band without downloading it + +Once a grid completes, the server attaches summary statistics to each band, so +you can get a quick overview without fetching the cells (which is what +[`to_numpy`](#read-grid-data-into-python) does). Pass a band key to +`band_summary`: + +```python +summary = topography.band_summary("elevation") +``` + +A continuous band reports `count`, `nodata_count`, `min_`, `max_`, `mean`, and +`std`; a categorical band reports `count`, `nodata_count`, and `unique_count`. +The `type_` field (`"continuous"` or `"categorical"`) tells you which. +`band_summary` returns `None` until the grid completes — call +[`wait`](#wait-for-a-grid-to-finish) first. + ## Retrieve a grid by id To fetch an existing grid using its domain and grid IDs: diff --git a/fastfuels_sdk/v2/grids.py b/fastfuels_sdk/v2/grids.py index 19eb35e..3265791 100644 --- a/fastfuels_sdk/v2/grids.py +++ b/fastfuels_sdk/v2/grids.py @@ -298,6 +298,14 @@ def _require_completed(self, action: str) -> None: ".wait() until it completes first." ) + def _band(self, band: str): + """Return the :class:`Band` with key ``band``, or raise if absent.""" + for grid_band in self.bands: + if grid_band.key == band: + return grid_band + keys = [b.key for b in self.bands] + raise ValueError(f"Grid has no band {band!r}. Available bands: {keys}.") + @classmethod def from_id(cls, domain_id: str, grid_id: str) -> "Grid": """Retrieve an existing Grid resource by its ID. @@ -663,6 +671,41 @@ def _read_chunk(self, band: str, chunk_index: int, array_format: str): ) return _decode_grid_chunk(response.content, response.headers) + def band_summary(self, band: str): + """Return summary statistics for one band, without downloading the data. + + The server computes a per-band summary when the grid completes, so this + is a cheap overview that does not fetch the grid's cells (unlike + :meth:`to_numpy`). + + Parameters + ---------- + band : str + The band key to summarize (see :attr:`bands` for available keys). + + Returns + ------- + ContinuousBandSummary or CategoricalBandSummary or None + The band's summary, discriminated by its ``type_``: + ``"continuous"`` carries ``count``, ``nodata_count``, ``min_``, + ``max_``, ``mean``, and ``std``; ``"categorical"`` carries + ``count``, ``nodata_count``, and ``unique_count``. ``None`` until + the grid completes (call :meth:`wait` first). + + Raises + ------ + ValueError + If ``band`` is not one of the grid's bands. + + Examples + -------- + >>> grid = ff.get_grid(domain, "def456").wait() + >>> grid.band_summary("elevation").mean + 2143.7 + """ + summary = self._band(band).summary + return None if summary is UNSET else summary + def to_numpy(self, band: str) -> "np.ndarray": """Read one band of this grid into an in-memory NumPy array. @@ -697,12 +740,7 @@ def to_numpy(self, band: str) -> "np.ndarray": (1200, 1600) """ self._require_completed("read data from") - band_keys = [b.key for b in self.bands] - if band not in band_keys: - raise ValueError( - f"Grid has no band {band!r}. Available bands: {band_keys}." - ) - nodata = next(b.nodata for b in self.bands if b.key == band) + nodata = self._band(band).nodata # 3D grids are stored sparsely; 2D rasters densely (413 falls back). array_format = "sparse" if len(self.georeference.shape) == 3 else "dense" diff --git a/tests/v2/test_grids.py b/tests/v2/test_grids.py index e200cae..848e39b 100644 --- a/tests/v2/test_grids.py +++ b/tests/v2/test_grids.py @@ -37,7 +37,9 @@ from fastfuels_sdk.v2.api import ensure_client from fastfuels_sdk.v2.client_library.api.grids import get_grid_data_json from fastfuels_sdk.v2.client_library.models import ( + Band, BandType, + ContinuousBandSummary, GridAlignmentDomainTarget, GridAlignmentGridTarget, GridAlignmentNativeTarget, @@ -836,3 +838,52 @@ def test_to_xarray(self, completed_topography_grid): assert dataset.sizes["x"] == grid.georeference.shape[-1] assert dataset.sizes["y"] == grid.georeference.shape[-2] assert dataset.attrs["crs"] == grid.georeference.crs + + +class TestBandSummary: + """Unit tests for the band-summary accessor (no API), plus a live check.""" + + def _grid_with_band(self, band): + return Grid( + id="g", + domain_id="d", + status=JobStatus.COMPLETED, + source=GridSource(), + bands=[band], + ) + + def test_returns_band_summary(self): + summary = ContinuousBandSummary( + type_="continuous", + count=10, + nodata_count=0, + min_=1.0, + max_=5.0, + mean=3.0, + std=1.0, + ) + grid = self._grid_with_band( + Band(key="elevation", type_=BandType.CONTINUOUS, index=0, summary=summary) + ) + assert grid.band_summary("elevation") is summary + assert grid.band_summary("elevation").mean == 3.0 + + def test_none_when_not_computed(self): + # summary defaults to UNSET (e.g. a pending grid) -> normalized to None + grid = self._grid_with_band( + Band(key="elevation", type_=BandType.CONTINUOUS, index=0) + ) + assert grid.band_summary("elevation") is None + + def test_unknown_band_raises(self): + grid = self._grid_with_band( + Band(key="elevation", type_=BandType.CONTINUOUS, index=0) + ) + with pytest.raises(ValueError, match="no band"): + grid.band_summary("nope") + + def test_continuous_summary_live(self, completed_topography_grid): + summary = completed_topography_grid.band_summary("elevation") + assert summary.type_ == "continuous" + assert summary.count > 0 + assert summary.mean is not None From e5acbf79a9536bcf1a0669d79494f1421dcc8b1c Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Sat, 13 Jun 2026 14:04:03 -0600 Subject: [PATCH 48/71] Add a "Modify and treat tree inventories" guide Consolidate the inventory modify/treat workflow into one focused How-To (guides/modify-treat-inventories.md): modifications (conditions + actions), treatments (ff.basal_area_treatment / ff.diameter_treatment), applying both at creation and to a held inventory (apply_modifications / apply_treatments), and the duplicate-to-branch pattern. Trim the overlap in inventories.md per the Diataxis split: the detailed "Modify trees at creation" / "Branch a scenario" sections become a short "Reshape the trees" pointer plus a "Duplicate an inventory" lifecycle section, both cross-linking the new guide. Add the guide to SUMMARY nav and retarget the two stale inventories.md#modify-trees-at-creation links (creating-grids, export tutorial) to it. The guide notes the one gap it surfaces: inventory modifications are still hand-built models while treatments/masks have builders (tracked as a follow-up for an ff.modify vocab). Validated with `mkdocs build --strict` (no broken links / nav errors). --- docs/v2/SUMMARY.md | 1 + docs/v2/guides/creating-grids.md | 2 +- docs/v2/guides/inventories.md | 87 +++---------- docs/v2/guides/modify-treat-inventories.md | 143 +++++++++++++++++++++ docs/v2/tutorials/export_to_quicfire.md | 3 +- 5 files changed, 163 insertions(+), 73 deletions(-) create mode 100644 docs/v2/guides/modify-treat-inventories.md diff --git a/docs/v2/SUMMARY.md b/docs/v2/SUMMARY.md index 2ed4620..d4ada31 100644 --- a/docs/v2/SUMMARY.md +++ b/docs/v2/SUMMARY.md @@ -9,6 +9,7 @@ * [Creating grids](guides/creating-grids.md) * [Working with grids](guides/working-with-grids.md) * [Inventories](guides/inventories.md) + * [Modify and treat inventories](guides/modify-treat-inventories.md) * [Point clouds](guides/point-clouds.md) * [Exports](guides/exports.md) * [Reference](reference.md) diff --git a/docs/v2/guides/creating-grids.md b/docs/v2/guides/creating-grids.md index 5056712..c02303a 100644 --- a/docs/v2/guides/creating-grids.md +++ b/docs/v2/guides/creating-grids.md @@ -233,7 +233,7 @@ voxels.wait() Because it is a 3D product, a voxel grid supports neither resampling nor post-hoc modifications — apply any tree modifications on the inventory before voxelizing (see -[Modify trees at creation](inventories.md#modify-trees-at-creation)). +[Modify and treat tree inventories](modify-treat-inventories.md)). For the concepts behind plot imputation and voxelization, see the [FastFuels documentation](https://docs.fastfuels.silvxlabs.com). diff --git a/docs/v2/guides/inventories.md b/docs/v2/guides/inventories.md index 0dc2fa6..ad6ef34 100644 --- a/docs/v2/guides/inventories.md +++ b/docs/v2/guides/inventories.md @@ -191,87 +191,32 @@ layout first: Data is only available once the inventory is `"completed"` — earlier calls raise `UnprocessableEntityException`. -## Modify trees at creation +## Reshape the trees -Every creator accepts `modifications=` — rules applied while the inventory -is derived. Each rule filters trees by conditions (ANDed together) and -applies actions to the matching rows. To thin from below by removing every -tree under 10 cm DBH: +Every creator accepts `modifications=` (rules that filter trees by conditions +and act on them) and `treatments=` (silvicultural thinning to a target), and +you can reshape an inventory you already hold with `apply_modifications` / +`apply_treatments`. See +[Modify and treat tree inventories](modify-treat-inventories.md) for the full +workflow. -```python -from fastfuels_sdk.v2.client_library.models import ( - InventoryAttribute, - InventoryModification, - InventoryModificationCondition, - Operator, - RemoveAction, -) - -thinned = ff.inventories.create_tree_inventory_from_pim_grid( - domain, - pim, - seed=42, - modifications=[ - InventoryModification( - conditions=[ - InventoryModificationCondition( - attribute=InventoryAttribute.DBH, - operator=Operator.LT, - value=10.0, - ) - ], - actions=[RemoveAction()], - ) - ], - name="Thinned", -) -``` - -With `seed=42` the unthinned expansion above produced 71,619 trees; the -same expansion with this rule keeps 40,620. Besides `RemoveAction`, an -`InventoryModificationAction` can `replace`, `multiply`, `divide`, `add`, -or `subtract` an attribute's value. Creators also accept `treatments=` — -silvicultural thinning prescriptions built with `ff.basal_area_treatment(...)` -(thin to a residual basal area) or `ff.diameter_treatment(...)` (thin to a -diameter limit), applied after modifications. - -## Branch a scenario +## Duplicate an inventory -To compare scenarios — say, a thinned stand against the original — keep -the original inventory untouched and work on copies. `duplicate` creates -an independent copy under a new ID by byte-copying the finished data, not -re-deriving it: +`duplicate` makes an independent copy under a new ID, byte-copying the finished +data rather than re-deriving it, so the copy starts identical to the source: ```python ->>> copy = inventory.duplicate(name="Thinning scenario") +>>> copy = inventory.duplicate(name="Scenario A") >>> copy.wait() >>> copy.checksum == inventory.checksum True ``` -The `checksum` is a version marker for an inventory's content: it changes -each time the data is rebuilt and is unaffected by metadata-only edits, so -an identical checksum means identical trees. - -To modify an inventory you already hold — appending rules to its -cumulative `modifications` list and re-deriving the data in place — call -`apply_modifications`. The inventory keeps its ID and returns to -`"pending"` while it rebuilds: - -```python -copy.apply_modifications([modification]) -copy.wait() -``` - -`apply_treatments` works the same way for silvicultural thinning: it appends -prescriptions to the inventory's cumulative `treatments` list and re-derives in -place. Build them with `ff.basal_area_treatment` (thin to a residual basal -area) or `ff.diameter_treatment` (thin to a diameter limit): - -```python -copy.apply_treatments([ff.basal_area_treatment("from_below", 25.0)]) -copy.wait() -``` +The `checksum` is a version marker for an inventory's content: it changes each +time the data is rebuilt and is unaffected by metadata-only edits, so an +identical checksum means identical trees. Duplicate before reshaping to keep +the original untouched (see +[Branch a scenario](modify-treat-inventories.md#branch-a-scenario)). ## Voxelize into a 3D fuel grid diff --git a/docs/v2/guides/modify-treat-inventories.md b/docs/v2/guides/modify-treat-inventories.md new file mode 100644 index 0000000..845abf7 --- /dev/null +++ b/docs/v2/guides/modify-treat-inventories.md @@ -0,0 +1,143 @@ +# How to Modify and Treat Tree Inventories + +!!! warning "Beta" + The v2 SDK targets the FastFuels v2 API and is under active + development. The v1 SDK remains the default — import v2 explicitly + from `fastfuels_sdk.v2`. + +Two tools reshape a tree inventory's stems: + +- **Modifications** — general rules that filter trees by *conditions* and + apply *actions* (remove, multiply, replace, …) to the matching rows. +- **Treatments** — silvicultural thinning prescriptions that remove stems + until the stand reaches a target basal area or diameter. + +Both can be applied two ways: at **creation**, via a creator's +`modifications=` / `treatments=` argument, or to an inventory you already +**hold**, via `apply_modifications` / `apply_treatments`. This guide covers +both. For creating and reading inventories in the first place, see +[Inventories](inventories.md). + +## Prerequisites + +- The FastFuels SDK installed: `pip install fastfuels-sdk` +- A FastFuels API key in the `FASTFUELS_API_KEY` environment variable +- An [inventory](inventories.md) (held or being created) + +## Modifications + +A modification is a rule with two parts: `conditions` (all ANDed — a tree must +satisfy every one) and `actions` (applied to the trees that match). To thin +from below by removing every tree under 10 cm DBH: + +```python +from fastfuels_sdk.v2.client_library.models import ( + InventoryAttribute, + InventoryModification, + InventoryModificationCondition, + Operator, + RemoveAction, +) + +modification = InventoryModification( + conditions=[ + InventoryModificationCondition( + attribute=InventoryAttribute.DBH, + operator=Operator.LT, + value=10.0, + ) + ], + actions=[RemoveAction()], +) +``` + +Conditions test an `InventoryAttribute` (`dbh`, `height`, `crown_ratio`, …) +with an `Operator` (`lt`, `gt`, `gte`, …). Besides `RemoveAction`, an +`InventoryModificationAction` can `replace`, `multiply`, `divide`, `add`, or +`subtract` an attribute's value. + +!!! note + Modification rules are still hand-built from the generated models above; + there is no `ff.modify(...)` builder yet (unlike treatments and the grid + [`ff.mask`](creating-grids.md#mask-out-features)). + +## Treatments + +A treatment thins to a target. Build one with `ff.basal_area_treatment` +(residual basal area) or `ff.diameter_treatment` (diameter limit) rather than +hand-building the model: + +```python +import fastfuels_sdk.v2 as ff + +# Thin from below to a residual basal area of 25 m**2/ha +treatment = ff.basal_area_treatment("from_below", 25.0) + +# Or remove every stem under 10 cm dbh +treatment = ff.diameter_treatment("from_below", 10.0) +``` + +`method` is `"from_below"` (smallest first), `"from_above"` (largest first), +or — for basal area only — `"proportional"` (across all size classes). + +## Apply at creation + +Every `create_tree_inventory_from_*` function accepts `modifications=` and +`treatments=`; modifications run first, then treatments, while the inventory is +derived: + +```python +thinned = ff.inventories.create_tree_inventory_from_pim_grid( + domain, + pim, + seed=42, + modifications=[modification], + treatments=[treatment], + name="Thinned", +) +thinned.wait() +``` + +## Apply to an inventory you hold + +`apply_modifications` and `apply_treatments` reshape an inventory **in place**: +the submitted rules are appended to the inventory's cumulative `modifications` +/ `treatments` list, its ID is kept, and the data is re-derived as a background +job — the inventory returns to `"pending"`, so `wait()` before using it again. + +```python +inventory.apply_treatments([ff.basal_area_treatment("from_below", 25.0)]) +inventory.wait() + +inventory.apply_modifications([modification]) +inventory.wait() +``` + +Both must be called on a `completed` inventory. Re-deriving overwrites the +inventory's data, so to keep the original, [duplicate](#branch-a-scenario) it +first and reshape the copy. + +## Branch a scenario + +To compare scenarios — a thinned stand against the original — keep the +original untouched and work on copies. `duplicate` makes an independent copy +under a new ID, byte-copying the finished data rather than re-deriving it, so +the copy starts identical (same `checksum`): + +```python +copy = inventory.duplicate(name="Thinning scenario") +copy.wait() +copy.apply_treatments([ff.basal_area_treatment("from_below", 25.0)]) +copy.wait() +``` + +The `checksum` is a version marker for an inventory's content: it changes each +time the data is rebuilt and is unaffected by metadata-only edits, so an +identical checksum means identical trees. + +## Next steps + +- Create and read inventories — see [Inventories](inventories.md) +- Voxelize a reshaped inventory into a 3D fuel grid — see + [Inventories](inventories.md#voxelize-into-a-3d-fuel-grid) +- Full signatures — see the [Reference](../reference.md) diff --git a/docs/v2/tutorials/export_to_quicfire.md b/docs/v2/tutorials/export_to_quicfire.md index 0f1dec1..c848c66 100644 --- a/docs/v2/tutorials/export_to_quicfire.md +++ b/docs/v2/tutorials/export_to_quicfire.md @@ -331,7 +331,8 @@ QUIC-Fire archive — masking roads out of the fuels along the way. - Mask water (and other features) into the fuels the same way we masked roads — see [Mask out features](../guides/creating-grids.md#mask-out-features). - Tune the tree inventory before voxelizing — thinning, treatments, and - modifications — see [Modify trees at creation](../guides/inventories.md#modify-trees-at-creation). + modifications — see + [Modify and treat tree inventories](../guides/modify-treat-inventories.md). - Define the fire grid from an existing grid's lattice, or change its resolution, with the export's alignment options — see [Bundle grids for QUIC-Fire](../guides/exports.md#bundle-grids-for-quic-fire). From ec20dee407e1ef9642f3ee085f88f559becf143f Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Sat, 13 Jun 2026 14:08:12 -0600 Subject: [PATCH 49/71] Refresh v2_api_design working note for the post-regen surface Bring the scratch design note current after the client re-sync: update the module layout (point_clouds.py, treatments.py, _uploads.py, _jobs.py; drop the stale [later] tags on inventories/exports; add the new Grid methods) and add a "Post-regen additions (2026-06-12)" section recording the new resources/ endpoints and the design calls behind them (GDAM + fbfm40-lookup are functions not methods; treatments have builders but inventory modifications don't yet; the shared put_upload header fix). Also reframed the modifications/treatments vocab open item to match what shipped. Docs/exports/__init__ exports and reference+SUMMARY nav for the new modules were already updated as part of #14-#21; this closes out the housekeeping. --- fastfuels_sdk/v2/v2_api_design.md | 50 ++++++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/fastfuels_sdk/v2/v2_api_design.md b/fastfuels_sdk/v2/v2_api_design.md index 511046d..4860a4d 100644 --- a/fastfuels_sdk/v2/v2_api_design.md +++ b/fastfuels_sdk/v2/v2_api_design.md @@ -121,13 +121,20 @@ Per-creator: ``` fastfuels_sdk/ - __init__.py set_api_key, Domain, wait_all, mask, list_*, get_*, ... + __init__.py set_api_key, Domain, wait_all, mask, basal_area_treatment, list_*, get_*, ... domains.py Domain (record + from_* + methods), list_domains - features.py create_*_feature_from_* (fns); Feature record + methods [rewrite creation only] + features.py create_*_feature_from_* (fns); Feature record + methods grids.py create_*_grid_from_* (fns; incl. create_fuel_grid_from_fbfm40_lookup); - Grid record + methods (wait/to_xarray/resample/export/...) [GREENFIELD #178] - inventories.py create_tree_inventory_from_* (fns); Inventory + methods [later] - exports.py create_quicfire_export (fn); Export record + methods [later] + Grid record + methods (wait/to_xarray/resample/duplicate/ + apply_modifications/band_summary/export/...) + inventories.py create_tree_inventory_from_* (fns; pim/chm/file/gdam); Inventory + methods + (duplicate/apply_modifications/apply_treatments/voxelize/export/...) + point_clouds.py create_point_cloud_from_file (fn); PointCloud record + methods (upload-only) + exports.py create_quicfire_export (fn); Export record + methods + modifications.py mask() -> GridModification + treatments.py basal_area_treatment()/diameter_treatment() -> Inventory*Treatment + _uploads.py put_upload(spec, path) — shared signed-upload helper (grids/inventories/point_clouds) + _jobs.py wait()/wait_all()/JobFailedError ``` ## Features — 10 endpoints (rewrite creation only; keep instance methods) @@ -214,7 +221,36 @@ lookup is the only such case today: it needs a grid carrying `fbfm` codes. - Single-grid export is a method (`grid.export`) but the QUIC-Fire bundle is a function (`ff.exports.create_quicfire_export`) — consistent with the rule (one held resource → method; assembled-from-many → function), but worth a look. -- Modifications/treatments vocab (`mask`/`modify`/`within`/`thin_to_*`) — least - settled; built with grids since grid creators accept `modifications=`. +- Modifications/treatments vocab (`mask`/`modify`/`within`/`thin_to_*`) — partly + settled: `mask` (grids) and `basal_area_treatment`/`diameter_treatment` + (inventories) ship as builders; inventory *modifications* are still hand-built + models (no `ff.modify` vocab yet — tracked as a follow-up). Spatial + *conditions* for treatments are a raw pass-through; no condition builders yet. - Returned-record implementation (wrap generated attrs model vs clean dataclass) — parked; shows up on every record. + +## Post-regen additions (2026-06-12) + +Wired after re-syncing the client to the live spec (FastFuels-API-v2 #358 + +later merges). Each new resource/endpoint kept to the settled rules above. + +- **point_clouds** — new upload-only resource: `PointCloud` record (lifecycle + only — no transforms/data-out), `create_point_cloud_from_file(domain, path, + point_cloud_type=)`, `list_point_clouds`/`get_point_cloud`. Nothing else + sources from a point cloud yet. +- **Inventory treatments** — `Inventory.apply_treatments([...])` (in-place + re-derive; unlike `apply_modifications` it does *not* hit #333) + the + `ff.basal_area_treatment`/`ff.diameter_treatment` builders (`treatments.py`). +- **GDAM** — `create_tree_inventory_from_gdam(domain, source_inventory, + impute_columns=)`. A *create* (new inventory) → a function in the + `create_tree_inventory_from_*` family, **not** a method — same call as the + fbfm40-lookup precedent (derive-a-new-resource = function). +- **Grid** gained `duplicate` (byte-copy clone, mirrors `Inventory.duplicate`), + `apply_modifications` (in-place re-derive; grid `modifications` list is *not* + echoed in the pending response, unlike inventories), and `band_summary(band)` + (cheap per-band stats from the new `Band.summary`; shares a `_band` helper + with `to_numpy`). +- **FCCS** reached alignment parity (#358) — creator now takes the alignment kwargs. +- **Uploads** — one shared `_uploads.put_upload(spec, path)` echoes the + server's signed `spec.headers` verbatim; replaced the two divergent + `_put_upload` copies (grids had been missing the GCS content-length-range). From f06bcfed79a13755cda47f7c0a0e328e12942da1 Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Sat, 13 Jun 2026 15:03:19 -0600 Subject: [PATCH 50/71] Add inventory modification builders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the vocab asymmetry the modify/treat guide surfaced: inventory modifications were hand-built generated models while grids (ff.mask) and inventory treatments (ff.basal_area_treatment) had builders. Broaden modifications.py from grid-only to all modification primitives and add four tree-scoped builders (exported top-level, string-coercing like mask): - tree_attribute(attr, operator, value) -> InventoryModificationCondition; operator accepts symbolic ("<") or named ("lt") forms. - tree_within(feature, buffer_m=, operator=) -> InventoryFeatureSpatialCondition (which treatments' conditions= also accept). - remove_trees(*conditions) -> InventoryModification with RemoveAction. - modify_trees(attr, modifier, value, *conditions) -> InventoryModification with an attribute action. Both assemblers require >=1 condition (the API 422s on an empty rule). The modify/treat guide now uses the builders (dropped the hand-built example and the "no ff.modify builder yet" note); design note updated to mark the vocab settled. Tests (TDD): 14 pure unit tests (coercion, condition ANDing, RemoveAction vs modifier, >=1-condition guard) red->green. Live: a create-time modification (remove_trees(tree_attribute("dbh","<",10)) at seed 42) completes with strictly fewer trees than the unmodified fixture — verified via the create path because in-place apply_modifications is #333-blocked. 14 unit + 1 live passed. --- docs/v2/guides/modify-treat-inventories.md | 51 +++-- fastfuels_sdk/v2/__init__.py | 12 +- fastfuels_sdk/v2/modifications.py | 207 ++++++++++++++++++++- fastfuels_sdk/v2/v2_api_design.md | 11 +- tests/v2/test_inventories.py | 25 +++ tests/v2/test_modifications.py | 109 +++++++++++ 6 files changed, 369 insertions(+), 46 deletions(-) create mode 100644 tests/v2/test_modifications.py diff --git a/docs/v2/guides/modify-treat-inventories.md b/docs/v2/guides/modify-treat-inventories.md index 845abf7..19cf6fe 100644 --- a/docs/v2/guides/modify-treat-inventories.md +++ b/docs/v2/guides/modify-treat-inventories.md @@ -27,39 +27,30 @@ both. For creating and reading inventories in the first place, see ## Modifications A modification is a rule with two parts: `conditions` (all ANDed — a tree must -satisfy every one) and `actions` (applied to the trees that match). To thin -from below by removing every tree under 10 cm DBH: +satisfy every one) and `actions` (applied to the matching trees). Build +conditions with `ff.tree_attribute` (a per-tree attribute test) or +`ff.tree_within` (trees inside a feature), then assemble the rule with +`ff.remove_trees` (drop the matching trees) or `ff.modify_trees` (change an +attribute on them): ```python -from fastfuels_sdk.v2.client_library.models import ( - InventoryAttribute, - InventoryModification, - InventoryModificationCondition, - Operator, - RemoveAction, -) +import fastfuels_sdk.v2 as ff -modification = InventoryModification( - conditions=[ - InventoryModificationCondition( - attribute=InventoryAttribute.DBH, - operator=Operator.LT, - value=10.0, - ) - ], - actions=[RemoveAction()], +# Remove every tree under 10 cm DBH +ff.remove_trees(ff.tree_attribute("dbh", "<", 10)) + +# Shrink crowns on the largest trees inside a stand boundary +ff.modify_trees( + "crown_ratio", "multiply", 0.8, + ff.tree_attribute("dbh", ">", 40), + ff.tree_within(stand), ) ``` -Conditions test an `InventoryAttribute` (`dbh`, `height`, `crown_ratio`, …) -with an `Operator` (`lt`, `gt`, `gte`, …). Besides `RemoveAction`, an -`InventoryModificationAction` can `replace`, `multiply`, `divide`, `add`, or -`subtract` an attribute's value. - -!!! note - Modification rules are still hand-built from the generated models above; - there is no `ff.modify(...)` builder yet (unlike treatments and the grid - [`ff.mask`](creating-grids.md#mask-out-features)). +`tree_attribute` takes an attribute (`"dbh"`, `"height"`, `"crown_ratio"`, +`"fia_species_code"`) and a comparison (`"<"`, `"<="`, `">"`, `">="`, `"=="`, +`"!="`); pass several conditions to AND them. `modify_trees`'s modifier is +`"replace"`, `"add"`, `"subtract"`, `"multiply"`, or `"divide"`. ## Treatments @@ -91,8 +82,8 @@ thinned = ff.inventories.create_tree_inventory_from_pim_grid( domain, pim, seed=42, - modifications=[modification], - treatments=[treatment], + modifications=[ff.remove_trees(ff.tree_attribute("dbh", "<", 10))], + treatments=[ff.basal_area_treatment("from_below", 25.0)], name="Thinned", ) thinned.wait() @@ -109,7 +100,7 @@ job — the inventory returns to `"pending"`, so `wait()` before using it again. inventory.apply_treatments([ff.basal_area_treatment("from_below", 25.0)]) inventory.wait() -inventory.apply_modifications([modification]) +inventory.apply_modifications([ff.remove_trees(ff.tree_attribute("dbh", "<", 10))]) inventory.wait() ``` diff --git a/fastfuels_sdk/v2/__init__.py b/fastfuels_sdk/v2/__init__.py index d221063..5a3fdbc 100644 --- a/fastfuels_sdk/v2/__init__.py +++ b/fastfuels_sdk/v2/__init__.py @@ -26,7 +26,13 @@ from fastfuels_sdk.v2.features import Feature, get_feature, list_features from fastfuels_sdk.v2.grids import Grid, get_grid, list_grids from fastfuels_sdk.v2.inventories import Inventory, get_inventory, list_inventories -from fastfuels_sdk.v2.modifications import mask +from fastfuels_sdk.v2.modifications import ( + mask, + modify_trees, + remove_trees, + tree_attribute, + tree_within, +) from fastfuels_sdk.v2.point_clouds import ( PointCloud, get_point_cloud, @@ -65,6 +71,10 @@ "reproject_geojson", # Modifications "mask", + "tree_attribute", + "tree_within", + "remove_trees", + "modify_trees", # Treatments "basal_area_treatment", "diameter_treatment", diff --git a/fastfuels_sdk/v2/modifications.py b/fastfuels_sdk/v2/modifications.py index ab3837d..ab4143a 100644 --- a/fastfuels_sdk/v2/modifications.py +++ b/fastfuels_sdk/v2/modifications.py @@ -1,15 +1,14 @@ """ fastfuels_sdk/v2/modifications.py -Modification primitives for grid creation. - -A grid modification is a rule applied to a grid *after* it is built from its -source: it pairs *conditions* (spatial or value tests) with *actions* that -rewrite band values for the cells those conditions select. The generated -models (``GridModification``, ``GridFeatureSpatialCondition``, -``GridModificationAction``) express the full vocabulary; ``mask`` is the one -primitive the core grids workflow needs and the v2 replacement for v1's -``feature_masks``. +Modification primitives for grids and tree inventories. + +A modification is a rule applied to a resource: it pairs *conditions* (spatial +or value tests, all ANDed) with *actions* applied to whatever the conditions +select. ``mask`` builds a grid modification (the v2 replacement for v1's +``feature_masks``); ``tree_attribute`` / ``tree_within`` / ``remove_trees`` / +``modify_trees`` build inventory modifications so callers don't hand-assemble +the generated models. """ from typing import List, Optional, Union @@ -19,12 +18,41 @@ GridModification, GridModificationAction, GridSpatialTarget, + InventoryAttribute, + InventoryFeatureSpatialCondition, + InventoryModification, + InventoryModificationAction, + InventoryModificationCondition, Modifier, + Operator, + RemoveAction, SpatialOperator, ) from fastfuels_sdk.v2.client_library.types import UNSET -__all__ = ["mask"] +__all__ = [ + "mask", + "tree_attribute", + "tree_within", + "remove_trees", + "modify_trees", +] + +# Comparison operators accept both their enum names and the symbolic forms. +_OPERATORS = { + "<": Operator.LT, + "lt": Operator.LT, + "<=": Operator.LE, + "le": Operator.LE, + ">": Operator.GT, + "gt": Operator.GT, + ">=": Operator.GE, + "ge": Operator.GE, + "==": Operator.EQ, + "eq": Operator.EQ, + "!=": Operator.NE, + "ne": Operator.NE, +} def _feature_id(feature) -> str: @@ -32,6 +60,19 @@ def _feature_id(feature) -> str: return getattr(feature, "id", feature) +def _operator(operator) -> Operator: + """Coerce a symbolic ("<") or named ("lt") operator, or enum member.""" + if isinstance(operator, Operator): + return operator + try: + return _OPERATORS[operator] + except (KeyError, TypeError): + raise ValueError( + f"Unknown operator {operator!r}. Use one of " + f"{sorted(_OPERATORS)} or an Operator member." + ) + + def mask( feature, band: Union[str, List[str]], @@ -102,3 +143,149 @@ def mask( for b in bands ] return GridModification(conditions=[condition], actions=actions) + + +# --------------------------------------------------------------------------- +# Tree-inventory modifications +# --------------------------------------------------------------------------- +# +# An inventory modification pairs conditions (ANDed) with actions applied to +# the matching trees. ``tree_attribute`` / ``tree_within`` build conditions; +# ``remove_trees`` / ``modify_trees`` assemble the modification. Pass the result +# to a tree-inventory creator's ``modifications=`` argument or to +# ``Inventory.apply_modifications``. + + +def tree_attribute(attribute, operator, value, *, unit: Optional[str] = None): + """Build a condition testing a per-tree attribute. + + Parameters + ---------- + attribute : str + The tree attribute to test: "dbh", "height", "crown_ratio", or + "fia_species_code" (an ``InventoryAttribute`` member is also accepted). + operator : str + A comparison: ``"<"``, ``"<="``, ``">"``, ``">="``, ``"=="``, ``"!="`` + (or the names ``"lt"``, ``"le"``, ``"gt"``, ``"ge"``, ``"eq"``, + ``"ne"``; an ``Operator`` member is also accepted). + value : float, int, str, or list + The value to compare against. + unit : str, optional + Unit of ``value`` if not the attribute's default. + + Returns + ------- + InventoryModificationCondition + A condition for ``remove_trees`` / ``modify_trees``. + """ + return InventoryModificationCondition( + attribute=_tree_attribute(attribute), + operator=_operator(operator), + value=value, + unit=unit if unit is not None else UNSET, + ) + + +def tree_within(feature, *, buffer_m: Optional[float] = None, operator: str = "within"): + """Build a condition selecting trees by their location relative to a feature. + + Parameters + ---------- + feature : Feature or str + The feature to test against, or its id. Must be ``completed`` and in + the same domain as the inventory. + buffer_m : float, optional + Buffer applied to the feature geometry, in meters in the domain's + projected CRS, before testing. + operator : {"within", "intersects", "outside"}, default "within" + Which trees to select relative to the feature geometry. + + Returns + ------- + InventoryFeatureSpatialCondition + A condition for ``remove_trees`` / ``modify_trees``. + """ + return InventoryFeatureSpatialCondition( + source="feature", + operator=SpatialOperator(operator), + feature_id=_feature_id(feature), + buffer_m=buffer_m if buffer_m is not None else UNSET, + ) + + +def remove_trees(*conditions) -> InventoryModification: + """Build a modification that removes the trees matching every condition. + + Parameters + ---------- + *conditions + One or more conditions from :func:`tree_attribute` / :func:`tree_within` + (ANDed). At least one is required. + + Returns + ------- + InventoryModification + For a creator's ``modifications=`` list or + :meth:`Inventory.apply_modifications`. + + Examples + -------- + >>> import fastfuels_sdk.v2 as ff + >>> inventory.apply_modifications([ff.remove_trees(ff.tree_attribute("dbh", "<", 10))]) + """ + if not conditions: + raise ValueError("remove_trees requires at least one condition.") + return InventoryModification(conditions=list(conditions), actions=[RemoveAction()]) + + +def modify_trees( + attribute, modifier, value, *conditions, unit: Optional[str] = None +) -> InventoryModification: + """Build a modification that changes an attribute on the matching trees. + + Parameters + ---------- + attribute : str + The attribute to change ("dbh", "height", "crown_ratio", + "fia_species_code"; an ``InventoryAttribute`` member is also accepted). + modifier : str + How to change it: "replace", "add", "subtract", "multiply", or + "divide" (a ``Modifier`` member is also accepted). To drop trees, use + :func:`remove_trees` instead. + value : float, int, or str + The operand for ``modifier``. + *conditions + One or more conditions from :func:`tree_attribute` / :func:`tree_within` + (ANDed). At least one is required. + unit : str, optional + Unit of ``value`` if not the attribute's default. + + Returns + ------- + InventoryModification + For a creator's ``modifications=`` list or + :meth:`Inventory.apply_modifications`. + + Examples + -------- + >>> import fastfuels_sdk.v2 as ff + >>> inventory.apply_modifications([ + ... ff.modify_trees("height", "multiply", 0.9, ff.tree_attribute("dbh", ">", 0)) + ... ]) + """ + if not conditions: + raise ValueError("modify_trees requires at least one condition.") + action = InventoryModificationAction( + attribute=_tree_attribute(attribute), + modifier=Modifier(modifier), + value=value, + unit=unit if unit is not None else UNSET, + ) + return InventoryModification(conditions=list(conditions), actions=[action]) + + +def _tree_attribute(attribute) -> InventoryAttribute: + """Coerce a string or enum member to an ``InventoryAttribute``.""" + if isinstance(attribute, InventoryAttribute): + return attribute + return InventoryAttribute(attribute) diff --git a/fastfuels_sdk/v2/v2_api_design.md b/fastfuels_sdk/v2/v2_api_design.md index 4860a4d..1431f18 100644 --- a/fastfuels_sdk/v2/v2_api_design.md +++ b/fastfuels_sdk/v2/v2_api_design.md @@ -221,11 +221,12 @@ lookup is the only such case today: it needs a grid carrying `fbfm` codes. - Single-grid export is a method (`grid.export`) but the QUIC-Fire bundle is a function (`ff.exports.create_quicfire_export`) — consistent with the rule (one held resource → method; assembled-from-many → function), but worth a look. -- Modifications/treatments vocab (`mask`/`modify`/`within`/`thin_to_*`) — partly - settled: `mask` (grids) and `basal_area_treatment`/`diameter_treatment` - (inventories) ship as builders; inventory *modifications* are still hand-built - models (no `ff.modify` vocab yet — tracked as a follow-up). Spatial - *conditions* for treatments are a raw pass-through; no condition builders yet. +- Modifications/treatments vocab (`mask`/`modify`/`within`/`thin_to_*`) — now + settled: builders ship for all three — `mask` (grids), `basal_area_treatment`/ + `diameter_treatment` (inventory treatments), and `tree_attribute`/`tree_within`/ + `remove_trees`/`modify_trees` (inventory modifications). `tree_within` builds an + `InventoryFeatureSpatialCondition`, which treatments' `conditions=` also accept; + geometry/expression conditions remain a raw pass-through. - Returned-record implementation (wrap generated attrs model vs clean dataclass) — parked; shows up on every record. diff --git a/tests/v2/test_inventories.py b/tests/v2/test_inventories.py index 30188c5..57b1a39 100644 --- a/tests/v2/test_inventories.py +++ b/tests/v2/test_inventories.py @@ -18,6 +18,7 @@ list_inventories, ) from fastfuels_sdk.v2.treatments import basal_area_treatment, diameter_treatment +from fastfuels_sdk.v2.modifications import remove_trees, tree_attribute from fastfuels_sdk.v2.client_library.models import ( InventoryAttribute, InventoryModification, @@ -269,6 +270,30 @@ def test_requires_completed_source(self, test_domain, completed_pim_grid): inventory.delete() +class TestModificationBuilders: + def test_remove_trees_at_creation( + self, test_domain, completed_pim_grid, completed_tree_inventory + ): + # In-place apply_modifications is #333-blocked, so verify the builders + # via the create-time path (which works). Same seed as the unmodified + # fixture, so removing dbh < 10 must yield strictly fewer trees. + modified = create_tree_inventory_from_pim_grid( + test_domain, + completed_pim_grid, + seed=42, + modifications=[remove_trees(tree_attribute("dbh", "<", 10))], + name="throwaway_modified", + ) + modified.wait() + assert modified.status == JobStatus.COMPLETED + assert ( + 0 + < len(modified.to_dataframe()) + < len(completed_tree_inventory.to_dataframe()) + ) + modified.delete() + + class TestVoxelize: def test_voxelize_returns_new_pending_grid(self, completed_tree_inventory): voxels = completed_tree_inventory.voxelize( diff --git a/tests/v2/test_modifications.py b/tests/v2/test_modifications.py new file mode 100644 index 0000000..1688a10 --- /dev/null +++ b/tests/v2/test_modifications.py @@ -0,0 +1,109 @@ +""" +tests/v2/test_modifications.py + +Unit tests for the inventory modification builders (no API). The grid `mask` +builder is exercised in tests/v2/test_grids.py. +""" + +# Core imports +from types import SimpleNamespace + +# Internal imports +from fastfuels_sdk.v2.modifications import ( + modify_trees, + remove_trees, + tree_attribute, + tree_within, +) +from fastfuels_sdk.v2.client_library.models import ( + InventoryAttribute, + InventoryFeatureSpatialCondition, + InventoryModification, + InventoryModificationAction, + InventoryModificationCondition, + Modifier, + Operator, + RemoveAction, + SpatialOperator, +) + +# External imports +import pytest + + +class TestTreeAttribute: + def test_symbolic_operator(self): + condition = tree_attribute("dbh", "<", 10) + assert isinstance(condition, InventoryModificationCondition) + assert condition.attribute == InventoryAttribute.DBH + assert condition.operator == Operator.LT + assert condition.value == 10 + + def test_enum_name_operator(self): + assert tree_attribute("height", "ge", 2).operator == Operator.GE + + def test_operator_member_passes_through(self): + assert tree_attribute("dbh", Operator.NE, 0).operator is Operator.NE + + def test_invalid_operator_raises(self): + with pytest.raises(ValueError): + tree_attribute("dbh", "~", 1) + + def test_invalid_attribute_raises(self): + with pytest.raises(ValueError): + tree_attribute("girth", "<", 1) + + +class TestTreeWithin: + def test_builds_feature_condition(self): + condition = tree_within("feat123", buffer_m=5) + assert isinstance(condition, InventoryFeatureSpatialCondition) + assert condition.source == "feature" + assert condition.feature_id == "feat123" + assert condition.operator == SpatialOperator.WITHIN + assert condition.buffer_m == 5 + + def test_accepts_feature_object(self): + assert tree_within(SimpleNamespace(id="f1")).feature_id == "f1" + + def test_operator(self): + assert tree_within("f", operator="outside").operator == SpatialOperator.OUTSIDE + + +class TestRemoveTrees: + def test_builds_modification(self): + modification = remove_trees(tree_attribute("dbh", "<", 10)) + assert isinstance(modification, InventoryModification) + assert len(modification.conditions) == 1 + assert len(modification.actions) == 1 + assert isinstance(modification.actions[0], RemoveAction) + + def test_requires_at_least_one_condition(self): + with pytest.raises(ValueError, match="condition"): + remove_trees() + + def test_multiple_conditions_anded(self): + modification = remove_trees(tree_attribute("dbh", "<", 10), tree_within("f")) + assert len(modification.conditions) == 2 + + +class TestModifyTrees: + def test_builds_action(self): + modification = modify_trees( + "height", "multiply", 0.9, tree_attribute("dbh", ">", 0) + ) + action = modification.actions[0] + assert isinstance(action, InventoryModificationAction) + assert action.attribute == InventoryAttribute.HEIGHT + assert action.modifier == Modifier.MULTIPLY + assert action.value == 0.9 + assert len(modification.conditions) == 1 + + def test_requires_at_least_one_condition(self): + with pytest.raises(ValueError, match="condition"): + modify_trees("height", "multiply", 0.9) + + def test_remove_is_not_a_modifier(self): + # "remove" is RemoveAction, not a modifier value + with pytest.raises(ValueError): + modify_trees("height", "remove", 0, tree_attribute("dbh", ">", 0)) From 6e17b71211a3348f815b76550c0b9acc8017caa3 Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Tue, 4 Aug 2026 09:14:00 -0600 Subject: [PATCH 51/71] Regenerate v2 client library --- .../api/applications/create_application.py | 26 +- .../api/applications/get_application_usage.py | 181 +++++++ .../api/domains/create_domain.py | 98 ++-- .../api/domains/preview_domain.py | 16 +- .../api/features/create_layerset.py | 26 +- .../api/features/create_osm_road_feature.py | 26 +- .../api/features/create_osm_water_feature.py | 26 +- .../api/grids/apply_grid_modifications.py | 58 ++- .../api/grids/create_3dep_topography.py | 26 +- .../api/grids/create_compose_grid.py | 212 ++++++++ .../api/grids/create_duet_grid.py | 452 +++++++++++++++++ .../api/grids/create_fbfm13_lookup.py | 408 +++++++++++++++ .../api/grids/create_fbfm40_lookup.py | 26 +- .../api/grids/create_fccs_lookup.py | 464 ++++++++++++++++++ .../api/grids/create_geotiff_upload.py | 26 +- .../api/grids/create_grid_export.py | 26 +- .../api/grids/create_landfire_canopy.py | 26 +- .../api/grids/create_landfire_fbfm13.py | 288 +++++++++++ .../api/grids/create_landfire_fbfm40.py | 26 +- .../api/grids/create_landfire_fccs.py | 26 +- .../api/grids/create_landfire_topography.py | 26 +- .../api/grids/create_landscape_export.py | 296 +++++++++++ .../api/grids/create_layerset_rasterize.py | 26 +- .../api/grids/create_meta_chm.py | 26 +- .../api/grids/create_naip_chm.py | 26 +- .../api/grids/create_netcdf_upload.py | 26 +- .../api/grids/create_point_cloud_chm.py | 400 +++++++++++++++ .../api/grids/create_quicfire_export.py | 70 ++- .../api/grids/create_resample.py | 26 +- .../api/grids/create_tree_inventory_grid.py | 26 +- .../api/grids/create_treemap.py | 26 +- .../api/grids/create_uniform_grid.py | 26 +- .../api/grids/duplicate_grid.py | 42 +- .../api/grids/get_grid_data_binary.py | 24 +- .../api/inventories/apply_modifications.py | 130 ++++- .../api/inventories/apply_treatments.py | 138 +++++- .../api/inventories/create_chm_inventory.py | 26 +- .../api/inventories/create_gdam_inventory.py | 26 +- .../inventories/create_inventory_export.py | 26 +- .../inventories/create_inventory_upload.py | 32 +- .../api/inventories/create_pim_inventory.py | 26 +- .../api/inventories/duplicate_inventory.py | 42 +- .../api/inventories/get_inventory_data_csv.py | 349 +++++++++++++ ...ory_data.py => get_inventory_data_json.py} | 104 ++-- .../check_3dep_point_cloud_coverage.py | 259 ++++++++++ .../point_clouds/create_3dep_point_cloud.py | 418 ++++++++++++++++ .../point_clouds/create_point_cloud_upload.py | 32 +- .../v2/client_library/api/users/__init__.py | 1 + .../v2/client_library/api/users/get_me.py | 136 +++++ .../client_library/api/users/get_me_usage.py | 136 +++++ fastfuels_sdk/v2/client_library/client.py | 8 +- .../v2/client_library/models/__init__.py | 98 +++- .../models/allometry_biomass_source.py | 3 +- ...lometry_biomass_source_component_states.py | 4 +- .../allometry_max_crown_radius_source.py | 3 +- .../v2/client_library/models/application.py | 71 ++- .../application_quota_overrides_type_0.py | 47 ++ .../apply_grid_modifications_request.py | 4 +- .../models/apply_modifications_request.py | 4 +- .../models/apply_treatments_request.py | 4 +- .../v2/client_library/models/band.py | 10 +- .../v2/client_library/models/base_model.py | 4 +- .../models/biomass_component_state.py | 4 +- .../models/categorical_band_summary.py | 3 +- .../models/categorical_column_summary.py | 93 ++++ .../v2/client_library/models/chunks.py | 4 +- .../models/chunks_count_by_axis_type_0.py | 4 +- .../v2/client_library/models/column.py | 59 ++- .../models/compose_attribute_condition.py | 109 ++++ .../models/compose_comparison_operator.py | 14 + .../client_library/models/compose_compute.py | 354 +++++++++++++ .../v2/client_library/models/compose_input.py | 70 +++ .../client_library/models/compose_literal.py | 106 ++++ .../client_library/models/compose_operator.py | 14 + .../client_library/models/compose_select.py | 296 +++++++++++ .../models/continuous_band_summary.py | 3 +- .../models/continuous_column_summary.py | 141 ++++++ .../v2/client_library/models/count_usage.py | 68 +++ .../models/create_application_request.py | 25 +- .../models/create_chm_inventory_request.py | 4 +- .../models/create_compose_request.py | 176 +++++++ .../models/create_domain_request_body.py | 3 +- .../models/create_duet_request.py | 168 +++++++ .../models/create_fbfm_13_lookup_request.py | 149 ++++++ .../models/create_fbfm_40_lookup_request.py | 4 +- .../models/create_fccs_lookup_request.py | 149 ++++++ .../models/create_gdam_inventory_request.py | 4 +- .../models/create_geo_tiff_upload_request.py | 4 +- .../models/create_inventory_upload_request.py | 4 +- .../models/create_key_request.py | 4 +- .../models/create_key_response.py | 4 +- .../models/create_landfire_canopy_request.py | 10 +- .../models/create_landfire_fbfm_13_request.py | 264 ++++++++++ .../models/create_landfire_fbfm_40_request.py | 10 +- .../models/create_landfire_fccs_request.py | 10 +- .../create_landfire_topography_request.py | 10 +- .../create_layerset_rasterize_request.py | 10 +- .../models/create_layerset_request_body.py | 3 +- .../models/create_meta_chm_request.py | 10 +- .../models/create_naip_chm_request.py | 10 +- .../models/create_netcdf_upload_request.py | 4 +- .../models/create_osm_road_feature_request.py | 3 +- .../create_osm_water_feature_request.py | 3 +- .../models/create_pim_inventory_request.py | 4 +- .../models/create_point_cloud_chm_request.py | 214 ++++++++ .../create_point_cloud_upload_request.py | 4 +- .../models/create_resample_request.py | 10 +- ...reate_resample_request_method_overrides.py | 4 +- .../create_three_dep_point_cloud_request.py | 118 +++++ .../create_three_dep_topography_request.py | 10 +- .../models/create_tree_inventory_request.py | 4 +- .../models/create_tree_map_request.py | 10 +- .../models/create_uniform_request.py | 4 +- .../client_library/models/dense_grid_data.py | 3 +- .../v2/client_library/models/domain.py | 3 +- .../client_library/models/domain_lattice.py | 4 +- .../v2/client_library/models/domain_style.py | 4 +- .../v2/client_library/models/duet_band.py | 22 + .../client_library/models/duet_calibration.py | 138 ++++++ .../duet_constant_calibration_target.py | 64 +++ .../models/duet_max_min_calibration_target.py | 74 +++ .../models/duet_mean_sd_calibration_target.py | 73 +++ .../models/duet_parameter_calibration.py | 415 ++++++++++++++++ .../models/duplicate_grid_request.py | 4 +- .../models/duplicate_inventory_request.py | 4 +- .../v2/client_library/models/export.py | 4 +- .../models/export_grid_request.py | 4 +- .../models/export_inventory_request.py | 4 +- .../v2/client_library/models/export_source.py | 4 +- .../models/fbfm_13_lookup_band.py | 16 + .../client_library/models/fccs_lookup_band.py | 19 + .../v2/client_library/models/feature.py | 4 +- .../models/feature_data_metadata.py | 4 +- .../models/feature_georeference.py | 4 +- .../models/feature_partition_info.py | 4 +- .../client_library/models/feature_source.py | 4 +- .../models/fia_species_group_share.py | 78 +++ .../v2/client_library/models/field_source.py | 4 +- .../models/fine_biomass_config.py | 3 +- .../v2/client_library/models/geo_json_crs.py | 3 +- .../models/geo_json_crs_properties.py | 4 +- .../client_library/models/geo_json_feature.py | 31 +- .../geo_json_feature_properties_type_0.py | 4 +- .../models/geometry_collection.py | 22 +- .../v2/client_library/models/georeference.py | 4 +- .../client_library/models/georeference_3d.py | 4 +- .../v2/client_library/models/grid.py | 10 +- .../models/grid_alignment_domain_target.py | 3 +- .../models/grid_alignment_grid_target.py | 3 +- .../models/grid_alignment_native_target.py | 3 +- .../models/grid_data_chunk_metadata.py | 4 +- .../models/grid_data_response.py | 4 +- .../models/grid_feature_spatial_condition.py | 3 +- .../models/grid_geometry_spatial_condition.py | 3 +- ...d_geometry_spatial_condition_crs_type_0.py | 4 +- ...rid_geometry_spatial_condition_geometry.py | 4 +- .../models/grid_modification.py | 10 +- .../models/grid_modification_action.py | 4 +- .../models/grid_modification_condition.py | 4 +- .../v2/client_library/models/grid_source.py | 4 +- .../models/grid_upload_created_response.py | 4 +- .../client_library/models/grid_upload_spec.py | 3 +- .../models/grid_upload_spec_headers.py | 4 +- .../models/http_validation_error.py | 4 +- .../client_library/models/inline_compute.py | 109 ++++ .../v2/client_library/models/inventory.py | 37 +- .../models/inventory_basal_area_treatment.py | 3 +- .../models/inventory_biomass_column.py | 4 +- .../models/inventory_column_mapping.py | 4 +- ...nventory_column_max_crown_radius_source.py | 3 +- .../inventory_columns_biomass_source.py | 3 +- ...nventory_columns_biomass_source_columns.py | 4 +- ...columns_biomass_source_component_states.py | 4 +- .../models/inventory_data_metadata.py | 4 +- .../models/inventory_data_response.py | 4 +- ...nventory_data_response_data_type_1_item.py | 4 +- .../models/inventory_diameter_treatment.py | 3 +- .../models/inventory_expression_condition.py | 4 +- .../inventory_feature_spatial_condition.py | 3 +- .../inventory_geometry_spatial_condition.py | 3 +- ...y_geometry_spatial_condition_crs_type_0.py | 4 +- ...ory_geometry_spatial_condition_geometry.py | 4 +- .../models/inventory_georeference.py | 4 +- .../models/inventory_modification.py | 14 +- .../models/inventory_modification_action.py | 4 +- .../inventory_modification_condition.py | 4 +- .../models/inventory_partition_info.py | 4 +- .../client_library/models/inventory_source.py | 4 +- .../inventory_upload_created_response.py | 4 +- .../models/inventory_upload_spec.py | 3 +- .../models/inventory_upload_spec_headers.py | 4 +- .../v2/client_library/models/job_error.py | 4 +- .../v2/client_library/models/job_progress.py | 4 +- .../models/job_resource_usage.py | 86 ++++ fastfuels_sdk/v2/client_library/models/key.py | 4 +- ..._format.py => landfire_fbfm_13_version.py} | 6 +- ...andscape_export_alignment_domain_target.py | 84 ++++ .../landscape_export_alignment_grid_target.py | 82 ++++ .../models/landscape_export_request.py | 254 ++++++++++ ...export_request_fire_behavior_fuel_model.py | 9 + .../models/landscape_field_source.py | 70 +++ .../v2/client_library/models/layerset_crs.py | 4 +- .../models/layerset_crs_properties.py | 4 +- .../client_library/models/layerset_feature.py | 9 +- .../models/layerset_properties.py | 4 +- .../v2/client_library/models/line_string.py | 3 +- .../models/list_applications_response.py | 4 +- .../models/list_domains_response.py | 4 +- .../models/list_exports_response.py | 4 +- .../models/list_features_response.py | 4 +- .../models/list_grids_response.py | 4 +- .../models/list_inventories_response.py | 4 +- .../models/list_keys_response.py | 4 +- .../models/list_point_clouds_response.py | 4 +- .../client_library/models/moisture_model.py | 4 +- .../models/multi_line_string.py | 3 +- .../v2/client_library/models/multi_point.py | 3 +- .../v2/client_library/models/multi_polygon.py | 3 +- .../v2/client_library/models/point.py | 3 +- .../v2/client_library/models/point_cloud.py | 4 +- .../models/point_cloud_georeference.py | 8 +- .../models/point_cloud_source.py | 4 +- .../models/point_cloud_summary.py | 4 +- ...point_cloud_three_dep_coverage_response.py | 122 +++++ .../point_cloud_upload_created_response.py | 4 +- .../models/point_cloud_upload_spec.py | 3 +- .../models/point_cloud_upload_spec_headers.py | 4 +- .../v2/client_library/models/polygon.py | 3 +- ...uic_fire_export_alignment_domain_target.py | 7 +- .../quic_fire_export_alignment_grid_target.py | 3 +- .../models/quicfire_export_request.py | 14 +- .../models/quota_exceeded_detail.py | 133 +++++ .../v2/client_library/models/quotas.py | 317 ++++++++++++ .../v2/client_library/models/remove_action.py | 3 +- .../v2/client_library/models/resolution_3d.py | 4 +- .../client_library/models/sparse_grid_data.py | 3 +- .../models/stem_isolation_lmf.py | 35 +- .../models/stem_isolation_vwf.py | 43 +- .../models/three_dep_coverage_response.py | 4 +- .../models/three_dep_dataset_coverage.py | 99 ++++ .../v2/client_library/models/tree_band.py | 1 + .../models/tree_forestry_metrics.py | 158 ++++++ .../models/uniform_band_input.py | 4 +- .../models/uniform_moisture_value.py | 3 +- .../models/update_application_request.py | 25 +- .../models/update_domain_request_body.py | 4 +- .../models/update_export_request_body.py | 4 +- .../models/update_feature_request_body.py | 4 +- .../models/update_grid_request_body.py | 4 +- .../models/update_inventory_request_body.py | 4 +- .../models/update_point_cloud_request_body.py | 4 +- .../models/upload_band_definition.py | 4 +- .../v2/client_library/models/usage.py | 136 +++++ .../v2/client_library/models/usage_count.py | 70 +++ .../client_library/models/usage_lifecycle.py | 119 +++++ .../v2/client_library/models/usage_storage.py | 70 +++ .../client_library/models/user_me_response.py | 94 ++++ .../models/user_me_response_kind.py | 9 + .../client_library/models/validation_error.py | 4 +- fastfuels_sdk/v2/generate_client.sh | 20 +- fastfuels_sdk/v2/inventories.py | 4 +- fastfuels_sdk/v2/v2_api_design.md | 91 ++++ 262 files changed, 11221 insertions(+), 913 deletions(-) create mode 100644 fastfuels_sdk/v2/client_library/api/applications/get_application_usage.py create mode 100644 fastfuels_sdk/v2/client_library/api/grids/create_compose_grid.py create mode 100644 fastfuels_sdk/v2/client_library/api/grids/create_duet_grid.py create mode 100644 fastfuels_sdk/v2/client_library/api/grids/create_fbfm13_lookup.py create mode 100644 fastfuels_sdk/v2/client_library/api/grids/create_fccs_lookup.py create mode 100644 fastfuels_sdk/v2/client_library/api/grids/create_landfire_fbfm13.py create mode 100644 fastfuels_sdk/v2/client_library/api/grids/create_landscape_export.py create mode 100644 fastfuels_sdk/v2/client_library/api/grids/create_point_cloud_chm.py create mode 100644 fastfuels_sdk/v2/client_library/api/inventories/get_inventory_data_csv.py rename fastfuels_sdk/v2/client_library/api/inventories/{get_inventory_data.py => get_inventory_data_json.py} (77%) create mode 100644 fastfuels_sdk/v2/client_library/api/point_clouds/check_3dep_point_cloud_coverage.py create mode 100644 fastfuels_sdk/v2/client_library/api/point_clouds/create_3dep_point_cloud.py create mode 100644 fastfuels_sdk/v2/client_library/api/users/__init__.py create mode 100644 fastfuels_sdk/v2/client_library/api/users/get_me.py create mode 100644 fastfuels_sdk/v2/client_library/api/users/get_me_usage.py create mode 100644 fastfuels_sdk/v2/client_library/models/application_quota_overrides_type_0.py create mode 100644 fastfuels_sdk/v2/client_library/models/categorical_column_summary.py create mode 100644 fastfuels_sdk/v2/client_library/models/compose_attribute_condition.py create mode 100644 fastfuels_sdk/v2/client_library/models/compose_comparison_operator.py create mode 100644 fastfuels_sdk/v2/client_library/models/compose_compute.py create mode 100644 fastfuels_sdk/v2/client_library/models/compose_input.py create mode 100644 fastfuels_sdk/v2/client_library/models/compose_literal.py create mode 100644 fastfuels_sdk/v2/client_library/models/compose_operator.py create mode 100644 fastfuels_sdk/v2/client_library/models/compose_select.py create mode 100644 fastfuels_sdk/v2/client_library/models/continuous_column_summary.py create mode 100644 fastfuels_sdk/v2/client_library/models/count_usage.py create mode 100644 fastfuels_sdk/v2/client_library/models/create_compose_request.py create mode 100644 fastfuels_sdk/v2/client_library/models/create_duet_request.py create mode 100644 fastfuels_sdk/v2/client_library/models/create_fbfm_13_lookup_request.py create mode 100644 fastfuels_sdk/v2/client_library/models/create_fccs_lookup_request.py create mode 100644 fastfuels_sdk/v2/client_library/models/create_landfire_fbfm_13_request.py create mode 100644 fastfuels_sdk/v2/client_library/models/create_point_cloud_chm_request.py create mode 100644 fastfuels_sdk/v2/client_library/models/create_three_dep_point_cloud_request.py create mode 100644 fastfuels_sdk/v2/client_library/models/duet_band.py create mode 100644 fastfuels_sdk/v2/client_library/models/duet_calibration.py create mode 100644 fastfuels_sdk/v2/client_library/models/duet_constant_calibration_target.py create mode 100644 fastfuels_sdk/v2/client_library/models/duet_max_min_calibration_target.py create mode 100644 fastfuels_sdk/v2/client_library/models/duet_mean_sd_calibration_target.py create mode 100644 fastfuels_sdk/v2/client_library/models/duet_parameter_calibration.py create mode 100644 fastfuels_sdk/v2/client_library/models/fbfm_13_lookup_band.py create mode 100644 fastfuels_sdk/v2/client_library/models/fccs_lookup_band.py create mode 100644 fastfuels_sdk/v2/client_library/models/fia_species_group_share.py create mode 100644 fastfuels_sdk/v2/client_library/models/inline_compute.py create mode 100644 fastfuels_sdk/v2/client_library/models/job_resource_usage.py rename fastfuels_sdk/v2/client_library/models/{inventory_data_format.py => landfire_fbfm_13_version.py} (51%) create mode 100644 fastfuels_sdk/v2/client_library/models/landscape_export_alignment_domain_target.py create mode 100644 fastfuels_sdk/v2/client_library/models/landscape_export_alignment_grid_target.py create mode 100644 fastfuels_sdk/v2/client_library/models/landscape_export_request.py create mode 100644 fastfuels_sdk/v2/client_library/models/landscape_export_request_fire_behavior_fuel_model.py create mode 100644 fastfuels_sdk/v2/client_library/models/landscape_field_source.py create mode 100644 fastfuels_sdk/v2/client_library/models/point_cloud_three_dep_coverage_response.py create mode 100644 fastfuels_sdk/v2/client_library/models/quota_exceeded_detail.py create mode 100644 fastfuels_sdk/v2/client_library/models/quotas.py create mode 100644 fastfuels_sdk/v2/client_library/models/three_dep_dataset_coverage.py create mode 100644 fastfuels_sdk/v2/client_library/models/tree_forestry_metrics.py create mode 100644 fastfuels_sdk/v2/client_library/models/usage.py create mode 100644 fastfuels_sdk/v2/client_library/models/usage_count.py create mode 100644 fastfuels_sdk/v2/client_library/models/usage_lifecycle.py create mode 100644 fastfuels_sdk/v2/client_library/models/usage_storage.py create mode 100644 fastfuels_sdk/v2/client_library/models/user_me_response.py create mode 100644 fastfuels_sdk/v2/client_library/models/user_me_response_kind.py diff --git a/fastfuels_sdk/v2/client_library/api/applications/create_application.py b/fastfuels_sdk/v2/client_library/api/applications/create_application.py index adf9d1f..e0e847b 100644 --- a/fastfuels_sdk/v2/client_library/api/applications/create_application.py +++ b/fastfuels_sdk/v2/client_library/api/applications/create_application.py @@ -8,6 +8,7 @@ from ...models.application import Application from ...models.create_application_request import CreateApplicationRequest from ...models.http_validation_error import HTTPValidationError +from ...models.quota_exceeded_detail import QuotaExceededDetail from ...types import Response @@ -32,7 +33,7 @@ def _get_kwargs( def _parse_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Application | HTTPValidationError | None: +) -> Application | HTTPValidationError | QuotaExceededDetail | None: if response.status_code == 201: response_201 = Application.from_dict(response.json()) @@ -43,6 +44,11 @@ def _parse_response( return response_422 + if response.status_code == 429: + response_429 = QuotaExceededDetail.from_dict(response.json()) + + return response_429 + if client.raise_on_unexpected_status: raise errors.UnexpectedStatus(response.status_code, response.content) else: @@ -51,7 +57,7 @@ def _parse_response( def _build_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[Application | HTTPValidationError]: +) -> Response[Application | HTTPValidationError | QuotaExceededDetail]: return Response( status_code=HTTPStatus(response.status_code), content=response.content, @@ -64,7 +70,7 @@ def sync_detailed( *, client: AuthenticatedClient, body: CreateApplicationRequest, -) -> Response[Application | HTTPValidationError]: +) -> Response[Application | HTTPValidationError | QuotaExceededDetail]: """Create an application Create a new application. Only personal-access users can create applications. @@ -77,7 +83,7 @@ def sync_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Application | HTTPValidationError] + Response[Application | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -95,7 +101,7 @@ def sync( *, client: AuthenticatedClient, body: CreateApplicationRequest, -) -> Application | HTTPValidationError | None: +) -> Application | HTTPValidationError | QuotaExceededDetail | None: """Create an application Create a new application. Only personal-access users can create applications. @@ -108,7 +114,7 @@ def sync( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Application | HTTPValidationError + Application | HTTPValidationError | QuotaExceededDetail """ return sync_detailed( @@ -121,7 +127,7 @@ async def asyncio_detailed( *, client: AuthenticatedClient, body: CreateApplicationRequest, -) -> Response[Application | HTTPValidationError]: +) -> Response[Application | HTTPValidationError | QuotaExceededDetail]: """Create an application Create a new application. Only personal-access users can create applications. @@ -134,7 +140,7 @@ async def asyncio_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Application | HTTPValidationError] + Response[Application | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -150,7 +156,7 @@ async def asyncio( *, client: AuthenticatedClient, body: CreateApplicationRequest, -) -> Application | HTTPValidationError | None: +) -> Application | HTTPValidationError | QuotaExceededDetail | None: """Create an application Create a new application. Only personal-access users can create applications. @@ -163,7 +169,7 @@ async def asyncio( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Application | HTTPValidationError + Application | HTTPValidationError | QuotaExceededDetail """ return ( diff --git a/fastfuels_sdk/v2/client_library/api/applications/get_application_usage.py b/fastfuels_sdk/v2/client_library/api/applications/get_application_usage.py new file mode 100644 index 0000000..1c9ae31 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/applications/get_application_usage.py @@ -0,0 +1,181 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.http_validation_error import HTTPValidationError +from ...models.usage import Usage +from ...types import Response + + +def _get_kwargs( + application_id: str, +) -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/applications/{application_id}/usage".format( + application_id=quote(str(application_id), safe=""), + ), + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> HTTPValidationError | Usage | None: + if response.status_code == 200: + response_200 = Usage.from_dict(response.json()) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[HTTPValidationError | Usage]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + application_id: str, + *, + client: AuthenticatedClient, +) -> Response[HTTPValidationError | Usage]: + """Get an application's usage + + Get an owned application's usage against its resolved limits, per resource type. + + Same shape as `GET /users/me/usage`, for an application the caller owns — + so a user can read an application's usage without authenticating as it. + + Args: + application_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | Usage] + """ + + kwargs = _get_kwargs( + application_id=application_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + application_id: str, + *, + client: AuthenticatedClient, +) -> HTTPValidationError | Usage | None: + """Get an application's usage + + Get an owned application's usage against its resolved limits, per resource type. + + Same shape as `GET /users/me/usage`, for an application the caller owns — + so a user can read an application's usage without authenticating as it. + + Args: + application_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | Usage + """ + + return sync_detailed( + application_id=application_id, + client=client, + ).parsed + + +async def asyncio_detailed( + application_id: str, + *, + client: AuthenticatedClient, +) -> Response[HTTPValidationError | Usage]: + """Get an application's usage + + Get an owned application's usage against its resolved limits, per resource type. + + Same shape as `GET /users/me/usage`, for an application the caller owns — + so a user can read an application's usage without authenticating as it. + + Args: + application_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | Usage] + """ + + kwargs = _get_kwargs( + application_id=application_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + application_id: str, + *, + client: AuthenticatedClient, +) -> HTTPValidationError | Usage | None: + """Get an application's usage + + Get an owned application's usage against its resolved limits, per resource type. + + Same shape as `GET /users/me/usage`, for an application the caller owns — + so a user can read an application's usage without authenticating as it. + + Args: + application_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | Usage + """ + + return ( + await asyncio_detailed( + application_id=application_id, + client=client, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/domains/create_domain.py b/fastfuels_sdk/v2/client_library/api/domains/create_domain.py index eee3033..4b86c51 100644 --- a/fastfuels_sdk/v2/client_library/api/domains/create_domain.py +++ b/fastfuels_sdk/v2/client_library/api/domains/create_domain.py @@ -8,6 +8,7 @@ from ...models.create_domain_request_body import CreateDomainRequestBody from ...models.domain import Domain from ...models.http_validation_error import HTTPValidationError +from ...models.quota_exceeded_detail import QuotaExceededDetail from ...types import Response @@ -32,7 +33,7 @@ def _get_kwargs( def _parse_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Domain | HTTPValidationError | None: +) -> Domain | HTTPValidationError | QuotaExceededDetail | None: if response.status_code == 201: response_201 = Domain.from_dict(response.json()) @@ -43,6 +44,11 @@ def _parse_response( return response_422 + if response.status_code == 429: + response_429 = QuotaExceededDetail.from_dict(response.json()) + + return response_429 + if client.raise_on_unexpected_status: raise errors.UnexpectedStatus(response.status_code, response.content) else: @@ -51,7 +57,7 @@ def _parse_response( def _build_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[Domain | HTTPValidationError]: +) -> Response[Domain | HTTPValidationError | QuotaExceededDetail]: return Response( status_code=HTTPStatus(response.status_code), content=response.content, @@ -64,7 +70,7 @@ def sync_detailed( *, client: AuthenticatedClient, body: CreateDomainRequestBody, -) -> Response[Domain | HTTPValidationError]: +) -> Response[Domain | HTTPValidationError | QuotaExceededDetail]: r"""Create a new domain # Create Domain Endpoint @@ -128,12 +134,10 @@ def sync_detailed( - **modified_on**: (datetime) When the domain was last modified. - **tags**: (array) The tags associated with the domain. - **crs**: (object) The coordinate reference system (always projected). - - **features**: (array) Two named features: - - **`name: \"domain\"`** — A polygon covering the working extent (bounding - box of the input, possibly padded). This is what griddle, standgen, - and exporter use as the authoritative spatial extent. - - **`name: \"input\"`** — The user's original projected geometry, preserved - for visualization and reference. + - **features**: (array) A single feature named `\"domain\"` — a polygon + covering the working extent (bounding box of the input, possibly + padded). This is what griddle, standgen, and exporter use as the + authoritative spatial extent. - **bbox**: (array) Standard GeoJSON bbox `[minx, miny, maxx, maxy]` in the domain's projected CRS. Equals the bounds of the \"domain\" feature. - **pad_to_resolution**: (number, optional) The padding value, if set. @@ -166,10 +170,10 @@ def sync_detailed( FeatureCollection input, not individual Feature objects. Wrap single features in a FeatureCollection. - 2. **Two-Feature Output**: The created domain stores two named features: - a \"domain\" feature (bounding box, the working extent used by all - downstream services) and an \"input\" feature (the user's original - polygon, preserved for visualization). + 2. **Working-Extent Output**: The created domain stores a single \"domain\" + feature — the bounding box of the input geometry, which is the working + extent used by all downstream services. The submitted geometry itself + is not stored. 3. **Projection**: Geographic coordinates are always projected to a suitable UTM zone for accurate area calculations and grid operations. @@ -193,7 +197,7 @@ def sync_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Domain | HTTPValidationError] + Response[Domain | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -211,7 +215,7 @@ def sync( *, client: AuthenticatedClient, body: CreateDomainRequestBody, -) -> Domain | HTTPValidationError | None: +) -> Domain | HTTPValidationError | QuotaExceededDetail | None: r"""Create a new domain # Create Domain Endpoint @@ -275,12 +279,10 @@ def sync( - **modified_on**: (datetime) When the domain was last modified. - **tags**: (array) The tags associated with the domain. - **crs**: (object) The coordinate reference system (always projected). - - **features**: (array) Two named features: - - **`name: \"domain\"`** — A polygon covering the working extent (bounding - box of the input, possibly padded). This is what griddle, standgen, - and exporter use as the authoritative spatial extent. - - **`name: \"input\"`** — The user's original projected geometry, preserved - for visualization and reference. + - **features**: (array) A single feature named `\"domain\"` — a polygon + covering the working extent (bounding box of the input, possibly + padded). This is what griddle, standgen, and exporter use as the + authoritative spatial extent. - **bbox**: (array) Standard GeoJSON bbox `[minx, miny, maxx, maxy]` in the domain's projected CRS. Equals the bounds of the \"domain\" feature. - **pad_to_resolution**: (number, optional) The padding value, if set. @@ -313,10 +315,10 @@ def sync( FeatureCollection input, not individual Feature objects. Wrap single features in a FeatureCollection. - 2. **Two-Feature Output**: The created domain stores two named features: - a \"domain\" feature (bounding box, the working extent used by all - downstream services) and an \"input\" feature (the user's original - polygon, preserved for visualization). + 2. **Working-Extent Output**: The created domain stores a single \"domain\" + feature — the bounding box of the input geometry, which is the working + extent used by all downstream services. The submitted geometry itself + is not stored. 3. **Projection**: Geographic coordinates are always projected to a suitable UTM zone for accurate area calculations and grid operations. @@ -340,7 +342,7 @@ def sync( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Domain | HTTPValidationError + Domain | HTTPValidationError | QuotaExceededDetail """ return sync_detailed( @@ -353,7 +355,7 @@ async def asyncio_detailed( *, client: AuthenticatedClient, body: CreateDomainRequestBody, -) -> Response[Domain | HTTPValidationError]: +) -> Response[Domain | HTTPValidationError | QuotaExceededDetail]: r"""Create a new domain # Create Domain Endpoint @@ -417,12 +419,10 @@ async def asyncio_detailed( - **modified_on**: (datetime) When the domain was last modified. - **tags**: (array) The tags associated with the domain. - **crs**: (object) The coordinate reference system (always projected). - - **features**: (array) Two named features: - - **`name: \"domain\"`** — A polygon covering the working extent (bounding - box of the input, possibly padded). This is what griddle, standgen, - and exporter use as the authoritative spatial extent. - - **`name: \"input\"`** — The user's original projected geometry, preserved - for visualization and reference. + - **features**: (array) A single feature named `\"domain\"` — a polygon + covering the working extent (bounding box of the input, possibly + padded). This is what griddle, standgen, and exporter use as the + authoritative spatial extent. - **bbox**: (array) Standard GeoJSON bbox `[minx, miny, maxx, maxy]` in the domain's projected CRS. Equals the bounds of the \"domain\" feature. - **pad_to_resolution**: (number, optional) The padding value, if set. @@ -455,10 +455,10 @@ async def asyncio_detailed( FeatureCollection input, not individual Feature objects. Wrap single features in a FeatureCollection. - 2. **Two-Feature Output**: The created domain stores two named features: - a \"domain\" feature (bounding box, the working extent used by all - downstream services) and an \"input\" feature (the user's original - polygon, preserved for visualization). + 2. **Working-Extent Output**: The created domain stores a single \"domain\" + feature — the bounding box of the input geometry, which is the working + extent used by all downstream services. The submitted geometry itself + is not stored. 3. **Projection**: Geographic coordinates are always projected to a suitable UTM zone for accurate area calculations and grid operations. @@ -482,7 +482,7 @@ async def asyncio_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Domain | HTTPValidationError] + Response[Domain | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -498,7 +498,7 @@ async def asyncio( *, client: AuthenticatedClient, body: CreateDomainRequestBody, -) -> Domain | HTTPValidationError | None: +) -> Domain | HTTPValidationError | QuotaExceededDetail | None: r"""Create a new domain # Create Domain Endpoint @@ -562,12 +562,10 @@ async def asyncio( - **modified_on**: (datetime) When the domain was last modified. - **tags**: (array) The tags associated with the domain. - **crs**: (object) The coordinate reference system (always projected). - - **features**: (array) Two named features: - - **`name: \"domain\"`** — A polygon covering the working extent (bounding - box of the input, possibly padded). This is what griddle, standgen, - and exporter use as the authoritative spatial extent. - - **`name: \"input\"`** — The user's original projected geometry, preserved - for visualization and reference. + - **features**: (array) A single feature named `\"domain\"` — a polygon + covering the working extent (bounding box of the input, possibly + padded). This is what griddle, standgen, and exporter use as the + authoritative spatial extent. - **bbox**: (array) Standard GeoJSON bbox `[minx, miny, maxx, maxy]` in the domain's projected CRS. Equals the bounds of the \"domain\" feature. - **pad_to_resolution**: (number, optional) The padding value, if set. @@ -600,10 +598,10 @@ async def asyncio( FeatureCollection input, not individual Feature objects. Wrap single features in a FeatureCollection. - 2. **Two-Feature Output**: The created domain stores two named features: - a \"domain\" feature (bounding box, the working extent used by all - downstream services) and an \"input\" feature (the user's original - polygon, preserved for visualization). + 2. **Working-Extent Output**: The created domain stores a single \"domain\" + feature — the bounding box of the input geometry, which is the working + extent used by all downstream services. The submitted geometry itself + is not stored. 3. **Projection**: Geographic coordinates are always projected to a suitable UTM zone for accurate area calculations and grid operations. @@ -627,7 +625,7 @@ async def asyncio( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Domain | HTTPValidationError + Domain | HTTPValidationError | QuotaExceededDetail """ return ( diff --git a/fastfuels_sdk/v2/client_library/api/domains/preview_domain.py b/fastfuels_sdk/v2/client_library/api/domains/preview_domain.py index 5f3b2aa..daa95a1 100644 --- a/fastfuels_sdk/v2/client_library/api/domains/preview_domain.py +++ b/fastfuels_sdk/v2/client_library/api/domains/preview_domain.py @@ -84,8 +84,8 @@ def sync_detailed( - **id**: Always `\"preview\"` — not a real domain identifier. - **created_on** / **modified_on**: Set to the current request time (not persisted). - - **features**: Two named features — `\"domain\"` (working extent) and `\"input\"` - (original polygon), identical to what create would return. + - **features**: A single `\"domain\"` feature (the working extent), + identical to what create would return. - **bbox**: Bounding box of the `\"domain\"` feature. - **crs**: Projected CRS, identical to what create would return. @@ -144,8 +144,8 @@ def sync( - **id**: Always `\"preview\"` — not a real domain identifier. - **created_on** / **modified_on**: Set to the current request time (not persisted). - - **features**: Two named features — `\"domain\"` (working extent) and `\"input\"` - (original polygon), identical to what create would return. + - **features**: A single `\"domain\"` feature (the working extent), + identical to what create would return. - **bbox**: Bounding box of the `\"domain\"` feature. - **crs**: Projected CRS, identical to what create would return. @@ -199,8 +199,8 @@ async def asyncio_detailed( - **id**: Always `\"preview\"` — not a real domain identifier. - **created_on** / **modified_on**: Set to the current request time (not persisted). - - **features**: Two named features — `\"domain\"` (working extent) and `\"input\"` - (original polygon), identical to what create would return. + - **features**: A single `\"domain\"` feature (the working extent), + identical to what create would return. - **bbox**: Bounding box of the `\"domain\"` feature. - **crs**: Projected CRS, identical to what create would return. @@ -257,8 +257,8 @@ async def asyncio( - **id**: Always `\"preview\"` — not a real domain identifier. - **created_on** / **modified_on**: Set to the current request time (not persisted). - - **features**: Two named features — `\"domain\"` (working extent) and `\"input\"` - (original polygon), identical to what create would return. + - **features**: A single `\"domain\"` feature (the working extent), + identical to what create would return. - **bbox**: Bounding box of the `\"domain\"` feature. - **crs**: Projected CRS, identical to what create would return. diff --git a/fastfuels_sdk/v2/client_library/api/features/create_layerset.py b/fastfuels_sdk/v2/client_library/api/features/create_layerset.py index 0ad3c53..8ebe7ed 100644 --- a/fastfuels_sdk/v2/client_library/api/features/create_layerset.py +++ b/fastfuels_sdk/v2/client_library/api/features/create_layerset.py @@ -9,6 +9,7 @@ from ...models.create_layerset_request_body import CreateLayersetRequestBody from ...models.feature import Feature from ...models.http_validation_error import HTTPValidationError +from ...models.quota_exceeded_detail import QuotaExceededDetail from ...types import Response @@ -36,7 +37,7 @@ def _get_kwargs( def _parse_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Feature | HTTPValidationError | None: +) -> Feature | HTTPValidationError | QuotaExceededDetail | None: if response.status_code == 201: response_201 = Feature.from_dict(response.json()) @@ -47,6 +48,11 @@ def _parse_response( return response_422 + if response.status_code == 429: + response_429 = QuotaExceededDetail.from_dict(response.json()) + + return response_429 + if client.raise_on_unexpected_status: raise errors.UnexpectedStatus(response.status_code, response.content) else: @@ -55,7 +61,7 @@ def _parse_response( def _build_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[Feature | HTTPValidationError]: +) -> Response[Feature | HTTPValidationError | QuotaExceededDetail]: return Response( status_code=HTTPStatus(response.status_code), content=response.content, @@ -69,7 +75,7 @@ def sync_detailed( *, client: AuthenticatedClient, body: CreateLayersetRequestBody, -) -> Response[Feature | HTTPValidationError]: +) -> Response[Feature | HTTPValidationError | QuotaExceededDetail]: r"""Upload a custom Layerset # Create Layerset Endpoint @@ -119,7 +125,7 @@ def sync_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Feature | HTTPValidationError] + Response[Feature | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -139,7 +145,7 @@ def sync( *, client: AuthenticatedClient, body: CreateLayersetRequestBody, -) -> Feature | HTTPValidationError | None: +) -> Feature | HTTPValidationError | QuotaExceededDetail | None: r"""Upload a custom Layerset # Create Layerset Endpoint @@ -189,7 +195,7 @@ def sync( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Feature | HTTPValidationError + Feature | HTTPValidationError | QuotaExceededDetail """ return sync_detailed( @@ -204,7 +210,7 @@ async def asyncio_detailed( *, client: AuthenticatedClient, body: CreateLayersetRequestBody, -) -> Response[Feature | HTTPValidationError]: +) -> Response[Feature | HTTPValidationError | QuotaExceededDetail]: r"""Upload a custom Layerset # Create Layerset Endpoint @@ -254,7 +260,7 @@ async def asyncio_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Feature | HTTPValidationError] + Response[Feature | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -272,7 +278,7 @@ async def asyncio( *, client: AuthenticatedClient, body: CreateLayersetRequestBody, -) -> Feature | HTTPValidationError | None: +) -> Feature | HTTPValidationError | QuotaExceededDetail | None: r"""Upload a custom Layerset # Create Layerset Endpoint @@ -322,7 +328,7 @@ async def asyncio( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Feature | HTTPValidationError + Feature | HTTPValidationError | QuotaExceededDetail """ return ( diff --git a/fastfuels_sdk/v2/client_library/api/features/create_osm_road_feature.py b/fastfuels_sdk/v2/client_library/api/features/create_osm_road_feature.py index f704b27..0754dfc 100644 --- a/fastfuels_sdk/v2/client_library/api/features/create_osm_road_feature.py +++ b/fastfuels_sdk/v2/client_library/api/features/create_osm_road_feature.py @@ -9,6 +9,7 @@ from ...models.create_osm_road_feature_request import CreateOsmRoadFeatureRequest from ...models.feature import Feature from ...models.http_validation_error import HTTPValidationError +from ...models.quota_exceeded_detail import QuotaExceededDetail from ...types import Response @@ -36,7 +37,7 @@ def _get_kwargs( def _parse_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Feature | HTTPValidationError | None: +) -> Feature | HTTPValidationError | QuotaExceededDetail | None: if response.status_code == 201: response_201 = Feature.from_dict(response.json()) @@ -47,6 +48,11 @@ def _parse_response( return response_422 + if response.status_code == 429: + response_429 = QuotaExceededDetail.from_dict(response.json()) + + return response_429 + if client.raise_on_unexpected_status: raise errors.UnexpectedStatus(response.status_code, response.content) else: @@ -55,7 +61,7 @@ def _parse_response( def _build_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[Feature | HTTPValidationError]: +) -> Response[Feature | HTTPValidationError | QuotaExceededDetail]: return Response( status_code=HTTPStatus(response.status_code), content=response.content, @@ -69,7 +75,7 @@ def sync_detailed( *, client: AuthenticatedClient, body: CreateOsmRoadFeatureRequest, -) -> Response[Feature | HTTPValidationError]: +) -> Response[Feature | HTTPValidationError | QuotaExceededDetail]: r"""Create a road feature from OpenStreetMap # Create OSM Road Feature @@ -113,7 +119,7 @@ def sync_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Feature | HTTPValidationError] + Response[Feature | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -133,7 +139,7 @@ def sync( *, client: AuthenticatedClient, body: CreateOsmRoadFeatureRequest, -) -> Feature | HTTPValidationError | None: +) -> Feature | HTTPValidationError | QuotaExceededDetail | None: r"""Create a road feature from OpenStreetMap # Create OSM Road Feature @@ -177,7 +183,7 @@ def sync( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Feature | HTTPValidationError + Feature | HTTPValidationError | QuotaExceededDetail """ return sync_detailed( @@ -192,7 +198,7 @@ async def asyncio_detailed( *, client: AuthenticatedClient, body: CreateOsmRoadFeatureRequest, -) -> Response[Feature | HTTPValidationError]: +) -> Response[Feature | HTTPValidationError | QuotaExceededDetail]: r"""Create a road feature from OpenStreetMap # Create OSM Road Feature @@ -236,7 +242,7 @@ async def asyncio_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Feature | HTTPValidationError] + Response[Feature | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -254,7 +260,7 @@ async def asyncio( *, client: AuthenticatedClient, body: CreateOsmRoadFeatureRequest, -) -> Feature | HTTPValidationError | None: +) -> Feature | HTTPValidationError | QuotaExceededDetail | None: r"""Create a road feature from OpenStreetMap # Create OSM Road Feature @@ -298,7 +304,7 @@ async def asyncio( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Feature | HTTPValidationError + Feature | HTTPValidationError | QuotaExceededDetail """ return ( diff --git a/fastfuels_sdk/v2/client_library/api/features/create_osm_water_feature.py b/fastfuels_sdk/v2/client_library/api/features/create_osm_water_feature.py index cb0ebaf..348431f 100644 --- a/fastfuels_sdk/v2/client_library/api/features/create_osm_water_feature.py +++ b/fastfuels_sdk/v2/client_library/api/features/create_osm_water_feature.py @@ -9,6 +9,7 @@ from ...models.create_osm_water_feature_request import CreateOsmWaterFeatureRequest from ...models.feature import Feature from ...models.http_validation_error import HTTPValidationError +from ...models.quota_exceeded_detail import QuotaExceededDetail from ...types import Response @@ -36,7 +37,7 @@ def _get_kwargs( def _parse_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Feature | HTTPValidationError | None: +) -> Feature | HTTPValidationError | QuotaExceededDetail | None: if response.status_code == 201: response_201 = Feature.from_dict(response.json()) @@ -47,6 +48,11 @@ def _parse_response( return response_422 + if response.status_code == 429: + response_429 = QuotaExceededDetail.from_dict(response.json()) + + return response_429 + if client.raise_on_unexpected_status: raise errors.UnexpectedStatus(response.status_code, response.content) else: @@ -55,7 +61,7 @@ def _parse_response( def _build_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[Feature | HTTPValidationError]: +) -> Response[Feature | HTTPValidationError | QuotaExceededDetail]: return Response( status_code=HTTPStatus(response.status_code), content=response.content, @@ -69,7 +75,7 @@ def sync_detailed( *, client: AuthenticatedClient, body: CreateOsmWaterFeatureRequest, -) -> Response[Feature | HTTPValidationError]: +) -> Response[Feature | HTTPValidationError | QuotaExceededDetail]: r"""Create a water feature from OpenStreetMap # Create OSM Water Feature @@ -113,7 +119,7 @@ def sync_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Feature | HTTPValidationError] + Response[Feature | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -133,7 +139,7 @@ def sync( *, client: AuthenticatedClient, body: CreateOsmWaterFeatureRequest, -) -> Feature | HTTPValidationError | None: +) -> Feature | HTTPValidationError | QuotaExceededDetail | None: r"""Create a water feature from OpenStreetMap # Create OSM Water Feature @@ -177,7 +183,7 @@ def sync( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Feature | HTTPValidationError + Feature | HTTPValidationError | QuotaExceededDetail """ return sync_detailed( @@ -192,7 +198,7 @@ async def asyncio_detailed( *, client: AuthenticatedClient, body: CreateOsmWaterFeatureRequest, -) -> Response[Feature | HTTPValidationError]: +) -> Response[Feature | HTTPValidationError | QuotaExceededDetail]: r"""Create a water feature from OpenStreetMap # Create OSM Water Feature @@ -236,7 +242,7 @@ async def asyncio_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Feature | HTTPValidationError] + Response[Feature | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -254,7 +260,7 @@ async def asyncio( *, client: AuthenticatedClient, body: CreateOsmWaterFeatureRequest, -) -> Feature | HTTPValidationError | None: +) -> Feature | HTTPValidationError | QuotaExceededDetail | None: r"""Create a water feature from OpenStreetMap # Create OSM Water Feature @@ -298,7 +304,7 @@ async def asyncio( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Feature | HTTPValidationError + Feature | HTTPValidationError | QuotaExceededDetail """ return ( diff --git a/fastfuels_sdk/v2/client_library/api/grids/apply_grid_modifications.py b/fastfuels_sdk/v2/client_library/api/grids/apply_grid_modifications.py index cc53659..ea419a8 100644 --- a/fastfuels_sdk/v2/client_library/api/grids/apply_grid_modifications.py +++ b/fastfuels_sdk/v2/client_library/api/grids/apply_grid_modifications.py @@ -9,6 +9,7 @@ from ...models.apply_grid_modifications_request import ApplyGridModificationsRequest from ...models.grid import Grid from ...models.http_validation_error import HTTPValidationError +from ...models.quota_exceeded_detail import QuotaExceededDetail from ...types import Response @@ -38,7 +39,7 @@ def _get_kwargs( def _parse_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Grid | HTTPValidationError | None: +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: if response.status_code == 200: response_200 = Grid.from_dict(response.json()) @@ -49,6 +50,11 @@ def _parse_response( return response_422 + if response.status_code == 429: + response_429 = QuotaExceededDetail.from_dict(response.json()) + + return response_429 + if client.raise_on_unexpected_status: raise errors.UnexpectedStatus(response.status_code, response.content) else: @@ -57,7 +63,7 @@ def _parse_response( def _build_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[Grid | HTTPValidationError]: +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: return Response( status_code=HTTPStatus(response.status_code), content=response.content, @@ -72,7 +78,7 @@ def sync_detailed( *, client: AuthenticatedClient, body: ApplyGridModificationsRequest, -) -> Response[Grid | HTTPValidationError]: +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: r"""Apply modifications to a grid in place # Apply Modifications to a Grid (in place) @@ -113,7 +119,9 @@ def sync_detailed( - `band`: dot-notation band key (e.g., `fbfm`, `fuel_load.1hr`) - `operator`: `eq`, `ne`, `gt`, `lt`, `ge`, `le` (`eq`/`ne` also accept a list of values) - - `value`: number, string, or list for `eq`/`ne` + - `value`: number or list for `eq`/`ne`. For `fbfm` bands you may use the + human-readable Scott-Burgan labels (`\"GR1\"`) or the numeric codes (`101`) + interchangeably — labels are resolved to codes when the rule is stored. **Spatial conditions** test each cell's location against a geometry. Two variants discriminated by the required `source` field: @@ -161,6 +169,10 @@ def sync_detailed( grid (apply modifications to the source tree inventory and re-voxelize instead); a referenced `feature_id` is missing, cross-domain, or not completed; or a referenced band does not exist on this grid. + - **429 Too Many Requests**: You have too many active grid jobs in progress + (your `max_active_grids` quota). Wait for jobs to complete or delete + unneeded grids, then retry. The response detail names the exact `quota` + and includes a `Retry-After` header. Args: domain_id (str): @@ -176,7 +188,7 @@ def sync_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Grid | HTTPValidationError] + Response[Grid | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -198,7 +210,7 @@ def sync( *, client: AuthenticatedClient, body: ApplyGridModificationsRequest, -) -> Grid | HTTPValidationError | None: +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: r"""Apply modifications to a grid in place # Apply Modifications to a Grid (in place) @@ -239,7 +251,9 @@ def sync( - `band`: dot-notation band key (e.g., `fbfm`, `fuel_load.1hr`) - `operator`: `eq`, `ne`, `gt`, `lt`, `ge`, `le` (`eq`/`ne` also accept a list of values) - - `value`: number, string, or list for `eq`/`ne` + - `value`: number or list for `eq`/`ne`. For `fbfm` bands you may use the + human-readable Scott-Burgan labels (`\"GR1\"`) or the numeric codes (`101`) + interchangeably — labels are resolved to codes when the rule is stored. **Spatial conditions** test each cell's location against a geometry. Two variants discriminated by the required `source` field: @@ -287,6 +301,10 @@ def sync( grid (apply modifications to the source tree inventory and re-voxelize instead); a referenced `feature_id` is missing, cross-domain, or not completed; or a referenced band does not exist on this grid. + - **429 Too Many Requests**: You have too many active grid jobs in progress + (your `max_active_grids` quota). Wait for jobs to complete or delete + unneeded grids, then retry. The response detail names the exact `quota` + and includes a `Retry-After` header. Args: domain_id (str): @@ -302,7 +320,7 @@ def sync( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Grid | HTTPValidationError + Grid | HTTPValidationError | QuotaExceededDetail """ return sync_detailed( @@ -319,7 +337,7 @@ async def asyncio_detailed( *, client: AuthenticatedClient, body: ApplyGridModificationsRequest, -) -> Response[Grid | HTTPValidationError]: +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: r"""Apply modifications to a grid in place # Apply Modifications to a Grid (in place) @@ -360,7 +378,9 @@ async def asyncio_detailed( - `band`: dot-notation band key (e.g., `fbfm`, `fuel_load.1hr`) - `operator`: `eq`, `ne`, `gt`, `lt`, `ge`, `le` (`eq`/`ne` also accept a list of values) - - `value`: number, string, or list for `eq`/`ne` + - `value`: number or list for `eq`/`ne`. For `fbfm` bands you may use the + human-readable Scott-Burgan labels (`\"GR1\"`) or the numeric codes (`101`) + interchangeably — labels are resolved to codes when the rule is stored. **Spatial conditions** test each cell's location against a geometry. Two variants discriminated by the required `source` field: @@ -408,6 +428,10 @@ async def asyncio_detailed( grid (apply modifications to the source tree inventory and re-voxelize instead); a referenced `feature_id` is missing, cross-domain, or not completed; or a referenced band does not exist on this grid. + - **429 Too Many Requests**: You have too many active grid jobs in progress + (your `max_active_grids` quota). Wait for jobs to complete or delete + unneeded grids, then retry. The response detail names the exact `quota` + and includes a `Retry-After` header. Args: domain_id (str): @@ -423,7 +447,7 @@ async def asyncio_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Grid | HTTPValidationError] + Response[Grid | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -443,7 +467,7 @@ async def asyncio( *, client: AuthenticatedClient, body: ApplyGridModificationsRequest, -) -> Grid | HTTPValidationError | None: +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: r"""Apply modifications to a grid in place # Apply Modifications to a Grid (in place) @@ -484,7 +508,9 @@ async def asyncio( - `band`: dot-notation band key (e.g., `fbfm`, `fuel_load.1hr`) - `operator`: `eq`, `ne`, `gt`, `lt`, `ge`, `le` (`eq`/`ne` also accept a list of values) - - `value`: number, string, or list for `eq`/`ne` + - `value`: number or list for `eq`/`ne`. For `fbfm` bands you may use the + human-readable Scott-Burgan labels (`\"GR1\"`) or the numeric codes (`101`) + interchangeably — labels are resolved to codes when the rule is stored. **Spatial conditions** test each cell's location against a geometry. Two variants discriminated by the required `source` field: @@ -532,6 +558,10 @@ async def asyncio( grid (apply modifications to the source tree inventory and re-voxelize instead); a referenced `feature_id` is missing, cross-domain, or not completed; or a referenced band does not exist on this grid. + - **429 Too Many Requests**: You have too many active grid jobs in progress + (your `max_active_grids` quota). Wait for jobs to complete or delete + unneeded grids, then retry. The response detail names the exact `quota` + and includes a `Retry-After` header. Args: domain_id (str): @@ -547,7 +577,7 @@ async def asyncio( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Grid | HTTPValidationError + Grid | HTTPValidationError | QuotaExceededDetail """ return ( diff --git a/fastfuels_sdk/v2/client_library/api/grids/create_3dep_topography.py b/fastfuels_sdk/v2/client_library/api/grids/create_3dep_topography.py index 62fcd5e..6dd4997 100644 --- a/fastfuels_sdk/v2/client_library/api/grids/create_3dep_topography.py +++ b/fastfuels_sdk/v2/client_library/api/grids/create_3dep_topography.py @@ -11,6 +11,7 @@ ) from ...models.grid import Grid from ...models.http_validation_error import HTTPValidationError +from ...models.quota_exceeded_detail import QuotaExceededDetail from ...types import Response @@ -38,7 +39,7 @@ def _get_kwargs( def _parse_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Grid | HTTPValidationError | None: +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: if response.status_code == 201: response_201 = Grid.from_dict(response.json()) @@ -49,6 +50,11 @@ def _parse_response( return response_422 + if response.status_code == 429: + response_429 = QuotaExceededDetail.from_dict(response.json()) + + return response_429 + if client.raise_on_unexpected_status: raise errors.UnexpectedStatus(response.status_code, response.content) else: @@ -57,7 +63,7 @@ def _parse_response( def _build_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[Grid | HTTPValidationError]: +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: return Response( status_code=HTTPStatus(response.status_code), content=response.content, @@ -71,7 +77,7 @@ def sync_detailed( *, client: AuthenticatedClient, body: CreateThreeDepTopographyRequest, -) -> Response[Grid | HTTPValidationError]: +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: r"""Create a grid from 3DEP topographic data # Create 3DEP Topography Grid @@ -123,7 +129,7 @@ def sync_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Grid | HTTPValidationError] + Response[Grid | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -143,7 +149,7 @@ def sync( *, client: AuthenticatedClient, body: CreateThreeDepTopographyRequest, -) -> Grid | HTTPValidationError | None: +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: r"""Create a grid from 3DEP topographic data # Create 3DEP Topography Grid @@ -195,7 +201,7 @@ def sync( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Grid | HTTPValidationError + Grid | HTTPValidationError | QuotaExceededDetail """ return sync_detailed( @@ -210,7 +216,7 @@ async def asyncio_detailed( *, client: AuthenticatedClient, body: CreateThreeDepTopographyRequest, -) -> Response[Grid | HTTPValidationError]: +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: r"""Create a grid from 3DEP topographic data # Create 3DEP Topography Grid @@ -262,7 +268,7 @@ async def asyncio_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Grid | HTTPValidationError] + Response[Grid | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -280,7 +286,7 @@ async def asyncio( *, client: AuthenticatedClient, body: CreateThreeDepTopographyRequest, -) -> Grid | HTTPValidationError | None: +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: r"""Create a grid from 3DEP topographic data # Create 3DEP Topography Grid @@ -332,7 +338,7 @@ async def asyncio( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Grid | HTTPValidationError + Grid | HTTPValidationError | QuotaExceededDetail """ return ( diff --git a/fastfuels_sdk/v2/client_library/api/grids/create_compose_grid.py b/fastfuels_sdk/v2/client_library/api/grids/create_compose_grid.py new file mode 100644 index 0000000..949a82a --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/grids/create_compose_grid.py @@ -0,0 +1,212 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.create_compose_request import CreateComposeRequest +from ...models.grid import Grid +from ...models.http_validation_error import HTTPValidationError +from ...models.quota_exceeded_detail import QuotaExceededDetail +from ...types import Response + + +def _get_kwargs( + domain_id: str, + *, + body: CreateComposeRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/domains/{domain_id}/grids/compose".format( + domain_id=quote(str(domain_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: + if response.status_code == 201: + response_201 = Grid.from_dict(response.json()) + + return response_201 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if response.status_code == 429: + response_429 = QuotaExceededDetail.from_dict(response.json()) + + return response_429 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateComposeRequest, +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: + """Create a grid by composing existing grids + + # Create Compose Grid + + Creates a new grid by selecting bands, computing bands, and applying + optional conditional fallback rules across one or more completed grids. + + Args: + domain_id (str): + body (CreateComposeRequest): Request to create a grid by composing one or more existing + grids. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError | QuotaExceededDetail] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateComposeRequest, +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: + """Create a grid by composing existing grids + + # Create Compose Grid + + Creates a new grid by selecting bands, computing bands, and applying + optional conditional fallback rules across one or more completed grids. + + Args: + domain_id (str): + body (CreateComposeRequest): Request to create a grid by composing one or more existing + grids. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError | QuotaExceededDetail + """ + + return sync_detailed( + domain_id=domain_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateComposeRequest, +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: + """Create a grid by composing existing grids + + # Create Compose Grid + + Creates a new grid by selecting bands, computing bands, and applying + optional conditional fallback rules across one or more completed grids. + + Args: + domain_id (str): + body (CreateComposeRequest): Request to create a grid by composing one or more existing + grids. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError | QuotaExceededDetail] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateComposeRequest, +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: + """Create a grid by composing existing grids + + # Create Compose Grid + + Creates a new grid by selecting bands, computing bands, and applying + optional conditional fallback rules across one or more completed grids. + + Args: + domain_id (str): + body (CreateComposeRequest): Request to create a grid by composing one or more existing + grids. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError | QuotaExceededDetail + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/grids/create_duet_grid.py b/fastfuels_sdk/v2/client_library/api/grids/create_duet_grid.py new file mode 100644 index 0000000..b42f5cf --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/grids/create_duet_grid.py @@ -0,0 +1,452 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.create_duet_request import CreateDuetRequest +from ...models.grid import Grid +from ...models.http_validation_error import HTTPValidationError +from ...models.quota_exceeded_detail import QuotaExceededDetail +from ...types import Response + + +def _get_kwargs( + domain_id: str, + *, + body: CreateDuetRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/domains/{domain_id}/grids/duet".format( + domain_id=quote(str(domain_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: + if response.status_code == 201: + response_201 = Grid.from_dict(response.json()) + + return response_201 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if response.status_code == 429: + response_429 = QuotaExceededDetail.from_dict(response.json()) + + return response_429 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateDuetRequest, +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: + r"""Create a surface fuel grid with DUET + + # Create a DUET Surface Fuel Grid + + Runs DUET (Distribution of Understory using Elliptical Transport) over a 3D + tree grid to produce 2D surface fuels. DUET drops leaf and needle litter + from each tree's crown along wind-driven elliptical fall trajectories, then + grows grass as a function of shade and litter cover — so litter accumulates + under and downwind of crowns, and grass fills the gaps between them. + + ## What DUET does and does not give you + + DUET supplies the **spatial pattern** of surface fuels, keyed to real canopy + structure. It does **not** supply physical magnitudes: raw DUET loadings are + idiosyncratic to the model and should not be read as fuel loads or fed to a + fire model as-is. Use `calibration` to impose magnitudes you trust — from + field data, from the literature, or from an FBFM40 grid. + + ## Request Body + + - **source_grid_id**: (required) A completed 3D tree grid carrying the + `bulk_density.foliage.live`, `spcd`, and `fuel_moisture.live` bands. + Create one with `POST /grids/voxelize/inventory/tree`, requesting those + three bands — `spcd` in particular is not voxelized by default. + - **years_since_burn**: (required) Years of litter accumulation to simulate, + 1–100. DUET starts from the year of the last fire, with grass and litter + consumed, so this is the stand's time since fire. It is the single most + consequential parameter: a low value yields almost no litter because there + has been no time for any to fall. It also drives runtime. + - **wind_direction**: (optional) Degrees clockwise from north. Default 270. + - **wind_variability**: (optional) Angular spread in degrees. Default 30. + - **bands**: (optional) Output bands. Defaults to `fuel_load.grass` and + `fuel_load.litter`. DUET separates fuels by type rather than size class, + so bands are named for `grass`, `litter` (and its `litter.coniferous` / + `litter.deciduous` parts), and `total`. + - **calibration**: (optional) Per-parameter, per-fuel-type targets. Omit to + store raw output. + - **name**, **description**, **tags**: (optional) Standard metadata. + + ## Calibration + + Each of `fuel_load`, `fuel_depth`, and `fuel_moisture` is calibrated + independently, and within each, per fuel type (`grass`, `coniferous`, + `deciduous`, `litter`, or `all` — which is exclusive of the others). Methods: + + - `maxmin` — rescale to a target maximum and minimum. Best when fuel data + are limited, or when their distribution does not resemble DUET's. + - `meansd` — rescale to a target mean and standard deviation. Appropriate + only when the targets come from a dataset large enough to approximate a + normal distribution. + - `constant` — assign a single value. Reasonable only when that is the only + value available. + + Calibration rescales only cells that already carry fuel; cells DUET left + empty stay empty. A consequence worth expecting: where cover is sparse, the + domain-wide mean will sit well below a `meansd` target, because the target + applies to the covered cells rather than to the domain. + + ## Response + + Returns the created Grid with status `\"pending\"` and `georeference: null`. + Treevox runs DUET asynchronously and updates the grid to `\"completed\"` with + a 2D `Georeference` when done. + + Args: + domain_id (str): + body (CreateDuetRequest): Request body for creating a DUET surface fuel grid from a tree + grid. + + Does not extend CreateGridRequestBase: like the 3D grids it derives from, + DUET grids do not support modifications. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError | QuotaExceededDetail] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateDuetRequest, +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: + r"""Create a surface fuel grid with DUET + + # Create a DUET Surface Fuel Grid + + Runs DUET (Distribution of Understory using Elliptical Transport) over a 3D + tree grid to produce 2D surface fuels. DUET drops leaf and needle litter + from each tree's crown along wind-driven elliptical fall trajectories, then + grows grass as a function of shade and litter cover — so litter accumulates + under and downwind of crowns, and grass fills the gaps between them. + + ## What DUET does and does not give you + + DUET supplies the **spatial pattern** of surface fuels, keyed to real canopy + structure. It does **not** supply physical magnitudes: raw DUET loadings are + idiosyncratic to the model and should not be read as fuel loads or fed to a + fire model as-is. Use `calibration` to impose magnitudes you trust — from + field data, from the literature, or from an FBFM40 grid. + + ## Request Body + + - **source_grid_id**: (required) A completed 3D tree grid carrying the + `bulk_density.foliage.live`, `spcd`, and `fuel_moisture.live` bands. + Create one with `POST /grids/voxelize/inventory/tree`, requesting those + three bands — `spcd` in particular is not voxelized by default. + - **years_since_burn**: (required) Years of litter accumulation to simulate, + 1–100. DUET starts from the year of the last fire, with grass and litter + consumed, so this is the stand's time since fire. It is the single most + consequential parameter: a low value yields almost no litter because there + has been no time for any to fall. It also drives runtime. + - **wind_direction**: (optional) Degrees clockwise from north. Default 270. + - **wind_variability**: (optional) Angular spread in degrees. Default 30. + - **bands**: (optional) Output bands. Defaults to `fuel_load.grass` and + `fuel_load.litter`. DUET separates fuels by type rather than size class, + so bands are named for `grass`, `litter` (and its `litter.coniferous` / + `litter.deciduous` parts), and `total`. + - **calibration**: (optional) Per-parameter, per-fuel-type targets. Omit to + store raw output. + - **name**, **description**, **tags**: (optional) Standard metadata. + + ## Calibration + + Each of `fuel_load`, `fuel_depth`, and `fuel_moisture` is calibrated + independently, and within each, per fuel type (`grass`, `coniferous`, + `deciduous`, `litter`, or `all` — which is exclusive of the others). Methods: + + - `maxmin` — rescale to a target maximum and minimum. Best when fuel data + are limited, or when their distribution does not resemble DUET's. + - `meansd` — rescale to a target mean and standard deviation. Appropriate + only when the targets come from a dataset large enough to approximate a + normal distribution. + - `constant` — assign a single value. Reasonable only when that is the only + value available. + + Calibration rescales only cells that already carry fuel; cells DUET left + empty stay empty. A consequence worth expecting: where cover is sparse, the + domain-wide mean will sit well below a `meansd` target, because the target + applies to the covered cells rather than to the domain. + + ## Response + + Returns the created Grid with status `\"pending\"` and `georeference: null`. + Treevox runs DUET asynchronously and updates the grid to `\"completed\"` with + a 2D `Georeference` when done. + + Args: + domain_id (str): + body (CreateDuetRequest): Request body for creating a DUET surface fuel grid from a tree + grid. + + Does not extend CreateGridRequestBase: like the 3D grids it derives from, + DUET grids do not support modifications. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError | QuotaExceededDetail + """ + + return sync_detailed( + domain_id=domain_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateDuetRequest, +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: + r"""Create a surface fuel grid with DUET + + # Create a DUET Surface Fuel Grid + + Runs DUET (Distribution of Understory using Elliptical Transport) over a 3D + tree grid to produce 2D surface fuels. DUET drops leaf and needle litter + from each tree's crown along wind-driven elliptical fall trajectories, then + grows grass as a function of shade and litter cover — so litter accumulates + under and downwind of crowns, and grass fills the gaps between them. + + ## What DUET does and does not give you + + DUET supplies the **spatial pattern** of surface fuels, keyed to real canopy + structure. It does **not** supply physical magnitudes: raw DUET loadings are + idiosyncratic to the model and should not be read as fuel loads or fed to a + fire model as-is. Use `calibration` to impose magnitudes you trust — from + field data, from the literature, or from an FBFM40 grid. + + ## Request Body + + - **source_grid_id**: (required) A completed 3D tree grid carrying the + `bulk_density.foliage.live`, `spcd`, and `fuel_moisture.live` bands. + Create one with `POST /grids/voxelize/inventory/tree`, requesting those + three bands — `spcd` in particular is not voxelized by default. + - **years_since_burn**: (required) Years of litter accumulation to simulate, + 1–100. DUET starts from the year of the last fire, with grass and litter + consumed, so this is the stand's time since fire. It is the single most + consequential parameter: a low value yields almost no litter because there + has been no time for any to fall. It also drives runtime. + - **wind_direction**: (optional) Degrees clockwise from north. Default 270. + - **wind_variability**: (optional) Angular spread in degrees. Default 30. + - **bands**: (optional) Output bands. Defaults to `fuel_load.grass` and + `fuel_load.litter`. DUET separates fuels by type rather than size class, + so bands are named for `grass`, `litter` (and its `litter.coniferous` / + `litter.deciduous` parts), and `total`. + - **calibration**: (optional) Per-parameter, per-fuel-type targets. Omit to + store raw output. + - **name**, **description**, **tags**: (optional) Standard metadata. + + ## Calibration + + Each of `fuel_load`, `fuel_depth`, and `fuel_moisture` is calibrated + independently, and within each, per fuel type (`grass`, `coniferous`, + `deciduous`, `litter`, or `all` — which is exclusive of the others). Methods: + + - `maxmin` — rescale to a target maximum and minimum. Best when fuel data + are limited, or when their distribution does not resemble DUET's. + - `meansd` — rescale to a target mean and standard deviation. Appropriate + only when the targets come from a dataset large enough to approximate a + normal distribution. + - `constant` — assign a single value. Reasonable only when that is the only + value available. + + Calibration rescales only cells that already carry fuel; cells DUET left + empty stay empty. A consequence worth expecting: where cover is sparse, the + domain-wide mean will sit well below a `meansd` target, because the target + applies to the covered cells rather than to the domain. + + ## Response + + Returns the created Grid with status `\"pending\"` and `georeference: null`. + Treevox runs DUET asynchronously and updates the grid to `\"completed\"` with + a 2D `Georeference` when done. + + Args: + domain_id (str): + body (CreateDuetRequest): Request body for creating a DUET surface fuel grid from a tree + grid. + + Does not extend CreateGridRequestBase: like the 3D grids it derives from, + DUET grids do not support modifications. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError | QuotaExceededDetail] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateDuetRequest, +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: + r"""Create a surface fuel grid with DUET + + # Create a DUET Surface Fuel Grid + + Runs DUET (Distribution of Understory using Elliptical Transport) over a 3D + tree grid to produce 2D surface fuels. DUET drops leaf and needle litter + from each tree's crown along wind-driven elliptical fall trajectories, then + grows grass as a function of shade and litter cover — so litter accumulates + under and downwind of crowns, and grass fills the gaps between them. + + ## What DUET does and does not give you + + DUET supplies the **spatial pattern** of surface fuels, keyed to real canopy + structure. It does **not** supply physical magnitudes: raw DUET loadings are + idiosyncratic to the model and should not be read as fuel loads or fed to a + fire model as-is. Use `calibration` to impose magnitudes you trust — from + field data, from the literature, or from an FBFM40 grid. + + ## Request Body + + - **source_grid_id**: (required) A completed 3D tree grid carrying the + `bulk_density.foliage.live`, `spcd`, and `fuel_moisture.live` bands. + Create one with `POST /grids/voxelize/inventory/tree`, requesting those + three bands — `spcd` in particular is not voxelized by default. + - **years_since_burn**: (required) Years of litter accumulation to simulate, + 1–100. DUET starts from the year of the last fire, with grass and litter + consumed, so this is the stand's time since fire. It is the single most + consequential parameter: a low value yields almost no litter because there + has been no time for any to fall. It also drives runtime. + - **wind_direction**: (optional) Degrees clockwise from north. Default 270. + - **wind_variability**: (optional) Angular spread in degrees. Default 30. + - **bands**: (optional) Output bands. Defaults to `fuel_load.grass` and + `fuel_load.litter`. DUET separates fuels by type rather than size class, + so bands are named for `grass`, `litter` (and its `litter.coniferous` / + `litter.deciduous` parts), and `total`. + - **calibration**: (optional) Per-parameter, per-fuel-type targets. Omit to + store raw output. + - **name**, **description**, **tags**: (optional) Standard metadata. + + ## Calibration + + Each of `fuel_load`, `fuel_depth`, and `fuel_moisture` is calibrated + independently, and within each, per fuel type (`grass`, `coniferous`, + `deciduous`, `litter`, or `all` — which is exclusive of the others). Methods: + + - `maxmin` — rescale to a target maximum and minimum. Best when fuel data + are limited, or when their distribution does not resemble DUET's. + - `meansd` — rescale to a target mean and standard deviation. Appropriate + only when the targets come from a dataset large enough to approximate a + normal distribution. + - `constant` — assign a single value. Reasonable only when that is the only + value available. + + Calibration rescales only cells that already carry fuel; cells DUET left + empty stay empty. A consequence worth expecting: where cover is sparse, the + domain-wide mean will sit well below a `meansd` target, because the target + applies to the covered cells rather than to the domain. + + ## Response + + Returns the created Grid with status `\"pending\"` and `georeference: null`. + Treevox runs DUET asynchronously and updates the grid to `\"completed\"` with + a 2D `Georeference` when done. + + Args: + domain_id (str): + body (CreateDuetRequest): Request body for creating a DUET surface fuel grid from a tree + grid. + + Does not extend CreateGridRequestBase: like the 3D grids it derives from, + DUET grids do not support modifications. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError | QuotaExceededDetail + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/grids/create_fbfm13_lookup.py b/fastfuels_sdk/v2/client_library/api/grids/create_fbfm13_lookup.py new file mode 100644 index 0000000..0985a5c --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/grids/create_fbfm13_lookup.py @@ -0,0 +1,408 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.create_fbfm_13_lookup_request import CreateFbfm13LookupRequest +from ...models.grid import Grid +from ...models.http_validation_error import HTTPValidationError +from ...models.quota_exceeded_detail import QuotaExceededDetail +from ...types import Response + + +def _get_kwargs( + domain_id: str, + *, + body: CreateFbfm13LookupRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/domains/{domain_id}/grids/lookup/fbfm13".format( + domain_id=quote(str(domain_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: + if response.status_code == 201: + response_201 = Grid.from_dict(response.json()) + + return response_201 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if response.status_code == 429: + response_429 = QuotaExceededDetail.from_dict(response.json()) + + return response_429 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateFbfm13LookupRequest, +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: + r"""Create a grid by looking up FBFM13 fuel parameters + + # Create FBFM13 Lookup Grid + + Converts Anderson 13 fuel model codes to fuel parameters using the + Anderson 13 lookup table. + + Takes a source grid containing categorical FBFM13 codes (from + `/grids/fbfm13/landfire`) and produces a new grid with the requested + continuous fuel parameters. + + ## Request Body + + - **source_grid_id**: (required) Grid containing FBFM13 codes. + - **bands**: (required) Bands to look up. Valid values: + - `fuel_load.1hr`, `fuel_load.10hr`, `fuel_load.100hr` - Dead fuel loads (kg/m**2) + - `fuel_load.live_foliage` - Live foliage fuel loads (kg/m**2) + - `savr.1hr`, `savr.10hr`, `savr.100hr` - Dead fuel SAV ratios (1/m) + - `savr.live_foliage` - Live foliage fuel SAV ratios (1/m) + - `fuel_depth` - Fuel bed depth (m) + - **source_band**: (optional) Band in source grid containing FBFM13 codes. Defaults to `\"fbfm13\"`. + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + + ## Valid FBFM13 Codes + + The source grid must contain only valid Anderson 13 fuel model codes. + The 18 valid codes are: + + - **NB** (non-burnable): 91, 92, 93, 98, 99 + - **Anderson 13 models**: 1–13 + + If any cell in the source grid contains a code not in this set (including 0 + or nodata), the job will fail with an `INVALID_FBFM_CODES` error listing + the invalid codes found. + + ## Response + + Returns the created Grid with status \"pending\". The backend applies the + lookup transformation and updates status to \"completed\" when ready. + + ## Notes + + - Domain is propagated from the source grid (derived grids carry the + same domain reference as their source). + - The output grid inherits georeference from the source grid. + - Non-burnable codes (91-99) produce zero values for all bands. + - Fuel parameter values are from Anderson, Hal E. 1982. *Aids to + determining fuel models for estimating fire behavior.* USDA Forest + Service General Technical Report INT-122. + - All output values are in metric units (converted from Anderson 13 imperial values). + + Args: + domain_id (str): + body (CreateFbfm13LookupRequest): Request to create a grid by looking up FBFM13 fuel + parameters. + + Unlike entry-point grid creation requests, domain_id is not required + because derived grids carry the same domain reference as their source. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError | QuotaExceededDetail] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateFbfm13LookupRequest, +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: + r"""Create a grid by looking up FBFM13 fuel parameters + + # Create FBFM13 Lookup Grid + + Converts Anderson 13 fuel model codes to fuel parameters using the + Anderson 13 lookup table. + + Takes a source grid containing categorical FBFM13 codes (from + `/grids/fbfm13/landfire`) and produces a new grid with the requested + continuous fuel parameters. + + ## Request Body + + - **source_grid_id**: (required) Grid containing FBFM13 codes. + - **bands**: (required) Bands to look up. Valid values: + - `fuel_load.1hr`, `fuel_load.10hr`, `fuel_load.100hr` - Dead fuel loads (kg/m**2) + - `fuel_load.live_foliage` - Live foliage fuel loads (kg/m**2) + - `savr.1hr`, `savr.10hr`, `savr.100hr` - Dead fuel SAV ratios (1/m) + - `savr.live_foliage` - Live foliage fuel SAV ratios (1/m) + - `fuel_depth` - Fuel bed depth (m) + - **source_band**: (optional) Band in source grid containing FBFM13 codes. Defaults to `\"fbfm13\"`. + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + + ## Valid FBFM13 Codes + + The source grid must contain only valid Anderson 13 fuel model codes. + The 18 valid codes are: + + - **NB** (non-burnable): 91, 92, 93, 98, 99 + - **Anderson 13 models**: 1–13 + + If any cell in the source grid contains a code not in this set (including 0 + or nodata), the job will fail with an `INVALID_FBFM_CODES` error listing + the invalid codes found. + + ## Response + + Returns the created Grid with status \"pending\". The backend applies the + lookup transformation and updates status to \"completed\" when ready. + + ## Notes + + - Domain is propagated from the source grid (derived grids carry the + same domain reference as their source). + - The output grid inherits georeference from the source grid. + - Non-burnable codes (91-99) produce zero values for all bands. + - Fuel parameter values are from Anderson, Hal E. 1982. *Aids to + determining fuel models for estimating fire behavior.* USDA Forest + Service General Technical Report INT-122. + - All output values are in metric units (converted from Anderson 13 imperial values). + + Args: + domain_id (str): + body (CreateFbfm13LookupRequest): Request to create a grid by looking up FBFM13 fuel + parameters. + + Unlike entry-point grid creation requests, domain_id is not required + because derived grids carry the same domain reference as their source. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError | QuotaExceededDetail + """ + + return sync_detailed( + domain_id=domain_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateFbfm13LookupRequest, +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: + r"""Create a grid by looking up FBFM13 fuel parameters + + # Create FBFM13 Lookup Grid + + Converts Anderson 13 fuel model codes to fuel parameters using the + Anderson 13 lookup table. + + Takes a source grid containing categorical FBFM13 codes (from + `/grids/fbfm13/landfire`) and produces a new grid with the requested + continuous fuel parameters. + + ## Request Body + + - **source_grid_id**: (required) Grid containing FBFM13 codes. + - **bands**: (required) Bands to look up. Valid values: + - `fuel_load.1hr`, `fuel_load.10hr`, `fuel_load.100hr` - Dead fuel loads (kg/m**2) + - `fuel_load.live_foliage` - Live foliage fuel loads (kg/m**2) + - `savr.1hr`, `savr.10hr`, `savr.100hr` - Dead fuel SAV ratios (1/m) + - `savr.live_foliage` - Live foliage fuel SAV ratios (1/m) + - `fuel_depth` - Fuel bed depth (m) + - **source_band**: (optional) Band in source grid containing FBFM13 codes. Defaults to `\"fbfm13\"`. + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + + ## Valid FBFM13 Codes + + The source grid must contain only valid Anderson 13 fuel model codes. + The 18 valid codes are: + + - **NB** (non-burnable): 91, 92, 93, 98, 99 + - **Anderson 13 models**: 1–13 + + If any cell in the source grid contains a code not in this set (including 0 + or nodata), the job will fail with an `INVALID_FBFM_CODES` error listing + the invalid codes found. + + ## Response + + Returns the created Grid with status \"pending\". The backend applies the + lookup transformation and updates status to \"completed\" when ready. + + ## Notes + + - Domain is propagated from the source grid (derived grids carry the + same domain reference as their source). + - The output grid inherits georeference from the source grid. + - Non-burnable codes (91-99) produce zero values for all bands. + - Fuel parameter values are from Anderson, Hal E. 1982. *Aids to + determining fuel models for estimating fire behavior.* USDA Forest + Service General Technical Report INT-122. + - All output values are in metric units (converted from Anderson 13 imperial values). + + Args: + domain_id (str): + body (CreateFbfm13LookupRequest): Request to create a grid by looking up FBFM13 fuel + parameters. + + Unlike entry-point grid creation requests, domain_id is not required + because derived grids carry the same domain reference as their source. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError | QuotaExceededDetail] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateFbfm13LookupRequest, +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: + r"""Create a grid by looking up FBFM13 fuel parameters + + # Create FBFM13 Lookup Grid + + Converts Anderson 13 fuel model codes to fuel parameters using the + Anderson 13 lookup table. + + Takes a source grid containing categorical FBFM13 codes (from + `/grids/fbfm13/landfire`) and produces a new grid with the requested + continuous fuel parameters. + + ## Request Body + + - **source_grid_id**: (required) Grid containing FBFM13 codes. + - **bands**: (required) Bands to look up. Valid values: + - `fuel_load.1hr`, `fuel_load.10hr`, `fuel_load.100hr` - Dead fuel loads (kg/m**2) + - `fuel_load.live_foliage` - Live foliage fuel loads (kg/m**2) + - `savr.1hr`, `savr.10hr`, `savr.100hr` - Dead fuel SAV ratios (1/m) + - `savr.live_foliage` - Live foliage fuel SAV ratios (1/m) + - `fuel_depth` - Fuel bed depth (m) + - **source_band**: (optional) Band in source grid containing FBFM13 codes. Defaults to `\"fbfm13\"`. + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + + ## Valid FBFM13 Codes + + The source grid must contain only valid Anderson 13 fuel model codes. + The 18 valid codes are: + + - **NB** (non-burnable): 91, 92, 93, 98, 99 + - **Anderson 13 models**: 1–13 + + If any cell in the source grid contains a code not in this set (including 0 + or nodata), the job will fail with an `INVALID_FBFM_CODES` error listing + the invalid codes found. + + ## Response + + Returns the created Grid with status \"pending\". The backend applies the + lookup transformation and updates status to \"completed\" when ready. + + ## Notes + + - Domain is propagated from the source grid (derived grids carry the + same domain reference as their source). + - The output grid inherits georeference from the source grid. + - Non-burnable codes (91-99) produce zero values for all bands. + - Fuel parameter values are from Anderson, Hal E. 1982. *Aids to + determining fuel models for estimating fire behavior.* USDA Forest + Service General Technical Report INT-122. + - All output values are in metric units (converted from Anderson 13 imperial values). + + Args: + domain_id (str): + body (CreateFbfm13LookupRequest): Request to create a grid by looking up FBFM13 fuel + parameters. + + Unlike entry-point grid creation requests, domain_id is not required + because derived grids carry the same domain reference as their source. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError | QuotaExceededDetail + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/grids/create_fbfm40_lookup.py b/fastfuels_sdk/v2/client_library/api/grids/create_fbfm40_lookup.py index cef9331..7ef9db3 100644 --- a/fastfuels_sdk/v2/client_library/api/grids/create_fbfm40_lookup.py +++ b/fastfuels_sdk/v2/client_library/api/grids/create_fbfm40_lookup.py @@ -9,6 +9,7 @@ from ...models.create_fbfm_40_lookup_request import CreateFbfm40LookupRequest from ...models.grid import Grid from ...models.http_validation_error import HTTPValidationError +from ...models.quota_exceeded_detail import QuotaExceededDetail from ...types import Response @@ -36,7 +37,7 @@ def _get_kwargs( def _parse_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Grid | HTTPValidationError | None: +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: if response.status_code == 201: response_201 = Grid.from_dict(response.json()) @@ -47,6 +48,11 @@ def _parse_response( return response_422 + if response.status_code == 429: + response_429 = QuotaExceededDetail.from_dict(response.json()) + + return response_429 + if client.raise_on_unexpected_status: raise errors.UnexpectedStatus(response.status_code, response.content) else: @@ -55,7 +61,7 @@ def _parse_response( def _build_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[Grid | HTTPValidationError]: +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: return Response( status_code=HTTPStatus(response.status_code), content=response.content, @@ -69,7 +75,7 @@ def sync_detailed( *, client: AuthenticatedClient, body: CreateFbfm40LookupRequest, -) -> Response[Grid | HTTPValidationError]: +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: r"""Create a grid by looking up FBFM40 fuel parameters # Create FBFM40 Lookup Grid @@ -138,7 +144,7 @@ def sync_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Grid | HTTPValidationError] + Response[Grid | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -158,7 +164,7 @@ def sync( *, client: AuthenticatedClient, body: CreateFbfm40LookupRequest, -) -> Grid | HTTPValidationError | None: +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: r"""Create a grid by looking up FBFM40 fuel parameters # Create FBFM40 Lookup Grid @@ -227,7 +233,7 @@ def sync( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Grid | HTTPValidationError + Grid | HTTPValidationError | QuotaExceededDetail """ return sync_detailed( @@ -242,7 +248,7 @@ async def asyncio_detailed( *, client: AuthenticatedClient, body: CreateFbfm40LookupRequest, -) -> Response[Grid | HTTPValidationError]: +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: r"""Create a grid by looking up FBFM40 fuel parameters # Create FBFM40 Lookup Grid @@ -311,7 +317,7 @@ async def asyncio_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Grid | HTTPValidationError] + Response[Grid | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -329,7 +335,7 @@ async def asyncio( *, client: AuthenticatedClient, body: CreateFbfm40LookupRequest, -) -> Grid | HTTPValidationError | None: +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: r"""Create a grid by looking up FBFM40 fuel parameters # Create FBFM40 Lookup Grid @@ -398,7 +404,7 @@ async def asyncio( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Grid | HTTPValidationError + Grid | HTTPValidationError | QuotaExceededDetail """ return ( diff --git a/fastfuels_sdk/v2/client_library/api/grids/create_fccs_lookup.py b/fastfuels_sdk/v2/client_library/api/grids/create_fccs_lookup.py new file mode 100644 index 0000000..d38fc88 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/grids/create_fccs_lookup.py @@ -0,0 +1,464 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.create_fccs_lookup_request import CreateFccsLookupRequest +from ...models.grid import Grid +from ...models.http_validation_error import HTTPValidationError +from ...models.quota_exceeded_detail import QuotaExceededDetail +from ...types import Response + + +def _get_kwargs( + domain_id: str, + *, + body: CreateFccsLookupRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/domains/{domain_id}/grids/lookup/fccs".format( + domain_id=quote(str(domain_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: + if response.status_code == 201: + response_201 = Grid.from_dict(response.json()) + + return response_201 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if response.status_code == 429: + response_429 = QuotaExceededDetail.from_dict(response.json()) + + return response_429 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateFccsLookupRequest, +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: + r"""Create a grid by looking up FCCS fuel parameters + + # Create FCCS Lookup Grid + + Converts FCCS fuelbed codes to fuel parameters using the FOFEM FCCS + fuelbed lookup table (see the + [FOFEM/SpatialFOFEM FCCS lookup table](https://www.landfire.gov/sites/default/files/CSV/SpatialFOFEM + _FCCS_Formatted_TS_06-27-24.csv), + the USDA Forest Service data source this endpoint converts). + + Takes a source grid containing categorical FCCS codes (from + `/grids/fccs/landfire`) and produces a new grid with the requested + continuous fuel parameters. + + ## Request Body + + - **source_grid_id**: (required) Grid containing FCCS codes. + - **bands**: (required) Bands to look up. Valid values: + - `fuel_load.litter`, `fuel_load.duff` - Ground fuel loads (kg/m**2) + - `duff_depth` - Duff layer depth (m) + - `fuel_load.live_shrub`, `fuel_load.live_herb` - Live surface fuel loads (kg/m**2) + - `fuel_load.1hr`, `fuel_load.10hr`, `fuel_load.100hr` - Dead fuel loads (kg/m**2) + - `fuel_load.1000hr_sound`, `fuel_load.1000hr_rotten` - Dead fuel loads, >3in. diameter (kg/m**2) + - `fuel_load.live_foliage`, `fuel_load.live_branch` - Live crown fuel loads (kg/m**2) + - **source_band**: (optional) Band in source grid containing FCCS codes. Defaults to `\"fccs\"`. + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + + ## Band Coverage + + These 12 bands are a starting subset of what FOFEM provides, not the + full table. `fuel_load.1000hr_sound` and `fuel_load.1000hr_rotten` + are each calculated by summing three FOFEM size-class columns + (3-9 in., 9-20 in., 20+ in.) rather than mapping to a single source + column. FOFEM also provides finer sound/rotten size-class + breakdowns, a cover-group code, and emission factors that aren't + exposed as bands here — additional bands can be added on request. + + ## Valid FCCS Codes + + Each `FCCS` code is a synthetic key: `base * 10_000 + suffix`, where + `base` is the `FCCSID` fuelbed number and the 3-digit `suffix` + encodes an FCCS Potential rating (Fire Behavior / Crown Fire / + Available Fuel Potential, each a 0-9 digit) per the [Fuel Characteristic + Classification System Version 3.0: Technical Documentation (PNW- + GTR-887)](https://www.fs.usda.gov/pnw/pubs/pnw_gtr887.pdf). + + The source grid must contain FCCS codes whose base fuelbed number matches + a recognized `FCCSID`. A code with a valid base but no matching row in the + FOFEM lookup table is not an error. It's a fuelbed/fire-potential combination + the table doesn't cover, so its output is `NaN` for every band, and a + progress warning lists these codes. + + ## Response + + Returns the created Grid with status \"pending\". The backend applies the + lookup transformation and updates status to \"completed\" when ready. + + ## Notes + + - Domain is propagated from the source grid (derived grids carry the + same domain reference as their source). + - The output grid inherits georeference from the source grid. + - All output values are in metric units (converted from FOFEM imperial + values). + + Args: + domain_id (str): + body (CreateFccsLookupRequest): Request to create a grid by looking up FCCS fuel + parameters. + + Unlike entry-point grid creation requests, domain_id is not required + because derived grids carry the same domain reference as their source. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError | QuotaExceededDetail] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateFccsLookupRequest, +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: + r"""Create a grid by looking up FCCS fuel parameters + + # Create FCCS Lookup Grid + + Converts FCCS fuelbed codes to fuel parameters using the FOFEM FCCS + fuelbed lookup table (see the + [FOFEM/SpatialFOFEM FCCS lookup table](https://www.landfire.gov/sites/default/files/CSV/SpatialFOFEM + _FCCS_Formatted_TS_06-27-24.csv), + the USDA Forest Service data source this endpoint converts). + + Takes a source grid containing categorical FCCS codes (from + `/grids/fccs/landfire`) and produces a new grid with the requested + continuous fuel parameters. + + ## Request Body + + - **source_grid_id**: (required) Grid containing FCCS codes. + - **bands**: (required) Bands to look up. Valid values: + - `fuel_load.litter`, `fuel_load.duff` - Ground fuel loads (kg/m**2) + - `duff_depth` - Duff layer depth (m) + - `fuel_load.live_shrub`, `fuel_load.live_herb` - Live surface fuel loads (kg/m**2) + - `fuel_load.1hr`, `fuel_load.10hr`, `fuel_load.100hr` - Dead fuel loads (kg/m**2) + - `fuel_load.1000hr_sound`, `fuel_load.1000hr_rotten` - Dead fuel loads, >3in. diameter (kg/m**2) + - `fuel_load.live_foliage`, `fuel_load.live_branch` - Live crown fuel loads (kg/m**2) + - **source_band**: (optional) Band in source grid containing FCCS codes. Defaults to `\"fccs\"`. + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + + ## Band Coverage + + These 12 bands are a starting subset of what FOFEM provides, not the + full table. `fuel_load.1000hr_sound` and `fuel_load.1000hr_rotten` + are each calculated by summing three FOFEM size-class columns + (3-9 in., 9-20 in., 20+ in.) rather than mapping to a single source + column. FOFEM also provides finer sound/rotten size-class + breakdowns, a cover-group code, and emission factors that aren't + exposed as bands here — additional bands can be added on request. + + ## Valid FCCS Codes + + Each `FCCS` code is a synthetic key: `base * 10_000 + suffix`, where + `base` is the `FCCSID` fuelbed number and the 3-digit `suffix` + encodes an FCCS Potential rating (Fire Behavior / Crown Fire / + Available Fuel Potential, each a 0-9 digit) per the [Fuel Characteristic + Classification System Version 3.0: Technical Documentation (PNW- + GTR-887)](https://www.fs.usda.gov/pnw/pubs/pnw_gtr887.pdf). + + The source grid must contain FCCS codes whose base fuelbed number matches + a recognized `FCCSID`. A code with a valid base but no matching row in the + FOFEM lookup table is not an error. It's a fuelbed/fire-potential combination + the table doesn't cover, so its output is `NaN` for every band, and a + progress warning lists these codes. + + ## Response + + Returns the created Grid with status \"pending\". The backend applies the + lookup transformation and updates status to \"completed\" when ready. + + ## Notes + + - Domain is propagated from the source grid (derived grids carry the + same domain reference as their source). + - The output grid inherits georeference from the source grid. + - All output values are in metric units (converted from FOFEM imperial + values). + + Args: + domain_id (str): + body (CreateFccsLookupRequest): Request to create a grid by looking up FCCS fuel + parameters. + + Unlike entry-point grid creation requests, domain_id is not required + because derived grids carry the same domain reference as their source. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError | QuotaExceededDetail + """ + + return sync_detailed( + domain_id=domain_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateFccsLookupRequest, +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: + r"""Create a grid by looking up FCCS fuel parameters + + # Create FCCS Lookup Grid + + Converts FCCS fuelbed codes to fuel parameters using the FOFEM FCCS + fuelbed lookup table (see the + [FOFEM/SpatialFOFEM FCCS lookup table](https://www.landfire.gov/sites/default/files/CSV/SpatialFOFEM + _FCCS_Formatted_TS_06-27-24.csv), + the USDA Forest Service data source this endpoint converts). + + Takes a source grid containing categorical FCCS codes (from + `/grids/fccs/landfire`) and produces a new grid with the requested + continuous fuel parameters. + + ## Request Body + + - **source_grid_id**: (required) Grid containing FCCS codes. + - **bands**: (required) Bands to look up. Valid values: + - `fuel_load.litter`, `fuel_load.duff` - Ground fuel loads (kg/m**2) + - `duff_depth` - Duff layer depth (m) + - `fuel_load.live_shrub`, `fuel_load.live_herb` - Live surface fuel loads (kg/m**2) + - `fuel_load.1hr`, `fuel_load.10hr`, `fuel_load.100hr` - Dead fuel loads (kg/m**2) + - `fuel_load.1000hr_sound`, `fuel_load.1000hr_rotten` - Dead fuel loads, >3in. diameter (kg/m**2) + - `fuel_load.live_foliage`, `fuel_load.live_branch` - Live crown fuel loads (kg/m**2) + - **source_band**: (optional) Band in source grid containing FCCS codes. Defaults to `\"fccs\"`. + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + + ## Band Coverage + + These 12 bands are a starting subset of what FOFEM provides, not the + full table. `fuel_load.1000hr_sound` and `fuel_load.1000hr_rotten` + are each calculated by summing three FOFEM size-class columns + (3-9 in., 9-20 in., 20+ in.) rather than mapping to a single source + column. FOFEM also provides finer sound/rotten size-class + breakdowns, a cover-group code, and emission factors that aren't + exposed as bands here — additional bands can be added on request. + + ## Valid FCCS Codes + + Each `FCCS` code is a synthetic key: `base * 10_000 + suffix`, where + `base` is the `FCCSID` fuelbed number and the 3-digit `suffix` + encodes an FCCS Potential rating (Fire Behavior / Crown Fire / + Available Fuel Potential, each a 0-9 digit) per the [Fuel Characteristic + Classification System Version 3.0: Technical Documentation (PNW- + GTR-887)](https://www.fs.usda.gov/pnw/pubs/pnw_gtr887.pdf). + + The source grid must contain FCCS codes whose base fuelbed number matches + a recognized `FCCSID`. A code with a valid base but no matching row in the + FOFEM lookup table is not an error. It's a fuelbed/fire-potential combination + the table doesn't cover, so its output is `NaN` for every band, and a + progress warning lists these codes. + + ## Response + + Returns the created Grid with status \"pending\". The backend applies the + lookup transformation and updates status to \"completed\" when ready. + + ## Notes + + - Domain is propagated from the source grid (derived grids carry the + same domain reference as their source). + - The output grid inherits georeference from the source grid. + - All output values are in metric units (converted from FOFEM imperial + values). + + Args: + domain_id (str): + body (CreateFccsLookupRequest): Request to create a grid by looking up FCCS fuel + parameters. + + Unlike entry-point grid creation requests, domain_id is not required + because derived grids carry the same domain reference as their source. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError | QuotaExceededDetail] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateFccsLookupRequest, +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: + r"""Create a grid by looking up FCCS fuel parameters + + # Create FCCS Lookup Grid + + Converts FCCS fuelbed codes to fuel parameters using the FOFEM FCCS + fuelbed lookup table (see the + [FOFEM/SpatialFOFEM FCCS lookup table](https://www.landfire.gov/sites/default/files/CSV/SpatialFOFEM + _FCCS_Formatted_TS_06-27-24.csv), + the USDA Forest Service data source this endpoint converts). + + Takes a source grid containing categorical FCCS codes (from + `/grids/fccs/landfire`) and produces a new grid with the requested + continuous fuel parameters. + + ## Request Body + + - **source_grid_id**: (required) Grid containing FCCS codes. + - **bands**: (required) Bands to look up. Valid values: + - `fuel_load.litter`, `fuel_load.duff` - Ground fuel loads (kg/m**2) + - `duff_depth` - Duff layer depth (m) + - `fuel_load.live_shrub`, `fuel_load.live_herb` - Live surface fuel loads (kg/m**2) + - `fuel_load.1hr`, `fuel_load.10hr`, `fuel_load.100hr` - Dead fuel loads (kg/m**2) + - `fuel_load.1000hr_sound`, `fuel_load.1000hr_rotten` - Dead fuel loads, >3in. diameter (kg/m**2) + - `fuel_load.live_foliage`, `fuel_load.live_branch` - Live crown fuel loads (kg/m**2) + - **source_band**: (optional) Band in source grid containing FCCS codes. Defaults to `\"fccs\"`. + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + + ## Band Coverage + + These 12 bands are a starting subset of what FOFEM provides, not the + full table. `fuel_load.1000hr_sound` and `fuel_load.1000hr_rotten` + are each calculated by summing three FOFEM size-class columns + (3-9 in., 9-20 in., 20+ in.) rather than mapping to a single source + column. FOFEM also provides finer sound/rotten size-class + breakdowns, a cover-group code, and emission factors that aren't + exposed as bands here — additional bands can be added on request. + + ## Valid FCCS Codes + + Each `FCCS` code is a synthetic key: `base * 10_000 + suffix`, where + `base` is the `FCCSID` fuelbed number and the 3-digit `suffix` + encodes an FCCS Potential rating (Fire Behavior / Crown Fire / + Available Fuel Potential, each a 0-9 digit) per the [Fuel Characteristic + Classification System Version 3.0: Technical Documentation (PNW- + GTR-887)](https://www.fs.usda.gov/pnw/pubs/pnw_gtr887.pdf). + + The source grid must contain FCCS codes whose base fuelbed number matches + a recognized `FCCSID`. A code with a valid base but no matching row in the + FOFEM lookup table is not an error. It's a fuelbed/fire-potential combination + the table doesn't cover, so its output is `NaN` for every band, and a + progress warning lists these codes. + + ## Response + + Returns the created Grid with status \"pending\". The backend applies the + lookup transformation and updates status to \"completed\" when ready. + + ## Notes + + - Domain is propagated from the source grid (derived grids carry the + same domain reference as their source). + - The output grid inherits georeference from the source grid. + - All output values are in metric units (converted from FOFEM imperial + values). + + Args: + domain_id (str): + body (CreateFccsLookupRequest): Request to create a grid by looking up FCCS fuel + parameters. + + Unlike entry-point grid creation requests, domain_id is not required + because derived grids carry the same domain reference as their source. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError | QuotaExceededDetail + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/grids/create_geotiff_upload.py b/fastfuels_sdk/v2/client_library/api/grids/create_geotiff_upload.py index 6c615fa..3a64c1a 100644 --- a/fastfuels_sdk/v2/client_library/api/grids/create_geotiff_upload.py +++ b/fastfuels_sdk/v2/client_library/api/grids/create_geotiff_upload.py @@ -9,6 +9,7 @@ from ...models.create_geo_tiff_upload_request import CreateGeoTIFFUploadRequest from ...models.grid_upload_created_response import GridUploadCreatedResponse from ...models.http_validation_error import HTTPValidationError +from ...models.quota_exceeded_detail import QuotaExceededDetail from ...types import Response @@ -36,7 +37,7 @@ def _get_kwargs( def _parse_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> GridUploadCreatedResponse | HTTPValidationError | None: +) -> GridUploadCreatedResponse | HTTPValidationError | QuotaExceededDetail | None: if response.status_code == 201: response_201 = GridUploadCreatedResponse.from_dict(response.json()) @@ -47,6 +48,11 @@ def _parse_response( return response_422 + if response.status_code == 429: + response_429 = QuotaExceededDetail.from_dict(response.json()) + + return response_429 + if client.raise_on_unexpected_status: raise errors.UnexpectedStatus(response.status_code, response.content) else: @@ -55,7 +61,7 @@ def _parse_response( def _build_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[GridUploadCreatedResponse | HTTPValidationError]: +) -> Response[GridUploadCreatedResponse | HTTPValidationError | QuotaExceededDetail]: return Response( status_code=HTTPStatus(response.status_code), content=response.content, @@ -69,7 +75,7 @@ def sync_detailed( *, client: AuthenticatedClient, body: CreateGeoTIFFUploadRequest, -) -> Response[GridUploadCreatedResponse | HTTPValidationError]: +) -> Response[GridUploadCreatedResponse | HTTPValidationError | QuotaExceededDetail]: r"""Create a grid from a direct GeoTIFF upload # Create Upload Grid (GeoTIFF) @@ -120,7 +126,7 @@ def sync_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[GridUploadCreatedResponse | HTTPValidationError] + Response[GridUploadCreatedResponse | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -140,7 +146,7 @@ def sync( *, client: AuthenticatedClient, body: CreateGeoTIFFUploadRequest, -) -> GridUploadCreatedResponse | HTTPValidationError | None: +) -> GridUploadCreatedResponse | HTTPValidationError | QuotaExceededDetail | None: r"""Create a grid from a direct GeoTIFF upload # Create Upload Grid (GeoTIFF) @@ -191,7 +197,7 @@ def sync( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - GridUploadCreatedResponse | HTTPValidationError + GridUploadCreatedResponse | HTTPValidationError | QuotaExceededDetail """ return sync_detailed( @@ -206,7 +212,7 @@ async def asyncio_detailed( *, client: AuthenticatedClient, body: CreateGeoTIFFUploadRequest, -) -> Response[GridUploadCreatedResponse | HTTPValidationError]: +) -> Response[GridUploadCreatedResponse | HTTPValidationError | QuotaExceededDetail]: r"""Create a grid from a direct GeoTIFF upload # Create Upload Grid (GeoTIFF) @@ -257,7 +263,7 @@ async def asyncio_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[GridUploadCreatedResponse | HTTPValidationError] + Response[GridUploadCreatedResponse | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -275,7 +281,7 @@ async def asyncio( *, client: AuthenticatedClient, body: CreateGeoTIFFUploadRequest, -) -> GridUploadCreatedResponse | HTTPValidationError | None: +) -> GridUploadCreatedResponse | HTTPValidationError | QuotaExceededDetail | None: r"""Create a grid from a direct GeoTIFF upload # Create Upload Grid (GeoTIFF) @@ -326,7 +332,7 @@ async def asyncio( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - GridUploadCreatedResponse | HTTPValidationError + GridUploadCreatedResponse | HTTPValidationError | QuotaExceededDetail """ return ( diff --git a/fastfuels_sdk/v2/client_library/api/grids/create_grid_export.py b/fastfuels_sdk/v2/client_library/api/grids/create_grid_export.py index 6f83cc9..090de14 100644 --- a/fastfuels_sdk/v2/client_library/api/grids/create_grid_export.py +++ b/fastfuels_sdk/v2/client_library/api/grids/create_grid_export.py @@ -10,6 +10,7 @@ from ...models.export_grid_request import ExportGridRequest from ...models.grid_export_format import GridExportFormat from ...models.http_validation_error import HTTPValidationError +from ...models.quota_exceeded_detail import QuotaExceededDetail from ...types import Response @@ -41,7 +42,7 @@ def _get_kwargs( def _parse_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Export | HTTPValidationError | None: +) -> Export | HTTPValidationError | QuotaExceededDetail | None: if response.status_code == 201: response_201 = Export.from_dict(response.json()) @@ -52,6 +53,11 @@ def _parse_response( return response_422 + if response.status_code == 429: + response_429 = QuotaExceededDetail.from_dict(response.json()) + + return response_429 + if client.raise_on_unexpected_status: raise errors.UnexpectedStatus(response.status_code, response.content) else: @@ -60,7 +66,7 @@ def _parse_response( def _build_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[Export | HTTPValidationError]: +) -> Response[Export | HTTPValidationError | QuotaExceededDetail]: return Response( status_code=HTTPStatus(response.status_code), content=response.content, @@ -76,7 +82,7 @@ def sync_detailed( *, client: AuthenticatedClient, body: ExportGridRequest, -) -> Response[Export | HTTPValidationError]: +) -> Response[Export | HTTPValidationError | QuotaExceededDetail]: """Export a grid Export a grid to the specified format. @@ -106,7 +112,7 @@ def sync_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Export | HTTPValidationError] + Response[Export | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -130,7 +136,7 @@ def sync( *, client: AuthenticatedClient, body: ExportGridRequest, -) -> Export | HTTPValidationError | None: +) -> Export | HTTPValidationError | QuotaExceededDetail | None: """Export a grid Export a grid to the specified format. @@ -160,7 +166,7 @@ def sync( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Export | HTTPValidationError + Export | HTTPValidationError | QuotaExceededDetail """ return sync_detailed( @@ -179,7 +185,7 @@ async def asyncio_detailed( *, client: AuthenticatedClient, body: ExportGridRequest, -) -> Response[Export | HTTPValidationError]: +) -> Response[Export | HTTPValidationError | QuotaExceededDetail]: """Export a grid Export a grid to the specified format. @@ -209,7 +215,7 @@ async def asyncio_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Export | HTTPValidationError] + Response[Export | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -231,7 +237,7 @@ async def asyncio( *, client: AuthenticatedClient, body: ExportGridRequest, -) -> Export | HTTPValidationError | None: +) -> Export | HTTPValidationError | QuotaExceededDetail | None: """Export a grid Export a grid to the specified format. @@ -261,7 +267,7 @@ async def asyncio( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Export | HTTPValidationError + Export | HTTPValidationError | QuotaExceededDetail """ return ( diff --git a/fastfuels_sdk/v2/client_library/api/grids/create_landfire_canopy.py b/fastfuels_sdk/v2/client_library/api/grids/create_landfire_canopy.py index 9032b97..57122c7 100644 --- a/fastfuels_sdk/v2/client_library/api/grids/create_landfire_canopy.py +++ b/fastfuels_sdk/v2/client_library/api/grids/create_landfire_canopy.py @@ -9,6 +9,7 @@ from ...models.create_landfire_canopy_request import CreateLandfireCanopyRequest from ...models.grid import Grid from ...models.http_validation_error import HTTPValidationError +from ...models.quota_exceeded_detail import QuotaExceededDetail from ...types import Response @@ -36,7 +37,7 @@ def _get_kwargs( def _parse_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Grid | HTTPValidationError | None: +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: if response.status_code == 201: response_201 = Grid.from_dict(response.json()) @@ -47,6 +48,11 @@ def _parse_response( return response_422 + if response.status_code == 429: + response_429 = QuotaExceededDetail.from_dict(response.json()) + + return response_429 + if client.raise_on_unexpected_status: raise errors.UnexpectedStatus(response.status_code, response.content) else: @@ -55,7 +61,7 @@ def _parse_response( def _build_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[Grid | HTTPValidationError]: +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: return Response( status_code=HTTPStatus(response.status_code), content=response.content, @@ -69,7 +75,7 @@ def sync_detailed( *, client: AuthenticatedClient, body: CreateLandfireCanopyRequest, -) -> Response[Grid | HTTPValidationError]: +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: r"""Create a grid from LANDFIRE canopy data # Create LANDFIRE Canopy Grid @@ -118,7 +124,7 @@ def sync_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Grid | HTTPValidationError] + Response[Grid | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -138,7 +144,7 @@ def sync( *, client: AuthenticatedClient, body: CreateLandfireCanopyRequest, -) -> Grid | HTTPValidationError | None: +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: r"""Create a grid from LANDFIRE canopy data # Create LANDFIRE Canopy Grid @@ -187,7 +193,7 @@ def sync( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Grid | HTTPValidationError + Grid | HTTPValidationError | QuotaExceededDetail """ return sync_detailed( @@ -202,7 +208,7 @@ async def asyncio_detailed( *, client: AuthenticatedClient, body: CreateLandfireCanopyRequest, -) -> Response[Grid | HTTPValidationError]: +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: r"""Create a grid from LANDFIRE canopy data # Create LANDFIRE Canopy Grid @@ -251,7 +257,7 @@ async def asyncio_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Grid | HTTPValidationError] + Response[Grid | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -269,7 +275,7 @@ async def asyncio( *, client: AuthenticatedClient, body: CreateLandfireCanopyRequest, -) -> Grid | HTTPValidationError | None: +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: r"""Create a grid from LANDFIRE canopy data # Create LANDFIRE Canopy Grid @@ -318,7 +324,7 @@ async def asyncio( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Grid | HTTPValidationError + Grid | HTTPValidationError | QuotaExceededDetail """ return ( diff --git a/fastfuels_sdk/v2/client_library/api/grids/create_landfire_fbfm13.py b/fastfuels_sdk/v2/client_library/api/grids/create_landfire_fbfm13.py new file mode 100644 index 0000000..5e5c6b9 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/grids/create_landfire_fbfm13.py @@ -0,0 +1,288 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.create_landfire_fbfm_13_request import CreateLandfireFbfm13Request +from ...models.grid import Grid +from ...models.http_validation_error import HTTPValidationError +from ...models.quota_exceeded_detail import QuotaExceededDetail +from ...types import Response + + +def _get_kwargs( + domain_id: str, + *, + body: CreateLandfireFbfm13Request, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/domains/{domain_id}/grids/fbfm13/landfire".format( + domain_id=quote(str(domain_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: + if response.status_code == 201: + response_201 = Grid.from_dict(response.json()) + + return response_201 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if response.status_code == 429: + response_429 = QuotaExceededDetail.from_dict(response.json()) + + return response_429 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateLandfireFbfm13Request, +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: + r"""Create a grid from LANDFIRE FBFM13 + + # Create LANDFIRE FBFM13 Grid + + Creates a grid with FBFM13 fuel model codes from LANDFIRE at 30m resolution. + + The grid contains a single categorical band (`fbfm13`) with Anderson 13 + fuel model codes (1-13). + + To convert fuel model codes to fuel parameters (fuel loads, SAV, + depth), use the `/grids/lookup/fbfm13` endpoint. + + ## Request Body + + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + - **version**: (optional) LANDFIRE version. Default: \"2024\". + + ## Response + + Returns the created Grid resource with status \"pending\". The backend will + fetch the data and update status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreateLandfireFbfm13Request): Request to create a grid from LANDFIRE FBFM13. + + Returns a single-band grid with categorical fuel model codes. + To convert codes to fuel parameters, use /grids/lookup/fbfm13. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError | QuotaExceededDetail] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateLandfireFbfm13Request, +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: + r"""Create a grid from LANDFIRE FBFM13 + + # Create LANDFIRE FBFM13 Grid + + Creates a grid with FBFM13 fuel model codes from LANDFIRE at 30m resolution. + + The grid contains a single categorical band (`fbfm13`) with Anderson 13 + fuel model codes (1-13). + + To convert fuel model codes to fuel parameters (fuel loads, SAV, + depth), use the `/grids/lookup/fbfm13` endpoint. + + ## Request Body + + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + - **version**: (optional) LANDFIRE version. Default: \"2024\". + + ## Response + + Returns the created Grid resource with status \"pending\". The backend will + fetch the data and update status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreateLandfireFbfm13Request): Request to create a grid from LANDFIRE FBFM13. + + Returns a single-band grid with categorical fuel model codes. + To convert codes to fuel parameters, use /grids/lookup/fbfm13. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError | QuotaExceededDetail + """ + + return sync_detailed( + domain_id=domain_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateLandfireFbfm13Request, +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: + r"""Create a grid from LANDFIRE FBFM13 + + # Create LANDFIRE FBFM13 Grid + + Creates a grid with FBFM13 fuel model codes from LANDFIRE at 30m resolution. + + The grid contains a single categorical band (`fbfm13`) with Anderson 13 + fuel model codes (1-13). + + To convert fuel model codes to fuel parameters (fuel loads, SAV, + depth), use the `/grids/lookup/fbfm13` endpoint. + + ## Request Body + + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + - **version**: (optional) LANDFIRE version. Default: \"2024\". + + ## Response + + Returns the created Grid resource with status \"pending\". The backend will + fetch the data and update status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreateLandfireFbfm13Request): Request to create a grid from LANDFIRE FBFM13. + + Returns a single-band grid with categorical fuel model codes. + To convert codes to fuel parameters, use /grids/lookup/fbfm13. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError | QuotaExceededDetail] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateLandfireFbfm13Request, +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: + r"""Create a grid from LANDFIRE FBFM13 + + # Create LANDFIRE FBFM13 Grid + + Creates a grid with FBFM13 fuel model codes from LANDFIRE at 30m resolution. + + The grid contains a single categorical band (`fbfm13`) with Anderson 13 + fuel model codes (1-13). + + To convert fuel model codes to fuel parameters (fuel loads, SAV, + depth), use the `/grids/lookup/fbfm13` endpoint. + + ## Request Body + + - **name**: (optional) Name for the grid. + - **description**: (optional) Description. + - **tags**: (optional) Tags for organizing grids. + - **version**: (optional) LANDFIRE version. Default: \"2024\". + + ## Response + + Returns the created Grid resource with status \"pending\". The backend will + fetch the data and update status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreateLandfireFbfm13Request): Request to create a grid from LANDFIRE FBFM13. + + Returns a single-band grid with categorical fuel model codes. + To convert codes to fuel parameters, use /grids/lookup/fbfm13. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError | QuotaExceededDetail + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/grids/create_landfire_fbfm40.py b/fastfuels_sdk/v2/client_library/api/grids/create_landfire_fbfm40.py index dfffb44..c873764 100644 --- a/fastfuels_sdk/v2/client_library/api/grids/create_landfire_fbfm40.py +++ b/fastfuels_sdk/v2/client_library/api/grids/create_landfire_fbfm40.py @@ -9,6 +9,7 @@ from ...models.create_landfire_fbfm_40_request import CreateLandfireFbfm40Request from ...models.grid import Grid from ...models.http_validation_error import HTTPValidationError +from ...models.quota_exceeded_detail import QuotaExceededDetail from ...types import Response @@ -36,7 +37,7 @@ def _get_kwargs( def _parse_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Grid | HTTPValidationError | None: +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: if response.status_code == 201: response_201 = Grid.from_dict(response.json()) @@ -47,6 +48,11 @@ def _parse_response( return response_422 + if response.status_code == 429: + response_429 = QuotaExceededDetail.from_dict(response.json()) + + return response_429 + if client.raise_on_unexpected_status: raise errors.UnexpectedStatus(response.status_code, response.content) else: @@ -55,7 +61,7 @@ def _parse_response( def _build_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[Grid | HTTPValidationError]: +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: return Response( status_code=HTTPStatus(response.status_code), content=response.content, @@ -69,7 +75,7 @@ def sync_detailed( *, client: AuthenticatedClient, body: CreateLandfireFbfm40Request, -) -> Response[Grid | HTTPValidationError]: +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: r"""Create a grid from LANDFIRE FBFM40 # Create LANDFIRE FBFM40 Grid @@ -106,7 +112,7 @@ def sync_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Grid | HTTPValidationError] + Response[Grid | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -126,7 +132,7 @@ def sync( *, client: AuthenticatedClient, body: CreateLandfireFbfm40Request, -) -> Grid | HTTPValidationError | None: +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: r"""Create a grid from LANDFIRE FBFM40 # Create LANDFIRE FBFM40 Grid @@ -163,7 +169,7 @@ def sync( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Grid | HTTPValidationError + Grid | HTTPValidationError | QuotaExceededDetail """ return sync_detailed( @@ -178,7 +184,7 @@ async def asyncio_detailed( *, client: AuthenticatedClient, body: CreateLandfireFbfm40Request, -) -> Response[Grid | HTTPValidationError]: +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: r"""Create a grid from LANDFIRE FBFM40 # Create LANDFIRE FBFM40 Grid @@ -215,7 +221,7 @@ async def asyncio_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Grid | HTTPValidationError] + Response[Grid | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -233,7 +239,7 @@ async def asyncio( *, client: AuthenticatedClient, body: CreateLandfireFbfm40Request, -) -> Grid | HTTPValidationError | None: +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: r"""Create a grid from LANDFIRE FBFM40 # Create LANDFIRE FBFM40 Grid @@ -270,7 +276,7 @@ async def asyncio( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Grid | HTTPValidationError + Grid | HTTPValidationError | QuotaExceededDetail """ return ( diff --git a/fastfuels_sdk/v2/client_library/api/grids/create_landfire_fccs.py b/fastfuels_sdk/v2/client_library/api/grids/create_landfire_fccs.py index 3ae3c88..9c254d9 100644 --- a/fastfuels_sdk/v2/client_library/api/grids/create_landfire_fccs.py +++ b/fastfuels_sdk/v2/client_library/api/grids/create_landfire_fccs.py @@ -9,6 +9,7 @@ from ...models.create_landfire_fccs_request import CreateLandfireFccsRequest from ...models.grid import Grid from ...models.http_validation_error import HTTPValidationError +from ...models.quota_exceeded_detail import QuotaExceededDetail from ...types import Response @@ -36,7 +37,7 @@ def _get_kwargs( def _parse_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Grid | HTTPValidationError | None: +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: if response.status_code == 201: response_201 = Grid.from_dict(response.json()) @@ -47,6 +48,11 @@ def _parse_response( return response_422 + if response.status_code == 429: + response_429 = QuotaExceededDetail.from_dict(response.json()) + + return response_429 + if client.raise_on_unexpected_status: raise errors.UnexpectedStatus(response.status_code, response.content) else: @@ -55,7 +61,7 @@ def _parse_response( def _build_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[Grid | HTTPValidationError]: +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: return Response( status_code=HTTPStatus(response.status_code), content=response.content, @@ -69,7 +75,7 @@ def sync_detailed( *, client: AuthenticatedClient, body: CreateLandfireFccsRequest, -) -> Response[Grid | HTTPValidationError]: +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: r"""Create a grid from LANDFIRE FCCS # Create LANDFIRE FCCS Grid @@ -108,7 +114,7 @@ def sync_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Grid | HTTPValidationError] + Response[Grid | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -128,7 +134,7 @@ def sync( *, client: AuthenticatedClient, body: CreateLandfireFccsRequest, -) -> Grid | HTTPValidationError | None: +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: r"""Create a grid from LANDFIRE FCCS # Create LANDFIRE FCCS Grid @@ -167,7 +173,7 @@ def sync( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Grid | HTTPValidationError + Grid | HTTPValidationError | QuotaExceededDetail """ return sync_detailed( @@ -182,7 +188,7 @@ async def asyncio_detailed( *, client: AuthenticatedClient, body: CreateLandfireFccsRequest, -) -> Response[Grid | HTTPValidationError]: +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: r"""Create a grid from LANDFIRE FCCS # Create LANDFIRE FCCS Grid @@ -221,7 +227,7 @@ async def asyncio_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Grid | HTTPValidationError] + Response[Grid | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -239,7 +245,7 @@ async def asyncio( *, client: AuthenticatedClient, body: CreateLandfireFccsRequest, -) -> Grid | HTTPValidationError | None: +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: r"""Create a grid from LANDFIRE FCCS # Create LANDFIRE FCCS Grid @@ -278,7 +284,7 @@ async def asyncio( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Grid | HTTPValidationError + Grid | HTTPValidationError | QuotaExceededDetail """ return ( diff --git a/fastfuels_sdk/v2/client_library/api/grids/create_landfire_topography.py b/fastfuels_sdk/v2/client_library/api/grids/create_landfire_topography.py index 4b6240d..824da4c 100644 --- a/fastfuels_sdk/v2/client_library/api/grids/create_landfire_topography.py +++ b/fastfuels_sdk/v2/client_library/api/grids/create_landfire_topography.py @@ -9,6 +9,7 @@ from ...models.create_landfire_topography_request import CreateLandfireTopographyRequest from ...models.grid import Grid from ...models.http_validation_error import HTTPValidationError +from ...models.quota_exceeded_detail import QuotaExceededDetail from ...types import Response @@ -36,7 +37,7 @@ def _get_kwargs( def _parse_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Grid | HTTPValidationError | None: +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: if response.status_code == 201: response_201 = Grid.from_dict(response.json()) @@ -47,6 +48,11 @@ def _parse_response( return response_422 + if response.status_code == 429: + response_429 = QuotaExceededDetail.from_dict(response.json()) + + return response_429 + if client.raise_on_unexpected_status: raise errors.UnexpectedStatus(response.status_code, response.content) else: @@ -55,7 +61,7 @@ def _parse_response( def _build_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[Grid | HTTPValidationError]: +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: return Response( status_code=HTTPStatus(response.status_code), content=response.content, @@ -69,7 +75,7 @@ def sync_detailed( *, client: AuthenticatedClient, body: CreateLandfireTopographyRequest, -) -> Response[Grid | HTTPValidationError]: +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: r"""Create a grid from LANDFIRE topographic data # Create LANDFIRE Topography Grid @@ -110,7 +116,7 @@ def sync_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Grid | HTTPValidationError] + Response[Grid | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -130,7 +136,7 @@ def sync( *, client: AuthenticatedClient, body: CreateLandfireTopographyRequest, -) -> Grid | HTTPValidationError | None: +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: r"""Create a grid from LANDFIRE topographic data # Create LANDFIRE Topography Grid @@ -171,7 +177,7 @@ def sync( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Grid | HTTPValidationError + Grid | HTTPValidationError | QuotaExceededDetail """ return sync_detailed( @@ -186,7 +192,7 @@ async def asyncio_detailed( *, client: AuthenticatedClient, body: CreateLandfireTopographyRequest, -) -> Response[Grid | HTTPValidationError]: +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: r"""Create a grid from LANDFIRE topographic data # Create LANDFIRE Topography Grid @@ -227,7 +233,7 @@ async def asyncio_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Grid | HTTPValidationError] + Response[Grid | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -245,7 +251,7 @@ async def asyncio( *, client: AuthenticatedClient, body: CreateLandfireTopographyRequest, -) -> Grid | HTTPValidationError | None: +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: r"""Create a grid from LANDFIRE topographic data # Create LANDFIRE Topography Grid @@ -286,7 +292,7 @@ async def asyncio( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Grid | HTTPValidationError + Grid | HTTPValidationError | QuotaExceededDetail """ return ( diff --git a/fastfuels_sdk/v2/client_library/api/grids/create_landscape_export.py b/fastfuels_sdk/v2/client_library/api/grids/create_landscape_export.py new file mode 100644 index 0000000..7a6e793 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/grids/create_landscape_export.py @@ -0,0 +1,296 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.export import Export +from ...models.http_validation_error import HTTPValidationError +from ...models.landscape_export_request import LandscapeExportRequest +from ...models.quota_exceeded_detail import QuotaExceededDetail +from ...types import Response + + +def _get_kwargs( + domain_id: str, + *, + body: LandscapeExportRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/domains/{domain_id}/grids/exports/landscape".format( + domain_id=quote(str(domain_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Export | HTTPValidationError | QuotaExceededDetail | None: + if response.status_code == 201: + response_201 = Export.from_dict(response.json()) + + return response_201 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if response.status_code == 429: + response_429 = QuotaExceededDetail.from_dict(response.json()) + + return response_429 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Export | HTTPValidationError | QuotaExceededDetail]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: LandscapeExportRequest, +) -> Response[Export | HTTPValidationError | QuotaExceededDetail]: + """Export terrain + fuel + canopy grids to a landscape GeoTIFF + + Assemble terrain, surface fuel model, and canopy grids into an + 8-band LANDFIRE-style landscape GeoTIFF for operational fire behavior + tools (FlamMap, IFTDSS, WFDSS). + + The output `landscape.tif` carries the standard LANDFIRE band order — + elevation, slope, aspect, fuel model, canopy cover, canopy height, + canopy base height, canopy bulk density — with LANDFIRE's int16 scaled + encodings, embedded CRS, and per-band name/unit metadata. This is the + format LANDFIRE distributes and IFTDSS accepts for upload. + + Returns an Export resource with status `pending`. Poll + `GET /exports/{export_id}` until status is `completed` to retrieve the + signed download URL. + + Args: + domain_id (str): + body (LandscapeExportRequest): Request body for creating a landscape export. + + Eight required roles produce an 8-band landscape GeoTIFF in LANDFIRE band + order: elevation, slope, aspect, fuel model, canopy cover, canopy height, + canopy base height, canopy bulk density. This is the shape modern fire + behavior tools consume — IFTDSS requires all eight bands for upload. + + The landscape lattice is defined by the `alignment` field — either the + Domain bounding box tiled at `resolution` (default 30 m, LANDFIRE-native), + or the lattice of an existing grid. Every role grid must be lattice-aligned + to the landscape and cover its full extent; otherwise the request is + rejected with 422. The exporter only crops oversized roles by integer + slicing — it never resamples or reprojects. To change a grid's resolution + or anchor, use `POST /v2/domains/{domain_id}/grids/{grid_id}/resample`. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Export | HTTPValidationError | QuotaExceededDetail] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + *, + client: AuthenticatedClient, + body: LandscapeExportRequest, +) -> Export | HTTPValidationError | QuotaExceededDetail | None: + """Export terrain + fuel + canopy grids to a landscape GeoTIFF + + Assemble terrain, surface fuel model, and canopy grids into an + 8-band LANDFIRE-style landscape GeoTIFF for operational fire behavior + tools (FlamMap, IFTDSS, WFDSS). + + The output `landscape.tif` carries the standard LANDFIRE band order — + elevation, slope, aspect, fuel model, canopy cover, canopy height, + canopy base height, canopy bulk density — with LANDFIRE's int16 scaled + encodings, embedded CRS, and per-band name/unit metadata. This is the + format LANDFIRE distributes and IFTDSS accepts for upload. + + Returns an Export resource with status `pending`. Poll + `GET /exports/{export_id}` until status is `completed` to retrieve the + signed download URL. + + Args: + domain_id (str): + body (LandscapeExportRequest): Request body for creating a landscape export. + + Eight required roles produce an 8-band landscape GeoTIFF in LANDFIRE band + order: elevation, slope, aspect, fuel model, canopy cover, canopy height, + canopy base height, canopy bulk density. This is the shape modern fire + behavior tools consume — IFTDSS requires all eight bands for upload. + + The landscape lattice is defined by the `alignment` field — either the + Domain bounding box tiled at `resolution` (default 30 m, LANDFIRE-native), + or the lattice of an existing grid. Every role grid must be lattice-aligned + to the landscape and cover its full extent; otherwise the request is + rejected with 422. The exporter only crops oversized roles by integer + slicing — it never resamples or reprojects. To change a grid's resolution + or anchor, use `POST /v2/domains/{domain_id}/grids/{grid_id}/resample`. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Export | HTTPValidationError | QuotaExceededDetail + """ + + return sync_detailed( + domain_id=domain_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: LandscapeExportRequest, +) -> Response[Export | HTTPValidationError | QuotaExceededDetail]: + """Export terrain + fuel + canopy grids to a landscape GeoTIFF + + Assemble terrain, surface fuel model, and canopy grids into an + 8-band LANDFIRE-style landscape GeoTIFF for operational fire behavior + tools (FlamMap, IFTDSS, WFDSS). + + The output `landscape.tif` carries the standard LANDFIRE band order — + elevation, slope, aspect, fuel model, canopy cover, canopy height, + canopy base height, canopy bulk density — with LANDFIRE's int16 scaled + encodings, embedded CRS, and per-band name/unit metadata. This is the + format LANDFIRE distributes and IFTDSS accepts for upload. + + Returns an Export resource with status `pending`. Poll + `GET /exports/{export_id}` until status is `completed` to retrieve the + signed download URL. + + Args: + domain_id (str): + body (LandscapeExportRequest): Request body for creating a landscape export. + + Eight required roles produce an 8-band landscape GeoTIFF in LANDFIRE band + order: elevation, slope, aspect, fuel model, canopy cover, canopy height, + canopy base height, canopy bulk density. This is the shape modern fire + behavior tools consume — IFTDSS requires all eight bands for upload. + + The landscape lattice is defined by the `alignment` field — either the + Domain bounding box tiled at `resolution` (default 30 m, LANDFIRE-native), + or the lattice of an existing grid. Every role grid must be lattice-aligned + to the landscape and cover its full extent; otherwise the request is + rejected with 422. The exporter only crops oversized roles by integer + slicing — it never resamples or reprojects. To change a grid's resolution + or anchor, use `POST /v2/domains/{domain_id}/grids/{grid_id}/resample`. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Export | HTTPValidationError | QuotaExceededDetail] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + *, + client: AuthenticatedClient, + body: LandscapeExportRequest, +) -> Export | HTTPValidationError | QuotaExceededDetail | None: + """Export terrain + fuel + canopy grids to a landscape GeoTIFF + + Assemble terrain, surface fuel model, and canopy grids into an + 8-band LANDFIRE-style landscape GeoTIFF for operational fire behavior + tools (FlamMap, IFTDSS, WFDSS). + + The output `landscape.tif` carries the standard LANDFIRE band order — + elevation, slope, aspect, fuel model, canopy cover, canopy height, + canopy base height, canopy bulk density — with LANDFIRE's int16 scaled + encodings, embedded CRS, and per-band name/unit metadata. This is the + format LANDFIRE distributes and IFTDSS accepts for upload. + + Returns an Export resource with status `pending`. Poll + `GET /exports/{export_id}` until status is `completed` to retrieve the + signed download URL. + + Args: + domain_id (str): + body (LandscapeExportRequest): Request body for creating a landscape export. + + Eight required roles produce an 8-band landscape GeoTIFF in LANDFIRE band + order: elevation, slope, aspect, fuel model, canopy cover, canopy height, + canopy base height, canopy bulk density. This is the shape modern fire + behavior tools consume — IFTDSS requires all eight bands for upload. + + The landscape lattice is defined by the `alignment` field — either the + Domain bounding box tiled at `resolution` (default 30 m, LANDFIRE-native), + or the lattice of an existing grid. Every role grid must be lattice-aligned + to the landscape and cover its full extent; otherwise the request is + rejected with 422. The exporter only crops oversized roles by integer + slicing — it never resamples or reprojects. To change a grid's resolution + or anchor, use `POST /v2/domains/{domain_id}/grids/{grid_id}/resample`. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Export | HTTPValidationError | QuotaExceededDetail + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/grids/create_layerset_rasterize.py b/fastfuels_sdk/v2/client_library/api/grids/create_layerset_rasterize.py index 45ff27b..37b7f4a 100644 --- a/fastfuels_sdk/v2/client_library/api/grids/create_layerset_rasterize.py +++ b/fastfuels_sdk/v2/client_library/api/grids/create_layerset_rasterize.py @@ -9,6 +9,7 @@ from ...models.create_layerset_rasterize_request import CreateLayersetRasterizeRequest from ...models.grid import Grid from ...models.http_validation_error import HTTPValidationError +from ...models.quota_exceeded_detail import QuotaExceededDetail from ...types import Response @@ -36,7 +37,7 @@ def _get_kwargs( def _parse_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Grid | HTTPValidationError | None: +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: if response.status_code == 201: response_201 = Grid.from_dict(response.json()) @@ -47,6 +48,11 @@ def _parse_response( return response_422 + if response.status_code == 429: + response_429 = QuotaExceededDetail.from_dict(response.json()) + + return response_429 + if client.raise_on_unexpected_status: raise errors.UnexpectedStatus(response.status_code, response.content) else: @@ -55,7 +61,7 @@ def _parse_response( def _build_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[Grid | HTTPValidationError]: +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: return Response( status_code=HTTPStatus(response.status_code), content=response.content, @@ -69,7 +75,7 @@ def sync_detailed( *, client: AuthenticatedClient, body: CreateLayersetRasterizeRequest, -) -> Response[Grid | HTTPValidationError]: +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: """Create a grid by rasterizing a layerset # Create Layerset-Rasterized Grid @@ -114,7 +120,7 @@ def sync_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Grid | HTTPValidationError] + Response[Grid | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -134,7 +140,7 @@ def sync( *, client: AuthenticatedClient, body: CreateLayersetRasterizeRequest, -) -> Grid | HTTPValidationError | None: +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: """Create a grid by rasterizing a layerset # Create Layerset-Rasterized Grid @@ -179,7 +185,7 @@ def sync( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Grid | HTTPValidationError + Grid | HTTPValidationError | QuotaExceededDetail """ return sync_detailed( @@ -194,7 +200,7 @@ async def asyncio_detailed( *, client: AuthenticatedClient, body: CreateLayersetRasterizeRequest, -) -> Response[Grid | HTTPValidationError]: +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: """Create a grid by rasterizing a layerset # Create Layerset-Rasterized Grid @@ -239,7 +245,7 @@ async def asyncio_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Grid | HTTPValidationError] + Response[Grid | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -257,7 +263,7 @@ async def asyncio( *, client: AuthenticatedClient, body: CreateLayersetRasterizeRequest, -) -> Grid | HTTPValidationError | None: +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: """Create a grid by rasterizing a layerset # Create Layerset-Rasterized Grid @@ -302,7 +308,7 @@ async def asyncio( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Grid | HTTPValidationError + Grid | HTTPValidationError | QuotaExceededDetail """ return ( diff --git a/fastfuels_sdk/v2/client_library/api/grids/create_meta_chm.py b/fastfuels_sdk/v2/client_library/api/grids/create_meta_chm.py index daa540e..5f832ba 100644 --- a/fastfuels_sdk/v2/client_library/api/grids/create_meta_chm.py +++ b/fastfuels_sdk/v2/client_library/api/grids/create_meta_chm.py @@ -9,6 +9,7 @@ from ...models.create_meta_chm_request import CreateMetaChmRequest from ...models.grid import Grid from ...models.http_validation_error import HTTPValidationError +from ...models.quota_exceeded_detail import QuotaExceededDetail from ...types import Response @@ -36,7 +37,7 @@ def _get_kwargs( def _parse_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Grid | HTTPValidationError | None: +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: if response.status_code == 201: response_201 = Grid.from_dict(response.json()) @@ -47,6 +48,11 @@ def _parse_response( return response_422 + if response.status_code == 429: + response_429 = QuotaExceededDetail.from_dict(response.json()) + + return response_429 + if client.raise_on_unexpected_status: raise errors.UnexpectedStatus(response.status_code, response.content) else: @@ -55,7 +61,7 @@ def _parse_response( def _build_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[Grid | HTTPValidationError]: +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: return Response( status_code=HTTPStatus(response.status_code), content=response.content, @@ -69,7 +75,7 @@ def sync_detailed( *, client: AuthenticatedClient, body: CreateMetaChmRequest, -) -> Response[Grid | HTTPValidationError]: +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: r"""Create a grid from Meta CHM # Create Meta CHM Grid @@ -104,7 +110,7 @@ def sync_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Grid | HTTPValidationError] + Response[Grid | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -124,7 +130,7 @@ def sync( *, client: AuthenticatedClient, body: CreateMetaChmRequest, -) -> Grid | HTTPValidationError | None: +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: r"""Create a grid from Meta CHM # Create Meta CHM Grid @@ -159,7 +165,7 @@ def sync( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Grid | HTTPValidationError + Grid | HTTPValidationError | QuotaExceededDetail """ return sync_detailed( @@ -174,7 +180,7 @@ async def asyncio_detailed( *, client: AuthenticatedClient, body: CreateMetaChmRequest, -) -> Response[Grid | HTTPValidationError]: +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: r"""Create a grid from Meta CHM # Create Meta CHM Grid @@ -209,7 +215,7 @@ async def asyncio_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Grid | HTTPValidationError] + Response[Grid | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -227,7 +233,7 @@ async def asyncio( *, client: AuthenticatedClient, body: CreateMetaChmRequest, -) -> Grid | HTTPValidationError | None: +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: r"""Create a grid from Meta CHM # Create Meta CHM Grid @@ -262,7 +268,7 @@ async def asyncio( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Grid | HTTPValidationError + Grid | HTTPValidationError | QuotaExceededDetail """ return ( diff --git a/fastfuels_sdk/v2/client_library/api/grids/create_naip_chm.py b/fastfuels_sdk/v2/client_library/api/grids/create_naip_chm.py index f792d11..fad5f86 100644 --- a/fastfuels_sdk/v2/client_library/api/grids/create_naip_chm.py +++ b/fastfuels_sdk/v2/client_library/api/grids/create_naip_chm.py @@ -9,6 +9,7 @@ from ...models.create_naip_chm_request import CreateNaipChmRequest from ...models.grid import Grid from ...models.http_validation_error import HTTPValidationError +from ...models.quota_exceeded_detail import QuotaExceededDetail from ...types import Response @@ -36,7 +37,7 @@ def _get_kwargs( def _parse_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Grid | HTTPValidationError | None: +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: if response.status_code == 201: response_201 = Grid.from_dict(response.json()) @@ -47,6 +48,11 @@ def _parse_response( return response_422 + if response.status_code == 429: + response_429 = QuotaExceededDetail.from_dict(response.json()) + + return response_429 + if client.raise_on_unexpected_status: raise errors.UnexpectedStatus(response.status_code, response.content) else: @@ -55,7 +61,7 @@ def _parse_response( def _build_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[Grid | HTTPValidationError]: +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: return Response( status_code=HTTPStatus(response.status_code), content=response.content, @@ -69,7 +75,7 @@ def sync_detailed( *, client: AuthenticatedClient, body: CreateNaipChmRequest, -) -> Response[Grid | HTTPValidationError]: +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: r"""Create a grid from NAIP CHM # Create NAIP CHM Grid @@ -99,7 +105,7 @@ def sync_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Grid | HTTPValidationError] + Response[Grid | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -119,7 +125,7 @@ def sync( *, client: AuthenticatedClient, body: CreateNaipChmRequest, -) -> Grid | HTTPValidationError | None: +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: r"""Create a grid from NAIP CHM # Create NAIP CHM Grid @@ -149,7 +155,7 @@ def sync( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Grid | HTTPValidationError + Grid | HTTPValidationError | QuotaExceededDetail """ return sync_detailed( @@ -164,7 +170,7 @@ async def asyncio_detailed( *, client: AuthenticatedClient, body: CreateNaipChmRequest, -) -> Response[Grid | HTTPValidationError]: +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: r"""Create a grid from NAIP CHM # Create NAIP CHM Grid @@ -194,7 +200,7 @@ async def asyncio_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Grid | HTTPValidationError] + Response[Grid | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -212,7 +218,7 @@ async def asyncio( *, client: AuthenticatedClient, body: CreateNaipChmRequest, -) -> Grid | HTTPValidationError | None: +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: r"""Create a grid from NAIP CHM # Create NAIP CHM Grid @@ -242,7 +248,7 @@ async def asyncio( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Grid | HTTPValidationError + Grid | HTTPValidationError | QuotaExceededDetail """ return ( diff --git a/fastfuels_sdk/v2/client_library/api/grids/create_netcdf_upload.py b/fastfuels_sdk/v2/client_library/api/grids/create_netcdf_upload.py index a2f24d7..e5f8bb6 100644 --- a/fastfuels_sdk/v2/client_library/api/grids/create_netcdf_upload.py +++ b/fastfuels_sdk/v2/client_library/api/grids/create_netcdf_upload.py @@ -9,6 +9,7 @@ from ...models.create_netcdf_upload_request import CreateNetcdfUploadRequest from ...models.grid_upload_created_response import GridUploadCreatedResponse from ...models.http_validation_error import HTTPValidationError +from ...models.quota_exceeded_detail import QuotaExceededDetail from ...types import Response @@ -36,7 +37,7 @@ def _get_kwargs( def _parse_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> GridUploadCreatedResponse | HTTPValidationError | None: +) -> GridUploadCreatedResponse | HTTPValidationError | QuotaExceededDetail | None: if response.status_code == 201: response_201 = GridUploadCreatedResponse.from_dict(response.json()) @@ -47,6 +48,11 @@ def _parse_response( return response_422 + if response.status_code == 429: + response_429 = QuotaExceededDetail.from_dict(response.json()) + + return response_429 + if client.raise_on_unexpected_status: raise errors.UnexpectedStatus(response.status_code, response.content) else: @@ -55,7 +61,7 @@ def _parse_response( def _build_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[GridUploadCreatedResponse | HTTPValidationError]: +) -> Response[GridUploadCreatedResponse | HTTPValidationError | QuotaExceededDetail]: return Response( status_code=HTTPStatus(response.status_code), content=response.content, @@ -69,7 +75,7 @@ def sync_detailed( *, client: AuthenticatedClient, body: CreateNetcdfUploadRequest, -) -> Response[GridUploadCreatedResponse | HTTPValidationError]: +) -> Response[GridUploadCreatedResponse | HTTPValidationError | QuotaExceededDetail]: r"""Create a grid from a direct netCDF upload # Create Upload Grid (netCDF) @@ -143,7 +149,7 @@ def sync_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[GridUploadCreatedResponse | HTTPValidationError] + Response[GridUploadCreatedResponse | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -163,7 +169,7 @@ def sync( *, client: AuthenticatedClient, body: CreateNetcdfUploadRequest, -) -> GridUploadCreatedResponse | HTTPValidationError | None: +) -> GridUploadCreatedResponse | HTTPValidationError | QuotaExceededDetail | None: r"""Create a grid from a direct netCDF upload # Create Upload Grid (netCDF) @@ -237,7 +243,7 @@ def sync( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - GridUploadCreatedResponse | HTTPValidationError + GridUploadCreatedResponse | HTTPValidationError | QuotaExceededDetail """ return sync_detailed( @@ -252,7 +258,7 @@ async def asyncio_detailed( *, client: AuthenticatedClient, body: CreateNetcdfUploadRequest, -) -> Response[GridUploadCreatedResponse | HTTPValidationError]: +) -> Response[GridUploadCreatedResponse | HTTPValidationError | QuotaExceededDetail]: r"""Create a grid from a direct netCDF upload # Create Upload Grid (netCDF) @@ -326,7 +332,7 @@ async def asyncio_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[GridUploadCreatedResponse | HTTPValidationError] + Response[GridUploadCreatedResponse | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -344,7 +350,7 @@ async def asyncio( *, client: AuthenticatedClient, body: CreateNetcdfUploadRequest, -) -> GridUploadCreatedResponse | HTTPValidationError | None: +) -> GridUploadCreatedResponse | HTTPValidationError | QuotaExceededDetail | None: r"""Create a grid from a direct netCDF upload # Create Upload Grid (netCDF) @@ -418,7 +424,7 @@ async def asyncio( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - GridUploadCreatedResponse | HTTPValidationError + GridUploadCreatedResponse | HTTPValidationError | QuotaExceededDetail """ return ( diff --git a/fastfuels_sdk/v2/client_library/api/grids/create_point_cloud_chm.py b/fastfuels_sdk/v2/client_library/api/grids/create_point_cloud_chm.py new file mode 100644 index 0000000..9b36f19 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/grids/create_point_cloud_chm.py @@ -0,0 +1,400 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.create_point_cloud_chm_request import CreatePointCloudChmRequest +from ...models.grid import Grid +from ...models.http_validation_error import HTTPValidationError +from ...models.quota_exceeded_detail import QuotaExceededDetail +from ...types import Response + + +def _get_kwargs( + domain_id: str, + *, + body: CreatePointCloudChmRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/domains/{domain_id}/grids/canopy/point_cloud".format( + domain_id=quote(str(domain_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: + if response.status_code == 201: + response_201 = Grid.from_dict(response.json()) + + return response_201 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if response.status_code == 429: + response_429 = QuotaExceededDetail.from_dict(response.json()) + + return response_429 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreatePointCloudChmRequest, +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: + r"""Create a CHM grid from a point cloud + + # Create a CHM Grid from a Point Cloud + + Creates a grid with canopy height data rasterized from a point cloud. Each + cell holds the greatest height above ground of any return that falls in it. + + The resulting grid carries the same `chm` band as the Meta, NAIP, and + LANDFIRE canopy sources, so it can be used anywhere they can — including as + the source for individual tree detection + (`POST /domains/{domain_id}/inventories/tree/chm`). + + ## Ground + + Canopy height is height above ground, so the ground surface underneath + matters. When the point cloud carries ASPRS ground classification + (class 2), those returns define the ground. When it does not — an upload + may carry no classification at all — the ground surface is derived from the + data. + + Which path was taken, and how well the data constrained it, is recorded on + the completed grid under `source.ground`. Derived ground is accurate in + forested terrain and degrades over wide areas with no ground returns, such + as large building footprints or very dense canopy; + `source.ground.ground_coverage` and `source.ground.max_ground_distance_m` + are what reveal that. + + ## Request Body + + - **source_point_cloud_id**: The point cloud to rasterize. Must be airborne + (`type: als`), `completed`, and in this domain. + - **alignment**: (optional) Output lattice. Against the domain + (`target: \"domain\"`, the default) `resolution` defaults to 1 m — unlike + the raster-backed canopy sources there is no source pixel size to + inherit. Against another grid (`target: \"grid\"`) omitting `resolution` + matches that grid cell-for-cell, and the output covers the target's + extent rather than the domain's; giving one keeps the target's origin at + the new cell size. The target grid must be in this domain's CRS. + `target: \"native\"` is not supported — a point cloud has no pixel anchor + to preserve. + - **name**, **description**, **tags**: (optional) Metadata. + + ## Response + + Returns the created Grid resource with status \"pending\". The backend will + build the grid and update status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreatePointCloudChmRequest): Request to create a canopy height model grid from a + point cloud. + + Returns a grid with a single continuous band: + - chm: Canopy height in meters + + The point cloud must be airborne (`type: als`) and `completed`. Cell size + comes from `alignment.resolution`, defaulting to 1 m — unlike the + raster-backed canopy sources there is no source pixel size to inherit. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError | QuotaExceededDetail] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreatePointCloudChmRequest, +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: + r"""Create a CHM grid from a point cloud + + # Create a CHM Grid from a Point Cloud + + Creates a grid with canopy height data rasterized from a point cloud. Each + cell holds the greatest height above ground of any return that falls in it. + + The resulting grid carries the same `chm` band as the Meta, NAIP, and + LANDFIRE canopy sources, so it can be used anywhere they can — including as + the source for individual tree detection + (`POST /domains/{domain_id}/inventories/tree/chm`). + + ## Ground + + Canopy height is height above ground, so the ground surface underneath + matters. When the point cloud carries ASPRS ground classification + (class 2), those returns define the ground. When it does not — an upload + may carry no classification at all — the ground surface is derived from the + data. + + Which path was taken, and how well the data constrained it, is recorded on + the completed grid under `source.ground`. Derived ground is accurate in + forested terrain and degrades over wide areas with no ground returns, such + as large building footprints or very dense canopy; + `source.ground.ground_coverage` and `source.ground.max_ground_distance_m` + are what reveal that. + + ## Request Body + + - **source_point_cloud_id**: The point cloud to rasterize. Must be airborne + (`type: als`), `completed`, and in this domain. + - **alignment**: (optional) Output lattice. Against the domain + (`target: \"domain\"`, the default) `resolution` defaults to 1 m — unlike + the raster-backed canopy sources there is no source pixel size to + inherit. Against another grid (`target: \"grid\"`) omitting `resolution` + matches that grid cell-for-cell, and the output covers the target's + extent rather than the domain's; giving one keeps the target's origin at + the new cell size. The target grid must be in this domain's CRS. + `target: \"native\"` is not supported — a point cloud has no pixel anchor + to preserve. + - **name**, **description**, **tags**: (optional) Metadata. + + ## Response + + Returns the created Grid resource with status \"pending\". The backend will + build the grid and update status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreatePointCloudChmRequest): Request to create a canopy height model grid from a + point cloud. + + Returns a grid with a single continuous band: + - chm: Canopy height in meters + + The point cloud must be airborne (`type: als`) and `completed`. Cell size + comes from `alignment.resolution`, defaulting to 1 m — unlike the + raster-backed canopy sources there is no source pixel size to inherit. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError | QuotaExceededDetail + """ + + return sync_detailed( + domain_id=domain_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreatePointCloudChmRequest, +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: + r"""Create a CHM grid from a point cloud + + # Create a CHM Grid from a Point Cloud + + Creates a grid with canopy height data rasterized from a point cloud. Each + cell holds the greatest height above ground of any return that falls in it. + + The resulting grid carries the same `chm` band as the Meta, NAIP, and + LANDFIRE canopy sources, so it can be used anywhere they can — including as + the source for individual tree detection + (`POST /domains/{domain_id}/inventories/tree/chm`). + + ## Ground + + Canopy height is height above ground, so the ground surface underneath + matters. When the point cloud carries ASPRS ground classification + (class 2), those returns define the ground. When it does not — an upload + may carry no classification at all — the ground surface is derived from the + data. + + Which path was taken, and how well the data constrained it, is recorded on + the completed grid under `source.ground`. Derived ground is accurate in + forested terrain and degrades over wide areas with no ground returns, such + as large building footprints or very dense canopy; + `source.ground.ground_coverage` and `source.ground.max_ground_distance_m` + are what reveal that. + + ## Request Body + + - **source_point_cloud_id**: The point cloud to rasterize. Must be airborne + (`type: als`), `completed`, and in this domain. + - **alignment**: (optional) Output lattice. Against the domain + (`target: \"domain\"`, the default) `resolution` defaults to 1 m — unlike + the raster-backed canopy sources there is no source pixel size to + inherit. Against another grid (`target: \"grid\"`) omitting `resolution` + matches that grid cell-for-cell, and the output covers the target's + extent rather than the domain's; giving one keeps the target's origin at + the new cell size. The target grid must be in this domain's CRS. + `target: \"native\"` is not supported — a point cloud has no pixel anchor + to preserve. + - **name**, **description**, **tags**: (optional) Metadata. + + ## Response + + Returns the created Grid resource with status \"pending\". The backend will + build the grid and update status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreatePointCloudChmRequest): Request to create a canopy height model grid from a + point cloud. + + Returns a grid with a single continuous band: + - chm: Canopy height in meters + + The point cloud must be airborne (`type: als`) and `completed`. Cell size + comes from `alignment.resolution`, defaulting to 1 m — unlike the + raster-backed canopy sources there is no source pixel size to inherit. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Grid | HTTPValidationError | QuotaExceededDetail] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreatePointCloudChmRequest, +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: + r"""Create a CHM grid from a point cloud + + # Create a CHM Grid from a Point Cloud + + Creates a grid with canopy height data rasterized from a point cloud. Each + cell holds the greatest height above ground of any return that falls in it. + + The resulting grid carries the same `chm` band as the Meta, NAIP, and + LANDFIRE canopy sources, so it can be used anywhere they can — including as + the source for individual tree detection + (`POST /domains/{domain_id}/inventories/tree/chm`). + + ## Ground + + Canopy height is height above ground, so the ground surface underneath + matters. When the point cloud carries ASPRS ground classification + (class 2), those returns define the ground. When it does not — an upload + may carry no classification at all — the ground surface is derived from the + data. + + Which path was taken, and how well the data constrained it, is recorded on + the completed grid under `source.ground`. Derived ground is accurate in + forested terrain and degrades over wide areas with no ground returns, such + as large building footprints or very dense canopy; + `source.ground.ground_coverage` and `source.ground.max_ground_distance_m` + are what reveal that. + + ## Request Body + + - **source_point_cloud_id**: The point cloud to rasterize. Must be airborne + (`type: als`), `completed`, and in this domain. + - **alignment**: (optional) Output lattice. Against the domain + (`target: \"domain\"`, the default) `resolution` defaults to 1 m — unlike + the raster-backed canopy sources there is no source pixel size to + inherit. Against another grid (`target: \"grid\"`) omitting `resolution` + matches that grid cell-for-cell, and the output covers the target's + extent rather than the domain's; giving one keeps the target's origin at + the new cell size. The target grid must be in this domain's CRS. + `target: \"native\"` is not supported — a point cloud has no pixel anchor + to preserve. + - **name**, **description**, **tags**: (optional) Metadata. + + ## Response + + Returns the created Grid resource with status \"pending\". The backend will + build the grid and update status to \"completed\" when ready. + + Args: + domain_id (str): + body (CreatePointCloudChmRequest): Request to create a canopy height model grid from a + point cloud. + + Returns a grid with a single continuous band: + - chm: Canopy height in meters + + The point cloud must be airborne (`type: als`) and `completed`. Cell size + comes from `alignment.resolution`, defaulting to 1 m — unlike the + raster-backed canopy sources there is no source pixel size to inherit. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Grid | HTTPValidationError | QuotaExceededDetail + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/grids/create_quicfire_export.py b/fastfuels_sdk/v2/client_library/api/grids/create_quicfire_export.py index 654304e..56fcf20 100644 --- a/fastfuels_sdk/v2/client_library/api/grids/create_quicfire_export.py +++ b/fastfuels_sdk/v2/client_library/api/grids/create_quicfire_export.py @@ -9,6 +9,7 @@ from ...models.export import Export from ...models.http_validation_error import HTTPValidationError from ...models.quicfire_export_request import QuicfireExportRequest +from ...models.quota_exceeded_detail import QuotaExceededDetail from ...types import Response @@ -36,7 +37,7 @@ def _get_kwargs( def _parse_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Export | HTTPValidationError | None: +) -> Export | HTTPValidationError | QuotaExceededDetail | None: if response.status_code == 201: response_201 = Export.from_dict(response.json()) @@ -47,6 +48,11 @@ def _parse_response( return response_422 + if response.status_code == 429: + response_429 = QuotaExceededDetail.from_dict(response.json()) + + return response_429 + if client.raise_on_unexpected_status: raise errors.UnexpectedStatus(response.status_code, response.content) else: @@ -55,7 +61,7 @@ def _parse_response( def _build_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[Export | HTTPValidationError]: +) -> Response[Export | HTTPValidationError | QuotaExceededDetail]: return Response( status_code=HTTPStatus(response.status_code), content=response.content, @@ -69,7 +75,7 @@ def sync_detailed( *, client: AuthenticatedClient, body: QuicfireExportRequest, -) -> Response[Export | HTTPValidationError]: +) -> Response[Export | HTTPValidationError | QuotaExceededDetail]: """Export combined fuel + topography grids to QUIC-Fire format Bundle surface fuel + canopy fuel + (optional) topography grids into a @@ -99,12 +105,23 @@ def sync_detailed( The exporter only crops oversized roles by integer slicing — it never resamples or reprojects. + The output resolution is set here, on the export, via `alignment.dx`/`dy` + (default 2 m, QUIC-Fire's recommended value). It is a separate setting + from the resolution of each grid you built — changing your grids does not + change the export, and vice versa. Because the exporter never resamples, + every role grid must already be built at the fire-grid resolution. To + export at 1 m, for example, set `dx`/`dy` to 1 and build all role grids at + 1 m (2D grids at 1 m via their `alignment.resolution`, and the 3D tree + grid at 1 m via `resolution.horizontal` — 3D grids cannot be resampled). + The same holds vertically: `alignment.dz` must equal the 3D tree grid's + `resolution.vertical`, or the request is rejected with 422. + Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Export | HTTPValidationError] + Response[Export | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -124,7 +141,7 @@ def sync( *, client: AuthenticatedClient, body: QuicfireExportRequest, -) -> Export | HTTPValidationError | None: +) -> Export | HTTPValidationError | QuotaExceededDetail | None: """Export combined fuel + topography grids to QUIC-Fire format Bundle surface fuel + canopy fuel + (optional) topography grids into a @@ -154,12 +171,23 @@ def sync( The exporter only crops oversized roles by integer slicing — it never resamples or reprojects. + The output resolution is set here, on the export, via `alignment.dx`/`dy` + (default 2 m, QUIC-Fire's recommended value). It is a separate setting + from the resolution of each grid you built — changing your grids does not + change the export, and vice versa. Because the exporter never resamples, + every role grid must already be built at the fire-grid resolution. To + export at 1 m, for example, set `dx`/`dy` to 1 and build all role grids at + 1 m (2D grids at 1 m via their `alignment.resolution`, and the 3D tree + grid at 1 m via `resolution.horizontal` — 3D grids cannot be resampled). + The same holds vertically: `alignment.dz` must equal the 3D tree grid's + `resolution.vertical`, or the request is rejected with 422. + Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Export | HTTPValidationError + Export | HTTPValidationError | QuotaExceededDetail """ return sync_detailed( @@ -174,7 +202,7 @@ async def asyncio_detailed( *, client: AuthenticatedClient, body: QuicfireExportRequest, -) -> Response[Export | HTTPValidationError]: +) -> Response[Export | HTTPValidationError | QuotaExceededDetail]: """Export combined fuel + topography grids to QUIC-Fire format Bundle surface fuel + canopy fuel + (optional) topography grids into a @@ -204,12 +232,23 @@ async def asyncio_detailed( The exporter only crops oversized roles by integer slicing — it never resamples or reprojects. + The output resolution is set here, on the export, via `alignment.dx`/`dy` + (default 2 m, QUIC-Fire's recommended value). It is a separate setting + from the resolution of each grid you built — changing your grids does not + change the export, and vice versa. Because the exporter never resamples, + every role grid must already be built at the fire-grid resolution. To + export at 1 m, for example, set `dx`/`dy` to 1 and build all role grids at + 1 m (2D grids at 1 m via their `alignment.resolution`, and the 3D tree + grid at 1 m via `resolution.horizontal` — 3D grids cannot be resampled). + The same holds vertically: `alignment.dz` must equal the 3D tree grid's + `resolution.vertical`, or the request is rejected with 422. + Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Export | HTTPValidationError] + Response[Export | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -227,7 +266,7 @@ async def asyncio( *, client: AuthenticatedClient, body: QuicfireExportRequest, -) -> Export | HTTPValidationError | None: +) -> Export | HTTPValidationError | QuotaExceededDetail | None: """Export combined fuel + topography grids to QUIC-Fire format Bundle surface fuel + canopy fuel + (optional) topography grids into a @@ -257,12 +296,23 @@ async def asyncio( The exporter only crops oversized roles by integer slicing — it never resamples or reprojects. + The output resolution is set here, on the export, via `alignment.dx`/`dy` + (default 2 m, QUIC-Fire's recommended value). It is a separate setting + from the resolution of each grid you built — changing your grids does not + change the export, and vice versa. Because the exporter never resamples, + every role grid must already be built at the fire-grid resolution. To + export at 1 m, for example, set `dx`/`dy` to 1 and build all role grids at + 1 m (2D grids at 1 m via their `alignment.resolution`, and the 3D tree + grid at 1 m via `resolution.horizontal` — 3D grids cannot be resampled). + The same holds vertically: `alignment.dz` must equal the 3D tree grid's + `resolution.vertical`, or the request is rejected with 422. + Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Export | HTTPValidationError + Export | HTTPValidationError | QuotaExceededDetail """ return ( diff --git a/fastfuels_sdk/v2/client_library/api/grids/create_resample.py b/fastfuels_sdk/v2/client_library/api/grids/create_resample.py index 948ddb7..39a84a1 100644 --- a/fastfuels_sdk/v2/client_library/api/grids/create_resample.py +++ b/fastfuels_sdk/v2/client_library/api/grids/create_resample.py @@ -9,6 +9,7 @@ from ...models.create_resample_request import CreateResampleRequest from ...models.grid import Grid from ...models.http_validation_error import HTTPValidationError +from ...models.quota_exceeded_detail import QuotaExceededDetail from ...types import Response @@ -36,7 +37,7 @@ def _get_kwargs( def _parse_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Grid | HTTPValidationError | None: +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: if response.status_code == 201: response_201 = Grid.from_dict(response.json()) @@ -47,6 +48,11 @@ def _parse_response( return response_422 + if response.status_code == 429: + response_429 = QuotaExceededDetail.from_dict(response.json()) + + return response_429 + if client.raise_on_unexpected_status: raise errors.UnexpectedStatus(response.status_code, response.content) else: @@ -55,7 +61,7 @@ def _parse_response( def _build_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[Grid | HTTPValidationError]: +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: return Response( status_code=HTTPStatus(response.status_code), content=response.content, @@ -69,7 +75,7 @@ def sync_detailed( *, client: AuthenticatedClient, body: CreateResampleRequest, -) -> Response[Grid | HTTPValidationError]: +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: r"""Create a grid by resampling an existing grid # Create Resampled Grid @@ -114,7 +120,7 @@ def sync_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Grid | HTTPValidationError] + Response[Grid | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -134,7 +140,7 @@ def sync( *, client: AuthenticatedClient, body: CreateResampleRequest, -) -> Grid | HTTPValidationError | None: +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: r"""Create a grid by resampling an existing grid # Create Resampled Grid @@ -179,7 +185,7 @@ def sync( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Grid | HTTPValidationError + Grid | HTTPValidationError | QuotaExceededDetail """ return sync_detailed( @@ -194,7 +200,7 @@ async def asyncio_detailed( *, client: AuthenticatedClient, body: CreateResampleRequest, -) -> Response[Grid | HTTPValidationError]: +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: r"""Create a grid by resampling an existing grid # Create Resampled Grid @@ -239,7 +245,7 @@ async def asyncio_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Grid | HTTPValidationError] + Response[Grid | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -257,7 +263,7 @@ async def asyncio( *, client: AuthenticatedClient, body: CreateResampleRequest, -) -> Grid | HTTPValidationError | None: +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: r"""Create a grid by resampling an existing grid # Create Resampled Grid @@ -302,7 +308,7 @@ async def asyncio( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Grid | HTTPValidationError + Grid | HTTPValidationError | QuotaExceededDetail """ return ( diff --git a/fastfuels_sdk/v2/client_library/api/grids/create_tree_inventory_grid.py b/fastfuels_sdk/v2/client_library/api/grids/create_tree_inventory_grid.py index 5904b80..6186421 100644 --- a/fastfuels_sdk/v2/client_library/api/grids/create_tree_inventory_grid.py +++ b/fastfuels_sdk/v2/client_library/api/grids/create_tree_inventory_grid.py @@ -9,6 +9,7 @@ from ...models.create_tree_inventory_request import CreateTreeInventoryRequest from ...models.grid import Grid from ...models.http_validation_error import HTTPValidationError +from ...models.quota_exceeded_detail import QuotaExceededDetail from ...types import Response @@ -36,7 +37,7 @@ def _get_kwargs( def _parse_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Grid | HTTPValidationError | None: +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: if response.status_code == 201: response_201 = Grid.from_dict(response.json()) @@ -47,6 +48,11 @@ def _parse_response( return response_422 + if response.status_code == 429: + response_429 = QuotaExceededDetail.from_dict(response.json()) + + return response_429 + if client.raise_on_unexpected_status: raise errors.UnexpectedStatus(response.status_code, response.content) else: @@ -55,7 +61,7 @@ def _parse_response( def _build_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[Grid | HTTPValidationError]: +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: return Response( status_code=HTTPStatus(response.status_code), content=response.content, @@ -69,7 +75,7 @@ def sync_detailed( *, client: AuthenticatedClient, body: CreateTreeInventoryRequest, -) -> Response[Grid | HTTPValidationError]: +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: r"""Create a 3D tree fuel grid from a tree inventory # Create Tree Inventory Grid @@ -131,7 +137,7 @@ def sync_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Grid | HTTPValidationError] + Response[Grid | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -151,7 +157,7 @@ def sync( *, client: AuthenticatedClient, body: CreateTreeInventoryRequest, -) -> Grid | HTTPValidationError | None: +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: r"""Create a 3D tree fuel grid from a tree inventory # Create Tree Inventory Grid @@ -213,7 +219,7 @@ def sync( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Grid | HTTPValidationError + Grid | HTTPValidationError | QuotaExceededDetail """ return sync_detailed( @@ -228,7 +234,7 @@ async def asyncio_detailed( *, client: AuthenticatedClient, body: CreateTreeInventoryRequest, -) -> Response[Grid | HTTPValidationError]: +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: r"""Create a 3D tree fuel grid from a tree inventory # Create Tree Inventory Grid @@ -290,7 +296,7 @@ async def asyncio_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Grid | HTTPValidationError] + Response[Grid | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -308,7 +314,7 @@ async def asyncio( *, client: AuthenticatedClient, body: CreateTreeInventoryRequest, -) -> Grid | HTTPValidationError | None: +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: r"""Create a 3D tree fuel grid from a tree inventory # Create Tree Inventory Grid @@ -370,7 +376,7 @@ async def asyncio( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Grid | HTTPValidationError + Grid | HTTPValidationError | QuotaExceededDetail """ return ( diff --git a/fastfuels_sdk/v2/client_library/api/grids/create_treemap.py b/fastfuels_sdk/v2/client_library/api/grids/create_treemap.py index ebbf004..48e5949 100644 --- a/fastfuels_sdk/v2/client_library/api/grids/create_treemap.py +++ b/fastfuels_sdk/v2/client_library/api/grids/create_treemap.py @@ -9,6 +9,7 @@ from ...models.create_tree_map_request import CreateTreeMapRequest from ...models.grid import Grid from ...models.http_validation_error import HTTPValidationError +from ...models.quota_exceeded_detail import QuotaExceededDetail from ...types import Response @@ -36,7 +37,7 @@ def _get_kwargs( def _parse_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Grid | HTTPValidationError | None: +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: if response.status_code == 201: response_201 = Grid.from_dict(response.json()) @@ -47,6 +48,11 @@ def _parse_response( return response_422 + if response.status_code == 429: + response_429 = QuotaExceededDetail.from_dict(response.json()) + + return response_429 + if client.raise_on_unexpected_status: raise errors.UnexpectedStatus(response.status_code, response.content) else: @@ -55,7 +61,7 @@ def _parse_response( def _build_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[Grid | HTTPValidationError]: +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: return Response( status_code=HTTPStatus(response.status_code), content=response.content, @@ -69,7 +75,7 @@ def sync_detailed( *, client: AuthenticatedClient, body: CreateTreeMapRequest, -) -> Response[Grid | HTTPValidationError]: +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: r"""Create a grid from TreeMap # Create TreeMap Grid @@ -109,7 +115,7 @@ def sync_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Grid | HTTPValidationError] + Response[Grid | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -129,7 +135,7 @@ def sync( *, client: AuthenticatedClient, body: CreateTreeMapRequest, -) -> Grid | HTTPValidationError | None: +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: r"""Create a grid from TreeMap # Create TreeMap Grid @@ -169,7 +175,7 @@ def sync( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Grid | HTTPValidationError + Grid | HTTPValidationError | QuotaExceededDetail """ return sync_detailed( @@ -184,7 +190,7 @@ async def asyncio_detailed( *, client: AuthenticatedClient, body: CreateTreeMapRequest, -) -> Response[Grid | HTTPValidationError]: +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: r"""Create a grid from TreeMap # Create TreeMap Grid @@ -224,7 +230,7 @@ async def asyncio_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Grid | HTTPValidationError] + Response[Grid | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -242,7 +248,7 @@ async def asyncio( *, client: AuthenticatedClient, body: CreateTreeMapRequest, -) -> Grid | HTTPValidationError | None: +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: r"""Create a grid from TreeMap # Create TreeMap Grid @@ -282,7 +288,7 @@ async def asyncio( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Grid | HTTPValidationError + Grid | HTTPValidationError | QuotaExceededDetail """ return ( diff --git a/fastfuels_sdk/v2/client_library/api/grids/create_uniform_grid.py b/fastfuels_sdk/v2/client_library/api/grids/create_uniform_grid.py index 816e8ad..db97ef2 100644 --- a/fastfuels_sdk/v2/client_library/api/grids/create_uniform_grid.py +++ b/fastfuels_sdk/v2/client_library/api/grids/create_uniform_grid.py @@ -9,6 +9,7 @@ from ...models.create_uniform_request import CreateUniformRequest from ...models.grid import Grid from ...models.http_validation_error import HTTPValidationError +from ...models.quota_exceeded_detail import QuotaExceededDetail from ...types import Response @@ -36,7 +37,7 @@ def _get_kwargs( def _parse_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Grid | HTTPValidationError | None: +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: if response.status_code == 201: response_201 = Grid.from_dict(response.json()) @@ -47,6 +48,11 @@ def _parse_response( return response_422 + if response.status_code == 429: + response_429 = QuotaExceededDetail.from_dict(response.json()) + + return response_429 + if client.raise_on_unexpected_status: raise errors.UnexpectedStatus(response.status_code, response.content) else: @@ -55,7 +61,7 @@ def _parse_response( def _build_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[Grid | HTTPValidationError]: +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: return Response( status_code=HTTPStatus(response.status_code), content=response.content, @@ -69,7 +75,7 @@ def sync_detailed( *, client: AuthenticatedClient, body: CreateUniformRequest, -) -> Response[Grid | HTTPValidationError]: +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: r"""Create a uniform (constant-value) grid # Create Uniform Grid @@ -118,7 +124,7 @@ def sync_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Grid | HTTPValidationError] + Response[Grid | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -138,7 +144,7 @@ def sync( *, client: AuthenticatedClient, body: CreateUniformRequest, -) -> Grid | HTTPValidationError | None: +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: r"""Create a uniform (constant-value) grid # Create Uniform Grid @@ -187,7 +193,7 @@ def sync( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Grid | HTTPValidationError + Grid | HTTPValidationError | QuotaExceededDetail """ return sync_detailed( @@ -202,7 +208,7 @@ async def asyncio_detailed( *, client: AuthenticatedClient, body: CreateUniformRequest, -) -> Response[Grid | HTTPValidationError]: +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: r"""Create a uniform (constant-value) grid # Create Uniform Grid @@ -251,7 +257,7 @@ async def asyncio_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Grid | HTTPValidationError] + Response[Grid | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -269,7 +275,7 @@ async def asyncio( *, client: AuthenticatedClient, body: CreateUniformRequest, -) -> Grid | HTTPValidationError | None: +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: r"""Create a uniform (constant-value) grid # Create Uniform Grid @@ -318,7 +324,7 @@ async def asyncio( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Grid | HTTPValidationError + Grid | HTTPValidationError | QuotaExceededDetail """ return ( diff --git a/fastfuels_sdk/v2/client_library/api/grids/duplicate_grid.py b/fastfuels_sdk/v2/client_library/api/grids/duplicate_grid.py index 5f64167..761d9ed 100644 --- a/fastfuels_sdk/v2/client_library/api/grids/duplicate_grid.py +++ b/fastfuels_sdk/v2/client_library/api/grids/duplicate_grid.py @@ -9,6 +9,7 @@ from ...models.duplicate_grid_request import DuplicateGridRequest from ...models.grid import Grid from ...models.http_validation_error import HTTPValidationError +from ...models.quota_exceeded_detail import QuotaExceededDetail from ...types import UNSET, Response, Unset @@ -41,7 +42,7 @@ def _get_kwargs( def _parse_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Grid | HTTPValidationError | None: +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: if response.status_code == 201: response_201 = Grid.from_dict(response.json()) @@ -52,6 +53,11 @@ def _parse_response( return response_422 + if response.status_code == 429: + response_429 = QuotaExceededDetail.from_dict(response.json()) + + return response_429 + if client.raise_on_unexpected_status: raise errors.UnexpectedStatus(response.status_code, response.content) else: @@ -60,7 +66,7 @@ def _parse_response( def _build_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[Grid | HTTPValidationError]: +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: return Response( status_code=HTTPStatus(response.status_code), content=response.content, @@ -75,7 +81,7 @@ def sync_detailed( *, client: AuthenticatedClient, body: DuplicateGridRequest | None | Unset = UNSET, -) -> Response[Grid | HTTPValidationError]: +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: r"""Duplicate a grid # Duplicate a Grid @@ -114,6 +120,10 @@ def sync_detailed( caller, or is not in this domain. - **422 Unprocessable Content**: The source grid exists but is not yet `completed`, so there is no finished artifact to copy. + - **429 Too Many Requests**: You have too many active grid jobs in progress + (your `max_active_grids` quota). Wait for jobs to complete or delete + unneeded grids, then retry. The response detail names the exact `quota` + and includes a `Retry-After` header. Args: domain_id (str): @@ -125,7 +135,7 @@ def sync_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Grid | HTTPValidationError] + Response[Grid | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -147,7 +157,7 @@ def sync( *, client: AuthenticatedClient, body: DuplicateGridRequest | None | Unset = UNSET, -) -> Grid | HTTPValidationError | None: +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: r"""Duplicate a grid # Duplicate a Grid @@ -186,6 +196,10 @@ def sync( caller, or is not in this domain. - **422 Unprocessable Content**: The source grid exists but is not yet `completed`, so there is no finished artifact to copy. + - **429 Too Many Requests**: You have too many active grid jobs in progress + (your `max_active_grids` quota). Wait for jobs to complete or delete + unneeded grids, then retry. The response detail names the exact `quota` + and includes a `Retry-After` header. Args: domain_id (str): @@ -197,7 +211,7 @@ def sync( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Grid | HTTPValidationError + Grid | HTTPValidationError | QuotaExceededDetail """ return sync_detailed( @@ -214,7 +228,7 @@ async def asyncio_detailed( *, client: AuthenticatedClient, body: DuplicateGridRequest | None | Unset = UNSET, -) -> Response[Grid | HTTPValidationError]: +) -> Response[Grid | HTTPValidationError | QuotaExceededDetail]: r"""Duplicate a grid # Duplicate a Grid @@ -253,6 +267,10 @@ async def asyncio_detailed( caller, or is not in this domain. - **422 Unprocessable Content**: The source grid exists but is not yet `completed`, so there is no finished artifact to copy. + - **429 Too Many Requests**: You have too many active grid jobs in progress + (your `max_active_grids` quota). Wait for jobs to complete or delete + unneeded grids, then retry. The response detail names the exact `quota` + and includes a `Retry-After` header. Args: domain_id (str): @@ -264,7 +282,7 @@ async def asyncio_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Grid | HTTPValidationError] + Response[Grid | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -284,7 +302,7 @@ async def asyncio( *, client: AuthenticatedClient, body: DuplicateGridRequest | None | Unset = UNSET, -) -> Grid | HTTPValidationError | None: +) -> Grid | HTTPValidationError | QuotaExceededDetail | None: r"""Duplicate a grid # Duplicate a Grid @@ -323,6 +341,10 @@ async def asyncio( caller, or is not in this domain. - **422 Unprocessable Content**: The source grid exists but is not yet `completed`, so there is no finished artifact to copy. + - **429 Too Many Requests**: You have too many active grid jobs in progress + (your `max_active_grids` quota). Wait for jobs to complete or delete + unneeded grids, then retry. The response detail names the exact `quota` + and includes a `Retry-After` header. Args: domain_id (str): @@ -334,7 +356,7 @@ async def asyncio( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Grid | HTTPValidationError + Grid | HTTPValidationError | QuotaExceededDetail """ return ( diff --git a/fastfuels_sdk/v2/client_library/api/grids/get_grid_data_binary.py b/fastfuels_sdk/v2/client_library/api/grids/get_grid_data_binary.py index aa38447..3e7b93f 100644 --- a/fastfuels_sdk/v2/client_library/api/grids/get_grid_data_binary.py +++ b/fastfuels_sdk/v2/client_library/api/grids/get_grid_data_binary.py @@ -1,5 +1,5 @@ from http import HTTPStatus -from typing import Any +from typing import Any, cast from urllib.parse import quote import httpx @@ -54,9 +54,9 @@ def _get_kwargs( def _parse_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Any | HTTPValidationError | None: +) -> HTTPValidationError | str | None: if response.status_code == 200: - response_200 = response.json() + response_200 = cast(str, response.content) return response_200 if response.status_code == 422: @@ -72,7 +72,7 @@ def _parse_response( def _build_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[Any | HTTPValidationError]: +) -> Response[HTTPValidationError | str]: return Response( status_code=HTTPStatus(response.status_code), content=response.content, @@ -90,7 +90,7 @@ def sync_detailed( client: AuthenticatedClient, array_format: GridDataArrayFormat | Unset = UNSET, order: GridDataOrder | Unset = UNSET, -) -> Response[Any | HTTPValidationError]: +) -> Response[HTTPValidationError | str]: """Get band data for a chunk (binary) # Get Grid Data (binary) @@ -173,7 +173,7 @@ def sync_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Any | HTTPValidationError] + Response[HTTPValidationError | str] """ kwargs = _get_kwargs( @@ -201,7 +201,7 @@ def sync( client: AuthenticatedClient, array_format: GridDataArrayFormat | Unset = UNSET, order: GridDataOrder | Unset = UNSET, -) -> Any | HTTPValidationError | None: +) -> HTTPValidationError | str | None: """Get band data for a chunk (binary) # Get Grid Data (binary) @@ -284,7 +284,7 @@ def sync( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Any | HTTPValidationError + HTTPValidationError | str """ return sync_detailed( @@ -307,7 +307,7 @@ async def asyncio_detailed( client: AuthenticatedClient, array_format: GridDataArrayFormat | Unset = UNSET, order: GridDataOrder | Unset = UNSET, -) -> Response[Any | HTTPValidationError]: +) -> Response[HTTPValidationError | str]: """Get band data for a chunk (binary) # Get Grid Data (binary) @@ -390,7 +390,7 @@ async def asyncio_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Any | HTTPValidationError] + Response[HTTPValidationError | str] """ kwargs = _get_kwargs( @@ -416,7 +416,7 @@ async def asyncio( client: AuthenticatedClient, array_format: GridDataArrayFormat | Unset = UNSET, order: GridDataOrder | Unset = UNSET, -) -> Any | HTTPValidationError | None: +) -> HTTPValidationError | str | None: """Get band data for a chunk (binary) # Get Grid Data (binary) @@ -499,7 +499,7 @@ async def asyncio( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Any | HTTPValidationError + HTTPValidationError | str """ return ( diff --git a/fastfuels_sdk/v2/client_library/api/inventories/apply_modifications.py b/fastfuels_sdk/v2/client_library/api/inventories/apply_modifications.py index 302a581..650e823 100644 --- a/fastfuels_sdk/v2/client_library/api/inventories/apply_modifications.py +++ b/fastfuels_sdk/v2/client_library/api/inventories/apply_modifications.py @@ -9,6 +9,7 @@ from ...models.apply_modifications_request import ApplyModificationsRequest from ...models.http_validation_error import HTTPValidationError from ...models.inventory import Inventory +from ...models.quota_exceeded_detail import QuotaExceededDetail from ...types import Response @@ -38,7 +39,7 @@ def _get_kwargs( def _parse_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> HTTPValidationError | Inventory | None: +) -> HTTPValidationError | Inventory | QuotaExceededDetail | None: if response.status_code == 200: response_200 = Inventory.from_dict(response.json()) @@ -49,6 +50,11 @@ def _parse_response( return response_422 + if response.status_code == 429: + response_429 = QuotaExceededDetail.from_dict(response.json()) + + return response_429 + if client.raise_on_unexpected_status: raise errors.UnexpectedStatus(response.status_code, response.content) else: @@ -57,7 +63,7 @@ def _parse_response( def _build_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[HTTPValidationError | Inventory]: +) -> Response[HTTPValidationError | Inventory | QuotaExceededDetail]: return Response( status_code=HTTPStatus(response.status_code), content=response.content, @@ -72,7 +78,7 @@ def sync_detailed( *, client: AuthenticatedClient, body: ApplyModificationsRequest, -) -> Response[HTTPValidationError | Inventory]: +) -> Response[HTTPValidationError | Inventory | QuotaExceededDetail]: r"""Apply modifications to an inventory in place # Apply Modifications to an Inventory (in place) @@ -135,10 +141,28 @@ def sync_detailed( ## Response - Returns this inventory (same ID) with status `\"pending\"` and the submitted - rules appended to `modifications`. Its `checksum` changes, so any resource - derived from it can detect that the source has changed. Poll the inventory - until status returns to `\"completed\"`. + Returns this inventory (same ID) with status `\"pending\"`. Its `checksum` + changes immediately, so any resource derived from it can detect that the + source has changed. The submitted rules appear in the inventory's + `modifications` list once processing completes — poll the inventory until + status returns to `\"completed\"`. + + If processing fails, the inventory's status becomes `\"failed\"` with error + details, the stored data is unchanged, and the queued rules are retained — + submit another POST to retry (the new rules are applied together with the + retained ones). + + ## Error Responses + + - **404 Not Found**: The inventory does not exist, is not owned by the + caller, or is not in this domain. + - **422 Unprocessable Content**: The inventory is not in `completed` status + (and is not a retryable failed modification); or a referenced `feature_id` + is missing, cross-domain, or not completed. + - **429 Too Many Requests**: You have too many active inventory jobs in + progress (your `max_active_inventories` quota). Wait for jobs to complete + or delete unneeded inventories, then retry. The response detail names the + exact `quota` and includes a `Retry-After` header. Args: domain_id (str): @@ -154,7 +178,7 @@ def sync_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[HTTPValidationError | Inventory] + Response[HTTPValidationError | Inventory | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -176,7 +200,7 @@ def sync( *, client: AuthenticatedClient, body: ApplyModificationsRequest, -) -> HTTPValidationError | Inventory | None: +) -> HTTPValidationError | Inventory | QuotaExceededDetail | None: r"""Apply modifications to an inventory in place # Apply Modifications to an Inventory (in place) @@ -239,10 +263,28 @@ def sync( ## Response - Returns this inventory (same ID) with status `\"pending\"` and the submitted - rules appended to `modifications`. Its `checksum` changes, so any resource - derived from it can detect that the source has changed. Poll the inventory - until status returns to `\"completed\"`. + Returns this inventory (same ID) with status `\"pending\"`. Its `checksum` + changes immediately, so any resource derived from it can detect that the + source has changed. The submitted rules appear in the inventory's + `modifications` list once processing completes — poll the inventory until + status returns to `\"completed\"`. + + If processing fails, the inventory's status becomes `\"failed\"` with error + details, the stored data is unchanged, and the queued rules are retained — + submit another POST to retry (the new rules are applied together with the + retained ones). + + ## Error Responses + + - **404 Not Found**: The inventory does not exist, is not owned by the + caller, or is not in this domain. + - **422 Unprocessable Content**: The inventory is not in `completed` status + (and is not a retryable failed modification); or a referenced `feature_id` + is missing, cross-domain, or not completed. + - **429 Too Many Requests**: You have too many active inventory jobs in + progress (your `max_active_inventories` quota). Wait for jobs to complete + or delete unneeded inventories, then retry. The response detail names the + exact `quota` and includes a `Retry-After` header. Args: domain_id (str): @@ -258,7 +300,7 @@ def sync( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - HTTPValidationError | Inventory + HTTPValidationError | Inventory | QuotaExceededDetail """ return sync_detailed( @@ -275,7 +317,7 @@ async def asyncio_detailed( *, client: AuthenticatedClient, body: ApplyModificationsRequest, -) -> Response[HTTPValidationError | Inventory]: +) -> Response[HTTPValidationError | Inventory | QuotaExceededDetail]: r"""Apply modifications to an inventory in place # Apply Modifications to an Inventory (in place) @@ -338,10 +380,28 @@ async def asyncio_detailed( ## Response - Returns this inventory (same ID) with status `\"pending\"` and the submitted - rules appended to `modifications`. Its `checksum` changes, so any resource - derived from it can detect that the source has changed. Poll the inventory - until status returns to `\"completed\"`. + Returns this inventory (same ID) with status `\"pending\"`. Its `checksum` + changes immediately, so any resource derived from it can detect that the + source has changed. The submitted rules appear in the inventory's + `modifications` list once processing completes — poll the inventory until + status returns to `\"completed\"`. + + If processing fails, the inventory's status becomes `\"failed\"` with error + details, the stored data is unchanged, and the queued rules are retained — + submit another POST to retry (the new rules are applied together with the + retained ones). + + ## Error Responses + + - **404 Not Found**: The inventory does not exist, is not owned by the + caller, or is not in this domain. + - **422 Unprocessable Content**: The inventory is not in `completed` status + (and is not a retryable failed modification); or a referenced `feature_id` + is missing, cross-domain, or not completed. + - **429 Too Many Requests**: You have too many active inventory jobs in + progress (your `max_active_inventories` quota). Wait for jobs to complete + or delete unneeded inventories, then retry. The response detail names the + exact `quota` and includes a `Retry-After` header. Args: domain_id (str): @@ -357,7 +417,7 @@ async def asyncio_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[HTTPValidationError | Inventory] + Response[HTTPValidationError | Inventory | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -377,7 +437,7 @@ async def asyncio( *, client: AuthenticatedClient, body: ApplyModificationsRequest, -) -> HTTPValidationError | Inventory | None: +) -> HTTPValidationError | Inventory | QuotaExceededDetail | None: r"""Apply modifications to an inventory in place # Apply Modifications to an Inventory (in place) @@ -440,10 +500,28 @@ async def asyncio( ## Response - Returns this inventory (same ID) with status `\"pending\"` and the submitted - rules appended to `modifications`. Its `checksum` changes, so any resource - derived from it can detect that the source has changed. Poll the inventory - until status returns to `\"completed\"`. + Returns this inventory (same ID) with status `\"pending\"`. Its `checksum` + changes immediately, so any resource derived from it can detect that the + source has changed. The submitted rules appear in the inventory's + `modifications` list once processing completes — poll the inventory until + status returns to `\"completed\"`. + + If processing fails, the inventory's status becomes `\"failed\"` with error + details, the stored data is unchanged, and the queued rules are retained — + submit another POST to retry (the new rules are applied together with the + retained ones). + + ## Error Responses + + - **404 Not Found**: The inventory does not exist, is not owned by the + caller, or is not in this domain. + - **422 Unprocessable Content**: The inventory is not in `completed` status + (and is not a retryable failed modification); or a referenced `feature_id` + is missing, cross-domain, or not completed. + - **429 Too Many Requests**: You have too many active inventory jobs in + progress (your `max_active_inventories` quota). Wait for jobs to complete + or delete unneeded inventories, then retry. The response detail names the + exact `quota` and includes a `Retry-After` header. Args: domain_id (str): @@ -459,7 +537,7 @@ async def asyncio( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - HTTPValidationError | Inventory + HTTPValidationError | Inventory | QuotaExceededDetail """ return ( diff --git a/fastfuels_sdk/v2/client_library/api/inventories/apply_treatments.py b/fastfuels_sdk/v2/client_library/api/inventories/apply_treatments.py index c8b6321..58dceec 100644 --- a/fastfuels_sdk/v2/client_library/api/inventories/apply_treatments.py +++ b/fastfuels_sdk/v2/client_library/api/inventories/apply_treatments.py @@ -9,6 +9,7 @@ from ...models.apply_treatments_request import ApplyTreatmentsRequest from ...models.http_validation_error import HTTPValidationError from ...models.inventory import Inventory +from ...models.quota_exceeded_detail import QuotaExceededDetail from ...types import Response @@ -38,7 +39,7 @@ def _get_kwargs( def _parse_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> HTTPValidationError | Inventory | None: +) -> HTTPValidationError | Inventory | QuotaExceededDetail | None: if response.status_code == 200: response_200 = Inventory.from_dict(response.json()) @@ -49,6 +50,11 @@ def _parse_response( return response_422 + if response.status_code == 429: + response_429 = QuotaExceededDetail.from_dict(response.json()) + + return response_429 + if client.raise_on_unexpected_status: raise errors.UnexpectedStatus(response.status_code, response.content) else: @@ -57,7 +63,7 @@ def _parse_response( def _build_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[HTTPValidationError | Inventory]: +) -> Response[HTTPValidationError | Inventory | QuotaExceededDetail]: return Response( status_code=HTTPStatus(response.status_code), content=response.content, @@ -72,7 +78,7 @@ def sync_detailed( *, client: AuthenticatedClient, body: ApplyTreatmentsRequest, -) -> Response[HTTPValidationError | Inventory]: +) -> Response[HTTPValidationError | Inventory | QuotaExceededDetail]: r"""Apply treatments to an inventory in place # Apply Treatments to an Inventory (in place) @@ -129,10 +135,30 @@ def sync_detailed( ## Response - Returns this inventory (same ID) with status `\"pending\"` and the submitted - treatments appended to `treatments`. Its `checksum` changes, so any resource - derived from it can detect that the source has changed. Poll the inventory - until status returns to `\"completed\"`. + Returns this inventory (same ID) with status `\"pending\"`. Its `checksum` + changes immediately, so any resource derived from it can detect that the + source has changed. The submitted treatments appear in the inventory's + `treatments` list once processing completes — poll the inventory until + status returns to `\"completed\"`. + + If processing fails, the inventory's status becomes `\"failed\"` with error + details, the stored data is unchanged, and the queued treatments are + retained — submit another POST to retry (the new treatments are applied + together with the retained ones). + + ## Error Responses + + - **404 Not Found**: The inventory does not exist, is not owned by the + caller, or is not in this domain. + - **422 Unprocessable Content**: The inventory is not in `completed` status + (and is not a retryable failed treatment); the inventory has no `dbh` + column to thin against (e.g. CHM-derived); an inventory-wide basal-area + treatment over a very large domain; or a referenced `feature_id` is + missing, cross-domain, or not completed. + - **429 Too Many Requests**: You have too many active inventory jobs in + progress (your `max_active_inventories` quota). Wait for jobs to complete + or delete unneeded inventories, then retry. The response detail names the + exact `quota` and includes a `Retry-After` header. Args: domain_id (str): @@ -148,7 +174,7 @@ def sync_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[HTTPValidationError | Inventory] + Response[HTTPValidationError | Inventory | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -170,7 +196,7 @@ def sync( *, client: AuthenticatedClient, body: ApplyTreatmentsRequest, -) -> HTTPValidationError | Inventory | None: +) -> HTTPValidationError | Inventory | QuotaExceededDetail | None: r"""Apply treatments to an inventory in place # Apply Treatments to an Inventory (in place) @@ -227,10 +253,30 @@ def sync( ## Response - Returns this inventory (same ID) with status `\"pending\"` and the submitted - treatments appended to `treatments`. Its `checksum` changes, so any resource - derived from it can detect that the source has changed. Poll the inventory - until status returns to `\"completed\"`. + Returns this inventory (same ID) with status `\"pending\"`. Its `checksum` + changes immediately, so any resource derived from it can detect that the + source has changed. The submitted treatments appear in the inventory's + `treatments` list once processing completes — poll the inventory until + status returns to `\"completed\"`. + + If processing fails, the inventory's status becomes `\"failed\"` with error + details, the stored data is unchanged, and the queued treatments are + retained — submit another POST to retry (the new treatments are applied + together with the retained ones). + + ## Error Responses + + - **404 Not Found**: The inventory does not exist, is not owned by the + caller, or is not in this domain. + - **422 Unprocessable Content**: The inventory is not in `completed` status + (and is not a retryable failed treatment); the inventory has no `dbh` + column to thin against (e.g. CHM-derived); an inventory-wide basal-area + treatment over a very large domain; or a referenced `feature_id` is + missing, cross-domain, or not completed. + - **429 Too Many Requests**: You have too many active inventory jobs in + progress (your `max_active_inventories` quota). Wait for jobs to complete + or delete unneeded inventories, then retry. The response detail names the + exact `quota` and includes a `Retry-After` header. Args: domain_id (str): @@ -246,7 +292,7 @@ def sync( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - HTTPValidationError | Inventory + HTTPValidationError | Inventory | QuotaExceededDetail """ return sync_detailed( @@ -263,7 +309,7 @@ async def asyncio_detailed( *, client: AuthenticatedClient, body: ApplyTreatmentsRequest, -) -> Response[HTTPValidationError | Inventory]: +) -> Response[HTTPValidationError | Inventory | QuotaExceededDetail]: r"""Apply treatments to an inventory in place # Apply Treatments to an Inventory (in place) @@ -320,10 +366,30 @@ async def asyncio_detailed( ## Response - Returns this inventory (same ID) with status `\"pending\"` and the submitted - treatments appended to `treatments`. Its `checksum` changes, so any resource - derived from it can detect that the source has changed. Poll the inventory - until status returns to `\"completed\"`. + Returns this inventory (same ID) with status `\"pending\"`. Its `checksum` + changes immediately, so any resource derived from it can detect that the + source has changed. The submitted treatments appear in the inventory's + `treatments` list once processing completes — poll the inventory until + status returns to `\"completed\"`. + + If processing fails, the inventory's status becomes `\"failed\"` with error + details, the stored data is unchanged, and the queued treatments are + retained — submit another POST to retry (the new treatments are applied + together with the retained ones). + + ## Error Responses + + - **404 Not Found**: The inventory does not exist, is not owned by the + caller, or is not in this domain. + - **422 Unprocessable Content**: The inventory is not in `completed` status + (and is not a retryable failed treatment); the inventory has no `dbh` + column to thin against (e.g. CHM-derived); an inventory-wide basal-area + treatment over a very large domain; or a referenced `feature_id` is + missing, cross-domain, or not completed. + - **429 Too Many Requests**: You have too many active inventory jobs in + progress (your `max_active_inventories` quota). Wait for jobs to complete + or delete unneeded inventories, then retry. The response detail names the + exact `quota` and includes a `Retry-After` header. Args: domain_id (str): @@ -339,7 +405,7 @@ async def asyncio_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[HTTPValidationError | Inventory] + Response[HTTPValidationError | Inventory | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -359,7 +425,7 @@ async def asyncio( *, client: AuthenticatedClient, body: ApplyTreatmentsRequest, -) -> HTTPValidationError | Inventory | None: +) -> HTTPValidationError | Inventory | QuotaExceededDetail | None: r"""Apply treatments to an inventory in place # Apply Treatments to an Inventory (in place) @@ -416,10 +482,30 @@ async def asyncio( ## Response - Returns this inventory (same ID) with status `\"pending\"` and the submitted - treatments appended to `treatments`. Its `checksum` changes, so any resource - derived from it can detect that the source has changed. Poll the inventory - until status returns to `\"completed\"`. + Returns this inventory (same ID) with status `\"pending\"`. Its `checksum` + changes immediately, so any resource derived from it can detect that the + source has changed. The submitted treatments appear in the inventory's + `treatments` list once processing completes — poll the inventory until + status returns to `\"completed\"`. + + If processing fails, the inventory's status becomes `\"failed\"` with error + details, the stored data is unchanged, and the queued treatments are + retained — submit another POST to retry (the new treatments are applied + together with the retained ones). + + ## Error Responses + + - **404 Not Found**: The inventory does not exist, is not owned by the + caller, or is not in this domain. + - **422 Unprocessable Content**: The inventory is not in `completed` status + (and is not a retryable failed treatment); the inventory has no `dbh` + column to thin against (e.g. CHM-derived); an inventory-wide basal-area + treatment over a very large domain; or a referenced `feature_id` is + missing, cross-domain, or not completed. + - **429 Too Many Requests**: You have too many active inventory jobs in + progress (your `max_active_inventories` quota). Wait for jobs to complete + or delete unneeded inventories, then retry. The response detail names the + exact `quota` and includes a `Retry-After` header. Args: domain_id (str): @@ -435,7 +521,7 @@ async def asyncio( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - HTTPValidationError | Inventory + HTTPValidationError | Inventory | QuotaExceededDetail """ return ( diff --git a/fastfuels_sdk/v2/client_library/api/inventories/create_chm_inventory.py b/fastfuels_sdk/v2/client_library/api/inventories/create_chm_inventory.py index 42e2ba2..a549b76 100644 --- a/fastfuels_sdk/v2/client_library/api/inventories/create_chm_inventory.py +++ b/fastfuels_sdk/v2/client_library/api/inventories/create_chm_inventory.py @@ -9,6 +9,7 @@ from ...models.create_chm_inventory_request import CreateChmInventoryRequest from ...models.http_validation_error import HTTPValidationError from ...models.inventory import Inventory +from ...models.quota_exceeded_detail import QuotaExceededDetail from ...types import Response @@ -36,7 +37,7 @@ def _get_kwargs( def _parse_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> HTTPValidationError | Inventory | None: +) -> HTTPValidationError | Inventory | QuotaExceededDetail | None: if response.status_code == 201: response_201 = Inventory.from_dict(response.json()) @@ -47,6 +48,11 @@ def _parse_response( return response_422 + if response.status_code == 429: + response_429 = QuotaExceededDetail.from_dict(response.json()) + + return response_429 + if client.raise_on_unexpected_status: raise errors.UnexpectedStatus(response.status_code, response.content) else: @@ -55,7 +61,7 @@ def _parse_response( def _build_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[HTTPValidationError | Inventory]: +) -> Response[HTTPValidationError | Inventory | QuotaExceededDetail]: return Response( status_code=HTTPStatus(response.status_code), content=response.content, @@ -69,7 +75,7 @@ def sync_detailed( *, client: AuthenticatedClient, body: CreateChmInventoryRequest, -) -> Response[HTTPValidationError | Inventory]: +) -> Response[HTTPValidationError | Inventory | QuotaExceededDetail]: r"""Create an inventory from a Canopy Height Model (CHM) # Create CHM Extraction Inventory @@ -108,7 +114,7 @@ def sync_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[HTTPValidationError | Inventory] + Response[HTTPValidationError | Inventory | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -128,7 +134,7 @@ def sync( *, client: AuthenticatedClient, body: CreateChmInventoryRequest, -) -> HTTPValidationError | Inventory | None: +) -> HTTPValidationError | Inventory | QuotaExceededDetail | None: r"""Create an inventory from a Canopy Height Model (CHM) # Create CHM Extraction Inventory @@ -167,7 +173,7 @@ def sync( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - HTTPValidationError | Inventory + HTTPValidationError | Inventory | QuotaExceededDetail """ return sync_detailed( @@ -182,7 +188,7 @@ async def asyncio_detailed( *, client: AuthenticatedClient, body: CreateChmInventoryRequest, -) -> Response[HTTPValidationError | Inventory]: +) -> Response[HTTPValidationError | Inventory | QuotaExceededDetail]: r"""Create an inventory from a Canopy Height Model (CHM) # Create CHM Extraction Inventory @@ -221,7 +227,7 @@ async def asyncio_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[HTTPValidationError | Inventory] + Response[HTTPValidationError | Inventory | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -239,7 +245,7 @@ async def asyncio( *, client: AuthenticatedClient, body: CreateChmInventoryRequest, -) -> HTTPValidationError | Inventory | None: +) -> HTTPValidationError | Inventory | QuotaExceededDetail | None: r"""Create an inventory from a Canopy Height Model (CHM) # Create CHM Extraction Inventory @@ -278,7 +284,7 @@ async def asyncio( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - HTTPValidationError | Inventory + HTTPValidationError | Inventory | QuotaExceededDetail """ return ( diff --git a/fastfuels_sdk/v2/client_library/api/inventories/create_gdam_inventory.py b/fastfuels_sdk/v2/client_library/api/inventories/create_gdam_inventory.py index 7b47dcf..26306a6 100644 --- a/fastfuels_sdk/v2/client_library/api/inventories/create_gdam_inventory.py +++ b/fastfuels_sdk/v2/client_library/api/inventories/create_gdam_inventory.py @@ -9,6 +9,7 @@ from ...models.create_gdam_inventory_request import CreateGdamInventoryRequest from ...models.http_validation_error import HTTPValidationError from ...models.inventory import Inventory +from ...models.quota_exceeded_detail import QuotaExceededDetail from ...types import Response @@ -36,7 +37,7 @@ def _get_kwargs( def _parse_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> HTTPValidationError | Inventory | None: +) -> HTTPValidationError | Inventory | QuotaExceededDetail | None: if response.status_code == 201: response_201 = Inventory.from_dict(response.json()) @@ -47,6 +48,11 @@ def _parse_response( return response_422 + if response.status_code == 429: + response_429 = QuotaExceededDetail.from_dict(response.json()) + + return response_429 + if client.raise_on_unexpected_status: raise errors.UnexpectedStatus(response.status_code, response.content) else: @@ -55,7 +61,7 @@ def _parse_response( def _build_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[HTTPValidationError | Inventory]: +) -> Response[HTTPValidationError | Inventory | QuotaExceededDetail]: return Response( status_code=HTTPStatus(response.status_code), content=response.content, @@ -69,7 +75,7 @@ def sync_detailed( *, client: AuthenticatedClient, body: CreateGdamInventoryRequest, -) -> Response[HTTPValidationError | Inventory]: +) -> Response[HTTPValidationError | Inventory | QuotaExceededDetail]: r"""Create an inventory by filling in another via GDAM # Create GDAM Allometry Inventory @@ -132,7 +138,7 @@ def sync_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[HTTPValidationError | Inventory] + Response[HTTPValidationError | Inventory | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -152,7 +158,7 @@ def sync( *, client: AuthenticatedClient, body: CreateGdamInventoryRequest, -) -> HTTPValidationError | Inventory | None: +) -> HTTPValidationError | Inventory | QuotaExceededDetail | None: r"""Create an inventory by filling in another via GDAM # Create GDAM Allometry Inventory @@ -215,7 +221,7 @@ def sync( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - HTTPValidationError | Inventory + HTTPValidationError | Inventory | QuotaExceededDetail """ return sync_detailed( @@ -230,7 +236,7 @@ async def asyncio_detailed( *, client: AuthenticatedClient, body: CreateGdamInventoryRequest, -) -> Response[HTTPValidationError | Inventory]: +) -> Response[HTTPValidationError | Inventory | QuotaExceededDetail]: r"""Create an inventory by filling in another via GDAM # Create GDAM Allometry Inventory @@ -293,7 +299,7 @@ async def asyncio_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[HTTPValidationError | Inventory] + Response[HTTPValidationError | Inventory | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -311,7 +317,7 @@ async def asyncio( *, client: AuthenticatedClient, body: CreateGdamInventoryRequest, -) -> HTTPValidationError | Inventory | None: +) -> HTTPValidationError | Inventory | QuotaExceededDetail | None: r"""Create an inventory by filling in another via GDAM # Create GDAM Allometry Inventory @@ -374,7 +380,7 @@ async def asyncio( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - HTTPValidationError | Inventory + HTTPValidationError | Inventory | QuotaExceededDetail """ return ( diff --git a/fastfuels_sdk/v2/client_library/api/inventories/create_inventory_export.py b/fastfuels_sdk/v2/client_library/api/inventories/create_inventory_export.py index adc408a..d71d8c6 100644 --- a/fastfuels_sdk/v2/client_library/api/inventories/create_inventory_export.py +++ b/fastfuels_sdk/v2/client_library/api/inventories/create_inventory_export.py @@ -10,6 +10,7 @@ from ...models.export_inventory_request import ExportInventoryRequest from ...models.http_validation_error import HTTPValidationError from ...models.inventory_export_format import InventoryExportFormat +from ...models.quota_exceeded_detail import QuotaExceededDetail from ...types import Response @@ -41,7 +42,7 @@ def _get_kwargs( def _parse_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Export | HTTPValidationError | None: +) -> Export | HTTPValidationError | QuotaExceededDetail | None: if response.status_code == 201: response_201 = Export.from_dict(response.json()) @@ -52,6 +53,11 @@ def _parse_response( return response_422 + if response.status_code == 429: + response_429 = QuotaExceededDetail.from_dict(response.json()) + + return response_429 + if client.raise_on_unexpected_status: raise errors.UnexpectedStatus(response.status_code, response.content) else: @@ -60,7 +66,7 @@ def _parse_response( def _build_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[Export | HTTPValidationError]: +) -> Response[Export | HTTPValidationError | QuotaExceededDetail]: return Response( status_code=HTTPStatus(response.status_code), content=response.content, @@ -76,7 +82,7 @@ def sync_detailed( *, client: AuthenticatedClient, body: ExportInventoryRequest, -) -> Response[Export | HTTPValidationError]: +) -> Response[Export | HTTPValidationError | QuotaExceededDetail]: """Export an inventory Export an inventory to the specified format. @@ -104,7 +110,7 @@ def sync_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Export | HTTPValidationError] + Response[Export | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -128,7 +134,7 @@ def sync( *, client: AuthenticatedClient, body: ExportInventoryRequest, -) -> Export | HTTPValidationError | None: +) -> Export | HTTPValidationError | QuotaExceededDetail | None: """Export an inventory Export an inventory to the specified format. @@ -156,7 +162,7 @@ def sync( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Export | HTTPValidationError + Export | HTTPValidationError | QuotaExceededDetail """ return sync_detailed( @@ -175,7 +181,7 @@ async def asyncio_detailed( *, client: AuthenticatedClient, body: ExportInventoryRequest, -) -> Response[Export | HTTPValidationError]: +) -> Response[Export | HTTPValidationError | QuotaExceededDetail]: """Export an inventory Export an inventory to the specified format. @@ -203,7 +209,7 @@ async def asyncio_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[Export | HTTPValidationError] + Response[Export | HTTPValidationError | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -225,7 +231,7 @@ async def asyncio( *, client: AuthenticatedClient, body: ExportInventoryRequest, -) -> Export | HTTPValidationError | None: +) -> Export | HTTPValidationError | QuotaExceededDetail | None: """Export an inventory Export an inventory to the specified format. @@ -253,7 +259,7 @@ async def asyncio( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Export | HTTPValidationError + Export | HTTPValidationError | QuotaExceededDetail """ return ( diff --git a/fastfuels_sdk/v2/client_library/api/inventories/create_inventory_upload.py b/fastfuels_sdk/v2/client_library/api/inventories/create_inventory_upload.py index f7705d2..250e051 100644 --- a/fastfuels_sdk/v2/client_library/api/inventories/create_inventory_upload.py +++ b/fastfuels_sdk/v2/client_library/api/inventories/create_inventory_upload.py @@ -9,6 +9,7 @@ from ...models.create_inventory_upload_request import CreateInventoryUploadRequest from ...models.http_validation_error import HTTPValidationError from ...models.inventory_upload_created_response import InventoryUploadCreatedResponse +from ...models.quota_exceeded_detail import QuotaExceededDetail from ...types import Response @@ -36,7 +37,7 @@ def _get_kwargs( def _parse_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> HTTPValidationError | InventoryUploadCreatedResponse | None: +) -> HTTPValidationError | InventoryUploadCreatedResponse | QuotaExceededDetail | None: if response.status_code == 201: response_201 = InventoryUploadCreatedResponse.from_dict(response.json()) @@ -47,6 +48,11 @@ def _parse_response( return response_422 + if response.status_code == 429: + response_429 = QuotaExceededDetail.from_dict(response.json()) + + return response_429 + if client.raise_on_unexpected_status: raise errors.UnexpectedStatus(response.status_code, response.content) else: @@ -55,7 +61,9 @@ def _parse_response( def _build_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[HTTPValidationError | InventoryUploadCreatedResponse]: +) -> Response[ + HTTPValidationError | InventoryUploadCreatedResponse | QuotaExceededDetail +]: return Response( status_code=HTTPStatus(response.status_code), content=response.content, @@ -69,7 +77,9 @@ def sync_detailed( *, client: AuthenticatedClient, body: CreateInventoryUploadRequest, -) -> Response[HTTPValidationError | InventoryUploadCreatedResponse]: +) -> Response[ + HTTPValidationError | InventoryUploadCreatedResponse | QuotaExceededDetail +]: r"""Create an inventory from a direct file upload # Create Upload Inventory @@ -112,7 +122,7 @@ def sync_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[HTTPValidationError | InventoryUploadCreatedResponse] + Response[HTTPValidationError | InventoryUploadCreatedResponse | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -132,7 +142,7 @@ def sync( *, client: AuthenticatedClient, body: CreateInventoryUploadRequest, -) -> HTTPValidationError | InventoryUploadCreatedResponse | None: +) -> HTTPValidationError | InventoryUploadCreatedResponse | QuotaExceededDetail | None: r"""Create an inventory from a direct file upload # Create Upload Inventory @@ -175,7 +185,7 @@ def sync( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - HTTPValidationError | InventoryUploadCreatedResponse + HTTPValidationError | InventoryUploadCreatedResponse | QuotaExceededDetail """ return sync_detailed( @@ -190,7 +200,9 @@ async def asyncio_detailed( *, client: AuthenticatedClient, body: CreateInventoryUploadRequest, -) -> Response[HTTPValidationError | InventoryUploadCreatedResponse]: +) -> Response[ + HTTPValidationError | InventoryUploadCreatedResponse | QuotaExceededDetail +]: r"""Create an inventory from a direct file upload # Create Upload Inventory @@ -233,7 +245,7 @@ async def asyncio_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[HTTPValidationError | InventoryUploadCreatedResponse] + Response[HTTPValidationError | InventoryUploadCreatedResponse | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -251,7 +263,7 @@ async def asyncio( *, client: AuthenticatedClient, body: CreateInventoryUploadRequest, -) -> HTTPValidationError | InventoryUploadCreatedResponse | None: +) -> HTTPValidationError | InventoryUploadCreatedResponse | QuotaExceededDetail | None: r"""Create an inventory from a direct file upload # Create Upload Inventory @@ -294,7 +306,7 @@ async def asyncio( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - HTTPValidationError | InventoryUploadCreatedResponse + HTTPValidationError | InventoryUploadCreatedResponse | QuotaExceededDetail """ return ( diff --git a/fastfuels_sdk/v2/client_library/api/inventories/create_pim_inventory.py b/fastfuels_sdk/v2/client_library/api/inventories/create_pim_inventory.py index aea0a3c..e5b0ea7 100644 --- a/fastfuels_sdk/v2/client_library/api/inventories/create_pim_inventory.py +++ b/fastfuels_sdk/v2/client_library/api/inventories/create_pim_inventory.py @@ -9,6 +9,7 @@ from ...models.create_pim_inventory_request import CreatePimInventoryRequest from ...models.http_validation_error import HTTPValidationError from ...models.inventory import Inventory +from ...models.quota_exceeded_detail import QuotaExceededDetail from ...types import Response @@ -36,7 +37,7 @@ def _get_kwargs( def _parse_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> HTTPValidationError | Inventory | None: +) -> HTTPValidationError | Inventory | QuotaExceededDetail | None: if response.status_code == 201: response_201 = Inventory.from_dict(response.json()) @@ -47,6 +48,11 @@ def _parse_response( return response_422 + if response.status_code == 429: + response_429 = QuotaExceededDetail.from_dict(response.json()) + + return response_429 + if client.raise_on_unexpected_status: raise errors.UnexpectedStatus(response.status_code, response.content) else: @@ -55,7 +61,7 @@ def _parse_response( def _build_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[HTTPValidationError | Inventory]: +) -> Response[HTTPValidationError | Inventory | QuotaExceededDetail]: return Response( status_code=HTTPStatus(response.status_code), content=response.content, @@ -69,7 +75,7 @@ def sync_detailed( *, client: AuthenticatedClient, body: CreatePimInventoryRequest, -) -> Response[HTTPValidationError | Inventory]: +) -> Response[HTTPValidationError | Inventory | QuotaExceededDetail]: r"""Create an inventory from PIM expansion # Create PIM Expansion Inventory @@ -127,7 +133,7 @@ def sync_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[HTTPValidationError | Inventory] + Response[HTTPValidationError | Inventory | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -147,7 +153,7 @@ def sync( *, client: AuthenticatedClient, body: CreatePimInventoryRequest, -) -> HTTPValidationError | Inventory | None: +) -> HTTPValidationError | Inventory | QuotaExceededDetail | None: r"""Create an inventory from PIM expansion # Create PIM Expansion Inventory @@ -205,7 +211,7 @@ def sync( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - HTTPValidationError | Inventory + HTTPValidationError | Inventory | QuotaExceededDetail """ return sync_detailed( @@ -220,7 +226,7 @@ async def asyncio_detailed( *, client: AuthenticatedClient, body: CreatePimInventoryRequest, -) -> Response[HTTPValidationError | Inventory]: +) -> Response[HTTPValidationError | Inventory | QuotaExceededDetail]: r"""Create an inventory from PIM expansion # Create PIM Expansion Inventory @@ -278,7 +284,7 @@ async def asyncio_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[HTTPValidationError | Inventory] + Response[HTTPValidationError | Inventory | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -296,7 +302,7 @@ async def asyncio( *, client: AuthenticatedClient, body: CreatePimInventoryRequest, -) -> HTTPValidationError | Inventory | None: +) -> HTTPValidationError | Inventory | QuotaExceededDetail | None: r"""Create an inventory from PIM expansion # Create PIM Expansion Inventory @@ -354,7 +360,7 @@ async def asyncio( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - HTTPValidationError | Inventory + HTTPValidationError | Inventory | QuotaExceededDetail """ return ( diff --git a/fastfuels_sdk/v2/client_library/api/inventories/duplicate_inventory.py b/fastfuels_sdk/v2/client_library/api/inventories/duplicate_inventory.py index faf94cc..1902763 100644 --- a/fastfuels_sdk/v2/client_library/api/inventories/duplicate_inventory.py +++ b/fastfuels_sdk/v2/client_library/api/inventories/duplicate_inventory.py @@ -9,6 +9,7 @@ from ...models.duplicate_inventory_request import DuplicateInventoryRequest from ...models.http_validation_error import HTTPValidationError from ...models.inventory import Inventory +from ...models.quota_exceeded_detail import QuotaExceededDetail from ...types import UNSET, Response, Unset @@ -41,7 +42,7 @@ def _get_kwargs( def _parse_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> HTTPValidationError | Inventory | None: +) -> HTTPValidationError | Inventory | QuotaExceededDetail | None: if response.status_code == 201: response_201 = Inventory.from_dict(response.json()) @@ -52,6 +53,11 @@ def _parse_response( return response_422 + if response.status_code == 429: + response_429 = QuotaExceededDetail.from_dict(response.json()) + + return response_429 + if client.raise_on_unexpected_status: raise errors.UnexpectedStatus(response.status_code, response.content) else: @@ -60,7 +66,7 @@ def _parse_response( def _build_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[HTTPValidationError | Inventory]: +) -> Response[HTTPValidationError | Inventory | QuotaExceededDetail]: return Response( status_code=HTTPStatus(response.status_code), content=response.content, @@ -75,7 +81,7 @@ def sync_detailed( *, client: AuthenticatedClient, body: DuplicateInventoryRequest | None | Unset = UNSET, -) -> Response[HTTPValidationError | Inventory]: +) -> Response[HTTPValidationError | Inventory | QuotaExceededDetail]: r"""Duplicate an inventory # Duplicate an Inventory @@ -112,6 +118,10 @@ def sync_detailed( caller, or is not in this domain. - **422 Unprocessable Content**: The source inventory exists but is not yet `completed`, so there is no finished artifact to copy. + - **429 Too Many Requests**: You have too many active inventory jobs in + progress (your `max_active_inventories` quota). Wait for jobs to complete + or delete unneeded inventories, then retry. The response detail names the + exact `quota` and includes a `Retry-After` header. Args: domain_id (str): @@ -123,7 +133,7 @@ def sync_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[HTTPValidationError | Inventory] + Response[HTTPValidationError | Inventory | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -145,7 +155,7 @@ def sync( *, client: AuthenticatedClient, body: DuplicateInventoryRequest | None | Unset = UNSET, -) -> HTTPValidationError | Inventory | None: +) -> HTTPValidationError | Inventory | QuotaExceededDetail | None: r"""Duplicate an inventory # Duplicate an Inventory @@ -182,6 +192,10 @@ def sync( caller, or is not in this domain. - **422 Unprocessable Content**: The source inventory exists but is not yet `completed`, so there is no finished artifact to copy. + - **429 Too Many Requests**: You have too many active inventory jobs in + progress (your `max_active_inventories` quota). Wait for jobs to complete + or delete unneeded inventories, then retry. The response detail names the + exact `quota` and includes a `Retry-After` header. Args: domain_id (str): @@ -193,7 +207,7 @@ def sync( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - HTTPValidationError | Inventory + HTTPValidationError | Inventory | QuotaExceededDetail """ return sync_detailed( @@ -210,7 +224,7 @@ async def asyncio_detailed( *, client: AuthenticatedClient, body: DuplicateInventoryRequest | None | Unset = UNSET, -) -> Response[HTTPValidationError | Inventory]: +) -> Response[HTTPValidationError | Inventory | QuotaExceededDetail]: r"""Duplicate an inventory # Duplicate an Inventory @@ -247,6 +261,10 @@ async def asyncio_detailed( caller, or is not in this domain. - **422 Unprocessable Content**: The source inventory exists but is not yet `completed`, so there is no finished artifact to copy. + - **429 Too Many Requests**: You have too many active inventory jobs in + progress (your `max_active_inventories` quota). Wait for jobs to complete + or delete unneeded inventories, then retry. The response detail names the + exact `quota` and includes a `Retry-After` header. Args: domain_id (str): @@ -258,7 +276,7 @@ async def asyncio_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[HTTPValidationError | Inventory] + Response[HTTPValidationError | Inventory | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -278,7 +296,7 @@ async def asyncio( *, client: AuthenticatedClient, body: DuplicateInventoryRequest | None | Unset = UNSET, -) -> HTTPValidationError | Inventory | None: +) -> HTTPValidationError | Inventory | QuotaExceededDetail | None: r"""Duplicate an inventory # Duplicate an Inventory @@ -315,6 +333,10 @@ async def asyncio( caller, or is not in this domain. - **422 Unprocessable Content**: The source inventory exists but is not yet `completed`, so there is no finished artifact to copy. + - **429 Too Many Requests**: You have too many active inventory jobs in + progress (your `max_active_inventories` quota). Wait for jobs to complete + or delete unneeded inventories, then retry. The response detail names the + exact `quota` and includes a `Retry-After` header. Args: domain_id (str): @@ -326,7 +348,7 @@ async def asyncio( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - HTTPValidationError | Inventory + HTTPValidationError | Inventory | QuotaExceededDetail """ return ( diff --git a/fastfuels_sdk/v2/client_library/api/inventories/get_inventory_data_csv.py b/fastfuels_sdk/v2/client_library/api/inventories/get_inventory_data_csv.py new file mode 100644 index 0000000..1acca14 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/inventories/get_inventory_data_csv.py @@ -0,0 +1,349 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.http_validation_error import HTTPValidationError +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + domain_id: str, + inventory_id: str, + partition_index: int, + *, + columns: None | str | Unset = UNSET, +) -> dict[str, Any]: + + params: dict[str, Any] = {} + + json_columns: None | str | Unset + if isinstance(columns, Unset): + json_columns = UNSET + else: + json_columns = columns + params["columns"] = json_columns + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/domains/{domain_id}/inventories/{inventory_id}/data/{partition_index}/csv".format( + domain_id=quote(str(domain_id), safe=""), + inventory_id=quote(str(inventory_id), safe=""), + partition_index=quote(str(partition_index), safe=""), + ), + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> HTTPValidationError | str | None: + if response.status_code == 200: + response_200 = response.text + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[HTTPValidationError | str]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + inventory_id: str, + partition_index: int, + *, + client: AuthenticatedClient, + columns: None | str | Unset = UNSET, +) -> Response[HTTPValidationError | str]: + """Get inventory data for a partition (CSV) + + # Get Inventory Data (CSV) + + Reads a single partition of a completed inventory's Parquet data on GCS and + returns the tree records as a `text/csv` body with a header row. Use this + when you want to hand the response straight to a CSV reader. + + For a structured JSON payload, use the JSON variant of this endpoint (drop + the trailing `/csv`). + + ## Path Parameters + + - **domain_id**: The domain the inventory belongs to. + - **inventory_id**: The unique identifier of the inventory. + - **partition_index**: Zero-based partition index. + + ## Query Parameters + + - **columns**: Comma-separated column subset (default: all). + + ## Response + + A `text/csv` body, with partition metadata in these response headers: + + - `X-Partition-Index`: the partition this body came from. + - `X-Row-Count`: rows in this partition. + - `X-Total-Rows`: rows across all partitions of the inventory. + - `X-Num-Partitions`: total number of partitions. + + ## Error Responses + + - **404 Not Found**: Inventory does not exist or user does not have access. + - **422 Unprocessable Entity**: Inventory not completed, partition index + out of range, invalid column names, or metadata not available. + + Args: + domain_id (str): + inventory_id (str): + partition_index (int): Zero-based partition index. + columns (None | str | Unset): Comma-separated column subset. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | str] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + inventory_id=inventory_id, + partition_index=partition_index, + columns=columns, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + inventory_id: str, + partition_index: int, + *, + client: AuthenticatedClient, + columns: None | str | Unset = UNSET, +) -> HTTPValidationError | str | None: + """Get inventory data for a partition (CSV) + + # Get Inventory Data (CSV) + + Reads a single partition of a completed inventory's Parquet data on GCS and + returns the tree records as a `text/csv` body with a header row. Use this + when you want to hand the response straight to a CSV reader. + + For a structured JSON payload, use the JSON variant of this endpoint (drop + the trailing `/csv`). + + ## Path Parameters + + - **domain_id**: The domain the inventory belongs to. + - **inventory_id**: The unique identifier of the inventory. + - **partition_index**: Zero-based partition index. + + ## Query Parameters + + - **columns**: Comma-separated column subset (default: all). + + ## Response + + A `text/csv` body, with partition metadata in these response headers: + + - `X-Partition-Index`: the partition this body came from. + - `X-Row-Count`: rows in this partition. + - `X-Total-Rows`: rows across all partitions of the inventory. + - `X-Num-Partitions`: total number of partitions. + + ## Error Responses + + - **404 Not Found**: Inventory does not exist or user does not have access. + - **422 Unprocessable Entity**: Inventory not completed, partition index + out of range, invalid column names, or metadata not available. + + Args: + domain_id (str): + inventory_id (str): + partition_index (int): Zero-based partition index. + columns (None | str | Unset): Comma-separated column subset. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | str + """ + + return sync_detailed( + domain_id=domain_id, + inventory_id=inventory_id, + partition_index=partition_index, + client=client, + columns=columns, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + inventory_id: str, + partition_index: int, + *, + client: AuthenticatedClient, + columns: None | str | Unset = UNSET, +) -> Response[HTTPValidationError | str]: + """Get inventory data for a partition (CSV) + + # Get Inventory Data (CSV) + + Reads a single partition of a completed inventory's Parquet data on GCS and + returns the tree records as a `text/csv` body with a header row. Use this + when you want to hand the response straight to a CSV reader. + + For a structured JSON payload, use the JSON variant of this endpoint (drop + the trailing `/csv`). + + ## Path Parameters + + - **domain_id**: The domain the inventory belongs to. + - **inventory_id**: The unique identifier of the inventory. + - **partition_index**: Zero-based partition index. + + ## Query Parameters + + - **columns**: Comma-separated column subset (default: all). + + ## Response + + A `text/csv` body, with partition metadata in these response headers: + + - `X-Partition-Index`: the partition this body came from. + - `X-Row-Count`: rows in this partition. + - `X-Total-Rows`: rows across all partitions of the inventory. + - `X-Num-Partitions`: total number of partitions. + + ## Error Responses + + - **404 Not Found**: Inventory does not exist or user does not have access. + - **422 Unprocessable Entity**: Inventory not completed, partition index + out of range, invalid column names, or metadata not available. + + Args: + domain_id (str): + inventory_id (str): + partition_index (int): Zero-based partition index. + columns (None | str | Unset): Comma-separated column subset. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | str] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + inventory_id=inventory_id, + partition_index=partition_index, + columns=columns, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + inventory_id: str, + partition_index: int, + *, + client: AuthenticatedClient, + columns: None | str | Unset = UNSET, +) -> HTTPValidationError | str | None: + """Get inventory data for a partition (CSV) + + # Get Inventory Data (CSV) + + Reads a single partition of a completed inventory's Parquet data on GCS and + returns the tree records as a `text/csv` body with a header row. Use this + when you want to hand the response straight to a CSV reader. + + For a structured JSON payload, use the JSON variant of this endpoint (drop + the trailing `/csv`). + + ## Path Parameters + + - **domain_id**: The domain the inventory belongs to. + - **inventory_id**: The unique identifier of the inventory. + - **partition_index**: Zero-based partition index. + + ## Query Parameters + + - **columns**: Comma-separated column subset (default: all). + + ## Response + + A `text/csv` body, with partition metadata in these response headers: + + - `X-Partition-Index`: the partition this body came from. + - `X-Row-Count`: rows in this partition. + - `X-Total-Rows`: rows across all partitions of the inventory. + - `X-Num-Partitions`: total number of partitions. + + ## Error Responses + + - **404 Not Found**: Inventory does not exist or user does not have access. + - **422 Unprocessable Entity**: Inventory not completed, partition index + out of range, invalid column names, or metadata not available. + + Args: + domain_id (str): + inventory_id (str): + partition_index (int): Zero-based partition index. + columns (None | str | Unset): Comma-separated column subset. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | str + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + inventory_id=inventory_id, + partition_index=partition_index, + client=client, + columns=columns, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/inventories/get_inventory_data.py b/fastfuels_sdk/v2/client_library/api/inventories/get_inventory_data_json.py similarity index 77% rename from fastfuels_sdk/v2/client_library/api/inventories/get_inventory_data.py rename to fastfuels_sdk/v2/client_library/api/inventories/get_inventory_data_json.py index f5b7a87..968bcb4 100644 --- a/fastfuels_sdk/v2/client_library/api/inventories/get_inventory_data.py +++ b/fastfuels_sdk/v2/client_library/api/inventories/get_inventory_data_json.py @@ -7,7 +7,6 @@ from ... import errors from ...client import AuthenticatedClient, Client from ...models.http_validation_error import HTTPValidationError -from ...models.inventory_data_format import InventoryDataFormat from ...models.inventory_data_response import InventoryDataResponse from ...models.inventory_json_orientation import InventoryJsonOrientation from ...types import UNSET, Response, Unset @@ -18,19 +17,12 @@ def _get_kwargs( inventory_id: str, partition_index: int, *, - format_: InventoryDataFormat | Unset = UNSET, json_orientation: InventoryJsonOrientation | Unset = UNSET, columns: None | str | Unset = UNSET, ) -> dict[str, Any]: params: dict[str, Any] = {} - json_format_: str | Unset = UNSET - if not isinstance(format_, Unset): - json_format_ = format_.value - - params["format"] = json_format_ - json_json_orientation: str | Unset = UNSET if not isinstance(json_orientation, Unset): json_json_orientation = json_orientation.value @@ -95,16 +87,18 @@ def sync_detailed( partition_index: int, *, client: AuthenticatedClient, - format_: InventoryDataFormat | Unset = UNSET, json_orientation: InventoryJsonOrientation | Unset = UNSET, columns: None | str | Unset = UNSET, ) -> Response[HTTPValidationError | InventoryDataResponse]: - """Get inventory data for a partition + """Get inventory data for a partition (JSON) + + # Get Inventory Data (JSON) - # Get Inventory Data + Reads a single partition of a completed inventory's Parquet data on GCS and + returns the tree records as a JSON payload — either a compact columnar + layout or self-describing row objects, selected via `json_orientation`. - Reads a single partition of a completed inventory's Parquet data on GCS. - Returns the tree records as JSON (split or records orientation) or CSV. + For a CSV body, use the CSV variant of this endpoint (append `/csv`). ## Path Parameters @@ -114,19 +108,15 @@ def sync_detailed( ## Query Parameters - - **format**: Response format: `json` (default) or `csv`. - **json_orientation**: JSON layout: `split` (default, compact) or - `records` (self-describing). Ignored for CSV. + `records` (self-describing). - **columns**: Comma-separated column subset (default: all). ## Response - **JSON split** (default): column names + 2D array of values. + **split** (default): column names + 2D array of values. - **JSON records**: list of row objects. - - **CSV**: `text/csv` body with metadata in response headers - `X-Partition-Index`, `X-Row-Count`, `X-Total-Rows`, `X-Num-Partitions`. + **records**: list of row objects. ## Error Responses @@ -138,7 +128,6 @@ def sync_detailed( domain_id (str): inventory_id (str): partition_index (int): Zero-based partition index. - format_ (InventoryDataFormat | Unset): json_orientation (InventoryJsonOrientation | Unset): columns (None | str | Unset): Comma-separated column subset. @@ -154,7 +143,6 @@ def sync_detailed( domain_id=domain_id, inventory_id=inventory_id, partition_index=partition_index, - format_=format_, json_orientation=json_orientation, columns=columns, ) @@ -172,16 +160,18 @@ def sync( partition_index: int, *, client: AuthenticatedClient, - format_: InventoryDataFormat | Unset = UNSET, json_orientation: InventoryJsonOrientation | Unset = UNSET, columns: None | str | Unset = UNSET, ) -> HTTPValidationError | InventoryDataResponse | None: - """Get inventory data for a partition + """Get inventory data for a partition (JSON) + + # Get Inventory Data (JSON) - # Get Inventory Data + Reads a single partition of a completed inventory's Parquet data on GCS and + returns the tree records as a JSON payload — either a compact columnar + layout or self-describing row objects, selected via `json_orientation`. - Reads a single partition of a completed inventory's Parquet data on GCS. - Returns the tree records as JSON (split or records orientation) or CSV. + For a CSV body, use the CSV variant of this endpoint (append `/csv`). ## Path Parameters @@ -191,19 +181,15 @@ def sync( ## Query Parameters - - **format**: Response format: `json` (default) or `csv`. - **json_orientation**: JSON layout: `split` (default, compact) or - `records` (self-describing). Ignored for CSV. + `records` (self-describing). - **columns**: Comma-separated column subset (default: all). ## Response - **JSON split** (default): column names + 2D array of values. + **split** (default): column names + 2D array of values. - **JSON records**: list of row objects. - - **CSV**: `text/csv` body with metadata in response headers - `X-Partition-Index`, `X-Row-Count`, `X-Total-Rows`, `X-Num-Partitions`. + **records**: list of row objects. ## Error Responses @@ -215,7 +201,6 @@ def sync( domain_id (str): inventory_id (str): partition_index (int): Zero-based partition index. - format_ (InventoryDataFormat | Unset): json_orientation (InventoryJsonOrientation | Unset): columns (None | str | Unset): Comma-separated column subset. @@ -232,7 +217,6 @@ def sync( inventory_id=inventory_id, partition_index=partition_index, client=client, - format_=format_, json_orientation=json_orientation, columns=columns, ).parsed @@ -244,16 +228,18 @@ async def asyncio_detailed( partition_index: int, *, client: AuthenticatedClient, - format_: InventoryDataFormat | Unset = UNSET, json_orientation: InventoryJsonOrientation | Unset = UNSET, columns: None | str | Unset = UNSET, ) -> Response[HTTPValidationError | InventoryDataResponse]: - """Get inventory data for a partition + """Get inventory data for a partition (JSON) + + # Get Inventory Data (JSON) - # Get Inventory Data + Reads a single partition of a completed inventory's Parquet data on GCS and + returns the tree records as a JSON payload — either a compact columnar + layout or self-describing row objects, selected via `json_orientation`. - Reads a single partition of a completed inventory's Parquet data on GCS. - Returns the tree records as JSON (split or records orientation) or CSV. + For a CSV body, use the CSV variant of this endpoint (append `/csv`). ## Path Parameters @@ -263,19 +249,15 @@ async def asyncio_detailed( ## Query Parameters - - **format**: Response format: `json` (default) or `csv`. - **json_orientation**: JSON layout: `split` (default, compact) or - `records` (self-describing). Ignored for CSV. + `records` (self-describing). - **columns**: Comma-separated column subset (default: all). ## Response - **JSON split** (default): column names + 2D array of values. - - **JSON records**: list of row objects. + **split** (default): column names + 2D array of values. - **CSV**: `text/csv` body with metadata in response headers - `X-Partition-Index`, `X-Row-Count`, `X-Total-Rows`, `X-Num-Partitions`. + **records**: list of row objects. ## Error Responses @@ -287,7 +269,6 @@ async def asyncio_detailed( domain_id (str): inventory_id (str): partition_index (int): Zero-based partition index. - format_ (InventoryDataFormat | Unset): json_orientation (InventoryJsonOrientation | Unset): columns (None | str | Unset): Comma-separated column subset. @@ -303,7 +284,6 @@ async def asyncio_detailed( domain_id=domain_id, inventory_id=inventory_id, partition_index=partition_index, - format_=format_, json_orientation=json_orientation, columns=columns, ) @@ -319,16 +299,18 @@ async def asyncio( partition_index: int, *, client: AuthenticatedClient, - format_: InventoryDataFormat | Unset = UNSET, json_orientation: InventoryJsonOrientation | Unset = UNSET, columns: None | str | Unset = UNSET, ) -> HTTPValidationError | InventoryDataResponse | None: - """Get inventory data for a partition + """Get inventory data for a partition (JSON) - # Get Inventory Data + # Get Inventory Data (JSON) - Reads a single partition of a completed inventory's Parquet data on GCS. - Returns the tree records as JSON (split or records orientation) or CSV. + Reads a single partition of a completed inventory's Parquet data on GCS and + returns the tree records as a JSON payload — either a compact columnar + layout or self-describing row objects, selected via `json_orientation`. + + For a CSV body, use the CSV variant of this endpoint (append `/csv`). ## Path Parameters @@ -338,19 +320,15 @@ async def asyncio( ## Query Parameters - - **format**: Response format: `json` (default) or `csv`. - **json_orientation**: JSON layout: `split` (default, compact) or - `records` (self-describing). Ignored for CSV. + `records` (self-describing). - **columns**: Comma-separated column subset (default: all). ## Response - **JSON split** (default): column names + 2D array of values. - - **JSON records**: list of row objects. + **split** (default): column names + 2D array of values. - **CSV**: `text/csv` body with metadata in response headers - `X-Partition-Index`, `X-Row-Count`, `X-Total-Rows`, `X-Num-Partitions`. + **records**: list of row objects. ## Error Responses @@ -362,7 +340,6 @@ async def asyncio( domain_id (str): inventory_id (str): partition_index (int): Zero-based partition index. - format_ (InventoryDataFormat | Unset): json_orientation (InventoryJsonOrientation | Unset): columns (None | str | Unset): Comma-separated column subset. @@ -380,7 +357,6 @@ async def asyncio( inventory_id=inventory_id, partition_index=partition_index, client=client, - format_=format_, json_orientation=json_orientation, columns=columns, ) diff --git a/fastfuels_sdk/v2/client_library/api/point_clouds/check_3dep_point_cloud_coverage.py b/fastfuels_sdk/v2/client_library/api/point_clouds/check_3dep_point_cloud_coverage.py new file mode 100644 index 0000000..3150a88 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/point_clouds/check_3dep_point_cloud_coverage.py @@ -0,0 +1,259 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.http_validation_error import HTTPValidationError +from ...models.point_cloud_three_dep_coverage_response import ( + PointCloudThreeDepCoverageResponse, +) +from ...types import Response + + +def _get_kwargs( + domain_id: str, +) -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/domains/{domain_id}/pointclouds/3dep/coverage".format( + domain_id=quote(str(domain_id), safe=""), + ), + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> HTTPValidationError | PointCloudThreeDepCoverageResponse | None: + if response.status_code == 200: + response_200 = PointCloudThreeDepCoverageResponse.from_dict(response.json()) + + return response_200 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[HTTPValidationError | PointCloudThreeDepCoverageResponse]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + *, + client: AuthenticatedClient, +) -> Response[HTTPValidationError | PointCloudThreeDepCoverageResponse]: + """Check 3DEP lidar coverage for a domain + + # Check 3DEP Lidar Coverage + + Immediate pre-flight check reporting which USGS 3DEP lidar surveys are + available for this domain, how much of it they cover, and roughly how many + points a fetch would return. Use it before creating a 3DEP point cloud to + avoid waiting on a background job only to find a coverage gap — 3DEP is + regional, and survey boundaries are irregular. + + This checks lidar point clouds. Elevation raster coverage is a separate + product with its own check at + `GET /domains/{domain_id}/grids/topography/3dep/coverage`. + + ## Response + + Reports whether any lidar is available, the fraction of the domain covered, + the surveys that would be read with what each contributes, and the + estimated point count against the per-fetch budget. `datasets[].name` + values can be passed as `datasets` when creating the point cloud to pin the + fetch. + + ## Error Responses + + - **503**: The USGS 3DEP catalog is temporarily unreachable. + + Args: + domain_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | PointCloudThreeDepCoverageResponse] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + *, + client: AuthenticatedClient, +) -> HTTPValidationError | PointCloudThreeDepCoverageResponse | None: + """Check 3DEP lidar coverage for a domain + + # Check 3DEP Lidar Coverage + + Immediate pre-flight check reporting which USGS 3DEP lidar surveys are + available for this domain, how much of it they cover, and roughly how many + points a fetch would return. Use it before creating a 3DEP point cloud to + avoid waiting on a background job only to find a coverage gap — 3DEP is + regional, and survey boundaries are irregular. + + This checks lidar point clouds. Elevation raster coverage is a separate + product with its own check at + `GET /domains/{domain_id}/grids/topography/3dep/coverage`. + + ## Response + + Reports whether any lidar is available, the fraction of the domain covered, + the surveys that would be read with what each contributes, and the + estimated point count against the per-fetch budget. `datasets[].name` + values can be passed as `datasets` when creating the point cloud to pin the + fetch. + + ## Error Responses + + - **503**: The USGS 3DEP catalog is temporarily unreachable. + + Args: + domain_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | PointCloudThreeDepCoverageResponse + """ + + return sync_detailed( + domain_id=domain_id, + client=client, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + *, + client: AuthenticatedClient, +) -> Response[HTTPValidationError | PointCloudThreeDepCoverageResponse]: + """Check 3DEP lidar coverage for a domain + + # Check 3DEP Lidar Coverage + + Immediate pre-flight check reporting which USGS 3DEP lidar surveys are + available for this domain, how much of it they cover, and roughly how many + points a fetch would return. Use it before creating a 3DEP point cloud to + avoid waiting on a background job only to find a coverage gap — 3DEP is + regional, and survey boundaries are irregular. + + This checks lidar point clouds. Elevation raster coverage is a separate + product with its own check at + `GET /domains/{domain_id}/grids/topography/3dep/coverage`. + + ## Response + + Reports whether any lidar is available, the fraction of the domain covered, + the surveys that would be read with what each contributes, and the + estimated point count against the per-fetch budget. `datasets[].name` + values can be passed as `datasets` when creating the point cloud to pin the + fetch. + + ## Error Responses + + - **503**: The USGS 3DEP catalog is temporarily unreachable. + + Args: + domain_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | PointCloudThreeDepCoverageResponse] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + *, + client: AuthenticatedClient, +) -> HTTPValidationError | PointCloudThreeDepCoverageResponse | None: + """Check 3DEP lidar coverage for a domain + + # Check 3DEP Lidar Coverage + + Immediate pre-flight check reporting which USGS 3DEP lidar surveys are + available for this domain, how much of it they cover, and roughly how many + points a fetch would return. Use it before creating a 3DEP point cloud to + avoid waiting on a background job only to find a coverage gap — 3DEP is + regional, and survey boundaries are irregular. + + This checks lidar point clouds. Elevation raster coverage is a separate + product with its own check at + `GET /domains/{domain_id}/grids/topography/3dep/coverage`. + + ## Response + + Reports whether any lidar is available, the fraction of the domain covered, + the surveys that would be read with what each contributes, and the + estimated point count against the per-fetch budget. `datasets[].name` + values can be passed as `datasets` when creating the point cloud to pin the + fetch. + + ## Error Responses + + - **503**: The USGS 3DEP catalog is temporarily unreachable. + + Args: + domain_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | PointCloudThreeDepCoverageResponse + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + client=client, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/point_clouds/create_3dep_point_cloud.py b/fastfuels_sdk/v2/client_library/api/point_clouds/create_3dep_point_cloud.py new file mode 100644 index 0000000..68ef124 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/point_clouds/create_3dep_point_cloud.py @@ -0,0 +1,418 @@ +from http import HTTPStatus +from typing import Any +from urllib.parse import quote + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.create_three_dep_point_cloud_request import ( + CreateThreeDepPointCloudRequest, +) +from ...models.http_validation_error import HTTPValidationError +from ...models.point_cloud import PointCloud +from ...models.quota_exceeded_detail import QuotaExceededDetail +from ...types import Response + + +def _get_kwargs( + domain_id: str, + *, + body: CreateThreeDepPointCloudRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/domains/{domain_id}/pointclouds/3dep".format( + domain_id=quote(str(domain_id), safe=""), + ), + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> HTTPValidationError | PointCloud | QuotaExceededDetail | None: + if response.status_code == 201: + response_201 = PointCloud.from_dict(response.json()) + + return response_201 + + if response.status_code == 422: + response_422 = HTTPValidationError.from_dict(response.json()) + + return response_422 + + if response.status_code == 429: + response_429 = QuotaExceededDetail.from_dict(response.json()) + + return response_429 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[HTTPValidationError | PointCloud | QuotaExceededDetail]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateThreeDepPointCloudRequest, +) -> Response[HTTPValidationError | PointCloud | QuotaExceededDetail]: + """Create a point cloud from USGS 3DEP + + # Create a Point Cloud from USGS 3DEP + + Fetches public airborne lidar from the USGS 3D Elevation Program for this + domain. The points are clipped to the domain, reprojected to the domain's + coordinate reference system, and stored as a point cloud you can build on — + most directly as a canopy height model, which in turn feeds a tree + inventory. + + The point cloud is returned immediately with `status` = `pending` and is + fetched in the background: `status` becomes `running`, then `completed` once + the points are stored and `georeference` and `summary` are filled in — or + `failed` if the fetch cannot be completed. Poll + `GET /domains/{domain_id}/pointclouds/{id}` to follow progress. + + 3DEP is airborne, so the resulting point cloud is always type `als`. There + is no acquisition type to choose. + + ## Choosing acquisitions + + 3DEP is published as separate surveys, which overlap and differ in age and + point density. By default the backend prefers a single survey that covers + the whole domain, and otherwise combines the fewest surveys that fill it — + each additional survey introduces a seam between flights of different dates + and densities. Pass `datasets` to pin the fetch to specific surveys + instead; check the coverage endpoint first to see what is available. + + Survey boundaries are irregular, so a domain is often covered to + 99-point-something percent rather than exactly 100. Any coverage above zero + produces a point cloud, and the fraction actually covered is recorded on the + result as `source.coverage_fraction` — check it if a gap would matter, since + `summary.density` is measured over the points that exist and looks healthy + either way. Use the coverage endpoint to see the shortfall before creating + anything. + + ## Request Body + + - **name**: (optional) Human-readable name. + - **description**: (optional) Longer free-text description. + - **tags**: (optional) Tags for organizing and filtering. + - **datasets**: (optional) Acquisition names to read, in priority order. + Omit to choose automatically. + + ## Coordinate reference system + + Points are reprojected to the domain's CRS. Only horizontal coordinates are + transformed — elevations are stored exactly as USGS published them, never + converted between reference surfaces. + + ## Error Responses + + - **422**: No 3DEP lidar covers this domain, a pinned acquisition is + unknown or does not overlap the domain, or the fetch would exceed the + point budget. + - **429**: A quota was exceeded. + - **503**: The USGS 3DEP catalog is temporarily unreachable. + + Args: + domain_id (str): + body (CreateThreeDepPointCloudRequest): Request body for fetching a point cloud from USGS + 3DEP. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | PointCloud | QuotaExceededDetail] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateThreeDepPointCloudRequest, +) -> HTTPValidationError | PointCloud | QuotaExceededDetail | None: + """Create a point cloud from USGS 3DEP + + # Create a Point Cloud from USGS 3DEP + + Fetches public airborne lidar from the USGS 3D Elevation Program for this + domain. The points are clipped to the domain, reprojected to the domain's + coordinate reference system, and stored as a point cloud you can build on — + most directly as a canopy height model, which in turn feeds a tree + inventory. + + The point cloud is returned immediately with `status` = `pending` and is + fetched in the background: `status` becomes `running`, then `completed` once + the points are stored and `georeference` and `summary` are filled in — or + `failed` if the fetch cannot be completed. Poll + `GET /domains/{domain_id}/pointclouds/{id}` to follow progress. + + 3DEP is airborne, so the resulting point cloud is always type `als`. There + is no acquisition type to choose. + + ## Choosing acquisitions + + 3DEP is published as separate surveys, which overlap and differ in age and + point density. By default the backend prefers a single survey that covers + the whole domain, and otherwise combines the fewest surveys that fill it — + each additional survey introduces a seam between flights of different dates + and densities. Pass `datasets` to pin the fetch to specific surveys + instead; check the coverage endpoint first to see what is available. + + Survey boundaries are irregular, so a domain is often covered to + 99-point-something percent rather than exactly 100. Any coverage above zero + produces a point cloud, and the fraction actually covered is recorded on the + result as `source.coverage_fraction` — check it if a gap would matter, since + `summary.density` is measured over the points that exist and looks healthy + either way. Use the coverage endpoint to see the shortfall before creating + anything. + + ## Request Body + + - **name**: (optional) Human-readable name. + - **description**: (optional) Longer free-text description. + - **tags**: (optional) Tags for organizing and filtering. + - **datasets**: (optional) Acquisition names to read, in priority order. + Omit to choose automatically. + + ## Coordinate reference system + + Points are reprojected to the domain's CRS. Only horizontal coordinates are + transformed — elevations are stored exactly as USGS published them, never + converted between reference surfaces. + + ## Error Responses + + - **422**: No 3DEP lidar covers this domain, a pinned acquisition is + unknown or does not overlap the domain, or the fetch would exceed the + point budget. + - **429**: A quota was exceeded. + - **503**: The USGS 3DEP catalog is temporarily unreachable. + + Args: + domain_id (str): + body (CreateThreeDepPointCloudRequest): Request body for fetching a point cloud from USGS + 3DEP. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | PointCloud | QuotaExceededDetail + """ + + return sync_detailed( + domain_id=domain_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateThreeDepPointCloudRequest, +) -> Response[HTTPValidationError | PointCloud | QuotaExceededDetail]: + """Create a point cloud from USGS 3DEP + + # Create a Point Cloud from USGS 3DEP + + Fetches public airborne lidar from the USGS 3D Elevation Program for this + domain. The points are clipped to the domain, reprojected to the domain's + coordinate reference system, and stored as a point cloud you can build on — + most directly as a canopy height model, which in turn feeds a tree + inventory. + + The point cloud is returned immediately with `status` = `pending` and is + fetched in the background: `status` becomes `running`, then `completed` once + the points are stored and `georeference` and `summary` are filled in — or + `failed` if the fetch cannot be completed. Poll + `GET /domains/{domain_id}/pointclouds/{id}` to follow progress. + + 3DEP is airborne, so the resulting point cloud is always type `als`. There + is no acquisition type to choose. + + ## Choosing acquisitions + + 3DEP is published as separate surveys, which overlap and differ in age and + point density. By default the backend prefers a single survey that covers + the whole domain, and otherwise combines the fewest surveys that fill it — + each additional survey introduces a seam between flights of different dates + and densities. Pass `datasets` to pin the fetch to specific surveys + instead; check the coverage endpoint first to see what is available. + + Survey boundaries are irregular, so a domain is often covered to + 99-point-something percent rather than exactly 100. Any coverage above zero + produces a point cloud, and the fraction actually covered is recorded on the + result as `source.coverage_fraction` — check it if a gap would matter, since + `summary.density` is measured over the points that exist and looks healthy + either way. Use the coverage endpoint to see the shortfall before creating + anything. + + ## Request Body + + - **name**: (optional) Human-readable name. + - **description**: (optional) Longer free-text description. + - **tags**: (optional) Tags for organizing and filtering. + - **datasets**: (optional) Acquisition names to read, in priority order. + Omit to choose automatically. + + ## Coordinate reference system + + Points are reprojected to the domain's CRS. Only horizontal coordinates are + transformed — elevations are stored exactly as USGS published them, never + converted between reference surfaces. + + ## Error Responses + + - **422**: No 3DEP lidar covers this domain, a pinned acquisition is + unknown or does not overlap the domain, or the fetch would exceed the + point budget. + - **429**: A quota was exceeded. + - **503**: The USGS 3DEP catalog is temporarily unreachable. + + Args: + domain_id (str): + body (CreateThreeDepPointCloudRequest): Request body for fetching a point cloud from USGS + 3DEP. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[HTTPValidationError | PointCloud | QuotaExceededDetail] + """ + + kwargs = _get_kwargs( + domain_id=domain_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + domain_id: str, + *, + client: AuthenticatedClient, + body: CreateThreeDepPointCloudRequest, +) -> HTTPValidationError | PointCloud | QuotaExceededDetail | None: + """Create a point cloud from USGS 3DEP + + # Create a Point Cloud from USGS 3DEP + + Fetches public airborne lidar from the USGS 3D Elevation Program for this + domain. The points are clipped to the domain, reprojected to the domain's + coordinate reference system, and stored as a point cloud you can build on — + most directly as a canopy height model, which in turn feeds a tree + inventory. + + The point cloud is returned immediately with `status` = `pending` and is + fetched in the background: `status` becomes `running`, then `completed` once + the points are stored and `georeference` and `summary` are filled in — or + `failed` if the fetch cannot be completed. Poll + `GET /domains/{domain_id}/pointclouds/{id}` to follow progress. + + 3DEP is airborne, so the resulting point cloud is always type `als`. There + is no acquisition type to choose. + + ## Choosing acquisitions + + 3DEP is published as separate surveys, which overlap and differ in age and + point density. By default the backend prefers a single survey that covers + the whole domain, and otherwise combines the fewest surveys that fill it — + each additional survey introduces a seam between flights of different dates + and densities. Pass `datasets` to pin the fetch to specific surveys + instead; check the coverage endpoint first to see what is available. + + Survey boundaries are irregular, so a domain is often covered to + 99-point-something percent rather than exactly 100. Any coverage above zero + produces a point cloud, and the fraction actually covered is recorded on the + result as `source.coverage_fraction` — check it if a gap would matter, since + `summary.density` is measured over the points that exist and looks healthy + either way. Use the coverage endpoint to see the shortfall before creating + anything. + + ## Request Body + + - **name**: (optional) Human-readable name. + - **description**: (optional) Longer free-text description. + - **tags**: (optional) Tags for organizing and filtering. + - **datasets**: (optional) Acquisition names to read, in priority order. + Omit to choose automatically. + + ## Coordinate reference system + + Points are reprojected to the domain's CRS. Only horizontal coordinates are + transformed — elevations are stored exactly as USGS published them, never + converted between reference surfaces. + + ## Error Responses + + - **422**: No 3DEP lidar covers this domain, a pinned acquisition is + unknown or does not overlap the domain, or the fetch would exceed the + point budget. + - **429**: A quota was exceeded. + - **503**: The USGS 3DEP catalog is temporarily unreachable. + + Args: + domain_id (str): + body (CreateThreeDepPointCloudRequest): Request body for fetching a point cloud from USGS + 3DEP. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + HTTPValidationError | PointCloud | QuotaExceededDetail + """ + + return ( + await asyncio_detailed( + domain_id=domain_id, + client=client, + body=body, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/point_clouds/create_point_cloud_upload.py b/fastfuels_sdk/v2/client_library/api/point_clouds/create_point_cloud_upload.py index 89a6be1..4b9fc79 100644 --- a/fastfuels_sdk/v2/client_library/api/point_clouds/create_point_cloud_upload.py +++ b/fastfuels_sdk/v2/client_library/api/point_clouds/create_point_cloud_upload.py @@ -11,6 +11,7 @@ from ...models.point_cloud_upload_created_response import ( PointCloudUploadCreatedResponse, ) +from ...models.quota_exceeded_detail import QuotaExceededDetail from ...types import Response @@ -38,7 +39,7 @@ def _get_kwargs( def _parse_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> HTTPValidationError | PointCloudUploadCreatedResponse | None: +) -> HTTPValidationError | PointCloudUploadCreatedResponse | QuotaExceededDetail | None: if response.status_code == 201: response_201 = PointCloudUploadCreatedResponse.from_dict(response.json()) @@ -49,6 +50,11 @@ def _parse_response( return response_422 + if response.status_code == 429: + response_429 = QuotaExceededDetail.from_dict(response.json()) + + return response_429 + if client.raise_on_unexpected_status: raise errors.UnexpectedStatus(response.status_code, response.content) else: @@ -57,7 +63,9 @@ def _parse_response( def _build_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[HTTPValidationError | PointCloudUploadCreatedResponse]: +) -> Response[ + HTTPValidationError | PointCloudUploadCreatedResponse | QuotaExceededDetail +]: return Response( status_code=HTTPStatus(response.status_code), content=response.content, @@ -71,7 +79,9 @@ def sync_detailed( *, client: AuthenticatedClient, body: CreatePointCloudUploadRequest, -) -> Response[HTTPValidationError | PointCloudUploadCreatedResponse]: +) -> Response[ + HTTPValidationError | PointCloudUploadCreatedResponse | QuotaExceededDetail +]: r"""Create a point cloud from a direct file upload # Upload a Point Cloud @@ -123,7 +133,7 @@ def sync_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[HTTPValidationError | PointCloudUploadCreatedResponse] + Response[HTTPValidationError | PointCloudUploadCreatedResponse | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -143,7 +153,7 @@ def sync( *, client: AuthenticatedClient, body: CreatePointCloudUploadRequest, -) -> HTTPValidationError | PointCloudUploadCreatedResponse | None: +) -> HTTPValidationError | PointCloudUploadCreatedResponse | QuotaExceededDetail | None: r"""Create a point cloud from a direct file upload # Upload a Point Cloud @@ -195,7 +205,7 @@ def sync( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - HTTPValidationError | PointCloudUploadCreatedResponse + HTTPValidationError | PointCloudUploadCreatedResponse | QuotaExceededDetail """ return sync_detailed( @@ -210,7 +220,9 @@ async def asyncio_detailed( *, client: AuthenticatedClient, body: CreatePointCloudUploadRequest, -) -> Response[HTTPValidationError | PointCloudUploadCreatedResponse]: +) -> Response[ + HTTPValidationError | PointCloudUploadCreatedResponse | QuotaExceededDetail +]: r"""Create a point cloud from a direct file upload # Upload a Point Cloud @@ -262,7 +274,7 @@ async def asyncio_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[HTTPValidationError | PointCloudUploadCreatedResponse] + Response[HTTPValidationError | PointCloudUploadCreatedResponse | QuotaExceededDetail] """ kwargs = _get_kwargs( @@ -280,7 +292,7 @@ async def asyncio( *, client: AuthenticatedClient, body: CreatePointCloudUploadRequest, -) -> HTTPValidationError | PointCloudUploadCreatedResponse | None: +) -> HTTPValidationError | PointCloudUploadCreatedResponse | QuotaExceededDetail | None: r"""Create a point cloud from a direct file upload # Upload a Point Cloud @@ -332,7 +344,7 @@ async def asyncio( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - HTTPValidationError | PointCloudUploadCreatedResponse + HTTPValidationError | PointCloudUploadCreatedResponse | QuotaExceededDetail """ return ( diff --git a/fastfuels_sdk/v2/client_library/api/users/__init__.py b/fastfuels_sdk/v2/client_library/api/users/__init__.py new file mode 100644 index 0000000..2d7c0b2 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/users/__init__.py @@ -0,0 +1 @@ +"""Contains endpoint functions for accessing the API""" diff --git a/fastfuels_sdk/v2/client_library/api/users/get_me.py b/fastfuels_sdk/v2/client_library/api/users/get_me.py new file mode 100644 index 0000000..367f883 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/users/get_me.py @@ -0,0 +1,136 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.user_me_response import UserMeResponse +from ...types import Response + + +def _get_kwargs() -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/users/me", + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> UserMeResponse | None: + if response.status_code == 200: + response_200 = UserMeResponse.from_dict(response.json()) + + return response_200 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[UserMeResponse]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, +) -> Response[UserMeResponse]: + """Get the authenticated owner + + Return the authenticated owner's identity, tier, and resolved quotas. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[UserMeResponse] + """ + + kwargs = _get_kwargs() + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, +) -> UserMeResponse | None: + """Get the authenticated owner + + Return the authenticated owner's identity, tier, and resolved quotas. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + UserMeResponse + """ + + return sync_detailed( + client=client, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, +) -> Response[UserMeResponse]: + """Get the authenticated owner + + Return the authenticated owner's identity, tier, and resolved quotas. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[UserMeResponse] + """ + + kwargs = _get_kwargs() + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, +) -> UserMeResponse | None: + """Get the authenticated owner + + Return the authenticated owner's identity, tier, and resolved quotas. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + UserMeResponse + """ + + return ( + await asyncio_detailed( + client=client, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/api/users/get_me_usage.py b/fastfuels_sdk/v2/client_library/api/users/get_me_usage.py new file mode 100644 index 0000000..57ae66d --- /dev/null +++ b/fastfuels_sdk/v2/client_library/api/users/get_me_usage.py @@ -0,0 +1,136 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.usage import Usage +from ...types import Response + + +def _get_kwargs() -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/users/me/usage", + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Usage | None: + if response.status_code == 200: + response_200 = Usage.from_dict(response.json()) + + return response_200 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Usage]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, +) -> Response[Usage]: + """Get the authenticated owner's usage + + Return current usage against the owner's resolved limits, per resource type. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Usage] + """ + + kwargs = _get_kwargs() + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, +) -> Usage | None: + """Get the authenticated owner's usage + + Return current usage against the owner's resolved limits, per resource type. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Usage + """ + + return sync_detailed( + client=client, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, +) -> Response[Usage]: + """Get the authenticated owner's usage + + Return current usage against the owner's resolved limits, per resource type. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Usage] + """ + + kwargs = _get_kwargs() + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, +) -> Usage | None: + """Get the authenticated owner's usage + + Return current usage against the owner's resolved limits, per resource type. + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Usage + """ + + return ( + await asyncio_detailed( + client=client, + ) + ).parsed diff --git a/fastfuels_sdk/v2/client_library/client.py b/fastfuels_sdk/v2/client_library/client.py index 0ab1589..a5f74f6 100644 --- a/fastfuels_sdk/v2/client_library/client.py +++ b/fastfuels_sdk/v2/client_library/client.py @@ -100,7 +100,7 @@ def __enter__(self) -> "Client": self.get_httpx_client().__enter__() return self - def __exit__(self, *args: Any, **kwargs: Any) -> None: + def __exit__(self, *args: object, **kwargs: Any) -> None: """Exit a context manager for internal httpx.Client (see httpx docs)""" self.get_httpx_client().__exit__(*args, **kwargs) @@ -131,7 +131,7 @@ async def __aenter__(self) -> "Client": await self.get_async_httpx_client().__aenter__() return self - async def __aexit__(self, *args: Any, **kwargs: Any) -> None: + async def __aexit__(self, *args: object, **kwargs: Any) -> None: """Exit a context manager for underlying httpx.AsyncClient (see httpx docs)""" await self.get_async_httpx_client().__aexit__(*args, **kwargs) @@ -241,7 +241,7 @@ def __enter__(self) -> "AuthenticatedClient": self.get_httpx_client().__enter__() return self - def __exit__(self, *args: Any, **kwargs: Any) -> None: + def __exit__(self, *args: object, **kwargs: Any) -> None: """Exit a context manager for internal httpx.Client (see httpx docs)""" self.get_httpx_client().__exit__(*args, **kwargs) @@ -277,6 +277,6 @@ async def __aenter__(self) -> "AuthenticatedClient": await self.get_async_httpx_client().__aenter__() return self - async def __aexit__(self, *args: Any, **kwargs: Any) -> None: + async def __aexit__(self, *args: object, **kwargs: Any) -> None: """Exit a context manager for underlying httpx.AsyncClient (see httpx docs)""" await self.get_async_httpx_client().__aexit__(*args, **kwargs) diff --git a/fastfuels_sdk/v2/client_library/models/__init__.py b/fastfuels_sdk/v2/client_library/models/__init__.py index 997bde2..1e2a645 100644 --- a/fastfuels_sdk/v2/client_library/models/__init__.py +++ b/fastfuels_sdk/v2/client_library/models/__init__.py @@ -7,6 +7,7 @@ ) from .allometry_max_crown_radius_source import AllometryMaxCrownRadiusSource from .application import Application +from .application_quota_overrides_type_0 import ApplicationQuotaOverridesType0 from .apply_grid_modifications_request import ApplyGridModificationsRequest from .apply_modifications_request import ApplyModificationsRequest from .apply_treatments_request import ApplyTreatmentsRequest @@ -18,15 +19,29 @@ from .biomass_equations import BiomassEquations from .biomass_unit import BiomassUnit from .categorical_band_summary import CategoricalBandSummary +from .categorical_column_summary import CategoricalColumnSummary from .chunks import Chunks from .chunks_count_by_axis_type_0 import ChunksCountByAxisType0 from .column import Column from .column_type import ColumnType +from .compose_attribute_condition import ComposeAttributeCondition +from .compose_comparison_operator import ComposeComparisonOperator +from .compose_compute import ComposeCompute +from .compose_input import ComposeInput +from .compose_literal import ComposeLiteral +from .compose_operator import ComposeOperator +from .compose_select import ComposeSelect from .continuous_band_summary import ContinuousBandSummary +from .continuous_column_summary import ContinuousColumnSummary +from .count_usage import CountUsage from .create_application_request import CreateApplicationRequest from .create_chm_inventory_request import CreateChmInventoryRequest +from .create_compose_request import CreateComposeRequest from .create_domain_request_body import CreateDomainRequestBody +from .create_duet_request import CreateDuetRequest +from .create_fbfm_13_lookup_request import CreateFbfm13LookupRequest from .create_fbfm_40_lookup_request import CreateFbfm40LookupRequest +from .create_fccs_lookup_request import CreateFccsLookupRequest from .create_gdam_inventory_request import CreateGdamInventoryRequest from .create_gdam_inventory_request_impute_columns_item import ( CreateGdamInventoryRequestImputeColumnsItem, @@ -36,6 +51,7 @@ from .create_key_request import CreateKeyRequest from .create_key_response import CreateKeyResponse from .create_landfire_canopy_request import CreateLandfireCanopyRequest +from .create_landfire_fbfm_13_request import CreateLandfireFbfm13Request from .create_landfire_fbfm_40_request import CreateLandfireFbfm40Request from .create_landfire_fccs_request import CreateLandfireFccsRequest from .create_landfire_topography_request import CreateLandfireTopographyRequest @@ -47,11 +63,13 @@ from .create_osm_road_feature_request import CreateOsmRoadFeatureRequest from .create_osm_water_feature_request import CreateOsmWaterFeatureRequest from .create_pim_inventory_request import CreatePimInventoryRequest +from .create_point_cloud_chm_request import CreatePointCloudChmRequest from .create_point_cloud_upload_request import CreatePointCloudUploadRequest from .create_resample_request import CreateResampleRequest from .create_resample_request_method_overrides import ( CreateResampleRequestMethodOverrides, ) +from .create_three_dep_point_cloud_request import CreateThreeDepPointCloudRequest from .create_three_dep_topography_request import CreateThreeDepTopographyRequest from .create_tree_inventory_request import CreateTreeInventoryRequest from .create_tree_map_request import CreateTreeMapRequest @@ -64,6 +82,12 @@ from .domain_sort_field import DomainSortField from .domain_sort_order import DomainSortOrder from .domain_style import DomainStyle +from .duet_band import DuetBand +from .duet_calibration import DuetCalibration +from .duet_constant_calibration_target import DuetConstantCalibrationTarget +from .duet_max_min_calibration_target import DuetMaxMinCalibrationTarget +from .duet_mean_sd_calibration_target import DuetMeanSdCalibrationTarget +from .duet_parameter_calibration import DuetParameterCalibration from .duplicate_grid_request import DuplicateGridRequest from .duplicate_inventory_request import DuplicateInventoryRequest from .export import Export @@ -71,7 +95,9 @@ from .export_inventory_request import ExportInventoryRequest from .export_sort_field import ExportSortField from .export_source import ExportSource +from .fbfm_13_lookup_band import Fbfm13LookupBand from .fbfm_40_lookup_band import Fbfm40LookupBand +from .fccs_lookup_band import FccsLookupBand from .feature import Feature from .feature_data_metadata import FeatureDataMetadata from .feature_georeference import FeatureGeoreference @@ -79,6 +105,7 @@ from .feature_sort_field import FeatureSortField from .feature_source import FeatureSource from .feature_type import FeatureType +from .fia_species_group_share import FIASpeciesGroupShare from .field_source import FieldSource from .fine_biomass_config import FineBiomassConfig from .geo_json_crs import GeoJsonCRS @@ -116,6 +143,7 @@ from .grid_upload_spec import GridUploadSpec from .grid_upload_spec_headers import GridUploadSpecHeaders from .http_validation_error import HTTPValidationError +from .inline_compute import InlineCompute from .inventory import Inventory from .inventory_attribute import InventoryAttribute from .inventory_basal_area_treatment import InventoryBasalAreaTreatment @@ -131,7 +159,6 @@ from .inventory_columns_biomass_source_component_states import ( InventoryColumnsBiomassSourceComponentStates, ) -from .inventory_data_format import InventoryDataFormat from .inventory_data_metadata import InventoryDataMetadata from .inventory_data_response import InventoryDataResponse from .inventory_data_response_data_type_1_item import InventoryDataResponseDataType1Item @@ -163,13 +190,24 @@ from .inventory_upload_spec_headers import InventoryUploadSpecHeaders from .job_error import JobError from .job_progress import JobProgress +from .job_resource_usage import JobResourceUsage from .job_status import JobStatus from .key import Key from .landfire_canopy_fuel_band import LandfireCanopyFuelBand from .landfire_canopy_version import LandfireCanopyVersion +from .landfire_fbfm_13_version import LandfireFbfm13Version from .landfire_fbfm_40_version import LandfireFbfm40Version from .landfire_fccs_version import LandfireFccsVersion from .landfire_topography_version import LandfireTopographyVersion +from .landscape_export_alignment_domain_target import ( + LandscapeExportAlignmentDomainTarget, +) +from .landscape_export_alignment_grid_target import LandscapeExportAlignmentGridTarget +from .landscape_export_request import LandscapeExportRequest +from .landscape_export_request_fire_behavior_fuel_model import ( + LandscapeExportRequestFireBehaviorFuelModel, +) +from .landscape_field_source import LandscapeFieldSource from .layerset_crs import LayersetCrs from .layerset_crs_properties import LayersetCrsProperties from .layerset_feature import LayersetFeature @@ -199,6 +237,7 @@ from .point_cloud_sort_field import PointCloudSortField from .point_cloud_source import PointCloudSource from .point_cloud_summary import PointCloudSummary +from .point_cloud_three_dep_coverage_response import PointCloudThreeDepCoverageResponse from .point_cloud_type import PointCloudType from .point_cloud_upload_created_response import PointCloudUploadCreatedResponse from .point_cloud_upload_spec import PointCloudUploadSpec @@ -211,6 +250,8 @@ from .quic_fire_export_alignment_grid_target import QUICFireExportAlignmentGridTarget from .quicfire_export_request import QuicfireExportRequest from .quicfire_export_request_moist_merge import QuicfireExportRequestMoistMerge +from .quota_exceeded_detail import QuotaExceededDetail +from .quotas import Quotas from .remove_action import RemoveAction from .resampling_method import ResamplingMethod from .resolution_3d import Resolution3D @@ -221,9 +262,11 @@ from .stem_isolation_lmf import StemIsolationLmf from .stem_isolation_vwf import StemIsolationVwf from .three_dep_coverage_response import ThreeDepCoverageResponse +from .three_dep_dataset_coverage import ThreeDepDatasetCoverage from .three_dep_resolution import ThreeDepResolution from .topography_band import TopographyBand from .tree_band import TreeBand +from .tree_forestry_metrics import TreeForestryMetrics from .tree_map_band import TreeMapBand from .tree_map_version import TreeMapVersion from .uniform_band import UniformBand @@ -237,6 +280,12 @@ from .update_inventory_request_body import UpdateInventoryRequestBody from .update_point_cloud_request_body import UpdatePointCloudRequestBody from .upload_band_definition import UploadBandDefinition +from .usage import Usage +from .usage_count import UsageCount +from .usage_lifecycle import UsageLifecycle +from .usage_storage import UsageStorage +from .user_me_response import UserMeResponse +from .user_me_response_kind import UserMeResponseKind from .validation_error import ValidationError __all__ = ( @@ -245,6 +294,7 @@ "AllometryBiomassSourceComponentStates", "AllometryMaxCrownRadiusSource", "Application", + "ApplicationQuotaOverridesType0", "ApplyGridModificationsRequest", "ApplyModificationsRequest", "ApplyTreatmentsRequest", @@ -256,15 +306,29 @@ "BiomassEquations", "BiomassUnit", "CategoricalBandSummary", + "CategoricalColumnSummary", "Chunks", "ChunksCountByAxisType0", "Column", "ColumnType", + "ComposeAttributeCondition", + "ComposeComparisonOperator", + "ComposeCompute", + "ComposeInput", + "ComposeLiteral", + "ComposeOperator", + "ComposeSelect", "ContinuousBandSummary", + "ContinuousColumnSummary", + "CountUsage", "CreateApplicationRequest", "CreateChmInventoryRequest", + "CreateComposeRequest", "CreateDomainRequestBody", + "CreateDuetRequest", + "CreateFbfm13LookupRequest", "CreateFbfm40LookupRequest", + "CreateFccsLookupRequest", "CreateGdamInventoryRequest", "CreateGdamInventoryRequestImputeColumnsItem", "CreateGeoTIFFUploadRequest", @@ -272,6 +336,7 @@ "CreateKeyRequest", "CreateKeyResponse", "CreateLandfireCanopyRequest", + "CreateLandfireFbfm13Request", "CreateLandfireFbfm40Request", "CreateLandfireFccsRequest", "CreateLandfireTopographyRequest", @@ -283,9 +348,11 @@ "CreateOsmRoadFeatureRequest", "CreateOsmWaterFeatureRequest", "CreatePimInventoryRequest", + "CreatePointCloudChmRequest", "CreatePointCloudUploadRequest", "CreateResampleRequest", "CreateResampleRequestMethodOverrides", + "CreateThreeDepPointCloudRequest", "CreateThreeDepTopographyRequest", "CreateTreeInventoryRequest", "CreateTreeMapRequest", @@ -298,6 +365,12 @@ "DomainSortField", "DomainSortOrder", "DomainStyle", + "DuetBand", + "DuetCalibration", + "DuetConstantCalibrationTarget", + "DuetMaxMinCalibrationTarget", + "DuetMeanSdCalibrationTarget", + "DuetParameterCalibration", "DuplicateGridRequest", "DuplicateInventoryRequest", "Export", @@ -305,7 +378,10 @@ "ExportInventoryRequest", "ExportSortField", "ExportSource", + "FIASpeciesGroupShare", + "Fbfm13LookupBand", "Fbfm40LookupBand", + "FccsLookupBand", "Feature", "FeatureDataMetadata", "FeatureGeoreference", @@ -346,6 +422,7 @@ "GridUploadSpec", "GridUploadSpecHeaders", "HTTPValidationError", + "InlineCompute", "Inventory", "InventoryAttribute", "InventoryBasalAreaTreatment", @@ -355,7 +432,6 @@ "InventoryColumnsBiomassSource", "InventoryColumnsBiomassSourceColumns", "InventoryColumnsBiomassSourceComponentStates", - "InventoryDataFormat", "InventoryDataMetadata", "InventoryDataResponse", "InventoryDataResponseDataType1Item", @@ -383,13 +459,20 @@ "InventoryUploadSpecHeaders", "JobError", "JobProgress", + "JobResourceUsage", "JobStatus", "Key", "LandfireCanopyFuelBand", "LandfireCanopyVersion", + "LandfireFbfm13Version", "LandfireFbfm40Version", "LandfireFccsVersion", "LandfireTopographyVersion", + "LandscapeExportAlignmentDomainTarget", + "LandscapeExportAlignmentGridTarget", + "LandscapeExportRequest", + "LandscapeExportRequestFireBehaviorFuelModel", + "LandscapeFieldSource", "LayersetCrs", "LayersetCrsProperties", "LayersetFeature", @@ -419,6 +502,7 @@ "PointCloudSortField", "PointCloudSource", "PointCloudSummary", + "PointCloudThreeDepCoverageResponse", "PointCloudType", "PointCloudUploadCreatedResponse", "PointCloudUploadSpec", @@ -429,6 +513,8 @@ "QUICFireExportAlignmentGridTarget", "QuicfireExportRequest", "QuicfireExportRequestMoistMerge", + "QuotaExceededDetail", + "Quotas", "RemoveAction", "ResamplingMethod", "Resolution3D", @@ -439,9 +525,11 @@ "StemIsolationLmf", "StemIsolationVwf", "ThreeDepCoverageResponse", + "ThreeDepDatasetCoverage", "ThreeDepResolution", "TopographyBand", "TreeBand", + "TreeForestryMetrics", "TreeMapBand", "TreeMapVersion", "UniformBand", @@ -455,5 +543,11 @@ "UpdateInventoryRequestBody", "UpdatePointCloudRequestBody", "UploadBandDefinition", + "Usage", + "UsageCount", + "UsageLifecycle", + "UsageStorage", + "UserMeResponse", + "UserMeResponseKind", "ValidationError", ) diff --git a/fastfuels_sdk/v2/client_library/models/allometry_biomass_source.py b/fastfuels_sdk/v2/client_library/models/allometry_biomass_source.py index f3ef171..e49b88d 100644 --- a/fastfuels_sdk/v2/client_library/models/allometry_biomass_source.py +++ b/fastfuels_sdk/v2/client_library/models/allometry_biomass_source.py @@ -5,6 +5,7 @@ TYPE_CHECKING, Any, Literal, + Self, TypeVar, cast, ) @@ -89,7 +90,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.allometry_biomass_source_component_states import ( AllometryBiomassSourceComponentStates, ) diff --git a/fastfuels_sdk/v2/client_library/models/allometry_biomass_source_component_states.py b/fastfuels_sdk/v2/client_library/models/allometry_biomass_source_component_states.py index 379674e..56035b9 100644 --- a/fastfuels_sdk/v2/client_library/models/allometry_biomass_source_component_states.py +++ b/fastfuels_sdk/v2/client_library/models/allometry_biomass_source_component_states.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any, Self, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -30,7 +30,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.biomass_component_state import BiomassComponentState d = dict(src_dict) diff --git a/fastfuels_sdk/v2/client_library/models/allometry_max_crown_radius_source.py b/fastfuels_sdk/v2/client_library/models/allometry_max_crown_radius_source.py index cb0c2a1..5f6f606 100644 --- a/fastfuels_sdk/v2/client_library/models/allometry_max_crown_radius_source.py +++ b/fastfuels_sdk/v2/client_library/models/allometry_max_crown_radius_source.py @@ -4,6 +4,7 @@ from typing import ( Any, Literal, + Self, TypeVar, cast, ) @@ -37,7 +38,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) type_ = cast(Literal["allometry"] | Unset, d.pop("type", UNSET)) if type_ != "allometry" and not isinstance(type_, Unset): diff --git a/fastfuels_sdk/v2/client_library/models/application.py b/fastfuels_sdk/v2/client_library/models/application.py index a5865a4..f943647 100644 --- a/fastfuels_sdk/v2/client_library/models/application.py +++ b/fastfuels_sdk/v2/client_library/models/application.py @@ -2,13 +2,19 @@ import datetime from collections.abc import Mapping -from typing import Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field from ..types import UNSET, Unset +if TYPE_CHECKING: + from ..models.application_quota_overrides_type_0 import ( + ApplicationQuotaOverridesType0, + ) + + T = TypeVar("T", bound="Application") @@ -23,6 +29,9 @@ class Application: description (None | str | Unset): Description of the application. created_on (datetime.datetime | Unset): When the application was created. modified_on (datetime.datetime | Unset): When the application was last modified. + tier (None | str | Unset): Quota tier for the application. Set by the FastFuels team. + quota_overrides (ApplicationQuotaOverridesType0 | None | Unset): Per-application quota overrides. Set by the + FastFuels team. """ id: str @@ -31,9 +40,15 @@ class Application: description: None | str | Unset = UNSET created_on: datetime.datetime | Unset = UNSET modified_on: datetime.datetime | Unset = UNSET + tier: None | str | Unset = UNSET + quota_overrides: ApplicationQuotaOverridesType0 | None | Unset = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: + from ..models.application_quota_overrides_type_0 import ( + ApplicationQuotaOverridesType0, + ) + id = self.id owner_id = self.owner_id @@ -54,6 +69,20 @@ def to_dict(self) -> dict[str, Any]: if not isinstance(self.modified_on, Unset): modified_on = self.modified_on.isoformat() + tier: None | str | Unset + if isinstance(self.tier, Unset): + tier = UNSET + else: + tier = self.tier + + quota_overrides: dict[str, Any] | None | Unset + if isinstance(self.quota_overrides, Unset): + quota_overrides = UNSET + elif isinstance(self.quota_overrides, ApplicationQuotaOverridesType0): + quota_overrides = self.quota_overrides.to_dict() + else: + quota_overrides = self.quota_overrides + field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update( @@ -69,11 +98,19 @@ def to_dict(self) -> dict[str, Any]: field_dict["created_on"] = created_on if modified_on is not UNSET: field_dict["modified_on"] = modified_on + if tier is not UNSET: + field_dict["tier"] = tier + if quota_overrides is not UNSET: + field_dict["quota_overrides"] = quota_overrides return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + from ..models.application_quota_overrides_type_0 import ( + ApplicationQuotaOverridesType0, + ) + d = dict(src_dict) id = d.pop("id") @@ -104,6 +141,34 @@ def _parse_description(data: object) -> None | str | Unset: else: modified_on = datetime.datetime.fromisoformat(_modified_on) + def _parse_tier(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + tier = _parse_tier(d.pop("tier", UNSET)) + + def _parse_quota_overrides( + data: object, + ) -> ApplicationQuotaOverridesType0 | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + quota_overrides_type_0 = ApplicationQuotaOverridesType0.from_dict(data) + + return quota_overrides_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(ApplicationQuotaOverridesType0 | None | Unset, data) + + quota_overrides = _parse_quota_overrides(d.pop("quota_overrides", UNSET)) + application = cls( id=id, owner_id=owner_id, @@ -111,6 +176,8 @@ def _parse_description(data: object) -> None | str | Unset: description=description, created_on=created_on, modified_on=modified_on, + tier=tier, + quota_overrides=quota_overrides, ) application.additional_properties = d diff --git a/fastfuels_sdk/v2/client_library/models/application_quota_overrides_type_0.py b/fastfuels_sdk/v2/client_library/models/application_quota_overrides_type_0.py new file mode 100644 index 0000000..b52a1c3 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/application_quota_overrides_type_0.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Self, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="ApplicationQuotaOverridesType0") + + +@_attrs_define +class ApplicationQuotaOverridesType0: + """ """ + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + application_quota_overrides_type_0 = cls() + + application_quota_overrides_type_0.additional_properties = d + return application_quota_overrides_type_0 + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/apply_grid_modifications_request.py b/fastfuels_sdk/v2/client_library/models/apply_grid_modifications_request.py index 9478c2d..8aafbfe 100644 --- a/fastfuels_sdk/v2/client_library/models/apply_grid_modifications_request.py +++ b/fastfuels_sdk/v2/client_library/models/apply_grid_modifications_request.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any, Self, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -44,7 +44,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.grid_modification import GridModification d = dict(src_dict) diff --git a/fastfuels_sdk/v2/client_library/models/apply_modifications_request.py b/fastfuels_sdk/v2/client_library/models/apply_modifications_request.py index 5d84958..815f15b 100644 --- a/fastfuels_sdk/v2/client_library/models/apply_modifications_request.py +++ b/fastfuels_sdk/v2/client_library/models/apply_modifications_request.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any, Self, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -44,7 +44,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.inventory_modification import InventoryModification d = dict(src_dict) diff --git a/fastfuels_sdk/v2/client_library/models/apply_treatments_request.py b/fastfuels_sdk/v2/client_library/models/apply_treatments_request.py index fd49724..5b8b331 100644 --- a/fastfuels_sdk/v2/client_library/models/apply_treatments_request.py +++ b/fastfuels_sdk/v2/client_library/models/apply_treatments_request.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any, Self, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -53,7 +53,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.inventory_basal_area_treatment import InventoryBasalAreaTreatment from ..models.inventory_diameter_treatment import InventoryDiameterTreatment diff --git a/fastfuels_sdk/v2/client_library/models/band.py b/fastfuels_sdk/v2/client_library/models/band.py index 6e1a46f..38d7349 100644 --- a/fastfuels_sdk/v2/client_library/models/band.py +++ b/fastfuels_sdk/v2/client_library/models/band.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -83,9 +83,9 @@ def to_dict(self) -> dict[str, Any]: summary: dict[str, Any] | None | Unset if isinstance(self.summary, Unset): summary = UNSET - elif isinstance(self.summary, ContinuousBandSummary): - summary = self.summary.to_dict() - elif isinstance(self.summary, CategoricalBandSummary): + elif isinstance(self.summary, ContinuousBandSummary) or isinstance( + self.summary, CategoricalBandSummary + ): summary = self.summary.to_dict() else: summary = self.summary @@ -113,7 +113,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.categorical_band_summary import CategoricalBandSummary from ..models.continuous_band_summary import ContinuousBandSummary diff --git a/fastfuels_sdk/v2/client_library/models/base_model.py b/fastfuels_sdk/v2/client_library/models/base_model.py index b915dc1..37e831d 100644 --- a/fastfuels_sdk/v2/client_library/models/base_model.py +++ b/fastfuels_sdk/v2/client_library/models/base_model.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar +from typing import Any, Self, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -23,7 +23,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) base_model = cls() diff --git a/fastfuels_sdk/v2/client_library/models/biomass_component_state.py b/fastfuels_sdk/v2/client_library/models/biomass_component_state.py index ee4b4ba..6b77593 100644 --- a/fastfuels_sdk/v2/client_library/models/biomass_component_state.py +++ b/fastfuels_sdk/v2/client_library/models/biomass_component_state.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar +from typing import Any, Self, TypeVar from attrs import define as _attrs_define @@ -38,7 +38,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) live = d.pop("live", UNSET) diff --git a/fastfuels_sdk/v2/client_library/models/categorical_band_summary.py b/fastfuels_sdk/v2/client_library/models/categorical_band_summary.py index 9042c3b..75c397a 100644 --- a/fastfuels_sdk/v2/client_library/models/categorical_band_summary.py +++ b/fastfuels_sdk/v2/client_library/models/categorical_band_summary.py @@ -4,6 +4,7 @@ from typing import ( Any, Literal, + Self, TypeVar, cast, ) @@ -53,7 +54,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) type_ = cast(Literal["categorical"], d.pop("type")) if type_ != "categorical": diff --git a/fastfuels_sdk/v2/client_library/models/categorical_column_summary.py b/fastfuels_sdk/v2/client_library/models/categorical_column_summary.py new file mode 100644 index 0000000..be09b27 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/categorical_column_summary.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + Any, + Literal, + Self, + TypeVar, + cast, +) + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="CategoricalColumnSummary") + + +@_attrs_define +class CategoricalColumnSummary: + """ + Attributes: + type_ (Literal['categorical']): + count (int): + null_count (int): + unique_count (int): + """ + + type_: Literal["categorical"] + count: int + null_count: int + unique_count: int + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + type_ = self.type_ + + count = self.count + + null_count = self.null_count + + unique_count = self.unique_count + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "type": type_, + "count": count, + "null_count": null_count, + "unique_count": unique_count, + } + ) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + type_ = cast(Literal["categorical"], d.pop("type")) + if type_ != "categorical": + raise ValueError(f"type must match const 'categorical', got '{type_}'") + + count = d.pop("count") + + null_count = d.pop("null_count") + + unique_count = d.pop("unique_count") + + categorical_column_summary = cls( + type_=type_, + count=count, + null_count=null_count, + unique_count=unique_count, + ) + + categorical_column_summary.additional_properties = d + return categorical_column_summary + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/chunks.py b/fastfuels_sdk/v2/client_library/models/chunks.py index fc900d7..6303419 100644 --- a/fastfuels_sdk/v2/client_library/models/chunks.py +++ b/fastfuels_sdk/v2/client_library/models/chunks.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -71,7 +71,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.chunks_count_by_axis_type_0 import ChunksCountByAxisType0 d = dict(src_dict) diff --git a/fastfuels_sdk/v2/client_library/models/chunks_count_by_axis_type_0.py b/fastfuels_sdk/v2/client_library/models/chunks_count_by_axis_type_0.py index ae5746d..2f2a6b5 100644 --- a/fastfuels_sdk/v2/client_library/models/chunks_count_by_axis_type_0.py +++ b/fastfuels_sdk/v2/client_library/models/chunks_count_by_axis_type_0.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar +from typing import Any, Self, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -23,7 +23,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) chunks_count_by_axis_type_0 = cls() diff --git a/fastfuels_sdk/v2/client_library/models/column.py b/fastfuels_sdk/v2/client_library/models/column.py index 97cda05..ba34ff5 100644 --- a/fastfuels_sdk/v2/client_library/models/column.py +++ b/fastfuels_sdk/v2/client_library/models/column.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -9,6 +9,11 @@ from ..models.column_type import ColumnType from ..types import UNSET, Unset +if TYPE_CHECKING: + from ..models.categorical_column_summary import CategoricalColumnSummary + from ..models.continuous_column_summary import ContinuousColumnSummary + + T = TypeVar("T", bound="Column") @@ -20,14 +25,19 @@ class Column: key (str): Column name (e.g., 'dbh', 'fia_species_code') type_ (ColumnType): Type of column data. unit (None | str | Unset): + summary (CategoricalColumnSummary | ContinuousColumnSummary | None | Unset): """ key: str type_: ColumnType unit: None | str | Unset = UNSET + summary: CategoricalColumnSummary | ContinuousColumnSummary | None | Unset = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: + from ..models.categorical_column_summary import CategoricalColumnSummary + from ..models.continuous_column_summary import ContinuousColumnSummary + key = self.key type_ = self.type_.value @@ -38,6 +48,16 @@ def to_dict(self) -> dict[str, Any]: else: unit = self.unit + summary: dict[str, Any] | None | Unset + if isinstance(self.summary, Unset): + summary = UNSET + elif isinstance(self.summary, ContinuousColumnSummary) or isinstance( + self.summary, CategoricalColumnSummary + ): + summary = self.summary.to_dict() + else: + summary = self.summary + field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update( @@ -48,11 +68,16 @@ def to_dict(self) -> dict[str, Any]: ) if unit is not UNSET: field_dict["unit"] = unit + if summary is not UNSET: + field_dict["summary"] = summary return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + from ..models.categorical_column_summary import CategoricalColumnSummary + from ..models.continuous_column_summary import ContinuousColumnSummary + d = dict(src_dict) key = d.pop("key") @@ -67,10 +92,40 @@ def _parse_unit(data: object) -> None | str | Unset: unit = _parse_unit(d.pop("unit", UNSET)) + def _parse_summary( + data: object, + ) -> CategoricalColumnSummary | ContinuousColumnSummary | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + summary_type_0_type_0 = ContinuousColumnSummary.from_dict(data) + + return summary_type_0_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + summary_type_0_type_1 = CategoricalColumnSummary.from_dict(data) + + return summary_type_0_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast( + CategoricalColumnSummary | ContinuousColumnSummary | None | Unset, data + ) + + summary = _parse_summary(d.pop("summary", UNSET)) + column = cls( key=key, type_=type_, unit=unit, + summary=summary, ) column.additional_properties = d diff --git a/fastfuels_sdk/v2/client_library/models/compose_attribute_condition.py b/fastfuels_sdk/v2/client_library/models/compose_attribute_condition.py new file mode 100644 index 0000000..455dffe --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/compose_attribute_condition.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Self, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.compose_comparison_operator import ComposeComparisonOperator + +T = TypeVar("T", bound="ComposeAttributeCondition") + + +@_attrs_define +class ComposeAttributeCondition: + """Attribute condition using an alias-qualified input band reference. + + Attributes: + band (str): Alias-qualified band ref, e.g. `a.fbfm`. + operator (ComposeComparisonOperator): Comparison operators for compose attribute conditions. + value (float | int | list[float | int | str] | str): + """ + + band: str + operator: ComposeComparisonOperator + value: float | int | list[float | int | str] | str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + band = self.band + + operator = self.operator.value + + value: float | int | list[float | int | str] | str + if isinstance(self.value, list): + value = [] + for value_type_3_item_data in self.value: + value_type_3_item: float | int | str + value_type_3_item = value_type_3_item_data + value.append(value_type_3_item) + + else: + value = self.value + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "band": band, + "operator": operator, + "value": value, + } + ) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + band = d.pop("band") + + operator = ComposeComparisonOperator(d.pop("operator")) + + def _parse_value(data: object) -> float | int | list[float | int | str] | str: + try: + if not isinstance(data, list): + raise TypeError() + value_type_3 = [] + _value_type_3 = data + for value_type_3_item_data in _value_type_3: + + def _parse_value_type_3_item(data: object) -> float | int | str: + return cast(float | int | str, data) + + value_type_3_item = _parse_value_type_3_item(value_type_3_item_data) + + value_type_3.append(value_type_3_item) + + return value_type_3 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(float | int | list[float | int | str] | str, data) + + value = _parse_value(d.pop("value")) + + compose_attribute_condition = cls( + band=band, + operator=operator, + value=value, + ) + + compose_attribute_condition.additional_properties = d + return compose_attribute_condition + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/compose_comparison_operator.py b/fastfuels_sdk/v2/client_library/models/compose_comparison_operator.py new file mode 100644 index 0000000..0d2e30c --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/compose_comparison_operator.py @@ -0,0 +1,14 @@ +from enum import Enum + + +class ComposeComparisonOperator(str, Enum): + EQ = "eq" + GE = "ge" + GT = "gt" + IN = "in" + LE = "le" + LT = "lt" + NE = "ne" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/compose_compute.py b/fastfuels_sdk/v2/client_library/models/compose_compute.py new file mode 100644 index 0000000..1637adb --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/compose_compute.py @@ -0,0 +1,354 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, Self, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.compose_operator import ComposeOperator +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.compose_attribute_condition import ComposeAttributeCondition + from ..models.compose_literal import ComposeLiteral + from ..models.grid_feature_spatial_condition import GridFeatureSpatialCondition + from ..models.grid_geometry_spatial_condition import GridGeometrySpatialCondition + from ..models.inline_compute import InlineCompute + + +T = TypeVar("T", bound="ComposeCompute") + + +@_attrs_define +class ComposeCompute: + """Compute an output band from one or more operands. + + The output band is always continuous and its unit is derived from the + operands (the product/quotient for `multiply`/`divide`, the operand unit + otherwise). Supply `unit` only to express the result in a different but + dimensionally compatible unit; the worker converts to it. + + Attributes: + operator (ComposeOperator): Operators available for compose computations. + operands (list[ComposeLiteral | float | int | str]): + output (str): Output band key, e.g. `fuel_load.1hr`. + name (None | str | Unset): Optional display name for the output band. + description (None | str | Unset): Optional description for the output band. + unit (None | str | Unset): Optional canonical output unit. Defaults to the unit derived from the operands; if + given it must be dimensionally compatible. + conditions (list[ComposeAttributeCondition | GridFeatureSpatialCondition | GridGeometrySpatialCondition] | None + | Unset): + else_ (ComposeLiteral | float | InlineCompute | int | None | str | Unset): + """ + + operator: ComposeOperator + operands: list[ComposeLiteral | float | int | str] + output: str + name: None | str | Unset = UNSET + description: None | str | Unset = UNSET + unit: None | str | Unset = UNSET + conditions: ( + list[ + ComposeAttributeCondition + | GridFeatureSpatialCondition + | GridGeometrySpatialCondition + ] + | None + | Unset + ) = UNSET + else_: ComposeLiteral | float | InlineCompute | int | None | str | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.compose_attribute_condition import ComposeAttributeCondition + from ..models.compose_literal import ComposeLiteral + from ..models.grid_geometry_spatial_condition import ( + GridGeometrySpatialCondition, + ) + from ..models.inline_compute import InlineCompute + + operator = self.operator.value + + operands = [] + for operands_item_data in self.operands: + operands_item: dict[str, Any] | float | int | str + if isinstance(operands_item_data, ComposeLiteral): + operands_item = operands_item_data.to_dict() + else: + operands_item = operands_item_data + operands.append(operands_item) + + output = self.output + + name: None | str | Unset + if isinstance(self.name, Unset): + name = UNSET + else: + name = self.name + + description: None | str | Unset + if isinstance(self.description, Unset): + description = UNSET + else: + description = self.description + + unit: None | str | Unset + if isinstance(self.unit, Unset): + unit = UNSET + else: + unit = self.unit + + conditions: list[dict[str, Any]] | None | Unset + if isinstance(self.conditions, Unset): + conditions = UNSET + elif isinstance(self.conditions, list): + conditions = [] + for conditions_type_0_item_data in self.conditions: + conditions_type_0_item: dict[str, Any] + if isinstance( + conditions_type_0_item_data, ComposeAttributeCondition + ) or isinstance( + conditions_type_0_item_data, GridGeometrySpatialCondition + ): + conditions_type_0_item = conditions_type_0_item_data.to_dict() + else: + conditions_type_0_item = conditions_type_0_item_data.to_dict() + + conditions.append(conditions_type_0_item) + + else: + conditions = self.conditions + + else_: dict[str, Any] | float | int | None | str | Unset + if isinstance(self.else_, Unset): + else_ = UNSET + elif isinstance(self.else_, ComposeLiteral) or isinstance( + self.else_, InlineCompute + ): + else_ = self.else_.to_dict() + else: + else_ = self.else_ + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "operator": operator, + "operands": operands, + "output": output, + } + ) + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if unit is not UNSET: + field_dict["unit"] = unit + if conditions is not UNSET: + field_dict["conditions"] = conditions + if else_ is not UNSET: + field_dict["else"] = else_ + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + from ..models.compose_attribute_condition import ComposeAttributeCondition + from ..models.compose_literal import ComposeLiteral + from ..models.grid_feature_spatial_condition import GridFeatureSpatialCondition + from ..models.grid_geometry_spatial_condition import ( + GridGeometrySpatialCondition, + ) + from ..models.inline_compute import InlineCompute + + d = dict(src_dict) + operator = ComposeOperator(d.pop("operator")) + + operands = [] + _operands = d.pop("operands") + for operands_item_data in _operands: + + def _parse_operands_item( + data: object, + ) -> ComposeLiteral | float | int | str: + try: + if not isinstance(data, dict): + raise TypeError() + operands_item_type_3 = ComposeLiteral.from_dict(data) + + return operands_item_type_3 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(ComposeLiteral | float | int | str, data) + + operands_item = _parse_operands_item(operands_item_data) + + operands.append(operands_item) + + output = d.pop("output") + + def _parse_name(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + name = _parse_name(d.pop("name", UNSET)) + + def _parse_description(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + description = _parse_description(d.pop("description", UNSET)) + + def _parse_unit(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + unit = _parse_unit(d.pop("unit", UNSET)) + + def _parse_conditions( + data: object, + ) -> ( + list[ + ComposeAttributeCondition + | GridFeatureSpatialCondition + | GridGeometrySpatialCondition + ] + | None + | Unset + ): + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, list): + raise TypeError() + conditions_type_0 = [] + _conditions_type_0 = data + for conditions_type_0_item_data in _conditions_type_0: + + def _parse_conditions_type_0_item( + data: object, + ) -> ( + ComposeAttributeCondition + | GridFeatureSpatialCondition + | GridGeometrySpatialCondition + ): + try: + if not isinstance(data, dict): + raise TypeError() + conditions_type_0_item_type_0 = ( + ComposeAttributeCondition.from_dict(data) + ) + + return conditions_type_0_item_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + conditions_type_0_item_type_1 = ( + GridGeometrySpatialCondition.from_dict(data) + ) + + return conditions_type_0_item_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + if not isinstance(data, dict): + raise TypeError() + conditions_type_0_item_type_2 = ( + GridFeatureSpatialCondition.from_dict(data) + ) + + return conditions_type_0_item_type_2 + + conditions_type_0_item = _parse_conditions_type_0_item( + conditions_type_0_item_data + ) + + conditions_type_0.append(conditions_type_0_item) + + return conditions_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast( + list[ + ComposeAttributeCondition + | GridFeatureSpatialCondition + | GridGeometrySpatialCondition + ] + | None + | Unset, + data, + ) + + conditions = _parse_conditions(d.pop("conditions", UNSET)) + + def _parse_else_( + data: object, + ) -> ComposeLiteral | float | InlineCompute | int | None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + else_type_3 = ComposeLiteral.from_dict(data) + + return else_type_3 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + else_type_4 = InlineCompute.from_dict(data) + + return else_type_4 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast( + ComposeLiteral | float | InlineCompute | int | None | str | Unset, data + ) + + else_ = _parse_else_(d.pop("else", UNSET)) + + compose_compute = cls( + operator=operator, + operands=operands, + output=output, + name=name, + description=description, + unit=unit, + conditions=conditions, + else_=else_, + ) + + compose_compute.additional_properties = d + return compose_compute + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/compose_input.py b/fastfuels_sdk/v2/client_library/models/compose_input.py new file mode 100644 index 0000000..710f56e --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/compose_input.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Self, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="ComposeInput") + + +@_attrs_define +class ComposeInput: + """A source grid participating in a compose request. + + Attributes: + grid_id (str): + alias (str): Short alias used to reference bands, e.g. `a.fuel_load.1hr`. + """ + + grid_id: str + alias: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + grid_id = self.grid_id + + alias = self.alias + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "grid_id": grid_id, + "alias": alias, + } + ) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + grid_id = d.pop("grid_id") + + alias = d.pop("alias") + + compose_input = cls( + grid_id=grid_id, + alias=alias, + ) + + compose_input.additional_properties = d + return compose_input + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/compose_literal.py b/fastfuels_sdk/v2/client_library/models/compose_literal.py new file mode 100644 index 0000000..2f5de45 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/compose_literal.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + Any, + Literal, + Self, + TypeVar, + cast, +) + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="ComposeLiteral") + + +@_attrs_define +class ComposeLiteral: + """Typed literal value for compose operands and fallback values. + + Attributes: + value (float | int | str): + type_ (Literal['literal'] | Unset): Default: 'literal'. + unit (None | str | Unset): Canonical unit for numeric values. Must be null for string literals. + """ + + value: float | int | str + type_: Literal["literal"] | Unset = "literal" + unit: None | str | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + value: float | int | str + value = self.value + + type_ = self.type_ + + unit: None | str | Unset + if isinstance(self.unit, Unset): + unit = UNSET + else: + unit = self.unit + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "value": value, + } + ) + if type_ is not UNSET: + field_dict["type"] = type_ + if unit is not UNSET: + field_dict["unit"] = unit + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + + def _parse_value(data: object) -> float | int | str: + return cast(float | int | str, data) + + value = _parse_value(d.pop("value")) + + type_ = cast(Literal["literal"] | Unset, d.pop("type", UNSET)) + if type_ != "literal" and not isinstance(type_, Unset): + raise ValueError(f"type must match const 'literal', got '{type_}'") + + def _parse_unit(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + unit = _parse_unit(d.pop("unit", UNSET)) + + compose_literal = cls( + value=value, + type_=type_, + unit=unit, + ) + + compose_literal.additional_properties = d + return compose_literal + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/compose_operator.py b/fastfuels_sdk/v2/client_library/models/compose_operator.py new file mode 100644 index 0000000..eab996d --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/compose_operator.py @@ -0,0 +1,14 @@ +from enum import Enum + + +class ComposeOperator(str, Enum): + ADD = "add" + AVERAGE = "average" + DIVIDE = "divide" + MAX = "max" + MIN = "min" + MULTIPLY = "multiply" + SUBTRACT = "subtract" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/compose_select.py b/fastfuels_sdk/v2/client_library/models/compose_select.py new file mode 100644 index 0000000..ef2e9c3 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/compose_select.py @@ -0,0 +1,296 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, Self, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.compose_attribute_condition import ComposeAttributeCondition + from ..models.compose_literal import ComposeLiteral + from ..models.grid_feature_spatial_condition import GridFeatureSpatialCondition + from ..models.grid_geometry_spatial_condition import GridGeometrySpatialCondition + from ..models.inline_compute import InlineCompute + + +T = TypeVar("T", bound="ComposeSelect") + + +@_attrs_define +class ComposeSelect: + """Select one input band into an output band, optionally conditionally. + + The output band's type and unit are inherited from the selected source + band; only the human-readable `name`/`description` are optional overrides. + + Attributes: + output (str): Output band key, e.g. `fuel_load.1hr`. + from_ (str): Alias-qualified source band ref. + name (None | str | Unset): Optional display name for the output band. + description (None | str | Unset): Optional description for the output band. + conditions (list[ComposeAttributeCondition | GridFeatureSpatialCondition | GridGeometrySpatialCondition] | None + | Unset): + else_ (ComposeLiteral | float | InlineCompute | int | None | str | Unset): + """ + + output: str + from_: str + name: None | str | Unset = UNSET + description: None | str | Unset = UNSET + conditions: ( + list[ + ComposeAttributeCondition + | GridFeatureSpatialCondition + | GridGeometrySpatialCondition + ] + | None + | Unset + ) = UNSET + else_: ComposeLiteral | float | InlineCompute | int | None | str | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.compose_attribute_condition import ComposeAttributeCondition + from ..models.compose_literal import ComposeLiteral + from ..models.grid_geometry_spatial_condition import ( + GridGeometrySpatialCondition, + ) + from ..models.inline_compute import InlineCompute + + output = self.output + + from_ = self.from_ + + name: None | str | Unset + if isinstance(self.name, Unset): + name = UNSET + else: + name = self.name + + description: None | str | Unset + if isinstance(self.description, Unset): + description = UNSET + else: + description = self.description + + conditions: list[dict[str, Any]] | None | Unset + if isinstance(self.conditions, Unset): + conditions = UNSET + elif isinstance(self.conditions, list): + conditions = [] + for conditions_type_0_item_data in self.conditions: + conditions_type_0_item: dict[str, Any] + if isinstance( + conditions_type_0_item_data, ComposeAttributeCondition + ) or isinstance( + conditions_type_0_item_data, GridGeometrySpatialCondition + ): + conditions_type_0_item = conditions_type_0_item_data.to_dict() + else: + conditions_type_0_item = conditions_type_0_item_data.to_dict() + + conditions.append(conditions_type_0_item) + + else: + conditions = self.conditions + + else_: dict[str, Any] | float | int | None | str | Unset + if isinstance(self.else_, Unset): + else_ = UNSET + elif isinstance(self.else_, ComposeLiteral) or isinstance( + self.else_, InlineCompute + ): + else_ = self.else_.to_dict() + else: + else_ = self.else_ + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "output": output, + "from": from_, + } + ) + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if conditions is not UNSET: + field_dict["conditions"] = conditions + if else_ is not UNSET: + field_dict["else"] = else_ + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + from ..models.compose_attribute_condition import ComposeAttributeCondition + from ..models.compose_literal import ComposeLiteral + from ..models.grid_feature_spatial_condition import GridFeatureSpatialCondition + from ..models.grid_geometry_spatial_condition import ( + GridGeometrySpatialCondition, + ) + from ..models.inline_compute import InlineCompute + + d = dict(src_dict) + output = d.pop("output") + + from_ = d.pop("from") + + def _parse_name(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + name = _parse_name(d.pop("name", UNSET)) + + def _parse_description(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + description = _parse_description(d.pop("description", UNSET)) + + def _parse_conditions( + data: object, + ) -> ( + list[ + ComposeAttributeCondition + | GridFeatureSpatialCondition + | GridGeometrySpatialCondition + ] + | None + | Unset + ): + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, list): + raise TypeError() + conditions_type_0 = [] + _conditions_type_0 = data + for conditions_type_0_item_data in _conditions_type_0: + + def _parse_conditions_type_0_item( + data: object, + ) -> ( + ComposeAttributeCondition + | GridFeatureSpatialCondition + | GridGeometrySpatialCondition + ): + try: + if not isinstance(data, dict): + raise TypeError() + conditions_type_0_item_type_0 = ( + ComposeAttributeCondition.from_dict(data) + ) + + return conditions_type_0_item_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + conditions_type_0_item_type_1 = ( + GridGeometrySpatialCondition.from_dict(data) + ) + + return conditions_type_0_item_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + if not isinstance(data, dict): + raise TypeError() + conditions_type_0_item_type_2 = ( + GridFeatureSpatialCondition.from_dict(data) + ) + + return conditions_type_0_item_type_2 + + conditions_type_0_item = _parse_conditions_type_0_item( + conditions_type_0_item_data + ) + + conditions_type_0.append(conditions_type_0_item) + + return conditions_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast( + list[ + ComposeAttributeCondition + | GridFeatureSpatialCondition + | GridGeometrySpatialCondition + ] + | None + | Unset, + data, + ) + + conditions = _parse_conditions(d.pop("conditions", UNSET)) + + def _parse_else_( + data: object, + ) -> ComposeLiteral | float | InlineCompute | int | None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + else_type_3 = ComposeLiteral.from_dict(data) + + return else_type_3 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + else_type_4 = InlineCompute.from_dict(data) + + return else_type_4 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast( + ComposeLiteral | float | InlineCompute | int | None | str | Unset, data + ) + + else_ = _parse_else_(d.pop("else", UNSET)) + + compose_select = cls( + output=output, + from_=from_, + name=name, + description=description, + conditions=conditions, + else_=else_, + ) + + compose_select.additional_properties = d + return compose_select + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/continuous_band_summary.py b/fastfuels_sdk/v2/client_library/models/continuous_band_summary.py index 781158e..1ac10e7 100644 --- a/fastfuels_sdk/v2/client_library/models/continuous_band_summary.py +++ b/fastfuels_sdk/v2/client_library/models/continuous_band_summary.py @@ -4,6 +4,7 @@ from typing import ( Any, Literal, + Self, TypeVar, cast, ) @@ -72,7 +73,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) type_ = cast(Literal["continuous"], d.pop("type")) if type_ != "continuous": diff --git a/fastfuels_sdk/v2/client_library/models/continuous_column_summary.py b/fastfuels_sdk/v2/client_library/models/continuous_column_summary.py new file mode 100644 index 0000000..d0f227e --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/continuous_column_summary.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + Any, + Literal, + Self, + TypeVar, + cast, +) + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="ContinuousColumnSummary") + + +@_attrs_define +class ContinuousColumnSummary: + """ + Attributes: + type_ (Literal['continuous']): + count (int): + null_count (int): + min_ (float | None): + max_ (float | None): + mean (float | None): + std (float | None): + """ + + type_: Literal["continuous"] + count: int + null_count: int + min_: float | None + max_: float | None + mean: float | None + std: float | None + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + type_ = self.type_ + + count = self.count + + null_count = self.null_count + + min_: float | None + min_ = self.min_ + + max_: float | None + max_ = self.max_ + + mean: float | None + mean = self.mean + + std: float | None + std = self.std + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "type": type_, + "count": count, + "null_count": null_count, + "min": min_, + "max": max_, + "mean": mean, + "std": std, + } + ) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + type_ = cast(Literal["continuous"], d.pop("type")) + if type_ != "continuous": + raise ValueError(f"type must match const 'continuous', got '{type_}'") + + count = d.pop("count") + + null_count = d.pop("null_count") + + def _parse_min_(data: object) -> float | None: + if data is None: + return data + return cast(float | None, data) + + min_ = _parse_min_(d.pop("min")) + + def _parse_max_(data: object) -> float | None: + if data is None: + return data + return cast(float | None, data) + + max_ = _parse_max_(d.pop("max")) + + def _parse_mean(data: object) -> float | None: + if data is None: + return data + return cast(float | None, data) + + mean = _parse_mean(d.pop("mean")) + + def _parse_std(data: object) -> float | None: + if data is None: + return data + return cast(float | None, data) + + std = _parse_std(d.pop("std")) + + continuous_column_summary = cls( + type_=type_, + count=count, + null_count=null_count, + min_=min_, + max_=max_, + mean=mean, + std=std, + ) + + continuous_column_summary.additional_properties = d + return continuous_column_summary + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/count_usage.py b/fastfuels_sdk/v2/client_library/models/count_usage.py new file mode 100644 index 0000000..fad9e55 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/count_usage.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, Self, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.usage_count import UsageCount + + +T = TypeVar("T", bound="CountUsage") + + +@_attrs_define +class CountUsage: + """Usage for a count-only resource type (domains, applications, API keys). + + Attributes: + total (UsageCount): A count-based usage/limit pair (resources or concurrent jobs). + """ + + total: UsageCount + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + total = self.total.to_dict() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "total": total, + } + ) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + from ..models.usage_count import UsageCount + + d = dict(src_dict) + total = UsageCount.from_dict(d.pop("total")) + + count_usage = cls( + total=total, + ) + + count_usage.additional_properties = d + return count_usage + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/create_application_request.py b/fastfuels_sdk/v2/client_library/models/create_application_request.py index a675d38..9cc32b7 100644 --- a/fastfuels_sdk/v2/client_library/models/create_application_request.py +++ b/fastfuels_sdk/v2/client_library/models/create_application_request.py @@ -1,10 +1,9 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar, cast +from typing import Any, Self, TypeVar, cast from attrs import define as _attrs_define -from attrs import field as _attrs_field from ..types import UNSET, Unset @@ -22,7 +21,6 @@ class CreateApplicationRequest: name: str description: None | str | Unset = UNSET - additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: name = self.name @@ -34,7 +32,7 @@ def to_dict(self) -> dict[str, Any]: description = self.description field_dict: dict[str, Any] = {} - field_dict.update(self.additional_properties) + field_dict.update( { "name": name, @@ -46,7 +44,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) name = d.pop("name") @@ -64,21 +62,4 @@ def _parse_description(data: object) -> None | str | Unset: description=description, ) - create_application_request.additional_properties = d return create_application_request - - @property - def additional_keys(self) -> list[str]: - return list(self.additional_properties.keys()) - - def __getitem__(self, key: str) -> Any: - return self.additional_properties[key] - - def __setitem__(self, key: str, value: Any) -> None: - self.additional_properties[key] = value - - def __delitem__(self, key: str) -> None: - del self.additional_properties[key] - - def __contains__(self, key: str) -> bool: - return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/create_chm_inventory_request.py b/fastfuels_sdk/v2/client_library/models/create_chm_inventory_request.py index d417888..5797190 100644 --- a/fastfuels_sdk/v2/client_library/models/create_chm_inventory_request.py +++ b/fastfuels_sdk/v2/client_library/models/create_chm_inventory_request.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -119,7 +119,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.inventory_basal_area_treatment import InventoryBasalAreaTreatment from ..models.inventory_diameter_treatment import InventoryDiameterTreatment from ..models.inventory_modification import InventoryModification diff --git a/fastfuels_sdk/v2/client_library/models/create_compose_request.py b/fastfuels_sdk/v2/client_library/models/create_compose_request.py new file mode 100644 index 0000000..9a1a476 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/create_compose_request.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, Self, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.compose_compute import ComposeCompute + from ..models.compose_input import ComposeInput + from ..models.compose_select import ComposeSelect + from ..models.grid_modification import GridModification + + +T = TypeVar("T", bound="CreateComposeRequest") + + +@_attrs_define +class CreateComposeRequest: + """Request to create a grid by composing one or more existing grids. + + Attributes: + inputs (list[ComposeInput]): + select (list[ComposeSelect] | Unset): + compute (list[ComposeCompute] | Unset): + name (str | Unset): Default: ''. + description (str | Unset): Default: ''. + tags (list[str] | Unset): + modifications (list[GridModification] | Unset): + """ + + inputs: list[ComposeInput] + select: list[ComposeSelect] | Unset = UNSET + compute: list[ComposeCompute] | Unset = UNSET + name: str | Unset = "" + description: str | Unset = "" + tags: list[str] | Unset = UNSET + modifications: list[GridModification] | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + inputs = [] + for inputs_item_data in self.inputs: + inputs_item = inputs_item_data.to_dict() + inputs.append(inputs_item) + + select: list[dict[str, Any]] | Unset = UNSET + if not isinstance(self.select, Unset): + select = [] + for select_item_data in self.select: + select_item = select_item_data.to_dict() + select.append(select_item) + + compute: list[dict[str, Any]] | Unset = UNSET + if not isinstance(self.compute, Unset): + compute = [] + for compute_item_data in self.compute: + compute_item = compute_item_data.to_dict() + compute.append(compute_item) + + name = self.name + + description = self.description + + tags: list[str] | Unset = UNSET + if not isinstance(self.tags, Unset): + tags = self.tags + + modifications: list[dict[str, Any]] | Unset = UNSET + if not isinstance(self.modifications, Unset): + modifications = [] + for modifications_item_data in self.modifications: + modifications_item = modifications_item_data.to_dict() + modifications.append(modifications_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "inputs": inputs, + } + ) + if select is not UNSET: + field_dict["select"] = select + if compute is not UNSET: + field_dict["compute"] = compute + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if tags is not UNSET: + field_dict["tags"] = tags + if modifications is not UNSET: + field_dict["modifications"] = modifications + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + from ..models.compose_compute import ComposeCompute + from ..models.compose_input import ComposeInput + from ..models.compose_select import ComposeSelect + from ..models.grid_modification import GridModification + + d = dict(src_dict) + inputs = [] + _inputs = d.pop("inputs") + for inputs_item_data in _inputs: + inputs_item = ComposeInput.from_dict(inputs_item_data) + + inputs.append(inputs_item) + + _select = d.pop("select", UNSET) + select: list[ComposeSelect] | Unset = UNSET + if _select is not UNSET: + select = [] + for select_item_data in _select: + select_item = ComposeSelect.from_dict(select_item_data) + + select.append(select_item) + + _compute = d.pop("compute", UNSET) + compute: list[ComposeCompute] | Unset = UNSET + if _compute is not UNSET: + compute = [] + for compute_item_data in _compute: + compute_item = ComposeCompute.from_dict(compute_item_data) + + compute.append(compute_item) + + name = d.pop("name", UNSET) + + description = d.pop("description", UNSET) + + tags = cast(list[str], d.pop("tags", UNSET)) + + _modifications = d.pop("modifications", UNSET) + modifications: list[GridModification] | Unset = UNSET + if _modifications is not UNSET: + modifications = [] + for modifications_item_data in _modifications: + modifications_item = GridModification.from_dict(modifications_item_data) + + modifications.append(modifications_item) + + create_compose_request = cls( + inputs=inputs, + select=select, + compute=compute, + name=name, + description=description, + tags=tags, + modifications=modifications, + ) + + create_compose_request.additional_properties = d + return create_compose_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/create_domain_request_body.py b/fastfuels_sdk/v2/client_library/models/create_domain_request_body.py index 09dce6a..185fe16 100644 --- a/fastfuels_sdk/v2/client_library/models/create_domain_request_body.py +++ b/fastfuels_sdk/v2/client_library/models/create_domain_request_body.py @@ -5,6 +5,7 @@ TYPE_CHECKING, Any, Literal, + Self, TypeVar, cast, ) @@ -131,7 +132,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.domain_style import DomainStyle from ..models.geo_json_crs import GeoJsonCRS from ..models.geo_json_feature import GeoJsonFeature diff --git a/fastfuels_sdk/v2/client_library/models/create_duet_request.py b/fastfuels_sdk/v2/client_library/models/create_duet_request.py new file mode 100644 index 0000000..7ffef1f --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/create_duet_request.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, Self, TypeVar, cast + +from attrs import define as _attrs_define + +from ..models.duet_band import DuetBand +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.duet_calibration import DuetCalibration + + +T = TypeVar("T", bound="CreateDuetRequest") + + +@_attrs_define +class CreateDuetRequest: + """Request body for creating a DUET surface fuel grid from a tree grid. + + Does not extend CreateGridRequestBase: like the 3D grids it derives from, + DUET grids do not support modifications. + + Attributes: + years_since_burn (int): Years of litter accumulation to simulate. DUET begins the year of the last burn, when + standing grass and litter have been consumed, so this is the stand's time since fire. It is the highest-leverage + parameter in the model and also drives runtime. + source_grid_id (str): ID of a completed 3D tree grid carrying the `bulk_density.foliage.live`, `spcd`, and + `fuel_moisture.live` bands. + wind_direction (int | Unset): Prevailing wind direction in whole degrees clockwise from north. Default: 270. + wind_variability (int | Unset): Angular spread of wind direction, in whole degrees. Default: 30. + name (str | Unset): Default: ''. + description (str | Unset): Default: ''. + tags (list[str] | Unset): + bands (list[DuetBand] | Unset): Which output bands to produce. Defaults to `fuel_load.grass` and + `fuel_load.litter`. + calibration (DuetCalibration | None | Unset): Optional calibration targets. DUET supplies the spatial pattern of + surface fuels; its raw magnitudes are not physical. Without calibration the raw values are stored as-is. + """ + + years_since_burn: int + source_grid_id: str + wind_direction: int | Unset = 270 + wind_variability: int | Unset = 30 + name: str | Unset = "" + description: str | Unset = "" + tags: list[str] | Unset = UNSET + bands: list[DuetBand] | Unset = UNSET + calibration: DuetCalibration | None | Unset = UNSET + + def to_dict(self) -> dict[str, Any]: + from ..models.duet_calibration import DuetCalibration + + years_since_burn = self.years_since_burn + + source_grid_id = self.source_grid_id + + wind_direction = self.wind_direction + + wind_variability = self.wind_variability + + name = self.name + + description = self.description + + tags: list[str] | Unset = UNSET + if not isinstance(self.tags, Unset): + tags = self.tags + + bands: list[str] | Unset = UNSET + if not isinstance(self.bands, Unset): + bands = [] + for bands_item_data in self.bands: + bands_item = bands_item_data.value + bands.append(bands_item) + + calibration: dict[str, Any] | None | Unset + if isinstance(self.calibration, Unset): + calibration = UNSET + elif isinstance(self.calibration, DuetCalibration): + calibration = self.calibration.to_dict() + else: + calibration = self.calibration + + field_dict: dict[str, Any] = {} + + field_dict.update( + { + "years_since_burn": years_since_burn, + "source_grid_id": source_grid_id, + } + ) + if wind_direction is not UNSET: + field_dict["wind_direction"] = wind_direction + if wind_variability is not UNSET: + field_dict["wind_variability"] = wind_variability + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if tags is not UNSET: + field_dict["tags"] = tags + if bands is not UNSET: + field_dict["bands"] = bands + if calibration is not UNSET: + field_dict["calibration"] = calibration + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + from ..models.duet_calibration import DuetCalibration + + d = dict(src_dict) + years_since_burn = d.pop("years_since_burn") + + source_grid_id = d.pop("source_grid_id") + + wind_direction = d.pop("wind_direction", UNSET) + + wind_variability = d.pop("wind_variability", UNSET) + + name = d.pop("name", UNSET) + + description = d.pop("description", UNSET) + + tags = cast(list[str], d.pop("tags", UNSET)) + + _bands = d.pop("bands", UNSET) + bands: list[DuetBand] | Unset = UNSET + if _bands is not UNSET: + bands = [] + for bands_item_data in _bands: + bands_item = DuetBand(bands_item_data) + + bands.append(bands_item) + + def _parse_calibration(data: object) -> DuetCalibration | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + calibration_type_0 = DuetCalibration.from_dict(data) + + return calibration_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(DuetCalibration | None | Unset, data) + + calibration = _parse_calibration(d.pop("calibration", UNSET)) + + create_duet_request = cls( + years_since_burn=years_since_burn, + source_grid_id=source_grid_id, + wind_direction=wind_direction, + wind_variability=wind_variability, + name=name, + description=description, + tags=tags, + bands=bands, + calibration=calibration, + ) + + return create_duet_request diff --git a/fastfuels_sdk/v2/client_library/models/create_fbfm_13_lookup_request.py b/fastfuels_sdk/v2/client_library/models/create_fbfm_13_lookup_request.py new file mode 100644 index 0000000..7e1d917 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/create_fbfm_13_lookup_request.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, Self, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.fbfm_13_lookup_band import Fbfm13LookupBand +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.grid_modification import GridModification + + +T = TypeVar("T", bound="CreateFbfm13LookupRequest") + + +@_attrs_define +class CreateFbfm13LookupRequest: + """Request to create a grid by looking up FBFM13 fuel parameters. + + Unlike entry-point grid creation requests, domain_id is not required + because derived grids carry the same domain reference as their source. + + Attributes: + source_grid_id (str): Grid containing FBFM13 codes + bands (list[Fbfm13LookupBand]): + source_band (str | Unset): Band in source grid containing FBFM13 codes Default: 'fbfm13'. + name (str | Unset): Default: ''. + description (str | Unset): Default: ''. + tags (list[str] | Unset): + modifications (list[GridModification] | Unset): + """ + + source_grid_id: str + bands: list[Fbfm13LookupBand] + source_band: str | Unset = "fbfm13" + name: str | Unset = "" + description: str | Unset = "" + tags: list[str] | Unset = UNSET + modifications: list[GridModification] | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + source_grid_id = self.source_grid_id + + bands = [] + for bands_item_data in self.bands: + bands_item = bands_item_data.value + bands.append(bands_item) + + source_band = self.source_band + + name = self.name + + description = self.description + + tags: list[str] | Unset = UNSET + if not isinstance(self.tags, Unset): + tags = self.tags + + modifications: list[dict[str, Any]] | Unset = UNSET + if not isinstance(self.modifications, Unset): + modifications = [] + for modifications_item_data in self.modifications: + modifications_item = modifications_item_data.to_dict() + modifications.append(modifications_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "source_grid_id": source_grid_id, + "bands": bands, + } + ) + if source_band is not UNSET: + field_dict["source_band"] = source_band + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if tags is not UNSET: + field_dict["tags"] = tags + if modifications is not UNSET: + field_dict["modifications"] = modifications + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + from ..models.grid_modification import GridModification + + d = dict(src_dict) + source_grid_id = d.pop("source_grid_id") + + bands = [] + _bands = d.pop("bands") + for bands_item_data in _bands: + bands_item = Fbfm13LookupBand(bands_item_data) + + bands.append(bands_item) + + source_band = d.pop("source_band", UNSET) + + name = d.pop("name", UNSET) + + description = d.pop("description", UNSET) + + tags = cast(list[str], d.pop("tags", UNSET)) + + _modifications = d.pop("modifications", UNSET) + modifications: list[GridModification] | Unset = UNSET + if _modifications is not UNSET: + modifications = [] + for modifications_item_data in _modifications: + modifications_item = GridModification.from_dict(modifications_item_data) + + modifications.append(modifications_item) + + create_fbfm_13_lookup_request = cls( + source_grid_id=source_grid_id, + bands=bands, + source_band=source_band, + name=name, + description=description, + tags=tags, + modifications=modifications, + ) + + create_fbfm_13_lookup_request.additional_properties = d + return create_fbfm_13_lookup_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/create_fbfm_40_lookup_request.py b/fastfuels_sdk/v2/client_library/models/create_fbfm_40_lookup_request.py index 8383329..90bea72 100644 --- a/fastfuels_sdk/v2/client_library/models/create_fbfm_40_lookup_request.py +++ b/fastfuels_sdk/v2/client_library/models/create_fbfm_40_lookup_request.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -89,7 +89,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.grid_modification import GridModification d = dict(src_dict) diff --git a/fastfuels_sdk/v2/client_library/models/create_fccs_lookup_request.py b/fastfuels_sdk/v2/client_library/models/create_fccs_lookup_request.py new file mode 100644 index 0000000..5b2fa37 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/create_fccs_lookup_request.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, Self, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.fccs_lookup_band import FccsLookupBand +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.grid_modification import GridModification + + +T = TypeVar("T", bound="CreateFccsLookupRequest") + + +@_attrs_define +class CreateFccsLookupRequest: + """Request to create a grid by looking up FCCS fuel parameters. + + Unlike entry-point grid creation requests, domain_id is not required + because derived grids carry the same domain reference as their source. + + Attributes: + source_grid_id (str): Grid containing FCCS codes + bands (list[FccsLookupBand]): + source_band (str | Unset): Band in source grid containing FCCS codes Default: 'fccs'. + name (str | Unset): Default: ''. + description (str | Unset): Default: ''. + tags (list[str] | Unset): + modifications (list[GridModification] | Unset): + """ + + source_grid_id: str + bands: list[FccsLookupBand] + source_band: str | Unset = "fccs" + name: str | Unset = "" + description: str | Unset = "" + tags: list[str] | Unset = UNSET + modifications: list[GridModification] | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + source_grid_id = self.source_grid_id + + bands = [] + for bands_item_data in self.bands: + bands_item = bands_item_data.value + bands.append(bands_item) + + source_band = self.source_band + + name = self.name + + description = self.description + + tags: list[str] | Unset = UNSET + if not isinstance(self.tags, Unset): + tags = self.tags + + modifications: list[dict[str, Any]] | Unset = UNSET + if not isinstance(self.modifications, Unset): + modifications = [] + for modifications_item_data in self.modifications: + modifications_item = modifications_item_data.to_dict() + modifications.append(modifications_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "source_grid_id": source_grid_id, + "bands": bands, + } + ) + if source_band is not UNSET: + field_dict["source_band"] = source_band + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if tags is not UNSET: + field_dict["tags"] = tags + if modifications is not UNSET: + field_dict["modifications"] = modifications + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + from ..models.grid_modification import GridModification + + d = dict(src_dict) + source_grid_id = d.pop("source_grid_id") + + bands = [] + _bands = d.pop("bands") + for bands_item_data in _bands: + bands_item = FccsLookupBand(bands_item_data) + + bands.append(bands_item) + + source_band = d.pop("source_band", UNSET) + + name = d.pop("name", UNSET) + + description = d.pop("description", UNSET) + + tags = cast(list[str], d.pop("tags", UNSET)) + + _modifications = d.pop("modifications", UNSET) + modifications: list[GridModification] | Unset = UNSET + if _modifications is not UNSET: + modifications = [] + for modifications_item_data in _modifications: + modifications_item = GridModification.from_dict(modifications_item_data) + + modifications.append(modifications_item) + + create_fccs_lookup_request = cls( + source_grid_id=source_grid_id, + bands=bands, + source_band=source_band, + name=name, + description=description, + tags=tags, + modifications=modifications, + ) + + create_fccs_lookup_request.additional_properties = d + return create_fccs_lookup_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/create_gdam_inventory_request.py b/fastfuels_sdk/v2/client_library/models/create_gdam_inventory_request.py index b83e14f..df5939e 100644 --- a/fastfuels_sdk/v2/client_library/models/create_gdam_inventory_request.py +++ b/fastfuels_sdk/v2/client_library/models/create_gdam_inventory_request.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar, cast +from typing import Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -83,7 +83,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) source_tree_inventory_id = d.pop("source_tree_inventory_id") diff --git a/fastfuels_sdk/v2/client_library/models/create_geo_tiff_upload_request.py b/fastfuels_sdk/v2/client_library/models/create_geo_tiff_upload_request.py index 2f8584f..f052e5c 100644 --- a/fastfuels_sdk/v2/client_library/models/create_geo_tiff_upload_request.py +++ b/fastfuels_sdk/v2/client_library/models/create_geo_tiff_upload_request.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -70,7 +70,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.upload_band_definition import UploadBandDefinition d = dict(src_dict) diff --git a/fastfuels_sdk/v2/client_library/models/create_inventory_upload_request.py b/fastfuels_sdk/v2/client_library/models/create_inventory_upload_request.py index a940dcd..9eb63fd 100644 --- a/fastfuels_sdk/v2/client_library/models/create_inventory_upload_request.py +++ b/fastfuels_sdk/v2/client_library/models/create_inventory_upload_request.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -73,7 +73,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.inventory_column_mapping import InventoryColumnMapping d = dict(src_dict) diff --git a/fastfuels_sdk/v2/client_library/models/create_key_request.py b/fastfuels_sdk/v2/client_library/models/create_key_request.py index c0db55c..4334e75 100644 --- a/fastfuels_sdk/v2/client_library/models/create_key_request.py +++ b/fastfuels_sdk/v2/client_library/models/create_key_request.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar, cast +from typing import Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -83,7 +83,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) name = d.pop("name") diff --git a/fastfuels_sdk/v2/client_library/models/create_key_response.py b/fastfuels_sdk/v2/client_library/models/create_key_response.py index d7d841b..195c1dc 100644 --- a/fastfuels_sdk/v2/client_library/models/create_key_response.py +++ b/fastfuels_sdk/v2/client_library/models/create_key_response.py @@ -2,7 +2,7 @@ import datetime from collections.abc import Mapping -from typing import Any, TypeVar, cast +from typing import Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -120,7 +120,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) id = d.pop("id") diff --git a/fastfuels_sdk/v2/client_library/models/create_landfire_canopy_request.py b/fastfuels_sdk/v2/client_library/models/create_landfire_canopy_request.py index 0084766..d77e7a9 100644 --- a/fastfuels_sdk/v2/client_library/models/create_landfire_canopy_request.py +++ b/fastfuels_sdk/v2/client_library/models/create_landfire_canopy_request.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -99,9 +99,9 @@ def to_dict(self) -> dict[str, Any]: alignment: dict[str, Any] | Unset if isinstance(self.alignment, Unset): alignment = UNSET - elif isinstance(self.alignment, GridAlignmentDomainTarget): - alignment = self.alignment.to_dict() - elif isinstance(self.alignment, GridAlignmentNativeTarget): + elif isinstance(self.alignment, GridAlignmentDomainTarget) or isinstance( + self.alignment, GridAlignmentNativeTarget + ): alignment = self.alignment.to_dict() else: alignment = self.alignment.to_dict() @@ -140,7 +140,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.grid_alignment_domain_target import GridAlignmentDomainTarget from ..models.grid_alignment_grid_target import GridAlignmentGridTarget from ..models.grid_alignment_native_target import GridAlignmentNativeTarget diff --git a/fastfuels_sdk/v2/client_library/models/create_landfire_fbfm_13_request.py b/fastfuels_sdk/v2/client_library/models/create_landfire_fbfm_13_request.py new file mode 100644 index 0000000..29d8d82 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/create_landfire_fbfm_13_request.py @@ -0,0 +1,264 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, Self, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.landfire_fbfm_13_version import LandfireFbfm13Version +from ..models.non_burnable_fuel_model import NonBurnableFuelModel +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.grid_alignment_domain_target import GridAlignmentDomainTarget + from ..models.grid_alignment_grid_target import GridAlignmentGridTarget + from ..models.grid_alignment_native_target import GridAlignmentNativeTarget + from ..models.grid_modification import GridModification + + +T = TypeVar("T", bound="CreateLandfireFbfm13Request") + + +@_attrs_define +class CreateLandfireFbfm13Request: + """Request to create a grid from LANDFIRE FBFM13. + + Returns a single-band grid with categorical fuel model codes. + To convert codes to fuel parameters, use /grids/lookup/fbfm13. + + Attributes: + name (str | Unset): Default: ''. + description (str | Unset): Default: ''. + tags (list[str] | Unset): + modifications (list[GridModification] | Unset): Rules applied to the grid after it is built from its source. + Each rule has a list of `conditions` (ANDed together) and a list of `actions` (applied where the conditions + match). Conditions can be attribute-based (compare a band value) or spatial (test cell location against a + geometry). Spatial conditions come in two variants discriminated by `source`: `geometry` (inline GeoJSON) or + `feature` (reference a persisted Feature resource — road, water, layerset — in the same domain by `feature_id`). + Both spatial variants accept `buffer_m` (meters, applied in the domain's projected CRS) to widen the geometry, + and `target` (`centroid` or `cell`) to choose which part of the cell is tested. Actions modify band values via + `replace`, `multiply`, `divide`, `add`, or `subtract`. See the `GridModification` schema for the full field + reference and worked examples. + extent_buffer_cells (int | Unset): Number of result-grid cells included as a buffer around the domain extent in + the stored grid. The buffer is measured after the source raster is projected into the domain CRS, so a cell + means one cell in the returned grid rather than one source raster cell. Provides context for later operations + (resample, reproject, focal filters, derivative calculations) that are sensitive to edges. Default 0 adds no + buffer. Maximum: 10 cells. Default: 0. + alignment (GridAlignmentDomainTarget | GridAlignmentGridTarget | GridAlignmentNativeTarget | Unset): Per-fetch + alignment target. Default `target="domain"` anchors output cells to the domain origin so cross-source + composition works by construction. `target="native"` preserves the source pixel anchor. `target="grid"` aligns + to an existing grid by id. + version (LandfireFbfm13Version | Unset): Available LANDFIRE FBFM13 data versions. + remove_non_burnable (list[NonBurnableFuelModel] | None | Unset): + """ + + name: str | Unset = "" + description: str | Unset = "" + tags: list[str] | Unset = UNSET + modifications: list[GridModification] | Unset = UNSET + extent_buffer_cells: int | Unset = 0 + alignment: ( + GridAlignmentDomainTarget + | GridAlignmentGridTarget + | GridAlignmentNativeTarget + | Unset + ) = UNSET + version: LandfireFbfm13Version | Unset = UNSET + remove_non_burnable: list[NonBurnableFuelModel] | None | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.grid_alignment_domain_target import GridAlignmentDomainTarget + from ..models.grid_alignment_native_target import GridAlignmentNativeTarget + + name = self.name + + description = self.description + + tags: list[str] | Unset = UNSET + if not isinstance(self.tags, Unset): + tags = self.tags + + modifications: list[dict[str, Any]] | Unset = UNSET + if not isinstance(self.modifications, Unset): + modifications = [] + for modifications_item_data in self.modifications: + modifications_item = modifications_item_data.to_dict() + modifications.append(modifications_item) + + extent_buffer_cells = self.extent_buffer_cells + + alignment: dict[str, Any] | Unset + if isinstance(self.alignment, Unset): + alignment = UNSET + elif isinstance(self.alignment, GridAlignmentDomainTarget) or isinstance( + self.alignment, GridAlignmentNativeTarget + ): + alignment = self.alignment.to_dict() + else: + alignment = self.alignment.to_dict() + + version: str | Unset = UNSET + if not isinstance(self.version, Unset): + version = self.version.value + + remove_non_burnable: list[str] | None | Unset + if isinstance(self.remove_non_burnable, Unset): + remove_non_burnable = UNSET + elif isinstance(self.remove_non_burnable, list): + remove_non_burnable = [] + for remove_non_burnable_type_0_item_data in self.remove_non_burnable: + remove_non_burnable_type_0_item = ( + remove_non_burnable_type_0_item_data.value + ) + remove_non_burnable.append(remove_non_burnable_type_0_item) + + else: + remove_non_burnable = self.remove_non_burnable + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if tags is not UNSET: + field_dict["tags"] = tags + if modifications is not UNSET: + field_dict["modifications"] = modifications + if extent_buffer_cells is not UNSET: + field_dict["extent_buffer_cells"] = extent_buffer_cells + if alignment is not UNSET: + field_dict["alignment"] = alignment + if version is not UNSET: + field_dict["version"] = version + if remove_non_burnable is not UNSET: + field_dict["remove_non_burnable"] = remove_non_burnable + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + from ..models.grid_alignment_domain_target import GridAlignmentDomainTarget + from ..models.grid_alignment_grid_target import GridAlignmentGridTarget + from ..models.grid_alignment_native_target import GridAlignmentNativeTarget + from ..models.grid_modification import GridModification + + d = dict(src_dict) + name = d.pop("name", UNSET) + + description = d.pop("description", UNSET) + + tags = cast(list[str], d.pop("tags", UNSET)) + + _modifications = d.pop("modifications", UNSET) + modifications: list[GridModification] | Unset = UNSET + if _modifications is not UNSET: + modifications = [] + for modifications_item_data in _modifications: + modifications_item = GridModification.from_dict(modifications_item_data) + + modifications.append(modifications_item) + + extent_buffer_cells = d.pop("extent_buffer_cells", UNSET) + + def _parse_alignment( + data: object, + ) -> ( + GridAlignmentDomainTarget + | GridAlignmentGridTarget + | GridAlignmentNativeTarget + | Unset + ): + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + alignment_type_0 = GridAlignmentDomainTarget.from_dict(data) + + return alignment_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + alignment_type_1 = GridAlignmentNativeTarget.from_dict(data) + + return alignment_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + if not isinstance(data, dict): + raise TypeError() + alignment_type_2 = GridAlignmentGridTarget.from_dict(data) + + return alignment_type_2 + + alignment = _parse_alignment(d.pop("alignment", UNSET)) + + _version = d.pop("version", UNSET) + version: LandfireFbfm13Version | Unset + if isinstance(_version, Unset): + version = UNSET + else: + version = LandfireFbfm13Version(_version) + + def _parse_remove_non_burnable( + data: object, + ) -> list[NonBurnableFuelModel] | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, list): + raise TypeError() + remove_non_burnable_type_0 = [] + _remove_non_burnable_type_0 = data + for remove_non_burnable_type_0_item_data in _remove_non_burnable_type_0: + remove_non_burnable_type_0_item = NonBurnableFuelModel( + remove_non_burnable_type_0_item_data + ) + + remove_non_burnable_type_0.append(remove_non_burnable_type_0_item) + + return remove_non_burnable_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(list[NonBurnableFuelModel] | None | Unset, data) + + remove_non_burnable = _parse_remove_non_burnable( + d.pop("remove_non_burnable", UNSET) + ) + + create_landfire_fbfm_13_request = cls( + name=name, + description=description, + tags=tags, + modifications=modifications, + extent_buffer_cells=extent_buffer_cells, + alignment=alignment, + version=version, + remove_non_burnable=remove_non_burnable, + ) + + create_landfire_fbfm_13_request.additional_properties = d + return create_landfire_fbfm_13_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/create_landfire_fbfm_40_request.py b/fastfuels_sdk/v2/client_library/models/create_landfire_fbfm_40_request.py index 2a40658..dfd542d 100644 --- a/fastfuels_sdk/v2/client_library/models/create_landfire_fbfm_40_request.py +++ b/fastfuels_sdk/v2/client_library/models/create_landfire_fbfm_40_request.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -92,9 +92,9 @@ def to_dict(self) -> dict[str, Any]: alignment: dict[str, Any] | Unset if isinstance(self.alignment, Unset): alignment = UNSET - elif isinstance(self.alignment, GridAlignmentDomainTarget): - alignment = self.alignment.to_dict() - elif isinstance(self.alignment, GridAlignmentNativeTarget): + elif isinstance(self.alignment, GridAlignmentDomainTarget) or isinstance( + self.alignment, GridAlignmentNativeTarget + ): alignment = self.alignment.to_dict() else: alignment = self.alignment.to_dict() @@ -140,7 +140,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.grid_alignment_domain_target import GridAlignmentDomainTarget from ..models.grid_alignment_grid_target import GridAlignmentGridTarget from ..models.grid_alignment_native_target import GridAlignmentNativeTarget diff --git a/fastfuels_sdk/v2/client_library/models/create_landfire_fccs_request.py b/fastfuels_sdk/v2/client_library/models/create_landfire_fccs_request.py index f25a8b7..796ad33 100644 --- a/fastfuels_sdk/v2/client_library/models/create_landfire_fccs_request.py +++ b/fastfuels_sdk/v2/client_library/models/create_landfire_fccs_request.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -91,9 +91,9 @@ def to_dict(self) -> dict[str, Any]: alignment: dict[str, Any] | Unset if isinstance(self.alignment, Unset): alignment = UNSET - elif isinstance(self.alignment, GridAlignmentDomainTarget): - alignment = self.alignment.to_dict() - elif isinstance(self.alignment, GridAlignmentNativeTarget): + elif isinstance(self.alignment, GridAlignmentDomainTarget) or isinstance( + self.alignment, GridAlignmentNativeTarget + ): alignment = self.alignment.to_dict() else: alignment = self.alignment.to_dict() @@ -127,7 +127,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.grid_alignment_domain_target import GridAlignmentDomainTarget from ..models.grid_alignment_grid_target import GridAlignmentGridTarget from ..models.grid_alignment_native_target import GridAlignmentNativeTarget diff --git a/fastfuels_sdk/v2/client_library/models/create_landfire_topography_request.py b/fastfuels_sdk/v2/client_library/models/create_landfire_topography_request.py index 1c6c9b3..791d98b 100644 --- a/fastfuels_sdk/v2/client_library/models/create_landfire_topography_request.py +++ b/fastfuels_sdk/v2/client_library/models/create_landfire_topography_request.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -92,9 +92,9 @@ def to_dict(self) -> dict[str, Any]: alignment: dict[str, Any] | Unset if isinstance(self.alignment, Unset): alignment = UNSET - elif isinstance(self.alignment, GridAlignmentDomainTarget): - alignment = self.alignment.to_dict() - elif isinstance(self.alignment, GridAlignmentNativeTarget): + elif isinstance(self.alignment, GridAlignmentDomainTarget) or isinstance( + self.alignment, GridAlignmentNativeTarget + ): alignment = self.alignment.to_dict() else: alignment = self.alignment.to_dict() @@ -133,7 +133,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.grid_alignment_domain_target import GridAlignmentDomainTarget from ..models.grid_alignment_grid_target import GridAlignmentGridTarget from ..models.grid_alignment_native_target import GridAlignmentNativeTarget diff --git a/fastfuels_sdk/v2/client_library/models/create_layerset_rasterize_request.py b/fastfuels_sdk/v2/client_library/models/create_layerset_rasterize_request.py index 8802237..118e2f2 100644 --- a/fastfuels_sdk/v2/client_library/models/create_layerset_rasterize_request.py +++ b/fastfuels_sdk/v2/client_library/models/create_layerset_rasterize_request.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -96,9 +96,9 @@ def to_dict(self) -> dict[str, Any]: alignment: dict[str, Any] | Unset if isinstance(self.alignment, Unset): alignment = UNSET - elif isinstance(self.alignment, GridAlignmentDomainTarget): - alignment = self.alignment.to_dict() - elif isinstance(self.alignment, GridAlignmentNativeTarget): + elif isinstance(self.alignment, GridAlignmentDomainTarget) or isinstance( + self.alignment, GridAlignmentNativeTarget + ): alignment = self.alignment.to_dict() else: alignment = self.alignment.to_dict() @@ -132,7 +132,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.grid_alignment_domain_target import GridAlignmentDomainTarget from ..models.grid_alignment_grid_target import GridAlignmentGridTarget from ..models.grid_alignment_native_target import GridAlignmentNativeTarget diff --git a/fastfuels_sdk/v2/client_library/models/create_layerset_request_body.py b/fastfuels_sdk/v2/client_library/models/create_layerset_request_body.py index d9462ff..ee41b7d 100644 --- a/fastfuels_sdk/v2/client_library/models/create_layerset_request_body.py +++ b/fastfuels_sdk/v2/client_library/models/create_layerset_request_body.py @@ -5,6 +5,7 @@ TYPE_CHECKING, Any, Literal, + Self, TypeVar, cast, ) @@ -116,7 +117,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.layerset_crs import LayersetCrs from ..models.layerset_feature import LayersetFeature diff --git a/fastfuels_sdk/v2/client_library/models/create_meta_chm_request.py b/fastfuels_sdk/v2/client_library/models/create_meta_chm_request.py index 2e7276d..a933e78 100644 --- a/fastfuels_sdk/v2/client_library/models/create_meta_chm_request.py +++ b/fastfuels_sdk/v2/client_library/models/create_meta_chm_request.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -89,9 +89,9 @@ def to_dict(self) -> dict[str, Any]: alignment: dict[str, Any] | Unset if isinstance(self.alignment, Unset): alignment = UNSET - elif isinstance(self.alignment, GridAlignmentDomainTarget): - alignment = self.alignment.to_dict() - elif isinstance(self.alignment, GridAlignmentNativeTarget): + elif isinstance(self.alignment, GridAlignmentDomainTarget) or isinstance( + self.alignment, GridAlignmentNativeTarget + ): alignment = self.alignment.to_dict() else: alignment = self.alignment.to_dict() @@ -121,7 +121,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.grid_alignment_domain_target import GridAlignmentDomainTarget from ..models.grid_alignment_grid_target import GridAlignmentGridTarget from ..models.grid_alignment_native_target import GridAlignmentNativeTarget diff --git a/fastfuels_sdk/v2/client_library/models/create_naip_chm_request.py b/fastfuels_sdk/v2/client_library/models/create_naip_chm_request.py index 0ff8168..58c1be3 100644 --- a/fastfuels_sdk/v2/client_library/models/create_naip_chm_request.py +++ b/fastfuels_sdk/v2/client_library/models/create_naip_chm_request.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -86,9 +86,9 @@ def to_dict(self) -> dict[str, Any]: alignment: dict[str, Any] | Unset if isinstance(self.alignment, Unset): alignment = UNSET - elif isinstance(self.alignment, GridAlignmentDomainTarget): - alignment = self.alignment.to_dict() - elif isinstance(self.alignment, GridAlignmentNativeTarget): + elif isinstance(self.alignment, GridAlignmentDomainTarget) or isinstance( + self.alignment, GridAlignmentNativeTarget + ): alignment = self.alignment.to_dict() else: alignment = self.alignment.to_dict() @@ -112,7 +112,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.grid_alignment_domain_target import GridAlignmentDomainTarget from ..models.grid_alignment_grid_target import GridAlignmentGridTarget from ..models.grid_alignment_native_target import GridAlignmentNativeTarget diff --git a/fastfuels_sdk/v2/client_library/models/create_netcdf_upload_request.py b/fastfuels_sdk/v2/client_library/models/create_netcdf_upload_request.py index dab3a91..2d53e5b 100644 --- a/fastfuels_sdk/v2/client_library/models/create_netcdf_upload_request.py +++ b/fastfuels_sdk/v2/client_library/models/create_netcdf_upload_request.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar, cast +from typing import Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -55,7 +55,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) name = d.pop("name", UNSET) diff --git a/fastfuels_sdk/v2/client_library/models/create_osm_road_feature_request.py b/fastfuels_sdk/v2/client_library/models/create_osm_road_feature_request.py index f510ff7..c5cd342 100644 --- a/fastfuels_sdk/v2/client_library/models/create_osm_road_feature_request.py +++ b/fastfuels_sdk/v2/client_library/models/create_osm_road_feature_request.py @@ -4,6 +4,7 @@ from typing import ( Any, Literal, + Self, TypeVar, cast, ) @@ -69,7 +70,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) type_ = cast(Literal["road"] | Unset, d.pop("type", UNSET)) if type_ != "road" and not isinstance(type_, Unset): diff --git a/fastfuels_sdk/v2/client_library/models/create_osm_water_feature_request.py b/fastfuels_sdk/v2/client_library/models/create_osm_water_feature_request.py index 17a76c5..a1bf229 100644 --- a/fastfuels_sdk/v2/client_library/models/create_osm_water_feature_request.py +++ b/fastfuels_sdk/v2/client_library/models/create_osm_water_feature_request.py @@ -4,6 +4,7 @@ from typing import ( Any, Literal, + Self, TypeVar, cast, ) @@ -69,7 +70,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) type_ = cast(Literal["water"] | Unset, d.pop("type", UNSET)) if type_ != "water" and not isinstance(type_, Unset): diff --git a/fastfuels_sdk/v2/client_library/models/create_pim_inventory_request.py b/fastfuels_sdk/v2/client_library/models/create_pim_inventory_request.py index 261844b..4c1dfbf 100644 --- a/fastfuels_sdk/v2/client_library/models/create_pim_inventory_request.py +++ b/fastfuels_sdk/v2/client_library/models/create_pim_inventory_request.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -118,7 +118,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.inventory_basal_area_treatment import InventoryBasalAreaTreatment from ..models.inventory_diameter_treatment import InventoryDiameterTreatment from ..models.inventory_modification import InventoryModification diff --git a/fastfuels_sdk/v2/client_library/models/create_point_cloud_chm_request.py b/fastfuels_sdk/v2/client_library/models/create_point_cloud_chm_request.py new file mode 100644 index 0000000..a53606c --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/create_point_cloud_chm_request.py @@ -0,0 +1,214 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, Self, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.grid_alignment_domain_target import GridAlignmentDomainTarget + from ..models.grid_alignment_grid_target import GridAlignmentGridTarget + from ..models.grid_alignment_native_target import GridAlignmentNativeTarget + from ..models.grid_modification import GridModification + + +T = TypeVar("T", bound="CreatePointCloudChmRequest") + + +@_attrs_define +class CreatePointCloudChmRequest: + """Request to create a canopy height model grid from a point cloud. + + Returns a grid with a single continuous band: + - chm: Canopy height in meters + + The point cloud must be airborne (`type: als`) and `completed`. Cell size + comes from `alignment.resolution`, defaulting to 1 m — unlike the + raster-backed canopy sources there is no source pixel size to inherit. + + Attributes: + source_point_cloud_id (str): ID of the point cloud to rasterize. Must be an ALS cloud in this domain. + name (str | Unset): Default: ''. + description (str | Unset): Default: ''. + tags (list[str] | Unset): + modifications (list[GridModification] | Unset): Rules applied to the grid after it is built from its source. + Each rule has a list of `conditions` (ANDed together) and a list of `actions` (applied where the conditions + match). Conditions can be attribute-based (compare a band value) or spatial (test cell location against a + geometry). Spatial conditions come in two variants discriminated by `source`: `geometry` (inline GeoJSON) or + `feature` (reference a persisted Feature resource — road, water, layerset — in the same domain by `feature_id`). + Both spatial variants accept `buffer_m` (meters, applied in the domain's projected CRS) to widen the geometry, + and `target` (`centroid` or `cell`) to choose which part of the cell is tested. Actions modify band values via + `replace`, `multiply`, `divide`, `add`, or `subtract`. See the `GridModification` schema for the full field + reference and worked examples. + extent_buffer_cells (int | Unset): Number of result-grid cells included as a buffer around the domain extent in + the stored grid. The buffer is measured after the source raster is projected into the domain CRS, so a cell + means one cell in the returned grid rather than one source raster cell. Provides context for later operations + (resample, reproject, focal filters, derivative calculations) that are sensitive to edges. Default 0 adds no + buffer. Maximum: 10 cells. Default: 0. + alignment (GridAlignmentDomainTarget | GridAlignmentGridTarget | GridAlignmentNativeTarget | Unset): Per-fetch + alignment target. Default `target="domain"` anchors output cells to the domain origin so cross-source + composition works by construction. `target="native"` preserves the source pixel anchor. `target="grid"` aligns + to an existing grid by id. + """ + + source_point_cloud_id: str + name: str | Unset = "" + description: str | Unset = "" + tags: list[str] | Unset = UNSET + modifications: list[GridModification] | Unset = UNSET + extent_buffer_cells: int | Unset = 0 + alignment: ( + GridAlignmentDomainTarget + | GridAlignmentGridTarget + | GridAlignmentNativeTarget + | Unset + ) = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.grid_alignment_domain_target import GridAlignmentDomainTarget + from ..models.grid_alignment_native_target import GridAlignmentNativeTarget + + source_point_cloud_id = self.source_point_cloud_id + + name = self.name + + description = self.description + + tags: list[str] | Unset = UNSET + if not isinstance(self.tags, Unset): + tags = self.tags + + modifications: list[dict[str, Any]] | Unset = UNSET + if not isinstance(self.modifications, Unset): + modifications = [] + for modifications_item_data in self.modifications: + modifications_item = modifications_item_data.to_dict() + modifications.append(modifications_item) + + extent_buffer_cells = self.extent_buffer_cells + + alignment: dict[str, Any] | Unset + if isinstance(self.alignment, Unset): + alignment = UNSET + elif isinstance(self.alignment, GridAlignmentDomainTarget) or isinstance( + self.alignment, GridAlignmentNativeTarget + ): + alignment = self.alignment.to_dict() + else: + alignment = self.alignment.to_dict() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "source_point_cloud_id": source_point_cloud_id, + } + ) + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if tags is not UNSET: + field_dict["tags"] = tags + if modifications is not UNSET: + field_dict["modifications"] = modifications + if extent_buffer_cells is not UNSET: + field_dict["extent_buffer_cells"] = extent_buffer_cells + if alignment is not UNSET: + field_dict["alignment"] = alignment + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + from ..models.grid_alignment_domain_target import GridAlignmentDomainTarget + from ..models.grid_alignment_grid_target import GridAlignmentGridTarget + from ..models.grid_alignment_native_target import GridAlignmentNativeTarget + from ..models.grid_modification import GridModification + + d = dict(src_dict) + source_point_cloud_id = d.pop("source_point_cloud_id") + + name = d.pop("name", UNSET) + + description = d.pop("description", UNSET) + + tags = cast(list[str], d.pop("tags", UNSET)) + + _modifications = d.pop("modifications", UNSET) + modifications: list[GridModification] | Unset = UNSET + if _modifications is not UNSET: + modifications = [] + for modifications_item_data in _modifications: + modifications_item = GridModification.from_dict(modifications_item_data) + + modifications.append(modifications_item) + + extent_buffer_cells = d.pop("extent_buffer_cells", UNSET) + + def _parse_alignment( + data: object, + ) -> ( + GridAlignmentDomainTarget + | GridAlignmentGridTarget + | GridAlignmentNativeTarget + | Unset + ): + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + alignment_type_0 = GridAlignmentDomainTarget.from_dict(data) + + return alignment_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + alignment_type_1 = GridAlignmentNativeTarget.from_dict(data) + + return alignment_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + if not isinstance(data, dict): + raise TypeError() + alignment_type_2 = GridAlignmentGridTarget.from_dict(data) + + return alignment_type_2 + + alignment = _parse_alignment(d.pop("alignment", UNSET)) + + create_point_cloud_chm_request = cls( + source_point_cloud_id=source_point_cloud_id, + name=name, + description=description, + tags=tags, + modifications=modifications, + extent_buffer_cells=extent_buffer_cells, + alignment=alignment, + ) + + create_point_cloud_chm_request.additional_properties = d + return create_point_cloud_chm_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/create_point_cloud_upload_request.py b/fastfuels_sdk/v2/client_library/models/create_point_cloud_upload_request.py index e6d1184..14aab41 100644 --- a/fastfuels_sdk/v2/client_library/models/create_point_cloud_upload_request.py +++ b/fastfuels_sdk/v2/client_library/models/create_point_cloud_upload_request.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar, cast +from typing import Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -69,7 +69,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) type_ = PointCloudType(d.pop("type")) diff --git a/fastfuels_sdk/v2/client_library/models/create_resample_request.py b/fastfuels_sdk/v2/client_library/models/create_resample_request.py index 8b7dc60..dc76df2 100644 --- a/fastfuels_sdk/v2/client_library/models/create_resample_request.py +++ b/fastfuels_sdk/v2/client_library/models/create_resample_request.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -70,9 +70,9 @@ def to_dict(self) -> dict[str, Any]: alignment: dict[str, Any] | Unset if isinstance(self.alignment, Unset): alignment = UNSET - elif isinstance(self.alignment, GridAlignmentDomainTarget): - alignment = self.alignment.to_dict() - elif isinstance(self.alignment, GridAlignmentNativeTarget): + elif isinstance(self.alignment, GridAlignmentDomainTarget) or isinstance( + self.alignment, GridAlignmentNativeTarget + ): alignment = self.alignment.to_dict() else: alignment = self.alignment.to_dict() @@ -119,7 +119,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.create_resample_request_method_overrides import ( CreateResampleRequestMethodOverrides, ) diff --git a/fastfuels_sdk/v2/client_library/models/create_resample_request_method_overrides.py b/fastfuels_sdk/v2/client_library/models/create_resample_request_method_overrides.py index 7e77611..15cca20 100644 --- a/fastfuels_sdk/v2/client_library/models/create_resample_request_method_overrides.py +++ b/fastfuels_sdk/v2/client_library/models/create_resample_request_method_overrides.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar +from typing import Any, Self, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -31,7 +31,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) create_resample_request_method_overrides = cls() diff --git a/fastfuels_sdk/v2/client_library/models/create_three_dep_point_cloud_request.py b/fastfuels_sdk/v2/client_library/models/create_three_dep_point_cloud_request.py new file mode 100644 index 0000000..fd7ebb6 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/create_three_dep_point_cloud_request.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Self, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="CreateThreeDepPointCloudRequest") + + +@_attrs_define +class CreateThreeDepPointCloudRequest: + """Request body for fetching a point cloud from USGS 3DEP. + + Attributes: + name (str | Unset): Human-readable name for the point cloud. Default: ''. + description (str | Unset): Longer free-text description of the point cloud. Default: ''. + tags (list[str] | Unset): Tags for organizing and filtering point clouds. + datasets (list[str] | None | Unset): Names of the 3DEP acquisitions to read, in priority order. Omit this to let + the backend choose, which it does by preferring a single acquisition that covers the whole domain and otherwise + combining the fewest acquisitions that fill it. Set it to pin the result to specific acquisitions — for example + to force a higher-density or more recent survey where several overlap. Where two listed acquisitions overlap, + the one listed first is used. Names come from the coverage endpoint; every name must exist and overlap the + domain. + """ + + name: str | Unset = "" + description: str | Unset = "" + tags: list[str] | Unset = UNSET + datasets: list[str] | None | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + name = self.name + + description = self.description + + tags: list[str] | Unset = UNSET + if not isinstance(self.tags, Unset): + tags = self.tags + + datasets: list[str] | None | Unset + if isinstance(self.datasets, Unset): + datasets = UNSET + elif isinstance(self.datasets, list): + datasets = self.datasets + + else: + datasets = self.datasets + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if tags is not UNSET: + field_dict["tags"] = tags + if datasets is not UNSET: + field_dict["datasets"] = datasets + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + name = d.pop("name", UNSET) + + description = d.pop("description", UNSET) + + tags = cast(list[str], d.pop("tags", UNSET)) + + def _parse_datasets(data: object) -> list[str] | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, list): + raise TypeError() + datasets_type_0 = cast(list[str], data) + + return datasets_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(list[str] | None | Unset, data) + + datasets = _parse_datasets(d.pop("datasets", UNSET)) + + create_three_dep_point_cloud_request = cls( + name=name, + description=description, + tags=tags, + datasets=datasets, + ) + + create_three_dep_point_cloud_request.additional_properties = d + return create_three_dep_point_cloud_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/create_three_dep_topography_request.py b/fastfuels_sdk/v2/client_library/models/create_three_dep_topography_request.py index b4356a4..94abfb8 100644 --- a/fastfuels_sdk/v2/client_library/models/create_three_dep_topography_request.py +++ b/fastfuels_sdk/v2/client_library/models/create_three_dep_topography_request.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -95,9 +95,9 @@ def to_dict(self) -> dict[str, Any]: alignment: dict[str, Any] | Unset if isinstance(self.alignment, Unset): alignment = UNSET - elif isinstance(self.alignment, GridAlignmentDomainTarget): - alignment = self.alignment.to_dict() - elif isinstance(self.alignment, GridAlignmentNativeTarget): + elif isinstance(self.alignment, GridAlignmentDomainTarget) or isinstance( + self.alignment, GridAlignmentNativeTarget + ): alignment = self.alignment.to_dict() else: alignment = self.alignment.to_dict() @@ -136,7 +136,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.grid_alignment_domain_target import GridAlignmentDomainTarget from ..models.grid_alignment_grid_target import GridAlignmentGridTarget from ..models.grid_alignment_native_target import GridAlignmentNativeTarget diff --git a/fastfuels_sdk/v2/client_library/models/create_tree_inventory_request.py b/fastfuels_sdk/v2/client_library/models/create_tree_inventory_request.py index baf214e..6e35552 100644 --- a/fastfuels_sdk/v2/client_library/models/create_tree_inventory_request.py +++ b/fastfuels_sdk/v2/client_library/models/create_tree_inventory_request.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, Self, TypeVar, cast from attrs import define as _attrs_define @@ -160,7 +160,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.allometry_biomass_source import AllometryBiomassSource from ..models.allometry_max_crown_radius_source import ( AllometryMaxCrownRadiusSource, diff --git a/fastfuels_sdk/v2/client_library/models/create_tree_map_request.py b/fastfuels_sdk/v2/client_library/models/create_tree_map_request.py index 3026335..fa9a44d 100644 --- a/fastfuels_sdk/v2/client_library/models/create_tree_map_request.py +++ b/fastfuels_sdk/v2/client_library/models/create_tree_map_request.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -93,9 +93,9 @@ def to_dict(self) -> dict[str, Any]: alignment: dict[str, Any] | Unset if isinstance(self.alignment, Unset): alignment = UNSET - elif isinstance(self.alignment, GridAlignmentDomainTarget): - alignment = self.alignment.to_dict() - elif isinstance(self.alignment, GridAlignmentNativeTarget): + elif isinstance(self.alignment, GridAlignmentDomainTarget) or isinstance( + self.alignment, GridAlignmentNativeTarget + ): alignment = self.alignment.to_dict() else: alignment = self.alignment.to_dict() @@ -134,7 +134,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.grid_alignment_domain_target import GridAlignmentDomainTarget from ..models.grid_alignment_grid_target import GridAlignmentGridTarget from ..models.grid_alignment_native_target import GridAlignmentNativeTarget diff --git a/fastfuels_sdk/v2/client_library/models/create_uniform_request.py b/fastfuels_sdk/v2/client_library/models/create_uniform_request.py index 787d563..935a31c 100644 --- a/fastfuels_sdk/v2/client_library/models/create_uniform_request.py +++ b/fastfuels_sdk/v2/client_library/models/create_uniform_request.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -92,7 +92,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.grid_modification import GridModification from ..models.uniform_band_input import UniformBandInput diff --git a/fastfuels_sdk/v2/client_library/models/dense_grid_data.py b/fastfuels_sdk/v2/client_library/models/dense_grid_data.py index 13eb053..ddda21f 100644 --- a/fastfuels_sdk/v2/client_library/models/dense_grid_data.py +++ b/fastfuels_sdk/v2/client_library/models/dense_grid_data.py @@ -4,6 +4,7 @@ from typing import ( Any, Literal, + Self, TypeVar, cast, ) @@ -47,7 +48,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) format_ = cast(Literal["dense"], d.pop("format")) if format_ != "dense": diff --git a/fastfuels_sdk/v2/client_library/models/domain.py b/fastfuels_sdk/v2/client_library/models/domain.py index 6bae497..3145ec2 100644 --- a/fastfuels_sdk/v2/client_library/models/domain.py +++ b/fastfuels_sdk/v2/client_library/models/domain.py @@ -6,6 +6,7 @@ TYPE_CHECKING, Any, Literal, + Self, TypeVar, cast, ) @@ -163,7 +164,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.domain_style import DomainStyle from ..models.geo_json_crs import GeoJsonCRS from ..models.geo_json_feature import GeoJsonFeature diff --git a/fastfuels_sdk/v2/client_library/models/domain_lattice.py b/fastfuels_sdk/v2/client_library/models/domain_lattice.py index f423c74..58ebc19 100644 --- a/fastfuels_sdk/v2/client_library/models/domain_lattice.py +++ b/fastfuels_sdk/v2/client_library/models/domain_lattice.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar, cast +from typing import Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -67,7 +67,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) crs = d.pop("crs") diff --git a/fastfuels_sdk/v2/client_library/models/domain_style.py b/fastfuels_sdk/v2/client_library/models/domain_style.py index f1cf66f..003204b 100644 --- a/fastfuels_sdk/v2/client_library/models/domain_style.py +++ b/fastfuels_sdk/v2/client_library/models/domain_style.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar, cast +from typing import Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -81,7 +81,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) def _parse_stroke_color(data: object) -> None | str | Unset: diff --git a/fastfuels_sdk/v2/client_library/models/duet_band.py b/fastfuels_sdk/v2/client_library/models/duet_band.py new file mode 100644 index 0000000..8f3b9af --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/duet_band.py @@ -0,0 +1,22 @@ +from enum import Enum + + +class DuetBand(str, Enum): + FUEL_DEPTH_GRASS = "fuel_depth.grass" + FUEL_DEPTH_LITTER = "fuel_depth.litter" + FUEL_DEPTH_LITTER_CONIFEROUS = "fuel_depth.litter.coniferous" + FUEL_DEPTH_LITTER_DECIDUOUS = "fuel_depth.litter.deciduous" + FUEL_DEPTH_TOTAL = "fuel_depth.total" + FUEL_LOAD_GRASS = "fuel_load.grass" + FUEL_LOAD_LITTER = "fuel_load.litter" + FUEL_LOAD_LITTER_CONIFEROUS = "fuel_load.litter.coniferous" + FUEL_LOAD_LITTER_DECIDUOUS = "fuel_load.litter.deciduous" + FUEL_LOAD_TOTAL = "fuel_load.total" + FUEL_MOISTURE_GRASS = "fuel_moisture.grass" + FUEL_MOISTURE_LITTER = "fuel_moisture.litter" + FUEL_MOISTURE_LITTER_CONIFEROUS = "fuel_moisture.litter.coniferous" + FUEL_MOISTURE_LITTER_DECIDUOUS = "fuel_moisture.litter.deciduous" + FUEL_MOISTURE_TOTAL = "fuel_moisture.total" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/duet_calibration.py b/fastfuels_sdk/v2/client_library/models/duet_calibration.py new file mode 100644 index 0000000..99f348d --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/duet_calibration.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, Self, TypeVar, cast + +from attrs import define as _attrs_define + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.duet_parameter_calibration import DuetParameterCalibration + + +T = TypeVar("T", bound="DuetCalibration") + + +@_attrs_define +class DuetCalibration: + """Calibration targets, keyed by fuel parameter. + + Each parameter is calibrated independently; omitted parameters keep DUET's + raw values. + + Attributes: + fuel_load (DuetParameterCalibration | None | Unset): + fuel_depth (DuetParameterCalibration | None | Unset): + fuel_moisture (DuetParameterCalibration | None | Unset): + """ + + fuel_load: DuetParameterCalibration | None | Unset = UNSET + fuel_depth: DuetParameterCalibration | None | Unset = UNSET + fuel_moisture: DuetParameterCalibration | None | Unset = UNSET + + def to_dict(self) -> dict[str, Any]: + from ..models.duet_parameter_calibration import DuetParameterCalibration + + fuel_load: dict[str, Any] | None | Unset + if isinstance(self.fuel_load, Unset): + fuel_load = UNSET + elif isinstance(self.fuel_load, DuetParameterCalibration): + fuel_load = self.fuel_load.to_dict() + else: + fuel_load = self.fuel_load + + fuel_depth: dict[str, Any] | None | Unset + if isinstance(self.fuel_depth, Unset): + fuel_depth = UNSET + elif isinstance(self.fuel_depth, DuetParameterCalibration): + fuel_depth = self.fuel_depth.to_dict() + else: + fuel_depth = self.fuel_depth + + fuel_moisture: dict[str, Any] | None | Unset + if isinstance(self.fuel_moisture, Unset): + fuel_moisture = UNSET + elif isinstance(self.fuel_moisture, DuetParameterCalibration): + fuel_moisture = self.fuel_moisture.to_dict() + else: + fuel_moisture = self.fuel_moisture + + field_dict: dict[str, Any] = {} + + field_dict.update({}) + if fuel_load is not UNSET: + field_dict["fuel_load"] = fuel_load + if fuel_depth is not UNSET: + field_dict["fuel_depth"] = fuel_depth + if fuel_moisture is not UNSET: + field_dict["fuel_moisture"] = fuel_moisture + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + from ..models.duet_parameter_calibration import DuetParameterCalibration + + d = dict(src_dict) + + def _parse_fuel_load(data: object) -> DuetParameterCalibration | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + fuel_load_type_0 = DuetParameterCalibration.from_dict(data) + + return fuel_load_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(DuetParameterCalibration | None | Unset, data) + + fuel_load = _parse_fuel_load(d.pop("fuel_load", UNSET)) + + def _parse_fuel_depth(data: object) -> DuetParameterCalibration | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + fuel_depth_type_0 = DuetParameterCalibration.from_dict(data) + + return fuel_depth_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(DuetParameterCalibration | None | Unset, data) + + fuel_depth = _parse_fuel_depth(d.pop("fuel_depth", UNSET)) + + def _parse_fuel_moisture( + data: object, + ) -> DuetParameterCalibration | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + fuel_moisture_type_0 = DuetParameterCalibration.from_dict(data) + + return fuel_moisture_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(DuetParameterCalibration | None | Unset, data) + + fuel_moisture = _parse_fuel_moisture(d.pop("fuel_moisture", UNSET)) + + duet_calibration = cls( + fuel_load=fuel_load, + fuel_depth=fuel_depth, + fuel_moisture=fuel_moisture, + ) + + return duet_calibration diff --git a/fastfuels_sdk/v2/client_library/models/duet_constant_calibration_target.py b/fastfuels_sdk/v2/client_library/models/duet_constant_calibration_target.py new file mode 100644 index 0000000..7200b82 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/duet_constant_calibration_target.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + Any, + Literal, + Self, + TypeVar, + cast, +) + +from attrs import define as _attrs_define + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="DuetConstantCalibrationTarget") + + +@_attrs_define +class DuetConstantCalibrationTarget: + """Assign a single value to every fuel-bearing cell. + + Reasonable only when that value is the only one available. + + Attributes: + value (float): Target value. + method (Literal['constant'] | Unset): Default: 'constant'. + """ + + value: float + method: Literal["constant"] | Unset = "constant" + + def to_dict(self) -> dict[str, Any]: + value = self.value + + method = self.method + + field_dict: dict[str, Any] = {} + + field_dict.update( + { + "value": value, + } + ) + if method is not UNSET: + field_dict["method"] = method + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + value = d.pop("value") + + method = cast(Literal["constant"] | Unset, d.pop("method", UNSET)) + if method != "constant" and not isinstance(method, Unset): + raise ValueError(f"method must match const 'constant', got '{method}'") + + duet_constant_calibration_target = cls( + value=value, + method=method, + ) + + return duet_constant_calibration_target diff --git a/fastfuels_sdk/v2/client_library/models/duet_max_min_calibration_target.py b/fastfuels_sdk/v2/client_library/models/duet_max_min_calibration_target.py new file mode 100644 index 0000000..b2d0af5 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/duet_max_min_calibration_target.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + Any, + Literal, + Self, + TypeVar, + cast, +) + +from attrs import define as _attrs_define + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="DuetMaxMinCalibrationTarget") + + +@_attrs_define +class DuetMaxMinCalibrationTarget: + """Rescale a fuel type to a target maximum and minimum. + + Best when fuel data are limited, or when their distribution does not + resemble DUET's. + + Attributes: + max_ (float): Target maximum. + method (Literal['maxmin'] | Unset): Default: 'maxmin'. + min_ (float | Unset): Target minimum. Default: 0.0. + """ + + max_: float + method: Literal["maxmin"] | Unset = "maxmin" + min_: float | Unset = 0.0 + + def to_dict(self) -> dict[str, Any]: + max_ = self.max_ + + method = self.method + + min_ = self.min_ + + field_dict: dict[str, Any] = {} + + field_dict.update( + { + "max": max_, + } + ) + if method is not UNSET: + field_dict["method"] = method + if min_ is not UNSET: + field_dict["min"] = min_ + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + max_ = d.pop("max") + + method = cast(Literal["maxmin"] | Unset, d.pop("method", UNSET)) + if method != "maxmin" and not isinstance(method, Unset): + raise ValueError(f"method must match const 'maxmin', got '{method}'") + + min_ = d.pop("min", UNSET) + + duet_max_min_calibration_target = cls( + max_=max_, + method=method, + min_=min_, + ) + + return duet_max_min_calibration_target diff --git a/fastfuels_sdk/v2/client_library/models/duet_mean_sd_calibration_target.py b/fastfuels_sdk/v2/client_library/models/duet_mean_sd_calibration_target.py new file mode 100644 index 0000000..688cea2 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/duet_mean_sd_calibration_target.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + Any, + Literal, + Self, + TypeVar, + cast, +) + +from attrs import define as _attrs_define + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="DuetMeanSdCalibrationTarget") + + +@_attrs_define +class DuetMeanSdCalibrationTarget: + """Rescale a fuel type to a target mean and standard deviation. + + Appropriate only when the targets come from a dataset large enough to + approximate a normal distribution. + + Attributes: + mean (float): Target mean. + sd (float): Target standard deviation. + method (Literal['meansd'] | Unset): Default: 'meansd'. + """ + + mean: float + sd: float + method: Literal["meansd"] | Unset = "meansd" + + def to_dict(self) -> dict[str, Any]: + mean = self.mean + + sd = self.sd + + method = self.method + + field_dict: dict[str, Any] = {} + + field_dict.update( + { + "mean": mean, + "sd": sd, + } + ) + if method is not UNSET: + field_dict["method"] = method + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + mean = d.pop("mean") + + sd = d.pop("sd") + + method = cast(Literal["meansd"] | Unset, d.pop("method", UNSET)) + if method != "meansd" and not isinstance(method, Unset): + raise ValueError(f"method must match const 'meansd', got '{method}'") + + duet_mean_sd_calibration_target = cls( + mean=mean, + sd=sd, + method=method, + ) + + return duet_mean_sd_calibration_target diff --git a/fastfuels_sdk/v2/client_library/models/duet_parameter_calibration.py b/fastfuels_sdk/v2/client_library/models/duet_parameter_calibration.py new file mode 100644 index 0000000..5b98fac --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/duet_parameter_calibration.py @@ -0,0 +1,415 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, Self, TypeVar, cast + +from attrs import define as _attrs_define + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.duet_constant_calibration_target import DuetConstantCalibrationTarget + from ..models.duet_max_min_calibration_target import DuetMaxMinCalibrationTarget + from ..models.duet_mean_sd_calibration_target import DuetMeanSdCalibrationTarget + + +T = TypeVar("T", bound="DuetParameterCalibration") + + +@_attrs_define +class DuetParameterCalibration: + """Per-fuel-type calibration targets for one fuel parameter. + + `all` is exclusive: it calibrates every fuel type together and cannot be + combined with a per-type target. + + Attributes: + grass (DuetConstantCalibrationTarget | DuetMaxMinCalibrationTarget | DuetMeanSdCalibrationTarget | None | + Unset): + coniferous (DuetConstantCalibrationTarget | DuetMaxMinCalibrationTarget | DuetMeanSdCalibrationTarget | None | + Unset): + deciduous (DuetConstantCalibrationTarget | DuetMaxMinCalibrationTarget | DuetMeanSdCalibrationTarget | None | + Unset): + litter (DuetConstantCalibrationTarget | DuetMaxMinCalibrationTarget | DuetMeanSdCalibrationTarget | None | + Unset): + all_ (DuetConstantCalibrationTarget | DuetMaxMinCalibrationTarget | DuetMeanSdCalibrationTarget | None | Unset): + """ + + grass: ( + DuetConstantCalibrationTarget + | DuetMaxMinCalibrationTarget + | DuetMeanSdCalibrationTarget + | None + | Unset + ) = UNSET + coniferous: ( + DuetConstantCalibrationTarget + | DuetMaxMinCalibrationTarget + | DuetMeanSdCalibrationTarget + | None + | Unset + ) = UNSET + deciduous: ( + DuetConstantCalibrationTarget + | DuetMaxMinCalibrationTarget + | DuetMeanSdCalibrationTarget + | None + | Unset + ) = UNSET + litter: ( + DuetConstantCalibrationTarget + | DuetMaxMinCalibrationTarget + | DuetMeanSdCalibrationTarget + | None + | Unset + ) = UNSET + all_: ( + DuetConstantCalibrationTarget + | DuetMaxMinCalibrationTarget + | DuetMeanSdCalibrationTarget + | None + | Unset + ) = UNSET + + def to_dict(self) -> dict[str, Any]: + from ..models.duet_constant_calibration_target import ( + DuetConstantCalibrationTarget, + ) + from ..models.duet_max_min_calibration_target import DuetMaxMinCalibrationTarget + from ..models.duet_mean_sd_calibration_target import DuetMeanSdCalibrationTarget + + grass: dict[str, Any] | None | Unset + if isinstance(self.grass, Unset): + grass = UNSET + elif ( + isinstance(self.grass, DuetMaxMinCalibrationTarget) + or isinstance(self.grass, DuetMeanSdCalibrationTarget) + or isinstance(self.grass, DuetConstantCalibrationTarget) + ): + grass = self.grass.to_dict() + else: + grass = self.grass + + coniferous: dict[str, Any] | None | Unset + if isinstance(self.coniferous, Unset): + coniferous = UNSET + elif ( + isinstance(self.coniferous, DuetMaxMinCalibrationTarget) + or isinstance(self.coniferous, DuetMeanSdCalibrationTarget) + or isinstance(self.coniferous, DuetConstantCalibrationTarget) + ): + coniferous = self.coniferous.to_dict() + else: + coniferous = self.coniferous + + deciduous: dict[str, Any] | None | Unset + if isinstance(self.deciduous, Unset): + deciduous = UNSET + elif ( + isinstance(self.deciduous, DuetMaxMinCalibrationTarget) + or isinstance(self.deciduous, DuetMeanSdCalibrationTarget) + or isinstance(self.deciduous, DuetConstantCalibrationTarget) + ): + deciduous = self.deciduous.to_dict() + else: + deciduous = self.deciduous + + litter: dict[str, Any] | None | Unset + if isinstance(self.litter, Unset): + litter = UNSET + elif ( + isinstance(self.litter, DuetMaxMinCalibrationTarget) + or isinstance(self.litter, DuetMeanSdCalibrationTarget) + or isinstance(self.litter, DuetConstantCalibrationTarget) + ): + litter = self.litter.to_dict() + else: + litter = self.litter + + all_: dict[str, Any] | None | Unset + if isinstance(self.all_, Unset): + all_ = UNSET + elif ( + isinstance(self.all_, DuetMaxMinCalibrationTarget) + or isinstance(self.all_, DuetMeanSdCalibrationTarget) + or isinstance(self.all_, DuetConstantCalibrationTarget) + ): + all_ = self.all_.to_dict() + else: + all_ = self.all_ + + field_dict: dict[str, Any] = {} + + field_dict.update({}) + if grass is not UNSET: + field_dict["grass"] = grass + if coniferous is not UNSET: + field_dict["coniferous"] = coniferous + if deciduous is not UNSET: + field_dict["deciduous"] = deciduous + if litter is not UNSET: + field_dict["litter"] = litter + if all_ is not UNSET: + field_dict["all"] = all_ + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + from ..models.duet_constant_calibration_target import ( + DuetConstantCalibrationTarget, + ) + from ..models.duet_max_min_calibration_target import DuetMaxMinCalibrationTarget + from ..models.duet_mean_sd_calibration_target import DuetMeanSdCalibrationTarget + + d = dict(src_dict) + + def _parse_grass( + data: object, + ) -> ( + DuetConstantCalibrationTarget + | DuetMaxMinCalibrationTarget + | DuetMeanSdCalibrationTarget + | None + | Unset + ): + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + grass_type_0_type_0 = DuetMaxMinCalibrationTarget.from_dict(data) + + return grass_type_0_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + grass_type_0_type_1 = DuetMeanSdCalibrationTarget.from_dict(data) + + return grass_type_0_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + grass_type_0_type_2 = DuetConstantCalibrationTarget.from_dict(data) + + return grass_type_0_type_2 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast( + DuetConstantCalibrationTarget + | DuetMaxMinCalibrationTarget + | DuetMeanSdCalibrationTarget + | None + | Unset, + data, + ) + + grass = _parse_grass(d.pop("grass", UNSET)) + + def _parse_coniferous( + data: object, + ) -> ( + DuetConstantCalibrationTarget + | DuetMaxMinCalibrationTarget + | DuetMeanSdCalibrationTarget + | None + | Unset + ): + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + coniferous_type_0_type_0 = DuetMaxMinCalibrationTarget.from_dict(data) + + return coniferous_type_0_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + coniferous_type_0_type_1 = DuetMeanSdCalibrationTarget.from_dict(data) + + return coniferous_type_0_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + coniferous_type_0_type_2 = DuetConstantCalibrationTarget.from_dict(data) + + return coniferous_type_0_type_2 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast( + DuetConstantCalibrationTarget + | DuetMaxMinCalibrationTarget + | DuetMeanSdCalibrationTarget + | None + | Unset, + data, + ) + + coniferous = _parse_coniferous(d.pop("coniferous", UNSET)) + + def _parse_deciduous( + data: object, + ) -> ( + DuetConstantCalibrationTarget + | DuetMaxMinCalibrationTarget + | DuetMeanSdCalibrationTarget + | None + | Unset + ): + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + deciduous_type_0_type_0 = DuetMaxMinCalibrationTarget.from_dict(data) + + return deciduous_type_0_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + deciduous_type_0_type_1 = DuetMeanSdCalibrationTarget.from_dict(data) + + return deciduous_type_0_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + deciduous_type_0_type_2 = DuetConstantCalibrationTarget.from_dict(data) + + return deciduous_type_0_type_2 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast( + DuetConstantCalibrationTarget + | DuetMaxMinCalibrationTarget + | DuetMeanSdCalibrationTarget + | None + | Unset, + data, + ) + + deciduous = _parse_deciduous(d.pop("deciduous", UNSET)) + + def _parse_litter( + data: object, + ) -> ( + DuetConstantCalibrationTarget + | DuetMaxMinCalibrationTarget + | DuetMeanSdCalibrationTarget + | None + | Unset + ): + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + litter_type_0_type_0 = DuetMaxMinCalibrationTarget.from_dict(data) + + return litter_type_0_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + litter_type_0_type_1 = DuetMeanSdCalibrationTarget.from_dict(data) + + return litter_type_0_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + litter_type_0_type_2 = DuetConstantCalibrationTarget.from_dict(data) + + return litter_type_0_type_2 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast( + DuetConstantCalibrationTarget + | DuetMaxMinCalibrationTarget + | DuetMeanSdCalibrationTarget + | None + | Unset, + data, + ) + + litter = _parse_litter(d.pop("litter", UNSET)) + + def _parse_all_( + data: object, + ) -> ( + DuetConstantCalibrationTarget + | DuetMaxMinCalibrationTarget + | DuetMeanSdCalibrationTarget + | None + | Unset + ): + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + all_type_0_type_0 = DuetMaxMinCalibrationTarget.from_dict(data) + + return all_type_0_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + all_type_0_type_1 = DuetMeanSdCalibrationTarget.from_dict(data) + + return all_type_0_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + try: + if not isinstance(data, dict): + raise TypeError() + all_type_0_type_2 = DuetConstantCalibrationTarget.from_dict(data) + + return all_type_0_type_2 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast( + DuetConstantCalibrationTarget + | DuetMaxMinCalibrationTarget + | DuetMeanSdCalibrationTarget + | None + | Unset, + data, + ) + + all_ = _parse_all_(d.pop("all", UNSET)) + + duet_parameter_calibration = cls( + grass=grass, + coniferous=coniferous, + deciduous=deciduous, + litter=litter, + all_=all_, + ) + + return duet_parameter_calibration diff --git a/fastfuels_sdk/v2/client_library/models/duplicate_grid_request.py b/fastfuels_sdk/v2/client_library/models/duplicate_grid_request.py index af58101..f5193f7 100644 --- a/fastfuels_sdk/v2/client_library/models/duplicate_grid_request.py +++ b/fastfuels_sdk/v2/client_library/models/duplicate_grid_request.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar, cast +from typing import Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -64,7 +64,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) def _parse_name(data: object) -> None | str | Unset: diff --git a/fastfuels_sdk/v2/client_library/models/duplicate_inventory_request.py b/fastfuels_sdk/v2/client_library/models/duplicate_inventory_request.py index cf04045..9b4a1d0 100644 --- a/fastfuels_sdk/v2/client_library/models/duplicate_inventory_request.py +++ b/fastfuels_sdk/v2/client_library/models/duplicate_inventory_request.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar, cast +from typing import Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -64,7 +64,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) def _parse_name(data: object) -> None | str | Unset: diff --git a/fastfuels_sdk/v2/client_library/models/export.py b/fastfuels_sdk/v2/client_library/models/export.py index d04ec53..1d2c1aa 100644 --- a/fastfuels_sdk/v2/client_library/models/export.py +++ b/fastfuels_sdk/v2/client_library/models/export.py @@ -2,7 +2,7 @@ import datetime from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -159,7 +159,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.export_source import ExportSource from ..models.job_error import JobError from ..models.job_progress import JobProgress diff --git a/fastfuels_sdk/v2/client_library/models/export_grid_request.py b/fastfuels_sdk/v2/client_library/models/export_grid_request.py index 4350a94..379d84b 100644 --- a/fastfuels_sdk/v2/client_library/models/export_grid_request.py +++ b/fastfuels_sdk/v2/client_library/models/export_grid_request.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar, cast +from typing import Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -71,7 +71,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) def _parse_bands(data: object) -> list[str] | None | Unset: diff --git a/fastfuels_sdk/v2/client_library/models/export_inventory_request.py b/fastfuels_sdk/v2/client_library/models/export_inventory_request.py index fd0a270..f9b96a4 100644 --- a/fastfuels_sdk/v2/client_library/models/export_inventory_request.py +++ b/fastfuels_sdk/v2/client_library/models/export_inventory_request.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar, cast +from typing import Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -71,7 +71,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) def _parse_columns(data: object) -> list[str] | None | Unset: diff --git a/fastfuels_sdk/v2/client_library/models/export_source.py b/fastfuels_sdk/v2/client_library/models/export_source.py index bd0f5d0..a0e9ca1 100644 --- a/fastfuels_sdk/v2/client_library/models/export_source.py +++ b/fastfuels_sdk/v2/client_library/models/export_source.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar +from typing import Any, Self, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -26,7 +26,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) export_source = cls() diff --git a/fastfuels_sdk/v2/client_library/models/fbfm_13_lookup_band.py b/fastfuels_sdk/v2/client_library/models/fbfm_13_lookup_band.py new file mode 100644 index 0000000..513bc23 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/fbfm_13_lookup_band.py @@ -0,0 +1,16 @@ +from enum import Enum + + +class Fbfm13LookupBand(str, Enum): + FUEL_DEPTH = "fuel_depth" + FUEL_LOAD_100HR = "fuel_load.100hr" + FUEL_LOAD_10HR = "fuel_load.10hr" + FUEL_LOAD_1HR = "fuel_load.1hr" + FUEL_LOAD_LIVE_FOLIAGE = "fuel_load.live_foliage" + SAVR_100HR = "savr.100hr" + SAVR_10HR = "savr.10hr" + SAVR_1HR = "savr.1hr" + SAVR_LIVE_FOLIAGE = "savr.live_foliage" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/fccs_lookup_band.py b/fastfuels_sdk/v2/client_library/models/fccs_lookup_band.py new file mode 100644 index 0000000..5aef201 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/fccs_lookup_band.py @@ -0,0 +1,19 @@ +from enum import Enum + + +class FccsLookupBand(str, Enum): + DUFF_DEPTH = "duff_depth" + FUEL_LOAD_1000HR_ROTTEN = "fuel_load.1000hr_rotten" + FUEL_LOAD_1000HR_SOUND = "fuel_load.1000hr_sound" + FUEL_LOAD_100HR = "fuel_load.100hr" + FUEL_LOAD_10HR = "fuel_load.10hr" + FUEL_LOAD_1HR = "fuel_load.1hr" + FUEL_LOAD_DUFF = "fuel_load.duff" + FUEL_LOAD_LITTER = "fuel_load.litter" + FUEL_LOAD_LIVE_BRANCH = "fuel_load.live_branch" + FUEL_LOAD_LIVE_FOLIAGE = "fuel_load.live_foliage" + FUEL_LOAD_LIVE_HERB = "fuel_load.live_herb" + FUEL_LOAD_LIVE_SHRUB = "fuel_load.live_shrub" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/feature.py b/fastfuels_sdk/v2/client_library/models/feature.py index 727db56..5645e31 100644 --- a/fastfuels_sdk/v2/client_library/models/feature.py +++ b/fastfuels_sdk/v2/client_library/models/feature.py @@ -2,7 +2,7 @@ import datetime from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -154,7 +154,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.feature_georeference import FeatureGeoreference from ..models.feature_source import FeatureSource from ..models.job_error import JobError diff --git a/fastfuels_sdk/v2/client_library/models/feature_data_metadata.py b/fastfuels_sdk/v2/client_library/models/feature_data_metadata.py index e0b3392..322906e 100644 --- a/fastfuels_sdk/v2/client_library/models/feature_data_metadata.py +++ b/fastfuels_sdk/v2/client_library/models/feature_data_metadata.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any, Self, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -53,7 +53,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.feature_partition_info import FeaturePartitionInfo d = dict(src_dict) diff --git a/fastfuels_sdk/v2/client_library/models/feature_georeference.py b/fastfuels_sdk/v2/client_library/models/feature_georeference.py index 4819a0c..b398998 100644 --- a/fastfuels_sdk/v2/client_library/models/feature_georeference.py +++ b/fastfuels_sdk/v2/client_library/models/feature_georeference.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar, cast +from typing import Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -43,7 +43,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) crs = d.pop("crs") diff --git a/fastfuels_sdk/v2/client_library/models/feature_partition_info.py b/fastfuels_sdk/v2/client_library/models/feature_partition_info.py index b82bc43..5de7399 100644 --- a/fastfuels_sdk/v2/client_library/models/feature_partition_info.py +++ b/fastfuels_sdk/v2/client_library/models/feature_partition_info.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar +from typing import Any, Self, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -39,7 +39,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) index = d.pop("index") diff --git a/fastfuels_sdk/v2/client_library/models/feature_source.py b/fastfuels_sdk/v2/client_library/models/feature_source.py index f527b8a..7c79b13 100644 --- a/fastfuels_sdk/v2/client_library/models/feature_source.py +++ b/fastfuels_sdk/v2/client_library/models/feature_source.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar +from typing import Any, Self, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -23,7 +23,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) feature_source = cls() diff --git a/fastfuels_sdk/v2/client_library/models/fia_species_group_share.py b/fastfuels_sdk/v2/client_library/models/fia_species_group_share.py new file mode 100644 index 0000000..17a5b7f --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/fia_species_group_share.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Self, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="FIASpeciesGroupShare") + + +@_attrs_define +class FIASpeciesGroupShare: + """Basal area share for a single FIA species group. + + Attributes: + spgrpcd (int): FIA Species Group Code (SPGRPCD). + name (str): Common group name, e.g. 'Douglas-fir'. + basal_area_share (float): Fraction of total stand basal area, 0..1. + """ + + spgrpcd: int + name: str + basal_area_share: float + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + spgrpcd = self.spgrpcd + + name = self.name + + basal_area_share = self.basal_area_share + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "spgrpcd": spgrpcd, + "name": name, + "basal_area_share": basal_area_share, + } + ) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + spgrpcd = d.pop("spgrpcd") + + name = d.pop("name") + + basal_area_share = d.pop("basal_area_share") + + fia_species_group_share = cls( + spgrpcd=spgrpcd, + name=name, + basal_area_share=basal_area_share, + ) + + fia_species_group_share.additional_properties = d + return fia_species_group_share + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/field_source.py b/fastfuels_sdk/v2/client_library/models/field_source.py index 9be4e53..a5aa117 100644 --- a/fastfuels_sdk/v2/client_library/models/field_source.py +++ b/fastfuels_sdk/v2/client_library/models/field_source.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar +from typing import Any, Self, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -45,7 +45,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) grid_id = d.pop("grid_id") diff --git a/fastfuels_sdk/v2/client_library/models/fine_biomass_config.py b/fastfuels_sdk/v2/client_library/models/fine_biomass_config.py index 8f77a62..120a5f5 100644 --- a/fastfuels_sdk/v2/client_library/models/fine_biomass_config.py +++ b/fastfuels_sdk/v2/client_library/models/fine_biomass_config.py @@ -4,6 +4,7 @@ from typing import ( Any, Literal, + Self, TypeVar, cast, ) @@ -42,7 +43,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) recipe = cast(Literal["foliage_plus_branchwood_fraction"], d.pop("recipe")) if recipe != "foliage_plus_branchwood_fraction": diff --git a/fastfuels_sdk/v2/client_library/models/geo_json_crs.py b/fastfuels_sdk/v2/client_library/models/geo_json_crs.py index 48187f3..89e9dfc 100644 --- a/fastfuels_sdk/v2/client_library/models/geo_json_crs.py +++ b/fastfuels_sdk/v2/client_library/models/geo_json_crs.py @@ -5,6 +5,7 @@ TYPE_CHECKING, Any, Literal, + Self, TypeVar, cast, ) @@ -51,7 +52,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.geo_json_crs_properties import GeoJsonCRSProperties d = dict(src_dict) diff --git a/fastfuels_sdk/v2/client_library/models/geo_json_crs_properties.py b/fastfuels_sdk/v2/client_library/models/geo_json_crs_properties.py index 8630eb4..7c682a2 100644 --- a/fastfuels_sdk/v2/client_library/models/geo_json_crs_properties.py +++ b/fastfuels_sdk/v2/client_library/models/geo_json_crs_properties.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar +from typing import Any, Self, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -33,7 +33,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) name = d.pop("name", UNSET) diff --git a/fastfuels_sdk/v2/client_library/models/geo_json_feature.py b/fastfuels_sdk/v2/client_library/models/geo_json_feature.py index 98949c9..5c99ca4 100644 --- a/fastfuels_sdk/v2/client_library/models/geo_json_feature.py +++ b/fastfuels_sdk/v2/client_library/models/geo_json_feature.py @@ -5,6 +5,7 @@ TYPE_CHECKING, Any, Literal, + Self, TypeVar, cast, ) @@ -76,27 +77,23 @@ def to_dict(self) -> dict[str, Any]: type_ = self.type_ geometry: dict[str, Any] | None - if isinstance(self.geometry, Point): - geometry = self.geometry.to_dict() - elif isinstance(self.geometry, MultiPoint): - geometry = self.geometry.to_dict() - elif isinstance(self.geometry, LineString): - geometry = self.geometry.to_dict() - elif isinstance(self.geometry, MultiLineString): - geometry = self.geometry.to_dict() - elif isinstance(self.geometry, Polygon): - geometry = self.geometry.to_dict() - elif isinstance(self.geometry, MultiPolygon): - geometry = self.geometry.to_dict() - elif isinstance(self.geometry, GeometryCollection): + if ( + isinstance(self.geometry, Point) + or isinstance(self.geometry, MultiPoint) + or isinstance(self.geometry, LineString) + or isinstance(self.geometry, MultiLineString) + or isinstance(self.geometry, Polygon) + or isinstance(self.geometry, MultiPolygon) + or isinstance(self.geometry, GeometryCollection) + ): geometry = self.geometry.to_dict() else: geometry = self.geometry properties: dict[str, Any] | None - if isinstance(self.properties, GeoJsonFeaturePropertiesType0): - properties = self.properties.to_dict() - elif isinstance(self.properties, BaseModel): + if isinstance(self.properties, GeoJsonFeaturePropertiesType0) or isinstance( + self.properties, BaseModel + ): properties = self.properties.to_dict() else: properties = self.properties @@ -137,7 +134,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.base_model import BaseModel from ..models.geo_json_feature_properties_type_0 import ( GeoJsonFeaturePropertiesType0, diff --git a/fastfuels_sdk/v2/client_library/models/geo_json_feature_properties_type_0.py b/fastfuels_sdk/v2/client_library/models/geo_json_feature_properties_type_0.py index 5b957e8..ec84fbe 100644 --- a/fastfuels_sdk/v2/client_library/models/geo_json_feature_properties_type_0.py +++ b/fastfuels_sdk/v2/client_library/models/geo_json_feature_properties_type_0.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar +from typing import Any, Self, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -23,7 +23,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) geo_json_feature_properties_type_0 = cls() diff --git a/fastfuels_sdk/v2/client_library/models/geometry_collection.py b/fastfuels_sdk/v2/client_library/models/geometry_collection.py index 6031dd5..fb1975b 100644 --- a/fastfuels_sdk/v2/client_library/models/geometry_collection.py +++ b/fastfuels_sdk/v2/client_library/models/geometry_collection.py @@ -5,6 +5,7 @@ TYPE_CHECKING, Any, Literal, + Self, TypeVar, cast, ) @@ -63,17 +64,14 @@ def to_dict(self) -> dict[str, Any]: geometries = [] for geometries_item_data in self.geometries: geometries_item: dict[str, Any] - if isinstance(geometries_item_data, Point): - geometries_item = geometries_item_data.to_dict() - elif isinstance(geometries_item_data, MultiPoint): - geometries_item = geometries_item_data.to_dict() - elif isinstance(geometries_item_data, LineString): - geometries_item = geometries_item_data.to_dict() - elif isinstance(geometries_item_data, MultiLineString): - geometries_item = geometries_item_data.to_dict() - elif isinstance(geometries_item_data, Polygon): - geometries_item = geometries_item_data.to_dict() - elif isinstance(geometries_item_data, MultiPolygon): + if ( + isinstance(geometries_item_data, Point) + or isinstance(geometries_item_data, MultiPoint) + or isinstance(geometries_item_data, LineString) + or isinstance(geometries_item_data, MultiLineString) + or isinstance(geometries_item_data, Polygon) + or isinstance(geometries_item_data, MultiPolygon) + ): geometries_item = geometries_item_data.to_dict() else: geometries_item = geometries_item_data.to_dict() @@ -107,7 +105,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.line_string import LineString from ..models.multi_line_string import MultiLineString from ..models.multi_point import MultiPoint diff --git a/fastfuels_sdk/v2/client_library/models/georeference.py b/fastfuels_sdk/v2/client_library/models/georeference.py index eb017be..c71034f 100644 --- a/fastfuels_sdk/v2/client_library/models/georeference.py +++ b/fastfuels_sdk/v2/client_library/models/georeference.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar, cast +from typing import Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -59,7 +59,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) crs = d.pop("crs") diff --git a/fastfuels_sdk/v2/client_library/models/georeference_3d.py b/fastfuels_sdk/v2/client_library/models/georeference_3d.py index f36c362..73c7e8d 100644 --- a/fastfuels_sdk/v2/client_library/models/georeference_3d.py +++ b/fastfuels_sdk/v2/client_library/models/georeference_3d.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar, cast +from typing import Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -62,7 +62,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) crs = d.pop("crs") diff --git a/fastfuels_sdk/v2/client_library/models/grid.py b/fastfuels_sdk/v2/client_library/models/grid.py index c674d5e..dfd6097 100644 --- a/fastfuels_sdk/v2/client_library/models/grid.py +++ b/fastfuels_sdk/v2/client_library/models/grid.py @@ -2,7 +2,7 @@ import datetime from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -142,9 +142,9 @@ def to_dict(self) -> dict[str, Any]: georeference: dict[str, Any] | None | Unset if isinstance(self.georeference, Unset): georeference = UNSET - elif isinstance(self.georeference, Georeference): - georeference = self.georeference.to_dict() - elif isinstance(self.georeference, Georeference3D): + elif isinstance(self.georeference, Georeference) or isinstance( + self.georeference, Georeference3D + ): georeference = self.georeference.to_dict() else: georeference = self.georeference @@ -206,7 +206,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.band import Band from ..models.chunks import Chunks from ..models.georeference import Georeference diff --git a/fastfuels_sdk/v2/client_library/models/grid_alignment_domain_target.py b/fastfuels_sdk/v2/client_library/models/grid_alignment_domain_target.py index adb633b..6cdf971 100644 --- a/fastfuels_sdk/v2/client_library/models/grid_alignment_domain_target.py +++ b/fastfuels_sdk/v2/client_library/models/grid_alignment_domain_target.py @@ -4,6 +4,7 @@ from typing import ( Any, Literal, + Self, TypeVar, cast, ) @@ -66,7 +67,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) target = cast(Literal["domain"] | Unset, d.pop("target", UNSET)) if target != "domain" and not isinstance(target, Unset): diff --git a/fastfuels_sdk/v2/client_library/models/grid_alignment_grid_target.py b/fastfuels_sdk/v2/client_library/models/grid_alignment_grid_target.py index 939f4c3..59debcb 100644 --- a/fastfuels_sdk/v2/client_library/models/grid_alignment_grid_target.py +++ b/fastfuels_sdk/v2/client_library/models/grid_alignment_grid_target.py @@ -4,6 +4,7 @@ from typing import ( Any, Literal, + Self, TypeVar, cast, ) @@ -74,7 +75,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) target = cast(Literal["grid"], d.pop("target")) if target != "grid": diff --git a/fastfuels_sdk/v2/client_library/models/grid_alignment_native_target.py b/fastfuels_sdk/v2/client_library/models/grid_alignment_native_target.py index d9f820c..1166eff 100644 --- a/fastfuels_sdk/v2/client_library/models/grid_alignment_native_target.py +++ b/fastfuels_sdk/v2/client_library/models/grid_alignment_native_target.py @@ -4,6 +4,7 @@ from typing import ( Any, Literal, + Self, TypeVar, cast, ) @@ -67,7 +68,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) target = cast(Literal["native"], d.pop("target")) if target != "native": diff --git a/fastfuels_sdk/v2/client_library/models/grid_data_chunk_metadata.py b/fastfuels_sdk/v2/client_library/models/grid_data_chunk_metadata.py index 54b9663..351c64f 100644 --- a/fastfuels_sdk/v2/client_library/models/grid_data_chunk_metadata.py +++ b/fastfuels_sdk/v2/client_library/models/grid_data_chunk_metadata.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar, cast +from typing import Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -86,7 +86,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) index = d.pop("index") diff --git a/fastfuels_sdk/v2/client_library/models/grid_data_response.py b/fastfuels_sdk/v2/client_library/models/grid_data_response.py index 9c2d56a..50ef9df 100644 --- a/fastfuels_sdk/v2/client_library/models/grid_data_response.py +++ b/fastfuels_sdk/v2/client_library/models/grid_data_response.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -62,7 +62,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.dense_grid_data import DenseGridData from ..models.grid_data_chunk_metadata import GridDataChunkMetadata from ..models.sparse_grid_data import SparseGridData diff --git a/fastfuels_sdk/v2/client_library/models/grid_feature_spatial_condition.py b/fastfuels_sdk/v2/client_library/models/grid_feature_spatial_condition.py index c30a96a..f1f45f3 100644 --- a/fastfuels_sdk/v2/client_library/models/grid_feature_spatial_condition.py +++ b/fastfuels_sdk/v2/client_library/models/grid_feature_spatial_condition.py @@ -4,6 +4,7 @@ from typing import ( Any, Literal, + Self, TypeVar, cast, ) @@ -88,7 +89,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) source = cast(Literal["feature"], d.pop("source")) if source != "feature": diff --git a/fastfuels_sdk/v2/client_library/models/grid_geometry_spatial_condition.py b/fastfuels_sdk/v2/client_library/models/grid_geometry_spatial_condition.py index e628c8f..0e97b0b 100644 --- a/fastfuels_sdk/v2/client_library/models/grid_geometry_spatial_condition.py +++ b/fastfuels_sdk/v2/client_library/models/grid_geometry_spatial_condition.py @@ -5,6 +5,7 @@ TYPE_CHECKING, Any, Literal, + Self, TypeVar, cast, ) @@ -114,7 +115,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.grid_geometry_spatial_condition_crs_type_0 import ( GridGeometrySpatialConditionCrsType0, ) diff --git a/fastfuels_sdk/v2/client_library/models/grid_geometry_spatial_condition_crs_type_0.py b/fastfuels_sdk/v2/client_library/models/grid_geometry_spatial_condition_crs_type_0.py index 1ccda3d..86582c7 100644 --- a/fastfuels_sdk/v2/client_library/models/grid_geometry_spatial_condition_crs_type_0.py +++ b/fastfuels_sdk/v2/client_library/models/grid_geometry_spatial_condition_crs_type_0.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar +from typing import Any, Self, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -23,7 +23,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) grid_geometry_spatial_condition_crs_type_0 = cls() diff --git a/fastfuels_sdk/v2/client_library/models/grid_geometry_spatial_condition_geometry.py b/fastfuels_sdk/v2/client_library/models/grid_geometry_spatial_condition_geometry.py index 52fa73d..c1e383d 100644 --- a/fastfuels_sdk/v2/client_library/models/grid_geometry_spatial_condition_geometry.py +++ b/fastfuels_sdk/v2/client_library/models/grid_geometry_spatial_condition_geometry.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar +from typing import Any, Self, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -26,7 +26,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) grid_geometry_spatial_condition_geometry = cls() diff --git a/fastfuels_sdk/v2/client_library/models/grid_modification.py b/fastfuels_sdk/v2/client_library/models/grid_modification.py index 0eb443f..05d7e32 100644 --- a/fastfuels_sdk/v2/client_library/models/grid_modification.py +++ b/fastfuels_sdk/v2/client_library/models/grid_modification.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any, Self, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -54,9 +54,9 @@ def to_dict(self) -> dict[str, Any]: conditions = [] for conditions_item_data in self.conditions: conditions_item: dict[str, Any] - if isinstance(conditions_item_data, GridModificationCondition): - conditions_item = conditions_item_data.to_dict() - elif isinstance(conditions_item_data, GridGeometrySpatialCondition): + if isinstance( + conditions_item_data, GridModificationCondition + ) or isinstance(conditions_item_data, GridGeometrySpatialCondition): conditions_item = conditions_item_data.to_dict() else: conditions_item = conditions_item_data.to_dict() @@ -80,7 +80,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.grid_feature_spatial_condition import GridFeatureSpatialCondition from ..models.grid_geometry_spatial_condition import ( GridGeometrySpatialCondition, diff --git a/fastfuels_sdk/v2/client_library/models/grid_modification_action.py b/fastfuels_sdk/v2/client_library/models/grid_modification_action.py index da566f2..654ae9c 100644 --- a/fastfuels_sdk/v2/client_library/models/grid_modification_action.py +++ b/fastfuels_sdk/v2/client_library/models/grid_modification_action.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar, cast +from typing import Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -49,7 +49,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) band = d.pop("band") diff --git a/fastfuels_sdk/v2/client_library/models/grid_modification_condition.py b/fastfuels_sdk/v2/client_library/models/grid_modification_condition.py index f1658d1..3e31b51 100644 --- a/fastfuels_sdk/v2/client_library/models/grid_modification_condition.py +++ b/fastfuels_sdk/v2/client_library/models/grid_modification_condition.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar, cast +from typing import Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -57,7 +57,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) band = d.pop("band") diff --git a/fastfuels_sdk/v2/client_library/models/grid_source.py b/fastfuels_sdk/v2/client_library/models/grid_source.py index f3ddee6..817c367 100644 --- a/fastfuels_sdk/v2/client_library/models/grid_source.py +++ b/fastfuels_sdk/v2/client_library/models/grid_source.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar +from typing import Any, Self, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -23,7 +23,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) grid_source = cls() diff --git a/fastfuels_sdk/v2/client_library/models/grid_upload_created_response.py b/fastfuels_sdk/v2/client_library/models/grid_upload_created_response.py index 584d634..1904764 100644 --- a/fastfuels_sdk/v2/client_library/models/grid_upload_created_response.py +++ b/fastfuels_sdk/v2/client_library/models/grid_upload_created_response.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any, Self, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -51,7 +51,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.grid import Grid from ..models.grid_upload_spec import GridUploadSpec diff --git a/fastfuels_sdk/v2/client_library/models/grid_upload_spec.py b/fastfuels_sdk/v2/client_library/models/grid_upload_spec.py index fddd5f1..0290085 100644 --- a/fastfuels_sdk/v2/client_library/models/grid_upload_spec.py +++ b/fastfuels_sdk/v2/client_library/models/grid_upload_spec.py @@ -6,6 +6,7 @@ TYPE_CHECKING, Any, Literal, + Self, TypeVar, cast, ) @@ -72,7 +73,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.grid_upload_spec_headers import GridUploadSpecHeaders d = dict(src_dict) diff --git a/fastfuels_sdk/v2/client_library/models/grid_upload_spec_headers.py b/fastfuels_sdk/v2/client_library/models/grid_upload_spec_headers.py index 92d8b33..c107541 100644 --- a/fastfuels_sdk/v2/client_library/models/grid_upload_spec_headers.py +++ b/fastfuels_sdk/v2/client_library/models/grid_upload_spec_headers.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar +from typing import Any, Self, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -23,7 +23,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) grid_upload_spec_headers = cls() diff --git a/fastfuels_sdk/v2/client_library/models/http_validation_error.py b/fastfuels_sdk/v2/client_library/models/http_validation_error.py index 195e5a7..69fd68a 100644 --- a/fastfuels_sdk/v2/client_library/models/http_validation_error.py +++ b/fastfuels_sdk/v2/client_library/models/http_validation_error.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any, Self, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -42,7 +42,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.validation_error import ValidationError d = dict(src_dict) diff --git a/fastfuels_sdk/v2/client_library/models/inline_compute.py b/fastfuels_sdk/v2/client_library/models/inline_compute.py new file mode 100644 index 0000000..615356f --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/inline_compute.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, Self, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.compose_operator import ComposeOperator + +if TYPE_CHECKING: + from ..models.compose_literal import ComposeLiteral + + +T = TypeVar("T", bound="InlineCompute") + + +@_attrs_define +class InlineCompute: + """A computation body: an operator over operands. + + Usable on its own as a conditional-fallback value; `ComposeCompute` + extends it with an output target and optional conditions. + + Attributes: + operator (ComposeOperator): Operators available for compose computations. + operands (list[ComposeLiteral | float | int | str]): + """ + + operator: ComposeOperator + operands: list[ComposeLiteral | float | int | str] + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.compose_literal import ComposeLiteral + + operator = self.operator.value + + operands = [] + for operands_item_data in self.operands: + operands_item: dict[str, Any] | float | int | str + if isinstance(operands_item_data, ComposeLiteral): + operands_item = operands_item_data.to_dict() + else: + operands_item = operands_item_data + operands.append(operands_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "operator": operator, + "operands": operands, + } + ) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + from ..models.compose_literal import ComposeLiteral + + d = dict(src_dict) + operator = ComposeOperator(d.pop("operator")) + + operands = [] + _operands = d.pop("operands") + for operands_item_data in _operands: + + def _parse_operands_item( + data: object, + ) -> ComposeLiteral | float | int | str: + try: + if not isinstance(data, dict): + raise TypeError() + operands_item_type_3 = ComposeLiteral.from_dict(data) + + return operands_item_type_3 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(ComposeLiteral | float | int | str, data) + + operands_item = _parse_operands_item(operands_item_data) + + operands.append(operands_item) + + inline_compute = cls( + operator=operator, + operands=operands, + ) + + inline_compute.additional_properties = d + return inline_compute + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/inventory.py b/fastfuels_sdk/v2/client_library/models/inventory.py index 399356a..dc09807 100644 --- a/fastfuels_sdk/v2/client_library/models/inventory.py +++ b/fastfuels_sdk/v2/client_library/models/inventory.py @@ -2,7 +2,7 @@ import datetime from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -20,6 +20,7 @@ from ..models.inventory_source import InventorySource from ..models.job_error import JobError from ..models.job_progress import JobProgress + from ..models.tree_forestry_metrics import TreeForestryMetrics T = TypeVar("T", bound="Inventory") @@ -51,6 +52,7 @@ class Inventory: modifications (list[InventoryModification] | Unset): treatments (list[InventoryBasalAreaTreatment | InventoryDiameterTreatment] | Unset): columns (list[Column] | Unset): + forestry_metrics (None | TreeForestryMetrics | Unset): georeference (InventoryGeoreference | None | Unset): Spatial reference. Null until backend completes processing. error (JobError | None | Unset): Error details if status is 'failed'. tags (list[str] | Unset): @@ -72,6 +74,7 @@ class Inventory: list[InventoryBasalAreaTreatment | InventoryDiameterTreatment] | Unset ) = UNSET columns: list[Column] | Unset = UNSET + forestry_metrics: None | TreeForestryMetrics | Unset = UNSET georeference: InventoryGeoreference | None | Unset = UNSET error: JobError | None | Unset = UNSET tags: list[str] | Unset = UNSET @@ -82,6 +85,7 @@ def to_dict(self) -> dict[str, Any]: from ..models.inventory_georeference import InventoryGeoreference from ..models.job_error import JobError from ..models.job_progress import JobProgress + from ..models.tree_forestry_metrics import TreeForestryMetrics id = self.id @@ -153,6 +157,14 @@ def to_dict(self) -> dict[str, Any]: columns_item = columns_item_data.to_dict() columns.append(columns_item) + forestry_metrics: dict[str, Any] | None | Unset + if isinstance(self.forestry_metrics, Unset): + forestry_metrics = UNSET + elif isinstance(self.forestry_metrics, TreeForestryMetrics): + forestry_metrics = self.forestry_metrics.to_dict() + else: + forestry_metrics = self.forestry_metrics + georeference: dict[str, Any] | None | Unset if isinstance(self.georeference, Unset): georeference = UNSET @@ -202,6 +214,8 @@ def to_dict(self) -> dict[str, Any]: field_dict["treatments"] = treatments if columns is not UNSET: field_dict["columns"] = columns + if forestry_metrics is not UNSET: + field_dict["forestry_metrics"] = forestry_metrics if georeference is not UNSET: field_dict["georeference"] = georeference if error is not UNSET: @@ -212,7 +226,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.column import Column from ..models.inventory_basal_area_treatment import InventoryBasalAreaTreatment from ..models.inventory_diameter_treatment import InventoryDiameterTreatment @@ -221,6 +235,7 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: from ..models.inventory_source import InventorySource from ..models.job_error import JobError from ..models.job_progress import JobProgress + from ..models.tree_forestry_metrics import TreeForestryMetrics d = dict(src_dict) id = d.pop("id") @@ -348,6 +363,23 @@ def _parse_treatments_item( columns.append(columns_item) + def _parse_forestry_metrics(data: object) -> None | TreeForestryMetrics | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + forestry_metrics_type_0 = TreeForestryMetrics.from_dict(data) + + return forestry_metrics_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(None | TreeForestryMetrics | Unset, data) + + forestry_metrics = _parse_forestry_metrics(d.pop("forestry_metrics", UNSET)) + def _parse_georeference(data: object) -> InventoryGeoreference | None | Unset: if data is None: return data @@ -399,6 +431,7 @@ def _parse_error(data: object) -> JobError | None | Unset: modifications=modifications, treatments=treatments, columns=columns, + forestry_metrics=forestry_metrics, georeference=georeference, error=error, tags=tags, diff --git a/fastfuels_sdk/v2/client_library/models/inventory_basal_area_treatment.py b/fastfuels_sdk/v2/client_library/models/inventory_basal_area_treatment.py index 17e7d09..07da686 100644 --- a/fastfuels_sdk/v2/client_library/models/inventory_basal_area_treatment.py +++ b/fastfuels_sdk/v2/client_library/models/inventory_basal_area_treatment.py @@ -5,6 +5,7 @@ TYPE_CHECKING, Any, Literal, + Self, TypeVar, cast, ) @@ -106,7 +107,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.inventory_feature_spatial_condition import ( InventoryFeatureSpatialCondition, ) diff --git a/fastfuels_sdk/v2/client_library/models/inventory_biomass_column.py b/fastfuels_sdk/v2/client_library/models/inventory_biomass_column.py index 24e9833..514a873 100644 --- a/fastfuels_sdk/v2/client_library/models/inventory_biomass_column.py +++ b/fastfuels_sdk/v2/client_library/models/inventory_biomass_column.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar +from typing import Any, Self, TypeVar from attrs import define as _attrs_define @@ -43,7 +43,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) column = d.pop("column") diff --git a/fastfuels_sdk/v2/client_library/models/inventory_column_mapping.py b/fastfuels_sdk/v2/client_library/models/inventory_column_mapping.py index 6f8d247..b0e2563 100644 --- a/fastfuels_sdk/v2/client_library/models/inventory_column_mapping.py +++ b/fastfuels_sdk/v2/client_library/models/inventory_column_mapping.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar, cast +from typing import Any, Self, TypeVar, cast from attrs import define as _attrs_define @@ -100,7 +100,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) def _parse_x(data: object) -> None | str | Unset: diff --git a/fastfuels_sdk/v2/client_library/models/inventory_column_max_crown_radius_source.py b/fastfuels_sdk/v2/client_library/models/inventory_column_max_crown_radius_source.py index 20d1d11..cccd085 100644 --- a/fastfuels_sdk/v2/client_library/models/inventory_column_max_crown_radius_source.py +++ b/fastfuels_sdk/v2/client_library/models/inventory_column_max_crown_radius_source.py @@ -4,6 +4,7 @@ from typing import ( Any, Literal, + Self, TypeVar, cast, ) @@ -57,7 +58,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) column = d.pop("column") diff --git a/fastfuels_sdk/v2/client_library/models/inventory_columns_biomass_source.py b/fastfuels_sdk/v2/client_library/models/inventory_columns_biomass_source.py index 3ab9860..271b599 100644 --- a/fastfuels_sdk/v2/client_library/models/inventory_columns_biomass_source.py +++ b/fastfuels_sdk/v2/client_library/models/inventory_columns_biomass_source.py @@ -5,6 +5,7 @@ TYPE_CHECKING, Any, Literal, + Self, TypeVar, cast, ) @@ -91,7 +92,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.fine_biomass_config import FineBiomassConfig from ..models.inventory_columns_biomass_source_columns import ( InventoryColumnsBiomassSourceColumns, diff --git a/fastfuels_sdk/v2/client_library/models/inventory_columns_biomass_source_columns.py b/fastfuels_sdk/v2/client_library/models/inventory_columns_biomass_source_columns.py index 53b3075..38ea05c 100644 --- a/fastfuels_sdk/v2/client_library/models/inventory_columns_biomass_source_columns.py +++ b/fastfuels_sdk/v2/client_library/models/inventory_columns_biomass_source_columns.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any, Self, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -30,7 +30,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.inventory_biomass_column import InventoryBiomassColumn d = dict(src_dict) diff --git a/fastfuels_sdk/v2/client_library/models/inventory_columns_biomass_source_component_states.py b/fastfuels_sdk/v2/client_library/models/inventory_columns_biomass_source_component_states.py index c41c2f0..b611df4 100644 --- a/fastfuels_sdk/v2/client_library/models/inventory_columns_biomass_source_component_states.py +++ b/fastfuels_sdk/v2/client_library/models/inventory_columns_biomass_source_component_states.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any, Self, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -30,7 +30,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.biomass_component_state import BiomassComponentState d = dict(src_dict) diff --git a/fastfuels_sdk/v2/client_library/models/inventory_data_metadata.py b/fastfuels_sdk/v2/client_library/models/inventory_data_metadata.py index b0bec7d..1f25cef 100644 --- a/fastfuels_sdk/v2/client_library/models/inventory_data_metadata.py +++ b/fastfuels_sdk/v2/client_library/models/inventory_data_metadata.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -60,7 +60,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.inventory_partition_info import InventoryPartitionInfo d = dict(src_dict) diff --git a/fastfuels_sdk/v2/client_library/models/inventory_data_response.py b/fastfuels_sdk/v2/client_library/models/inventory_data_response.py index 5d75137..559f448 100644 --- a/fastfuels_sdk/v2/client_library/models/inventory_data_response.py +++ b/fastfuels_sdk/v2/client_library/models/inventory_data_response.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -66,7 +66,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.inventory_data_response_data_type_1_item import ( InventoryDataResponseDataType1Item, ) diff --git a/fastfuels_sdk/v2/client_library/models/inventory_data_response_data_type_1_item.py b/fastfuels_sdk/v2/client_library/models/inventory_data_response_data_type_1_item.py index 6f10a02..a82bdb4 100644 --- a/fastfuels_sdk/v2/client_library/models/inventory_data_response_data_type_1_item.py +++ b/fastfuels_sdk/v2/client_library/models/inventory_data_response_data_type_1_item.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar +from typing import Any, Self, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -23,7 +23,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) inventory_data_response_data_type_1_item = cls() diff --git a/fastfuels_sdk/v2/client_library/models/inventory_diameter_treatment.py b/fastfuels_sdk/v2/client_library/models/inventory_diameter_treatment.py index 102e54f..ec11248 100644 --- a/fastfuels_sdk/v2/client_library/models/inventory_diameter_treatment.py +++ b/fastfuels_sdk/v2/client_library/models/inventory_diameter_treatment.py @@ -5,6 +5,7 @@ TYPE_CHECKING, Any, Literal, + Self, TypeVar, cast, ) @@ -106,7 +107,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.inventory_feature_spatial_condition import ( InventoryFeatureSpatialCondition, ) diff --git a/fastfuels_sdk/v2/client_library/models/inventory_expression_condition.py b/fastfuels_sdk/v2/client_library/models/inventory_expression_condition.py index 67aefba..941c805 100644 --- a/fastfuels_sdk/v2/client_library/models/inventory_expression_condition.py +++ b/fastfuels_sdk/v2/client_library/models/inventory_expression_condition.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar +from typing import Any, Self, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -39,7 +39,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) expression = d.pop("expression") diff --git a/fastfuels_sdk/v2/client_library/models/inventory_feature_spatial_condition.py b/fastfuels_sdk/v2/client_library/models/inventory_feature_spatial_condition.py index 2b650ee..9263e08 100644 --- a/fastfuels_sdk/v2/client_library/models/inventory_feature_spatial_condition.py +++ b/fastfuels_sdk/v2/client_library/models/inventory_feature_spatial_condition.py @@ -4,6 +4,7 @@ from typing import ( Any, Literal, + Self, TypeVar, cast, ) @@ -79,7 +80,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) source = cast(Literal["feature"], d.pop("source")) if source != "feature": diff --git a/fastfuels_sdk/v2/client_library/models/inventory_geometry_spatial_condition.py b/fastfuels_sdk/v2/client_library/models/inventory_geometry_spatial_condition.py index ef5b9fb..7556759 100644 --- a/fastfuels_sdk/v2/client_library/models/inventory_geometry_spatial_condition.py +++ b/fastfuels_sdk/v2/client_library/models/inventory_geometry_spatial_condition.py @@ -5,6 +5,7 @@ TYPE_CHECKING, Any, Literal, + Self, TypeVar, cast, ) @@ -103,7 +104,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.inventory_geometry_spatial_condition_crs_type_0 import ( InventoryGeometrySpatialConditionCrsType0, ) diff --git a/fastfuels_sdk/v2/client_library/models/inventory_geometry_spatial_condition_crs_type_0.py b/fastfuels_sdk/v2/client_library/models/inventory_geometry_spatial_condition_crs_type_0.py index 3317577..dfc6ea2 100644 --- a/fastfuels_sdk/v2/client_library/models/inventory_geometry_spatial_condition_crs_type_0.py +++ b/fastfuels_sdk/v2/client_library/models/inventory_geometry_spatial_condition_crs_type_0.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar +from typing import Any, Self, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -23,7 +23,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) inventory_geometry_spatial_condition_crs_type_0 = cls() diff --git a/fastfuels_sdk/v2/client_library/models/inventory_geometry_spatial_condition_geometry.py b/fastfuels_sdk/v2/client_library/models/inventory_geometry_spatial_condition_geometry.py index caf368f..a606aa2 100644 --- a/fastfuels_sdk/v2/client_library/models/inventory_geometry_spatial_condition_geometry.py +++ b/fastfuels_sdk/v2/client_library/models/inventory_geometry_spatial_condition_geometry.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar +from typing import Any, Self, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -26,7 +26,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) inventory_geometry_spatial_condition_geometry = cls() diff --git a/fastfuels_sdk/v2/client_library/models/inventory_georeference.py b/fastfuels_sdk/v2/client_library/models/inventory_georeference.py index 81d9895..23fdba5 100644 --- a/fastfuels_sdk/v2/client_library/models/inventory_georeference.py +++ b/fastfuels_sdk/v2/client_library/models/inventory_georeference.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar, cast +from typing import Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -43,7 +43,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) crs = d.pop("crs") diff --git a/fastfuels_sdk/v2/client_library/models/inventory_modification.py b/fastfuels_sdk/v2/client_library/models/inventory_modification.py index f11c7f8..0651b54 100644 --- a/fastfuels_sdk/v2/client_library/models/inventory_modification.py +++ b/fastfuels_sdk/v2/client_library/models/inventory_modification.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any, Self, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -56,11 +56,11 @@ def to_dict(self) -> dict[str, Any]: conditions = [] for conditions_item_data in self.conditions: conditions_item: dict[str, Any] - if isinstance(conditions_item_data, InventoryModificationCondition): - conditions_item = conditions_item_data.to_dict() - elif isinstance(conditions_item_data, InventoryExpressionCondition): - conditions_item = conditions_item_data.to_dict() - elif isinstance(conditions_item_data, InventoryGeometrySpatialCondition): + if ( + isinstance(conditions_item_data, InventoryModificationCondition) + or isinstance(conditions_item_data, InventoryExpressionCondition) + or isinstance(conditions_item_data, InventoryGeometrySpatialCondition) + ): conditions_item = conditions_item_data.to_dict() else: conditions_item = conditions_item_data.to_dict() @@ -89,7 +89,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.inventory_expression_condition import InventoryExpressionCondition from ..models.inventory_feature_spatial_condition import ( InventoryFeatureSpatialCondition, diff --git a/fastfuels_sdk/v2/client_library/models/inventory_modification_action.py b/fastfuels_sdk/v2/client_library/models/inventory_modification_action.py index d4a0d84..95d27ad 100644 --- a/fastfuels_sdk/v2/client_library/models/inventory_modification_action.py +++ b/fastfuels_sdk/v2/client_library/models/inventory_modification_action.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar, cast +from typing import Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -62,7 +62,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) attribute = InventoryAttribute(d.pop("attribute")) diff --git a/fastfuels_sdk/v2/client_library/models/inventory_modification_condition.py b/fastfuels_sdk/v2/client_library/models/inventory_modification_condition.py index 8e21fc8..2af741e 100644 --- a/fastfuels_sdk/v2/client_library/models/inventory_modification_condition.py +++ b/fastfuels_sdk/v2/client_library/models/inventory_modification_condition.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar, cast +from typing import Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -71,7 +71,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) attribute = InventoryAttribute(d.pop("attribute")) diff --git a/fastfuels_sdk/v2/client_library/models/inventory_partition_info.py b/fastfuels_sdk/v2/client_library/models/inventory_partition_info.py index 9d0875b..0e85381 100644 --- a/fastfuels_sdk/v2/client_library/models/inventory_partition_info.py +++ b/fastfuels_sdk/v2/client_library/models/inventory_partition_info.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar +from typing import Any, Self, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -38,7 +38,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) index = d.pop("index") diff --git a/fastfuels_sdk/v2/client_library/models/inventory_source.py b/fastfuels_sdk/v2/client_library/models/inventory_source.py index ca88567..8d2eaff 100644 --- a/fastfuels_sdk/v2/client_library/models/inventory_source.py +++ b/fastfuels_sdk/v2/client_library/models/inventory_source.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar +from typing import Any, Self, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -23,7 +23,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) inventory_source = cls() diff --git a/fastfuels_sdk/v2/client_library/models/inventory_upload_created_response.py b/fastfuels_sdk/v2/client_library/models/inventory_upload_created_response.py index 650450f..6eefcf8 100644 --- a/fastfuels_sdk/v2/client_library/models/inventory_upload_created_response.py +++ b/fastfuels_sdk/v2/client_library/models/inventory_upload_created_response.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any, Self, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -47,7 +47,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.inventory import Inventory from ..models.inventory_upload_spec import InventoryUploadSpec diff --git a/fastfuels_sdk/v2/client_library/models/inventory_upload_spec.py b/fastfuels_sdk/v2/client_library/models/inventory_upload_spec.py index 6c4998b..f33710a 100644 --- a/fastfuels_sdk/v2/client_library/models/inventory_upload_spec.py +++ b/fastfuels_sdk/v2/client_library/models/inventory_upload_spec.py @@ -6,6 +6,7 @@ TYPE_CHECKING, Any, Literal, + Self, TypeVar, cast, ) @@ -72,7 +73,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.inventory_upload_spec_headers import InventoryUploadSpecHeaders d = dict(src_dict) diff --git a/fastfuels_sdk/v2/client_library/models/inventory_upload_spec_headers.py b/fastfuels_sdk/v2/client_library/models/inventory_upload_spec_headers.py index e13467a..1b2bcc1 100644 --- a/fastfuels_sdk/v2/client_library/models/inventory_upload_spec_headers.py +++ b/fastfuels_sdk/v2/client_library/models/inventory_upload_spec_headers.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar +from typing import Any, Self, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -23,7 +23,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) inventory_upload_spec_headers = cls() diff --git a/fastfuels_sdk/v2/client_library/models/job_error.py b/fastfuels_sdk/v2/client_library/models/job_error.py index 289843d..e5086cb 100644 --- a/fastfuels_sdk/v2/client_library/models/job_error.py +++ b/fastfuels_sdk/v2/client_library/models/job_error.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar, cast +from typing import Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -73,7 +73,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) code = d.pop("code") diff --git a/fastfuels_sdk/v2/client_library/models/job_progress.py b/fastfuels_sdk/v2/client_library/models/job_progress.py index 6d8914e..39a12d2 100644 --- a/fastfuels_sdk/v2/client_library/models/job_progress.py +++ b/fastfuels_sdk/v2/client_library/models/job_progress.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar, cast +from typing import Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -53,7 +53,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) message = d.pop("message") diff --git a/fastfuels_sdk/v2/client_library/models/job_resource_usage.py b/fastfuels_sdk/v2/client_library/models/job_resource_usage.py new file mode 100644 index 0000000..fc77e1a --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/job_resource_usage.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, Self, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.usage_count import UsageCount + from ..models.usage_storage import UsageStorage + + +T = TypeVar("T", bound="JobResourceUsage") + + +@_attrs_define +class JobResourceUsage: + """Usage for a resource type that produces jobs and stores artifacts. + + Attributes: + active (UsageCount): A count-based usage/limit pair (resources or concurrent jobs). + total (UsageCount): A count-based usage/limit pair (resources or concurrent jobs). + storage (UsageStorage): A storage usage/limit pair, in bytes. + """ + + active: UsageCount + total: UsageCount + storage: UsageStorage + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + active = self.active.to_dict() + + total = self.total.to_dict() + + storage = self.storage.to_dict() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "active": active, + "total": total, + "storage": storage, + } + ) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + from ..models.usage_count import UsageCount + from ..models.usage_storage import UsageStorage + + d = dict(src_dict) + active = UsageCount.from_dict(d.pop("active")) + + total = UsageCount.from_dict(d.pop("total")) + + storage = UsageStorage.from_dict(d.pop("storage")) + + job_resource_usage = cls( + active=active, + total=total, + storage=storage, + ) + + job_resource_usage.additional_properties = d + return job_resource_usage + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/key.py b/fastfuels_sdk/v2/client_library/models/key.py index 0d4ab5f..59fb99d 100644 --- a/fastfuels_sdk/v2/client_library/models/key.py +++ b/fastfuels_sdk/v2/client_library/models/key.py @@ -2,7 +2,7 @@ import datetime from collections.abc import Mapping -from typing import Any, TypeVar, cast +from typing import Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -115,7 +115,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) id = d.pop("id") diff --git a/fastfuels_sdk/v2/client_library/models/inventory_data_format.py b/fastfuels_sdk/v2/client_library/models/landfire_fbfm_13_version.py similarity index 51% rename from fastfuels_sdk/v2/client_library/models/inventory_data_format.py rename to fastfuels_sdk/v2/client_library/models/landfire_fbfm_13_version.py index 824cd1e..72f2ec6 100644 --- a/fastfuels_sdk/v2/client_library/models/inventory_data_format.py +++ b/fastfuels_sdk/v2/client_library/models/landfire_fbfm_13_version.py @@ -1,9 +1,9 @@ from enum import Enum -class InventoryDataFormat(str, Enum): - CSV = "csv" - JSON = "json" +class LandfireFbfm13Version(str, Enum): + VALUE_0 = "2023" + VALUE_1 = "2024" def __str__(self) -> str: return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/landscape_export_alignment_domain_target.py b/fastfuels_sdk/v2/client_library/models/landscape_export_alignment_domain_target.py new file mode 100644 index 0000000..a1bf524 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/landscape_export_alignment_domain_target.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + Any, + Literal, + Self, + TypeVar, + cast, +) + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="LandscapeExportAlignmentDomainTarget") + + +@_attrs_define +class LandscapeExportAlignmentDomainTarget: + """Anchor the landscape to the Domain bounding box. + + Output cells tile the Domain bbox at `resolution`, padded outward if the + bbox isn't already a whole multiple. The default 30 m matches LANDFIRE's + native resolution. + + Attributes: + target (Literal['domain'] | Unset): Default: 'domain'. + resolution (float | Unset): Landscape cell size in meters. Defaults to 30 m, LANDFIRE's native resolution. + Default: 30.0. + """ + + target: Literal["domain"] | Unset = "domain" + resolution: float | Unset = 30.0 + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + target = self.target + + resolution = self.resolution + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if target is not UNSET: + field_dict["target"] = target + if resolution is not UNSET: + field_dict["resolution"] = resolution + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + target = cast(Literal["domain"] | Unset, d.pop("target", UNSET)) + if target != "domain" and not isinstance(target, Unset): + raise ValueError(f"target must match const 'domain', got '{target}'") + + resolution = d.pop("resolution", UNSET) + + landscape_export_alignment_domain_target = cls( + target=target, + resolution=resolution, + ) + + landscape_export_alignment_domain_target.additional_properties = d + return landscape_export_alignment_domain_target + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/landscape_export_alignment_grid_target.py b/fastfuels_sdk/v2/client_library/models/landscape_export_alignment_grid_target.py new file mode 100644 index 0000000..6b8143e --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/landscape_export_alignment_grid_target.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + Any, + Literal, + Self, + TypeVar, + cast, +) + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="LandscapeExportAlignmentGridTarget") + + +@_attrs_define +class LandscapeExportAlignmentGridTarget: + """Anchor the landscape to an existing grid's lattice. + + Useful when role grids share a non-Domain-anchored lattice (e.g. all + chained off a `target="native"` master grid). The referenced grid's + CRS, transform, and shape become the landscape lattice. + + Attributes: + target (Literal['grid']): + grid_id (str): Existing grid whose lattice (CRS, transform, shape) the landscape should match exactly. + """ + + target: Literal["grid"] + grid_id: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + target = self.target + + grid_id = self.grid_id + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "target": target, + "grid_id": grid_id, + } + ) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + target = cast(Literal["grid"], d.pop("target")) + if target != "grid": + raise ValueError(f"target must match const 'grid', got '{target}'") + + grid_id = d.pop("grid_id") + + landscape_export_alignment_grid_target = cls( + target=target, + grid_id=grid_id, + ) + + landscape_export_alignment_grid_target.additional_properties = d + return landscape_export_alignment_grid_target + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/landscape_export_request.py b/fastfuels_sdk/v2/client_library/models/landscape_export_request.py new file mode 100644 index 0000000..19c5825 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/landscape_export_request.py @@ -0,0 +1,254 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, Self, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.landscape_export_request_fire_behavior_fuel_model import ( + LandscapeExportRequestFireBehaviorFuelModel, +) +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.landscape_export_alignment_domain_target import ( + LandscapeExportAlignmentDomainTarget, + ) + from ..models.landscape_export_alignment_grid_target import ( + LandscapeExportAlignmentGridTarget, + ) + from ..models.landscape_field_source import LandscapeFieldSource + + +T = TypeVar("T", bound="LandscapeExportRequest") + + +@_attrs_define +class LandscapeExportRequest: + """Request body for creating a landscape export. + + Eight required roles produce an 8-band landscape GeoTIFF in LANDFIRE band + order: elevation, slope, aspect, fuel model, canopy cover, canopy height, + canopy base height, canopy bulk density. This is the shape modern fire + behavior tools consume — IFTDSS requires all eight bands for upload. + + The landscape lattice is defined by the `alignment` field — either the + Domain bounding box tiled at `resolution` (default 30 m, LANDFIRE-native), + or the lattice of an existing grid. Every role grid must be lattice-aligned + to the landscape and cover its full extent; otherwise the request is + rejected with 422. The exporter only crops oversized roles by integer + slicing — it never resamples or reprojects. To change a grid's resolution + or anchor, use `POST /v2/domains/{domain_id}/grids/{grid_id}/resample`. + + Attributes: + fire_behavior_fuel_model (LandscapeExportRequestFireBehaviorFuelModel): How the `fuel_model` band's codes should + be interpreted: `'fbfm40'` (Scott-Burgan 40) or `'fbfm13'` (Anderson 13). Recorded in the landscape file so fire + behavior tools apply the right classification. + elevation (LandscapeFieldSource): A single landscape band drawn from one band on one grid. + slope (LandscapeFieldSource): A single landscape band drawn from one band on one grid. + aspect (LandscapeFieldSource): A single landscape band drawn from one band on one grid. + fuel_model (LandscapeFieldSource): A single landscape band drawn from one band on one grid. + canopy_cover (LandscapeFieldSource): A single landscape band drawn from one band on one grid. + canopy_height (LandscapeFieldSource): A single landscape band drawn from one band on one grid. + canopy_base_height (LandscapeFieldSource): A single landscape band drawn from one band on one grid. + canopy_bulk_density (LandscapeFieldSource): A single landscape band drawn from one band on one grid. + alignment (LandscapeExportAlignmentDomainTarget | LandscapeExportAlignmentGridTarget | Unset): How the landscape + lattice is defined. Discriminated by `target`: `'domain'` (default) tiles the Domain bbox at `resolution`; + `'grid'` matches an existing grid's lattice exactly. Omit for the default Domain-anchored 30 m landscape. + expiration_days (int | Unset): Days until the signed download URL expires (max 7). Default: 7. + name (str | Unset): Default: ''. + description (str | Unset): Default: ''. + tags (list[str] | Unset): + """ + + fire_behavior_fuel_model: LandscapeExportRequestFireBehaviorFuelModel + elevation: LandscapeFieldSource + slope: LandscapeFieldSource + aspect: LandscapeFieldSource + fuel_model: LandscapeFieldSource + canopy_cover: LandscapeFieldSource + canopy_height: LandscapeFieldSource + canopy_base_height: LandscapeFieldSource + canopy_bulk_density: LandscapeFieldSource + alignment: ( + LandscapeExportAlignmentDomainTarget + | LandscapeExportAlignmentGridTarget + | Unset + ) = UNSET + expiration_days: int | Unset = 7 + name: str | Unset = "" + description: str | Unset = "" + tags: list[str] | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + from ..models.landscape_export_alignment_domain_target import ( + LandscapeExportAlignmentDomainTarget, + ) + + fire_behavior_fuel_model = self.fire_behavior_fuel_model.value + + elevation = self.elevation.to_dict() + + slope = self.slope.to_dict() + + aspect = self.aspect.to_dict() + + fuel_model = self.fuel_model.to_dict() + + canopy_cover = self.canopy_cover.to_dict() + + canopy_height = self.canopy_height.to_dict() + + canopy_base_height = self.canopy_base_height.to_dict() + + canopy_bulk_density = self.canopy_bulk_density.to_dict() + + alignment: dict[str, Any] | Unset + if isinstance(self.alignment, Unset): + alignment = UNSET + elif isinstance(self.alignment, LandscapeExportAlignmentDomainTarget): + alignment = self.alignment.to_dict() + else: + alignment = self.alignment.to_dict() + + expiration_days = self.expiration_days + + name = self.name + + description = self.description + + tags: list[str] | Unset = UNSET + if not isinstance(self.tags, Unset): + tags = self.tags + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "fire_behavior_fuel_model": fire_behavior_fuel_model, + "elevation": elevation, + "slope": slope, + "aspect": aspect, + "fuel_model": fuel_model, + "canopy_cover": canopy_cover, + "canopy_height": canopy_height, + "canopy_base_height": canopy_base_height, + "canopy_bulk_density": canopy_bulk_density, + } + ) + if alignment is not UNSET: + field_dict["alignment"] = alignment + if expiration_days is not UNSET: + field_dict["expiration_days"] = expiration_days + if name is not UNSET: + field_dict["name"] = name + if description is not UNSET: + field_dict["description"] = description + if tags is not UNSET: + field_dict["tags"] = tags + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + from ..models.landscape_export_alignment_domain_target import ( + LandscapeExportAlignmentDomainTarget, + ) + from ..models.landscape_export_alignment_grid_target import ( + LandscapeExportAlignmentGridTarget, + ) + from ..models.landscape_field_source import LandscapeFieldSource + + d = dict(src_dict) + fire_behavior_fuel_model = LandscapeExportRequestFireBehaviorFuelModel( + d.pop("fire_behavior_fuel_model") + ) + + elevation = LandscapeFieldSource.from_dict(d.pop("elevation")) + + slope = LandscapeFieldSource.from_dict(d.pop("slope")) + + aspect = LandscapeFieldSource.from_dict(d.pop("aspect")) + + fuel_model = LandscapeFieldSource.from_dict(d.pop("fuel_model")) + + canopy_cover = LandscapeFieldSource.from_dict(d.pop("canopy_cover")) + + canopy_height = LandscapeFieldSource.from_dict(d.pop("canopy_height")) + + canopy_base_height = LandscapeFieldSource.from_dict(d.pop("canopy_base_height")) + + canopy_bulk_density = LandscapeFieldSource.from_dict( + d.pop("canopy_bulk_density") + ) + + def _parse_alignment( + data: object, + ) -> ( + LandscapeExportAlignmentDomainTarget + | LandscapeExportAlignmentGridTarget + | Unset + ): + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + alignment_type_0 = LandscapeExportAlignmentDomainTarget.from_dict(data) + + return alignment_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + if not isinstance(data, dict): + raise TypeError() + alignment_type_1 = LandscapeExportAlignmentGridTarget.from_dict(data) + + return alignment_type_1 + + alignment = _parse_alignment(d.pop("alignment", UNSET)) + + expiration_days = d.pop("expiration_days", UNSET) + + name = d.pop("name", UNSET) + + description = d.pop("description", UNSET) + + tags = cast(list[str], d.pop("tags", UNSET)) + + landscape_export_request = cls( + fire_behavior_fuel_model=fire_behavior_fuel_model, + elevation=elevation, + slope=slope, + aspect=aspect, + fuel_model=fuel_model, + canopy_cover=canopy_cover, + canopy_height=canopy_height, + canopy_base_height=canopy_base_height, + canopy_bulk_density=canopy_bulk_density, + alignment=alignment, + expiration_days=expiration_days, + name=name, + description=description, + tags=tags, + ) + + landscape_export_request.additional_properties = d + return landscape_export_request + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/landscape_export_request_fire_behavior_fuel_model.py b/fastfuels_sdk/v2/client_library/models/landscape_export_request_fire_behavior_fuel_model.py new file mode 100644 index 0000000..2d9a922 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/landscape_export_request_fire_behavior_fuel_model.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class LandscapeExportRequestFireBehaviorFuelModel(str, Enum): + FBFM13 = "fbfm13" + FBFM40 = "fbfm40" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/landscape_field_source.py b/fastfuels_sdk/v2/client_library/models/landscape_field_source.py new file mode 100644 index 0000000..1456dd8 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/landscape_field_source.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Self, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="LandscapeFieldSource") + + +@_attrs_define +class LandscapeFieldSource: + """A single landscape band drawn from one band on one grid. + + Attributes: + grid_id (str): Grid containing the source band. + band (str): Band key on that grid (e.g. 'elevation'). + """ + + grid_id: str + band: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + grid_id = self.grid_id + + band = self.band + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "grid_id": grid_id, + "band": band, + } + ) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + grid_id = d.pop("grid_id") + + band = d.pop("band") + + landscape_field_source = cls( + grid_id=grid_id, + band=band, + ) + + landscape_field_source.additional_properties = d + return landscape_field_source + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/layerset_crs.py b/fastfuels_sdk/v2/client_library/models/layerset_crs.py index c603528..b0a0d0f 100644 --- a/fastfuels_sdk/v2/client_library/models/layerset_crs.py +++ b/fastfuels_sdk/v2/client_library/models/layerset_crs.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any, Self, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -51,7 +51,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.layerset_crs_properties import LayersetCrsProperties d = dict(src_dict) diff --git a/fastfuels_sdk/v2/client_library/models/layerset_crs_properties.py b/fastfuels_sdk/v2/client_library/models/layerset_crs_properties.py index 011abb3..7376139 100644 --- a/fastfuels_sdk/v2/client_library/models/layerset_crs_properties.py +++ b/fastfuels_sdk/v2/client_library/models/layerset_crs_properties.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar +from typing import Any, Self, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -23,7 +23,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) layerset_crs_properties = cls() diff --git a/fastfuels_sdk/v2/client_library/models/layerset_feature.py b/fastfuels_sdk/v2/client_library/models/layerset_feature.py index 17e2b2c..ea8cb02 100644 --- a/fastfuels_sdk/v2/client_library/models/layerset_feature.py +++ b/fastfuels_sdk/v2/client_library/models/layerset_feature.py @@ -5,6 +5,7 @@ TYPE_CHECKING, Any, Literal, + Self, TypeVar, cast, ) @@ -60,9 +61,9 @@ def to_dict(self) -> dict[str, Any]: type_ = self.type_ geometry: dict[str, Any] | None - if isinstance(self.geometry, Polygon): - geometry = self.geometry.to_dict() - elif isinstance(self.geometry, MultiPolygon): + if isinstance(self.geometry, Polygon) or isinstance( + self.geometry, MultiPolygon + ): geometry = self.geometry.to_dict() else: geometry = self.geometry @@ -105,7 +106,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.layerset_properties import LayersetProperties from ..models.multi_polygon import MultiPolygon from ..models.polygon import Polygon diff --git a/fastfuels_sdk/v2/client_library/models/layerset_properties.py b/fastfuels_sdk/v2/client_library/models/layerset_properties.py index 35ea5be..a005fbe 100644 --- a/fastfuels_sdk/v2/client_library/models/layerset_properties.py +++ b/fastfuels_sdk/v2/client_library/models/layerset_properties.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar, cast +from typing import Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -123,7 +123,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) fuel_type = d.pop("fuel_type") diff --git a/fastfuels_sdk/v2/client_library/models/line_string.py b/fastfuels_sdk/v2/client_library/models/line_string.py index 5e5f89e..f2c03a4 100644 --- a/fastfuels_sdk/v2/client_library/models/line_string.py +++ b/fastfuels_sdk/v2/client_library/models/line_string.py @@ -4,6 +4,7 @@ from typing import ( Any, Literal, + Self, TypeVar, cast, ) @@ -75,7 +76,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) type_ = cast(Literal["LineString"], d.pop("type")) if type_ != "LineString": diff --git a/fastfuels_sdk/v2/client_library/models/list_applications_response.py b/fastfuels_sdk/v2/client_library/models/list_applications_response.py index 3b59df8..b3e2f88 100644 --- a/fastfuels_sdk/v2/client_library/models/list_applications_response.py +++ b/fastfuels_sdk/v2/client_library/models/list_applications_response.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any, Self, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -56,7 +56,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.application import Application d = dict(src_dict) diff --git a/fastfuels_sdk/v2/client_library/models/list_domains_response.py b/fastfuels_sdk/v2/client_library/models/list_domains_response.py index 608c0fb..bb2d82e 100644 --- a/fastfuels_sdk/v2/client_library/models/list_domains_response.py +++ b/fastfuels_sdk/v2/client_library/models/list_domains_response.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any, Self, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -56,7 +56,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.domain import Domain d = dict(src_dict) diff --git a/fastfuels_sdk/v2/client_library/models/list_exports_response.py b/fastfuels_sdk/v2/client_library/models/list_exports_response.py index 87b8eba..7c22339 100644 --- a/fastfuels_sdk/v2/client_library/models/list_exports_response.py +++ b/fastfuels_sdk/v2/client_library/models/list_exports_response.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any, Self, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -56,7 +56,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.export import Export d = dict(src_dict) diff --git a/fastfuels_sdk/v2/client_library/models/list_features_response.py b/fastfuels_sdk/v2/client_library/models/list_features_response.py index 4453c35..7f6991d 100644 --- a/fastfuels_sdk/v2/client_library/models/list_features_response.py +++ b/fastfuels_sdk/v2/client_library/models/list_features_response.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any, Self, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -56,7 +56,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.feature import Feature d = dict(src_dict) diff --git a/fastfuels_sdk/v2/client_library/models/list_grids_response.py b/fastfuels_sdk/v2/client_library/models/list_grids_response.py index 6b9cfa0..096bbd9 100644 --- a/fastfuels_sdk/v2/client_library/models/list_grids_response.py +++ b/fastfuels_sdk/v2/client_library/models/list_grids_response.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any, Self, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -56,7 +56,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.grid import Grid d = dict(src_dict) diff --git a/fastfuels_sdk/v2/client_library/models/list_inventories_response.py b/fastfuels_sdk/v2/client_library/models/list_inventories_response.py index afe4a8a..29f44dd 100644 --- a/fastfuels_sdk/v2/client_library/models/list_inventories_response.py +++ b/fastfuels_sdk/v2/client_library/models/list_inventories_response.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any, Self, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -56,7 +56,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.inventory import Inventory d = dict(src_dict) diff --git a/fastfuels_sdk/v2/client_library/models/list_keys_response.py b/fastfuels_sdk/v2/client_library/models/list_keys_response.py index 03013ec..d8bba0e 100644 --- a/fastfuels_sdk/v2/client_library/models/list_keys_response.py +++ b/fastfuels_sdk/v2/client_library/models/list_keys_response.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any, Self, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -56,7 +56,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.key import Key d = dict(src_dict) diff --git a/fastfuels_sdk/v2/client_library/models/list_point_clouds_response.py b/fastfuels_sdk/v2/client_library/models/list_point_clouds_response.py index 8e0db4c..03114b9 100644 --- a/fastfuels_sdk/v2/client_library/models/list_point_clouds_response.py +++ b/fastfuels_sdk/v2/client_library/models/list_point_clouds_response.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any, Self, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -56,7 +56,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.point_cloud import PointCloud d = dict(src_dict) diff --git a/fastfuels_sdk/v2/client_library/models/moisture_model.py b/fastfuels_sdk/v2/client_library/models/moisture_model.py index 989e385..a3ea4aa 100644 --- a/fastfuels_sdk/v2/client_library/models/moisture_model.py +++ b/fastfuels_sdk/v2/client_library/models/moisture_model.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, Self, TypeVar, cast from attrs import define as _attrs_define @@ -56,7 +56,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.uniform_moisture_value import UniformMoistureValue d = dict(src_dict) diff --git a/fastfuels_sdk/v2/client_library/models/multi_line_string.py b/fastfuels_sdk/v2/client_library/models/multi_line_string.py index 3be320d..dfe66d7 100644 --- a/fastfuels_sdk/v2/client_library/models/multi_line_string.py +++ b/fastfuels_sdk/v2/client_library/models/multi_line_string.py @@ -4,6 +4,7 @@ from typing import ( Any, Literal, + Self, TypeVar, cast, ) @@ -81,7 +82,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) type_ = cast(Literal["MultiLineString"], d.pop("type")) if type_ != "MultiLineString": diff --git a/fastfuels_sdk/v2/client_library/models/multi_point.py b/fastfuels_sdk/v2/client_library/models/multi_point.py index 13a9956..2ae7708 100644 --- a/fastfuels_sdk/v2/client_library/models/multi_point.py +++ b/fastfuels_sdk/v2/client_library/models/multi_point.py @@ -4,6 +4,7 @@ from typing import ( Any, Literal, + Self, TypeVar, cast, ) @@ -75,7 +76,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) type_ = cast(Literal["MultiPoint"], d.pop("type")) if type_ != "MultiPoint": diff --git a/fastfuels_sdk/v2/client_library/models/multi_polygon.py b/fastfuels_sdk/v2/client_library/models/multi_polygon.py index 09c6355..b0edd40 100644 --- a/fastfuels_sdk/v2/client_library/models/multi_polygon.py +++ b/fastfuels_sdk/v2/client_library/models/multi_polygon.py @@ -4,6 +4,7 @@ from typing import ( Any, Literal, + Self, TypeVar, cast, ) @@ -87,7 +88,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) type_ = cast(Literal["MultiPolygon"], d.pop("type")) if type_ != "MultiPolygon": diff --git a/fastfuels_sdk/v2/client_library/models/point.py b/fastfuels_sdk/v2/client_library/models/point.py index 135f977..8ad31b4 100644 --- a/fastfuels_sdk/v2/client_library/models/point.py +++ b/fastfuels_sdk/v2/client_library/models/point.py @@ -4,6 +4,7 @@ from typing import ( Any, Literal, + Self, TypeVar, cast, ) @@ -71,7 +72,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) type_ = cast(Literal["Point"], d.pop("type")) if type_ != "Point": diff --git a/fastfuels_sdk/v2/client_library/models/point_cloud.py b/fastfuels_sdk/v2/client_library/models/point_cloud.py index 3ea4ea1..b2b8a88 100644 --- a/fastfuels_sdk/v2/client_library/models/point_cloud.py +++ b/fastfuels_sdk/v2/client_library/models/point_cloud.py @@ -2,7 +2,7 @@ import datetime from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -206,7 +206,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.job_error import JobError from ..models.job_progress import JobProgress from ..models.point_cloud_georeference import PointCloudGeoreference diff --git a/fastfuels_sdk/v2/client_library/models/point_cloud_georeference.py b/fastfuels_sdk/v2/client_library/models/point_cloud_georeference.py index 094b32b..bf456d4 100644 --- a/fastfuels_sdk/v2/client_library/models/point_cloud_georeference.py +++ b/fastfuels_sdk/v2/client_library/models/point_cloud_georeference.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar, cast +from typing import Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -18,7 +18,9 @@ class PointCloudGeoreference: Attributes: crs (str): Coordinate reference system the points are stored in, as an authority code (e.g. `EPSG:32613`). This - is always the domain's CRS: uploads in a different CRS are reprojected during ingestion. + is always the domain's CRS: uploads in a different CRS are reprojected during ingestion. Only horizontal + coordinates are transformed — elevations are stored exactly as the source provided them and are never converted + between reference surfaces. bounds (list[float]): Axis-aligned 3D bounding box of every point, given as `[min_x, min_y, min_z, max_x, max_y, max_z]` in the units of `crs`. Point clouds are three-dimensional, so the box includes a vertical (z) extent. Use it to check coverage against a domain before deriving products from the cloud. @@ -49,7 +51,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) crs = d.pop("crs") diff --git a/fastfuels_sdk/v2/client_library/models/point_cloud_source.py b/fastfuels_sdk/v2/client_library/models/point_cloud_source.py index 7c367e9..65c8ced 100644 --- a/fastfuels_sdk/v2/client_library/models/point_cloud_source.py +++ b/fastfuels_sdk/v2/client_library/models/point_cloud_source.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar +from typing import Any, Self, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -27,7 +27,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) point_cloud_source = cls() diff --git a/fastfuels_sdk/v2/client_library/models/point_cloud_summary.py b/fastfuels_sdk/v2/client_library/models/point_cloud_summary.py index 82c3f59..71972e7 100644 --- a/fastfuels_sdk/v2/client_library/models/point_cloud_summary.py +++ b/fastfuels_sdk/v2/client_library/models/point_cloud_summary.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar, cast +from typing import Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -49,7 +49,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) point_count = d.pop("point_count") diff --git a/fastfuels_sdk/v2/client_library/models/point_cloud_three_dep_coverage_response.py b/fastfuels_sdk/v2/client_library/models/point_cloud_three_dep_coverage_response.py new file mode 100644 index 0000000..1366f4f --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/point_cloud_three_dep_coverage_response.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, Self, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.three_dep_dataset_coverage import ThreeDepDatasetCoverage + + +T = TypeVar("T", bound="PointCloudThreeDepCoverageResponse") + + +@_attrs_define +class PointCloudThreeDepCoverageResponse: + """Response model for the 3DEP point cloud coverage pre-flight check. + + Attributes: + available (bool): Whether any 3DEP lidar covers this domain. When false, a create request for this domain is + rejected. + coverage_fraction (float): Fraction of the domain covered by 3DEP lidar, from `0.0` to `1.0`. This is the union + of every available acquisition, so it never exceeds 1.0 no matter how much the acquisitions overlap. + datasets (list[ThreeDepDatasetCoverage]): Acquisitions that would be read, in the order they would be used. + Empty when no lidar covers the domain. + estimated_point_count (int): Approximate total number of points a fetch would return, summed across + acquisitions. + point_budget (int): Maximum number of points a single fetch may return. Shrink the domain if the estimate + exceeds it. + exceeds_point_budget (bool): Whether the estimate is over `point_budget`. When true, a create request for this + domain is rejected, so check this before committing to a fetch. + """ + + available: bool + coverage_fraction: float + datasets: list[ThreeDepDatasetCoverage] + estimated_point_count: int + point_budget: int + exceeds_point_budget: bool + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + available = self.available + + coverage_fraction = self.coverage_fraction + + datasets = [] + for datasets_item_data in self.datasets: + datasets_item = datasets_item_data.to_dict() + datasets.append(datasets_item) + + estimated_point_count = self.estimated_point_count + + point_budget = self.point_budget + + exceeds_point_budget = self.exceeds_point_budget + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "available": available, + "coverage_fraction": coverage_fraction, + "datasets": datasets, + "estimated_point_count": estimated_point_count, + "point_budget": point_budget, + "exceeds_point_budget": exceeds_point_budget, + } + ) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + from ..models.three_dep_dataset_coverage import ThreeDepDatasetCoverage + + d = dict(src_dict) + available = d.pop("available") + + coverage_fraction = d.pop("coverage_fraction") + + datasets = [] + _datasets = d.pop("datasets") + for datasets_item_data in _datasets: + datasets_item = ThreeDepDatasetCoverage.from_dict(datasets_item_data) + + datasets.append(datasets_item) + + estimated_point_count = d.pop("estimated_point_count") + + point_budget = d.pop("point_budget") + + exceeds_point_budget = d.pop("exceeds_point_budget") + + point_cloud_three_dep_coverage_response = cls( + available=available, + coverage_fraction=coverage_fraction, + datasets=datasets, + estimated_point_count=estimated_point_count, + point_budget=point_budget, + exceeds_point_budget=exceeds_point_budget, + ) + + point_cloud_three_dep_coverage_response.additional_properties = d + return point_cloud_three_dep_coverage_response + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/point_cloud_upload_created_response.py b/fastfuels_sdk/v2/client_library/models/point_cloud_upload_created_response.py index ba57d40..e18e3a9 100644 --- a/fastfuels_sdk/v2/client_library/models/point_cloud_upload_created_response.py +++ b/fastfuels_sdk/v2/client_library/models/point_cloud_upload_created_response.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any, Self, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -59,7 +59,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.point_cloud import PointCloud from ..models.point_cloud_upload_spec import PointCloudUploadSpec diff --git a/fastfuels_sdk/v2/client_library/models/point_cloud_upload_spec.py b/fastfuels_sdk/v2/client_library/models/point_cloud_upload_spec.py index 7f01271..ba75354 100644 --- a/fastfuels_sdk/v2/client_library/models/point_cloud_upload_spec.py +++ b/fastfuels_sdk/v2/client_library/models/point_cloud_upload_spec.py @@ -6,6 +6,7 @@ TYPE_CHECKING, Any, Literal, + Self, TypeVar, cast, ) @@ -78,7 +79,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.point_cloud_upload_spec_headers import PointCloudUploadSpecHeaders d = dict(src_dict) diff --git a/fastfuels_sdk/v2/client_library/models/point_cloud_upload_spec_headers.py b/fastfuels_sdk/v2/client_library/models/point_cloud_upload_spec_headers.py index 6ba4143..29f7370 100644 --- a/fastfuels_sdk/v2/client_library/models/point_cloud_upload_spec_headers.py +++ b/fastfuels_sdk/v2/client_library/models/point_cloud_upload_spec_headers.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar +from typing import Any, Self, TypeVar from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -26,7 +26,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) point_cloud_upload_spec_headers = cls() diff --git a/fastfuels_sdk/v2/client_library/models/polygon.py b/fastfuels_sdk/v2/client_library/models/polygon.py index 73589ef..904cc72 100644 --- a/fastfuels_sdk/v2/client_library/models/polygon.py +++ b/fastfuels_sdk/v2/client_library/models/polygon.py @@ -4,6 +4,7 @@ from typing import ( Any, Literal, + Self, TypeVar, cast, ) @@ -81,7 +82,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) type_ = cast(Literal["Polygon"], d.pop("type")) if type_ != "Polygon": diff --git a/fastfuels_sdk/v2/client_library/models/quic_fire_export_alignment_domain_target.py b/fastfuels_sdk/v2/client_library/models/quic_fire_export_alignment_domain_target.py index 76a5521..3a9a9cb 100644 --- a/fastfuels_sdk/v2/client_library/models/quic_fire_export_alignment_domain_target.py +++ b/fastfuels_sdk/v2/client_library/models/quic_fire_export_alignment_domain_target.py @@ -4,6 +4,7 @@ from typing import ( Any, Literal, + Self, TypeVar, cast, ) @@ -32,7 +33,9 @@ class QUICFireExportAlignmentDomainTarget: Default: 2.0. dy (float | Unset): Horizontal fire-grid cell size in y (UTM north-south), in meters. Must equal `dx` — non- square fire-grid cells are not supported. Default: 2.0. - dz (float | Unset): Vertical fire-grid cell size, in meters. QUIC-Fire recommends 1 m. Default: 1.0. + dz (float | Unset): Vertical fire-grid cell size, in meters. QUIC-Fire recommends 1 m. Must equal the 3D tree + grid's voxelization vertical resolution (`resolution.vertical`): the exporter never resamples vertically, so a + mismatch is rejected with 422 rather than silently applied. Default: 1.0. """ target: Literal["domain"] | Unset = "domain" @@ -65,7 +68,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) target = cast(Literal["domain"] | Unset, d.pop("target", UNSET)) if target != "domain" and not isinstance(target, Unset): diff --git a/fastfuels_sdk/v2/client_library/models/quic_fire_export_alignment_grid_target.py b/fastfuels_sdk/v2/client_library/models/quic_fire_export_alignment_grid_target.py index ce99705..31466d7 100644 --- a/fastfuels_sdk/v2/client_library/models/quic_fire_export_alignment_grid_target.py +++ b/fastfuels_sdk/v2/client_library/models/quic_fire_export_alignment_grid_target.py @@ -4,6 +4,7 @@ from typing import ( Any, Literal, + Self, TypeVar, cast, ) @@ -50,7 +51,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) target = cast(Literal["grid"], d.pop("target")) if target != "grid": diff --git a/fastfuels_sdk/v2/client_library/models/quicfire_export_request.py b/fastfuels_sdk/v2/client_library/models/quicfire_export_request.py index 11ab90f..3c758a6 100644 --- a/fastfuels_sdk/v2/client_library/models/quicfire_export_request.py +++ b/fastfuels_sdk/v2/client_library/models/quicfire_export_request.py @@ -5,6 +5,7 @@ TYPE_CHECKING, Any, Literal, + Self, TypeVar, cast, ) @@ -43,6 +44,17 @@ class QuicfireExportRequest: The exporter only crops oversized roles by integer slicing — it never resamples or reprojects. + The output resolution is set here, on the export, via `alignment.dx`/`dy` + (default 2 m, QUIC-Fire's recommended value). It is a separate setting + from the resolution of each grid you built — changing your grids does not + change the export, and vice versa. Because the exporter never resamples, + every role grid must already be built at the fire-grid resolution. To + export at 1 m, for example, set `dx`/`dy` to 1 and build all role grids at + 1 m (2D grids at 1 m via their `alignment.resolution`, and the 3D tree + grid at 1 m via `resolution.horizontal` — 3D grids cannot be resampled). + The same holds vertically: `alignment.dz` must equal the 3D tree grid's + `resolution.vertical`, or the request is rejected with 422. + Attributes: canopy_bulk_density (FieldSource): A single physical quantity drawn from one band on one grid. @@ -227,7 +239,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.field_source import FieldSource from ..models.quic_fire_export_alignment_domain_target import ( QUICFireExportAlignmentDomainTarget, diff --git a/fastfuels_sdk/v2/client_library/models/quota_exceeded_detail.py b/fastfuels_sdk/v2/client_library/models/quota_exceeded_detail.py new file mode 100644 index 0000000..e8e2201 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/quota_exceeded_detail.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import datetime +from collections.abc import Mapping +from typing import Any, Self, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="QuotaExceededDetail") + + +@_attrs_define +class QuotaExceededDetail: + """Structured ``detail`` for a 429 quota rejection. + + The flat ``{reason, quota, message, current, limit}`` shape is the template + for future structured error details: a machine-readable ``reason`` code plus + flat, typed context fields. + + Attributes: + quota (str): The Quotas field that was exceeded. + message (str): Human-readable explanation and next steps. + current (int): The owner's current usage for this quota. + limit (int): The limit that was reached. + reason (str | Unset): Machine-readable error code. Default: 'QUOTA_EXCEEDED'. + window_reset_on (datetime.datetime | None | Unset): When a windowed (weekly) quota resets; absent for non- + windowed quotas. + """ + + quota: str + message: str + current: int + limit: int + reason: str | Unset = "QUOTA_EXCEEDED" + window_reset_on: datetime.datetime | None | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + quota = self.quota + + message = self.message + + current = self.current + + limit = self.limit + + reason = self.reason + + window_reset_on: None | str | Unset + if isinstance(self.window_reset_on, Unset): + window_reset_on = UNSET + elif isinstance(self.window_reset_on, datetime.datetime): + window_reset_on = self.window_reset_on.isoformat() + else: + window_reset_on = self.window_reset_on + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "quota": quota, + "message": message, + "current": current, + "limit": limit, + } + ) + if reason is not UNSET: + field_dict["reason"] = reason + if window_reset_on is not UNSET: + field_dict["window_reset_on"] = window_reset_on + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + quota = d.pop("quota") + + message = d.pop("message") + + current = d.pop("current") + + limit = d.pop("limit") + + reason = d.pop("reason", UNSET) + + def _parse_window_reset_on(data: object) -> datetime.datetime | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, str): + raise TypeError() + window_reset_on_type_0 = datetime.datetime.fromisoformat(data) + + return window_reset_on_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(datetime.datetime | None | Unset, data) + + window_reset_on = _parse_window_reset_on(d.pop("window_reset_on", UNSET)) + + quota_exceeded_detail = cls( + quota=quota, + message=message, + current=current, + limit=limit, + reason=reason, + window_reset_on=window_reset_on, + ) + + quota_exceeded_detail.additional_properties = d + return quota_exceeded_detail + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/quotas.py b/fastfuels_sdk/v2/client_library/models/quotas.py new file mode 100644 index 0000000..b421536 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/quotas.py @@ -0,0 +1,317 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Self, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="Quotas") + + +@_attrs_define +class Quotas: + """Usage limits for an owner. Field defaults are the standard tier. + + Attributes: + max_active_grids (int | Unset): Default: 25. + max_active_exports (int | Unset): Default: 10. + max_active_inventories (int | Unset): Default: 10. + max_active_features (int | Unset): Default: 10. + max_active_pointclouds (int | Unset): Default: 5. + max_domains (int | Unset): Default: 50. + max_grids (int | Unset): Default: 1000. + max_exports (int | Unset): Default: 500. + max_inventories (int | Unset): Default: 500. + max_features (int | Unset): Default: 500. + max_pointclouds (int | Unset): Default: 50. + max_api_keys (int | Unset): Default: 50. + max_applications (int | Unset): Default: 5. + max_grid_storage_bytes (int | Unset): Default: 53687091200. + max_export_storage_bytes (int | Unset): Default: 26843545600. + max_inventory_storage_bytes (int | Unset): Default: 10737418240. + max_feature_storage_bytes (int | Unset): Default: 1073741824. + max_pointcloud_storage_bytes (int | Unset): Default: 53687091200. + max_weekly_grid_dispatches (int | Unset): Grid worker jobs allowed per ISO week (Monday 00:00 UTC reset): + creates, modifications, duplicates, and uploads all count. Deleting grids does not refund spent budget. Default: + 500. + max_weekly_export_dispatches (int | Unset): Export worker jobs allowed per ISO week (Monday 00:00 UTC reset). + Deleting exports does not refund spent budget. Default: 250. + max_weekly_inventory_dispatches (int | Unset): Inventory worker jobs allowed per ISO week (Monday 00:00 UTC + reset): creates, modifications, treatments, duplicates, and uploads all count. Deleting inventories does not + refund spent budget. Default: 250. + max_weekly_feature_dispatches (int | Unset): Feature worker jobs allowed per ISO week (Monday 00:00 UTC reset). + Synchronous layerset creates are exempt. Deleting features does not refund spent budget. Default: 250. + max_weekly_pointcloud_dispatches (int | Unset): Point cloud worker jobs allowed per ISO week (Monday 00:00 UTC + reset): each upload counts. Deleting point clouds does not refund spent budget. Default: 50. + resource_ttl_days (int | None | Unset): Default: 180. + failed_resource_ttl_days (int | None | Unset): Default: 14. + """ + + max_active_grids: int | Unset = 25 + max_active_exports: int | Unset = 10 + max_active_inventories: int | Unset = 10 + max_active_features: int | Unset = 10 + max_active_pointclouds: int | Unset = 5 + max_domains: int | Unset = 50 + max_grids: int | Unset = 1000 + max_exports: int | Unset = 500 + max_inventories: int | Unset = 500 + max_features: int | Unset = 500 + max_pointclouds: int | Unset = 50 + max_api_keys: int | Unset = 50 + max_applications: int | Unset = 5 + max_grid_storage_bytes: int | Unset = 53687091200 + max_export_storage_bytes: int | Unset = 26843545600 + max_inventory_storage_bytes: int | Unset = 10737418240 + max_feature_storage_bytes: int | Unset = 1073741824 + max_pointcloud_storage_bytes: int | Unset = 53687091200 + max_weekly_grid_dispatches: int | Unset = 500 + max_weekly_export_dispatches: int | Unset = 250 + max_weekly_inventory_dispatches: int | Unset = 250 + max_weekly_feature_dispatches: int | Unset = 250 + max_weekly_pointcloud_dispatches: int | Unset = 50 + resource_ttl_days: int | None | Unset = 180 + failed_resource_ttl_days: int | None | Unset = 14 + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + max_active_grids = self.max_active_grids + + max_active_exports = self.max_active_exports + + max_active_inventories = self.max_active_inventories + + max_active_features = self.max_active_features + + max_active_pointclouds = self.max_active_pointclouds + + max_domains = self.max_domains + + max_grids = self.max_grids + + max_exports = self.max_exports + + max_inventories = self.max_inventories + + max_features = self.max_features + + max_pointclouds = self.max_pointclouds + + max_api_keys = self.max_api_keys + + max_applications = self.max_applications + + max_grid_storage_bytes = self.max_grid_storage_bytes + + max_export_storage_bytes = self.max_export_storage_bytes + + max_inventory_storage_bytes = self.max_inventory_storage_bytes + + max_feature_storage_bytes = self.max_feature_storage_bytes + + max_pointcloud_storage_bytes = self.max_pointcloud_storage_bytes + + max_weekly_grid_dispatches = self.max_weekly_grid_dispatches + + max_weekly_export_dispatches = self.max_weekly_export_dispatches + + max_weekly_inventory_dispatches = self.max_weekly_inventory_dispatches + + max_weekly_feature_dispatches = self.max_weekly_feature_dispatches + + max_weekly_pointcloud_dispatches = self.max_weekly_pointcloud_dispatches + + resource_ttl_days: int | None | Unset + if isinstance(self.resource_ttl_days, Unset): + resource_ttl_days = UNSET + else: + resource_ttl_days = self.resource_ttl_days + + failed_resource_ttl_days: int | None | Unset + if isinstance(self.failed_resource_ttl_days, Unset): + failed_resource_ttl_days = UNSET + else: + failed_resource_ttl_days = self.failed_resource_ttl_days + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if max_active_grids is not UNSET: + field_dict["max_active_grids"] = max_active_grids + if max_active_exports is not UNSET: + field_dict["max_active_exports"] = max_active_exports + if max_active_inventories is not UNSET: + field_dict["max_active_inventories"] = max_active_inventories + if max_active_features is not UNSET: + field_dict["max_active_features"] = max_active_features + if max_active_pointclouds is not UNSET: + field_dict["max_active_pointclouds"] = max_active_pointclouds + if max_domains is not UNSET: + field_dict["max_domains"] = max_domains + if max_grids is not UNSET: + field_dict["max_grids"] = max_grids + if max_exports is not UNSET: + field_dict["max_exports"] = max_exports + if max_inventories is not UNSET: + field_dict["max_inventories"] = max_inventories + if max_features is not UNSET: + field_dict["max_features"] = max_features + if max_pointclouds is not UNSET: + field_dict["max_pointclouds"] = max_pointclouds + if max_api_keys is not UNSET: + field_dict["max_api_keys"] = max_api_keys + if max_applications is not UNSET: + field_dict["max_applications"] = max_applications + if max_grid_storage_bytes is not UNSET: + field_dict["max_grid_storage_bytes"] = max_grid_storage_bytes + if max_export_storage_bytes is not UNSET: + field_dict["max_export_storage_bytes"] = max_export_storage_bytes + if max_inventory_storage_bytes is not UNSET: + field_dict["max_inventory_storage_bytes"] = max_inventory_storage_bytes + if max_feature_storage_bytes is not UNSET: + field_dict["max_feature_storage_bytes"] = max_feature_storage_bytes + if max_pointcloud_storage_bytes is not UNSET: + field_dict["max_pointcloud_storage_bytes"] = max_pointcloud_storage_bytes + if max_weekly_grid_dispatches is not UNSET: + field_dict["max_weekly_grid_dispatches"] = max_weekly_grid_dispatches + if max_weekly_export_dispatches is not UNSET: + field_dict["max_weekly_export_dispatches"] = max_weekly_export_dispatches + if max_weekly_inventory_dispatches is not UNSET: + field_dict["max_weekly_inventory_dispatches"] = ( + max_weekly_inventory_dispatches + ) + if max_weekly_feature_dispatches is not UNSET: + field_dict["max_weekly_feature_dispatches"] = max_weekly_feature_dispatches + if max_weekly_pointcloud_dispatches is not UNSET: + field_dict["max_weekly_pointcloud_dispatches"] = ( + max_weekly_pointcloud_dispatches + ) + if resource_ttl_days is not UNSET: + field_dict["resource_ttl_days"] = resource_ttl_days + if failed_resource_ttl_days is not UNSET: + field_dict["failed_resource_ttl_days"] = failed_resource_ttl_days + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + max_active_grids = d.pop("max_active_grids", UNSET) + + max_active_exports = d.pop("max_active_exports", UNSET) + + max_active_inventories = d.pop("max_active_inventories", UNSET) + + max_active_features = d.pop("max_active_features", UNSET) + + max_active_pointclouds = d.pop("max_active_pointclouds", UNSET) + + max_domains = d.pop("max_domains", UNSET) + + max_grids = d.pop("max_grids", UNSET) + + max_exports = d.pop("max_exports", UNSET) + + max_inventories = d.pop("max_inventories", UNSET) + + max_features = d.pop("max_features", UNSET) + + max_pointclouds = d.pop("max_pointclouds", UNSET) + + max_api_keys = d.pop("max_api_keys", UNSET) + + max_applications = d.pop("max_applications", UNSET) + + max_grid_storage_bytes = d.pop("max_grid_storage_bytes", UNSET) + + max_export_storage_bytes = d.pop("max_export_storage_bytes", UNSET) + + max_inventory_storage_bytes = d.pop("max_inventory_storage_bytes", UNSET) + + max_feature_storage_bytes = d.pop("max_feature_storage_bytes", UNSET) + + max_pointcloud_storage_bytes = d.pop("max_pointcloud_storage_bytes", UNSET) + + max_weekly_grid_dispatches = d.pop("max_weekly_grid_dispatches", UNSET) + + max_weekly_export_dispatches = d.pop("max_weekly_export_dispatches", UNSET) + + max_weekly_inventory_dispatches = d.pop( + "max_weekly_inventory_dispatches", UNSET + ) + + max_weekly_feature_dispatches = d.pop("max_weekly_feature_dispatches", UNSET) + + max_weekly_pointcloud_dispatches = d.pop( + "max_weekly_pointcloud_dispatches", UNSET + ) + + def _parse_resource_ttl_days(data: object) -> int | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(int | None | Unset, data) + + resource_ttl_days = _parse_resource_ttl_days(d.pop("resource_ttl_days", UNSET)) + + def _parse_failed_resource_ttl_days(data: object) -> int | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(int | None | Unset, data) + + failed_resource_ttl_days = _parse_failed_resource_ttl_days( + d.pop("failed_resource_ttl_days", UNSET) + ) + + quotas = cls( + max_active_grids=max_active_grids, + max_active_exports=max_active_exports, + max_active_inventories=max_active_inventories, + max_active_features=max_active_features, + max_active_pointclouds=max_active_pointclouds, + max_domains=max_domains, + max_grids=max_grids, + max_exports=max_exports, + max_inventories=max_inventories, + max_features=max_features, + max_pointclouds=max_pointclouds, + max_api_keys=max_api_keys, + max_applications=max_applications, + max_grid_storage_bytes=max_grid_storage_bytes, + max_export_storage_bytes=max_export_storage_bytes, + max_inventory_storage_bytes=max_inventory_storage_bytes, + max_feature_storage_bytes=max_feature_storage_bytes, + max_pointcloud_storage_bytes=max_pointcloud_storage_bytes, + max_weekly_grid_dispatches=max_weekly_grid_dispatches, + max_weekly_export_dispatches=max_weekly_export_dispatches, + max_weekly_inventory_dispatches=max_weekly_inventory_dispatches, + max_weekly_feature_dispatches=max_weekly_feature_dispatches, + max_weekly_pointcloud_dispatches=max_weekly_pointcloud_dispatches, + resource_ttl_days=resource_ttl_days, + failed_resource_ttl_days=failed_resource_ttl_days, + ) + + quotas.additional_properties = d + return quotas + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/remove_action.py b/fastfuels_sdk/v2/client_library/models/remove_action.py index 3b13b43..31d19ed 100644 --- a/fastfuels_sdk/v2/client_library/models/remove_action.py +++ b/fastfuels_sdk/v2/client_library/models/remove_action.py @@ -4,6 +4,7 @@ from typing import ( Any, Literal, + Self, TypeVar, cast, ) @@ -39,7 +40,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) modifier = cast(Literal["remove"] | Unset, d.pop("modifier", UNSET)) if modifier != "remove" and not isinstance(modifier, Unset): diff --git a/fastfuels_sdk/v2/client_library/models/resolution_3d.py b/fastfuels_sdk/v2/client_library/models/resolution_3d.py index 4bef871..ab84440 100644 --- a/fastfuels_sdk/v2/client_library/models/resolution_3d.py +++ b/fastfuels_sdk/v2/client_library/models/resolution_3d.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar +from typing import Any, Self, TypeVar from attrs import define as _attrs_define @@ -40,7 +40,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) horizontal = d.pop("horizontal") diff --git a/fastfuels_sdk/v2/client_library/models/sparse_grid_data.py b/fastfuels_sdk/v2/client_library/models/sparse_grid_data.py index d3bc816..00ad40e 100644 --- a/fastfuels_sdk/v2/client_library/models/sparse_grid_data.py +++ b/fastfuels_sdk/v2/client_library/models/sparse_grid_data.py @@ -4,6 +4,7 @@ from typing import ( Any, Literal, + Self, TypeVar, cast, ) @@ -58,7 +59,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) format_ = cast(Literal["sparse"], d.pop("format")) if format_ != "sparse": diff --git a/fastfuels_sdk/v2/client_library/models/stem_isolation_lmf.py b/fastfuels_sdk/v2/client_library/models/stem_isolation_lmf.py index 3fb865e..6da4bcc 100644 --- a/fastfuels_sdk/v2/client_library/models/stem_isolation_lmf.py +++ b/fastfuels_sdk/v2/client_library/models/stem_isolation_lmf.py @@ -4,6 +4,7 @@ from typing import ( Any, Literal, + Self, TypeVar, cast, ) @@ -20,14 +21,20 @@ class StemIsolationLmf: """Parameters for Local Maximum Filter (LMF) stem isolation. - Attributes: - name (Literal['lmf'] | Unset): Default: 'lmf'. - min_height (float | Unset): Minimum height threshold (in CHM units) for a treetop. Default: 2.0. - footprint_size (int | Unset): Diameter of the circular footprint in pixels. Must be an odd integer. Default: 3. + When set, ``max_height`` must be greater than ``min_height``. + + Attributes: + name (Literal['lmf'] | Unset): Default: 'lmf'. + min_height (float | Unset): Minimum height threshold (in meters) for a treetop. Default: 2.0. + max_height (float | None | Unset): Maximum height threshold (in meters) for a treetop. CHM returns taller than + this are treated as artifacts (e.g. LiDAR noise spikes) and excluded before detection. Defaults to 120, above + the tallest known tree; set to null to disable the ceiling. Default: 120.0. + footprint_size (int | Unset): Diameter of the circular footprint in pixels. Must be an odd integer. Default: 3. """ name: Literal["lmf"] | Unset = "lmf" min_height: float | Unset = 2.0 + max_height: float | None | Unset = 120.0 footprint_size: int | Unset = 3 additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) @@ -36,6 +43,12 @@ def to_dict(self) -> dict[str, Any]: min_height = self.min_height + max_height: float | None | Unset + if isinstance(self.max_height, Unset): + max_height = UNSET + else: + max_height = self.max_height + footprint_size = self.footprint_size field_dict: dict[str, Any] = {} @@ -45,13 +58,15 @@ def to_dict(self) -> dict[str, Any]: field_dict["name"] = name if min_height is not UNSET: field_dict["min_height"] = min_height + if max_height is not UNSET: + field_dict["max_height"] = max_height if footprint_size is not UNSET: field_dict["footprint_size"] = footprint_size return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) name = cast(Literal["lmf"] | Unset, d.pop("name", UNSET)) if name != "lmf" and not isinstance(name, Unset): @@ -59,11 +74,21 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: min_height = d.pop("min_height", UNSET) + def _parse_max_height(data: object) -> float | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(float | None | Unset, data) + + max_height = _parse_max_height(d.pop("max_height", UNSET)) + footprint_size = d.pop("footprint_size", UNSET) stem_isolation_lmf = cls( name=name, min_height=min_height, + max_height=max_height, footprint_size=footprint_size, ) diff --git a/fastfuels_sdk/v2/client_library/models/stem_isolation_vwf.py b/fastfuels_sdk/v2/client_library/models/stem_isolation_vwf.py index b2112c0..183ff9b 100644 --- a/fastfuels_sdk/v2/client_library/models/stem_isolation_vwf.py +++ b/fastfuels_sdk/v2/client_library/models/stem_isolation_vwf.py @@ -4,6 +4,7 @@ from typing import ( Any, Literal, + Self, TypeVar, cast, ) @@ -20,18 +21,24 @@ class StemIsolationVwf: """Parameters for Variable Window Filter (VWF) stem isolation. - Attributes: - name (Literal['vwf'] | Unset): Default: 'vwf'. - min_height (float | Unset): Minimum height threshold (in CHM units) for a treetop. Default: 2.0. - spatial_resolution (float | None | Unset): Spatial resolution of the CHM. If omitted, it will be automatically - inferred from the source grid metadata. - crown_ratio (float | Unset): Multiplier used to dynamically scale the search window based on pixel height. - Default: 0.1. - crown_offset (float | Unset): Constant offset (in meters) added to the dynamic search window. Default: 1.0. + When set, ``max_height`` must be greater than ``min_height``. + + Attributes: + name (Literal['vwf'] | Unset): Default: 'vwf'. + min_height (float | Unset): Minimum height threshold (in meters) for a treetop. Default: 2.0. + max_height (float | None | Unset): Maximum height threshold (in meters) for a treetop. CHM returns taller than + this are treated as artifacts (e.g. LiDAR noise spikes) and excluded before detection. Defaults to 120, above + the tallest known tree; set to null to disable the ceiling. Default: 120.0. + spatial_resolution (float | None | Unset): Spatial resolution of the CHM. If omitted, it will be automatically + inferred from the source grid metadata. + crown_ratio (float | Unset): Multiplier used to dynamically scale the search window based on pixel height. + Default: 0.1. + crown_offset (float | Unset): Constant offset (in meters) added to the dynamic search window. Default: 1.0. """ name: Literal["vwf"] | Unset = "vwf" min_height: float | Unset = 2.0 + max_height: float | None | Unset = 120.0 spatial_resolution: float | None | Unset = UNSET crown_ratio: float | Unset = 0.1 crown_offset: float | Unset = 1.0 @@ -42,6 +49,12 @@ def to_dict(self) -> dict[str, Any]: min_height = self.min_height + max_height: float | None | Unset + if isinstance(self.max_height, Unset): + max_height = UNSET + else: + max_height = self.max_height + spatial_resolution: float | None | Unset if isinstance(self.spatial_resolution, Unset): spatial_resolution = UNSET @@ -59,6 +72,8 @@ def to_dict(self) -> dict[str, Any]: field_dict["name"] = name if min_height is not UNSET: field_dict["min_height"] = min_height + if max_height is not UNSET: + field_dict["max_height"] = max_height if spatial_resolution is not UNSET: field_dict["spatial_resolution"] = spatial_resolution if crown_ratio is not UNSET: @@ -69,7 +84,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) name = cast(Literal["vwf"] | Unset, d.pop("name", UNSET)) if name != "vwf" and not isinstance(name, Unset): @@ -77,6 +92,15 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: min_height = d.pop("min_height", UNSET) + def _parse_max_height(data: object) -> float | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(float | None | Unset, data) + + max_height = _parse_max_height(d.pop("max_height", UNSET)) + def _parse_spatial_resolution(data: object) -> float | None | Unset: if data is None: return data @@ -95,6 +119,7 @@ def _parse_spatial_resolution(data: object) -> float | None | Unset: stem_isolation_vwf = cls( name=name, min_height=min_height, + max_height=max_height, spatial_resolution=spatial_resolution, crown_ratio=crown_ratio, crown_offset=crown_offset, diff --git a/fastfuels_sdk/v2/client_library/models/three_dep_coverage_response.py b/fastfuels_sdk/v2/client_library/models/three_dep_coverage_response.py index b282e25..d1d8175 100644 --- a/fastfuels_sdk/v2/client_library/models/three_dep_coverage_response.py +++ b/fastfuels_sdk/v2/client_library/models/three_dep_coverage_response.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar, cast +from typing import Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -65,7 +65,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) resolution = ThreeDepResolution(d.pop("resolution")) diff --git a/fastfuels_sdk/v2/client_library/models/three_dep_dataset_coverage.py b/fastfuels_sdk/v2/client_library/models/three_dep_dataset_coverage.py new file mode 100644 index 0000000..32997f8 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/three_dep_dataset_coverage.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Self, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="ThreeDepDatasetCoverage") + + +@_attrs_define +class ThreeDepDatasetCoverage: + """One 3DEP acquisition available over a domain, and what it would supply. + + Attributes: + name (str): USGS acquisition name. Pass it in `datasets` on a create request to pin the fetch to this + acquisition. + url (str): Location of the acquisition's Entwine Point Tile index. + contribution_fraction (float): Fraction of the domain this acquisition would supply, from `0.0` to `1.0`. + Acquisitions overlap each other freely, so this is the share left over after the acquisitions listed before it + have taken their part — not the raw overlap. The values are therefore disjoint and sum to `coverage_fraction`. + estimated_density (float): Average point density of the acquisition, in points per square metre, computed over + its full published extent. + estimated_points (int): Approximate number of points this acquisition would contribute. Derived from its density + and the area it covers, so treat it as an order-of-magnitude figure rather than an exact count. + """ + + name: str + url: str + contribution_fraction: float + estimated_density: float + estimated_points: int + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + name = self.name + + url = self.url + + contribution_fraction = self.contribution_fraction + + estimated_density = self.estimated_density + + estimated_points = self.estimated_points + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "name": name, + "url": url, + "contribution_fraction": contribution_fraction, + "estimated_density": estimated_density, + "estimated_points": estimated_points, + } + ) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + name = d.pop("name") + + url = d.pop("url") + + contribution_fraction = d.pop("contribution_fraction") + + estimated_density = d.pop("estimated_density") + + estimated_points = d.pop("estimated_points") + + three_dep_dataset_coverage = cls( + name=name, + url=url, + contribution_fraction=contribution_fraction, + estimated_density=estimated_density, + estimated_points=estimated_points, + ) + + three_dep_dataset_coverage.additional_properties = d + return three_dep_dataset_coverage + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/tree_band.py b/fastfuels_sdk/v2/client_library/models/tree_band.py index 0626250..af1cc95 100644 --- a/fastfuels_sdk/v2/client_library/models/tree_band.py +++ b/fastfuels_sdk/v2/client_library/models/tree_band.py @@ -10,6 +10,7 @@ class TreeBand(str, Enum): BULK_DENSITY_FOLIAGE_LIVE = "bulk_density.foliage.live" FUEL_MOISTURE_DEAD = "fuel_moisture.dead" FUEL_MOISTURE_LIVE = "fuel_moisture.live" + LEAF_AREA_DENSITY = "leaf_area_density" SAVR_FOLIAGE = "savr.foliage" SPCD = "spcd" TREE_ID = "tree_id" diff --git a/fastfuels_sdk/v2/client_library/models/tree_forestry_metrics.py b/fastfuels_sdk/v2/client_library/models/tree_forestry_metrics.py new file mode 100644 index 0000000..4290a87 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/tree_forestry_metrics.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import ( + TYPE_CHECKING, + Any, + Literal, + Self, + TypeVar, + cast, +) + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.fia_species_group_share import FIASpeciesGroupShare + + +T = TypeVar("T", bound="TreeForestryMetrics") + + +@_attrs_define +class TreeForestryMetrics: + """Stand-level forestry scalars for a tree inventory. + + Attributes: + type_ (Literal['tree']): + tree_count (int): Total trees in the inventory. + basal_area_per_area (float | None): Stand basal area divided by domain area. Unit: ft**2/acre. + tree_density (float | None): Trees per unit domain area (TPA). Unit: 1/acre. + quadratic_mean_diameter (float | None): Quadratic mean DBH. Unit: in. + dominant_species_groups (list[FIASpeciesGroupShare] | Unset): The N FIA species groups with the largest basal + area share, sorted descending (N defaults to 5). Only the top N are returned; any remaining groups are omitted, + so the listed shares may sum to less than 1. + """ + + type_: Literal["tree"] + tree_count: int + basal_area_per_area: float | None + tree_density: float | None + quadratic_mean_diameter: float | None + dominant_species_groups: list[FIASpeciesGroupShare] | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + type_ = self.type_ + + tree_count = self.tree_count + + basal_area_per_area: float | None + basal_area_per_area = self.basal_area_per_area + + tree_density: float | None + tree_density = self.tree_density + + quadratic_mean_diameter: float | None + quadratic_mean_diameter = self.quadratic_mean_diameter + + dominant_species_groups: list[dict[str, Any]] | Unset = UNSET + if not isinstance(self.dominant_species_groups, Unset): + dominant_species_groups = [] + for dominant_species_groups_item_data in self.dominant_species_groups: + dominant_species_groups_item = ( + dominant_species_groups_item_data.to_dict() + ) + dominant_species_groups.append(dominant_species_groups_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "type": type_, + "tree_count": tree_count, + "basal_area_per_area": basal_area_per_area, + "tree_density": tree_density, + "quadratic_mean_diameter": quadratic_mean_diameter, + } + ) + if dominant_species_groups is not UNSET: + field_dict["dominant_species_groups"] = dominant_species_groups + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + from ..models.fia_species_group_share import FIASpeciesGroupShare + + d = dict(src_dict) + type_ = cast(Literal["tree"], d.pop("type")) + if type_ != "tree": + raise ValueError(f"type must match const 'tree', got '{type_}'") + + tree_count = d.pop("tree_count") + + def _parse_basal_area_per_area(data: object) -> float | None: + if data is None: + return data + return cast(float | None, data) + + basal_area_per_area = _parse_basal_area_per_area(d.pop("basal_area_per_area")) + + def _parse_tree_density(data: object) -> float | None: + if data is None: + return data + return cast(float | None, data) + + tree_density = _parse_tree_density(d.pop("tree_density")) + + def _parse_quadratic_mean_diameter(data: object) -> float | None: + if data is None: + return data + return cast(float | None, data) + + quadratic_mean_diameter = _parse_quadratic_mean_diameter( + d.pop("quadratic_mean_diameter") + ) + + _dominant_species_groups = d.pop("dominant_species_groups", UNSET) + dominant_species_groups: list[FIASpeciesGroupShare] | Unset = UNSET + if _dominant_species_groups is not UNSET: + dominant_species_groups = [] + for dominant_species_groups_item_data in _dominant_species_groups: + dominant_species_groups_item = FIASpeciesGroupShare.from_dict( + dominant_species_groups_item_data + ) + + dominant_species_groups.append(dominant_species_groups_item) + + tree_forestry_metrics = cls( + type_=type_, + tree_count=tree_count, + basal_area_per_area=basal_area_per_area, + tree_density=tree_density, + quadratic_mean_diameter=quadratic_mean_diameter, + dominant_species_groups=dominant_species_groups, + ) + + tree_forestry_metrics.additional_properties = d + return tree_forestry_metrics + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/uniform_band_input.py b/fastfuels_sdk/v2/client_library/models/uniform_band_input.py index 0dae3da..90313b3 100644 --- a/fastfuels_sdk/v2/client_library/models/uniform_band_input.py +++ b/fastfuels_sdk/v2/client_library/models/uniform_band_input.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar, cast +from typing import Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -45,7 +45,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) key = UniformBand(d.pop("key")) diff --git a/fastfuels_sdk/v2/client_library/models/uniform_moisture_value.py b/fastfuels_sdk/v2/client_library/models/uniform_moisture_value.py index c90a684..cc1ca31 100644 --- a/fastfuels_sdk/v2/client_library/models/uniform_moisture_value.py +++ b/fastfuels_sdk/v2/client_library/models/uniform_moisture_value.py @@ -4,6 +4,7 @@ from typing import ( Any, Literal, + Self, TypeVar, cast, ) @@ -45,7 +46,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) method = cast(Literal["uniform"] | Unset, d.pop("method", UNSET)) if method != "uniform" and not isinstance(method, Unset): diff --git a/fastfuels_sdk/v2/client_library/models/update_application_request.py b/fastfuels_sdk/v2/client_library/models/update_application_request.py index d0f9905..fd59f6c 100644 --- a/fastfuels_sdk/v2/client_library/models/update_application_request.py +++ b/fastfuels_sdk/v2/client_library/models/update_application_request.py @@ -1,10 +1,9 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar, cast +from typing import Any, Self, TypeVar, cast from attrs import define as _attrs_define -from attrs import field as _attrs_field from ..types import UNSET, Unset @@ -22,7 +21,6 @@ class UpdateApplicationRequest: name: None | str | Unset = UNSET description: None | str | Unset = UNSET - additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: name: None | str | Unset @@ -38,7 +36,7 @@ def to_dict(self) -> dict[str, Any]: description = self.description field_dict: dict[str, Any] = {} - field_dict.update(self.additional_properties) + field_dict.update({}) if name is not UNSET: field_dict["name"] = name @@ -48,7 +46,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) def _parse_name(data: object) -> None | str | Unset: @@ -74,21 +72,4 @@ def _parse_description(data: object) -> None | str | Unset: description=description, ) - update_application_request.additional_properties = d return update_application_request - - @property - def additional_keys(self) -> list[str]: - return list(self.additional_properties.keys()) - - def __getitem__(self, key: str) -> Any: - return self.additional_properties[key] - - def __setitem__(self, key: str, value: Any) -> None: - self.additional_properties[key] = value - - def __delitem__(self, key: str) -> None: - del self.additional_properties[key] - - def __contains__(self, key: str) -> bool: - return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/update_domain_request_body.py b/fastfuels_sdk/v2/client_library/models/update_domain_request_body.py index 18f8f8a..64867f9 100644 --- a/fastfuels_sdk/v2/client_library/models/update_domain_request_body.py +++ b/fastfuels_sdk/v2/client_library/models/update_domain_request_body.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -83,7 +83,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: from ..models.domain_style import DomainStyle d = dict(src_dict) diff --git a/fastfuels_sdk/v2/client_library/models/update_export_request_body.py b/fastfuels_sdk/v2/client_library/models/update_export_request_body.py index 2725755..b11952e 100644 --- a/fastfuels_sdk/v2/client_library/models/update_export_request_body.py +++ b/fastfuels_sdk/v2/client_library/models/update_export_request_body.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar, cast +from typing import Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -61,7 +61,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) def _parse_name(data: object) -> None | str | Unset: diff --git a/fastfuels_sdk/v2/client_library/models/update_feature_request_body.py b/fastfuels_sdk/v2/client_library/models/update_feature_request_body.py index aa044ed..042acde 100644 --- a/fastfuels_sdk/v2/client_library/models/update_feature_request_body.py +++ b/fastfuels_sdk/v2/client_library/models/update_feature_request_body.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar, cast +from typing import Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -61,7 +61,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) def _parse_name(data: object) -> None | str | Unset: diff --git a/fastfuels_sdk/v2/client_library/models/update_grid_request_body.py b/fastfuels_sdk/v2/client_library/models/update_grid_request_body.py index 7e26569..219573f 100644 --- a/fastfuels_sdk/v2/client_library/models/update_grid_request_body.py +++ b/fastfuels_sdk/v2/client_library/models/update_grid_request_body.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar, cast +from typing import Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -61,7 +61,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) def _parse_name(data: object) -> None | str | Unset: diff --git a/fastfuels_sdk/v2/client_library/models/update_inventory_request_body.py b/fastfuels_sdk/v2/client_library/models/update_inventory_request_body.py index f8ec94d..f0ef04b 100644 --- a/fastfuels_sdk/v2/client_library/models/update_inventory_request_body.py +++ b/fastfuels_sdk/v2/client_library/models/update_inventory_request_body.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar, cast +from typing import Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -61,7 +61,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) def _parse_name(data: object) -> None | str | Unset: diff --git a/fastfuels_sdk/v2/client_library/models/update_point_cloud_request_body.py b/fastfuels_sdk/v2/client_library/models/update_point_cloud_request_body.py index cce3f14..43723a4 100644 --- a/fastfuels_sdk/v2/client_library/models/update_point_cloud_request_body.py +++ b/fastfuels_sdk/v2/client_library/models/update_point_cloud_request_body.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar, cast +from typing import Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -65,7 +65,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) def _parse_name(data: object) -> None | str | Unset: diff --git a/fastfuels_sdk/v2/client_library/models/upload_band_definition.py b/fastfuels_sdk/v2/client_library/models/upload_band_definition.py index 92508a7..9c0455e 100644 --- a/fastfuels_sdk/v2/client_library/models/upload_band_definition.py +++ b/fastfuels_sdk/v2/client_library/models/upload_band_definition.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar, cast +from typing import Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -53,7 +53,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) key = d.pop("key") diff --git a/fastfuels_sdk/v2/client_library/models/usage.py b/fastfuels_sdk/v2/client_library/models/usage.py new file mode 100644 index 0000000..6f0b0ec --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/usage.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, Self, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.count_usage import CountUsage + from ..models.job_resource_usage import JobResourceUsage + from ..models.usage_lifecycle import UsageLifecycle + + +T = TypeVar("T", bound="Usage") + + +@_attrs_define +class Usage: + """An owner's current usage against their resolved limits. + + Attributes: + grids (JobResourceUsage): Usage for a resource type that produces jobs and stores artifacts. + exports (JobResourceUsage): Usage for a resource type that produces jobs and stores artifacts. + inventories (JobResourceUsage): Usage for a resource type that produces jobs and stores artifacts. + features (JobResourceUsage): Usage for a resource type that produces jobs and stores artifacts. + pointclouds (JobResourceUsage): Usage for a resource type that produces jobs and stores artifacts. + domains (CountUsage): Usage for a count-only resource type (domains, applications, API keys). + applications (CountUsage): Usage for a count-only resource type (domains, applications, API keys). + api_keys (CountUsage): Usage for a count-only resource type (domains, applications, API keys). + lifecycle (UsageLifecycle): Retention policy in effect for the owner's resources. + """ + + grids: JobResourceUsage + exports: JobResourceUsage + inventories: JobResourceUsage + features: JobResourceUsage + pointclouds: JobResourceUsage + domains: CountUsage + applications: CountUsage + api_keys: CountUsage + lifecycle: UsageLifecycle + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + grids = self.grids.to_dict() + + exports = self.exports.to_dict() + + inventories = self.inventories.to_dict() + + features = self.features.to_dict() + + pointclouds = self.pointclouds.to_dict() + + domains = self.domains.to_dict() + + applications = self.applications.to_dict() + + api_keys = self.api_keys.to_dict() + + lifecycle = self.lifecycle.to_dict() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "grids": grids, + "exports": exports, + "inventories": inventories, + "features": features, + "pointclouds": pointclouds, + "domains": domains, + "applications": applications, + "api_keys": api_keys, + "lifecycle": lifecycle, + } + ) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + from ..models.count_usage import CountUsage + from ..models.job_resource_usage import JobResourceUsage + from ..models.usage_lifecycle import UsageLifecycle + + d = dict(src_dict) + grids = JobResourceUsage.from_dict(d.pop("grids")) + + exports = JobResourceUsage.from_dict(d.pop("exports")) + + inventories = JobResourceUsage.from_dict(d.pop("inventories")) + + features = JobResourceUsage.from_dict(d.pop("features")) + + pointclouds = JobResourceUsage.from_dict(d.pop("pointclouds")) + + domains = CountUsage.from_dict(d.pop("domains")) + + applications = CountUsage.from_dict(d.pop("applications")) + + api_keys = CountUsage.from_dict(d.pop("api_keys")) + + lifecycle = UsageLifecycle.from_dict(d.pop("lifecycle")) + + usage = cls( + grids=grids, + exports=exports, + inventories=inventories, + features=features, + pointclouds=pointclouds, + domains=domains, + applications=applications, + api_keys=api_keys, + lifecycle=lifecycle, + ) + + usage.additional_properties = d + return usage + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/usage_count.py b/fastfuels_sdk/v2/client_library/models/usage_count.py new file mode 100644 index 0000000..f36336a --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/usage_count.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Self, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="UsageCount") + + +@_attrs_define +class UsageCount: + """A count-based usage/limit pair (resources or concurrent jobs). + + Attributes: + usage (int): Current count. + limit (int): The limit this count is measured against. + """ + + usage: int + limit: int + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + usage = self.usage + + limit = self.limit + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "usage": usage, + "limit": limit, + } + ) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + usage = d.pop("usage") + + limit = d.pop("limit") + + usage_count = cls( + usage=usage, + limit=limit, + ) + + usage_count.additional_properties = d + return usage_count + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/usage_lifecycle.py b/fastfuels_sdk/v2/client_library/models/usage_lifecycle.py new file mode 100644 index 0000000..10c08ee --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/usage_lifecycle.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import datetime +from collections.abc import Mapping +from typing import Any, Self, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="UsageLifecycle") + + +@_attrs_define +class UsageLifecycle: + """Retention policy in effect for the owner's resources. + + Attributes: + resource_ttl_days (int | None): Days a resource is retained after last modification; null never expires. + failed_resource_ttl_days (int | None): Shorter retention for failed resources; null never expires. + next_expiry_on (datetime.datetime | None | Unset): When the owner's next resource is scheduled to expire. + Populated once retention enforcement ships. + """ + + resource_ttl_days: int | None + failed_resource_ttl_days: int | None + next_expiry_on: datetime.datetime | None | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + resource_ttl_days: int | None + resource_ttl_days = self.resource_ttl_days + + failed_resource_ttl_days: int | None + failed_resource_ttl_days = self.failed_resource_ttl_days + + next_expiry_on: None | str | Unset + if isinstance(self.next_expiry_on, Unset): + next_expiry_on = UNSET + elif isinstance(self.next_expiry_on, datetime.datetime): + next_expiry_on = self.next_expiry_on.isoformat() + else: + next_expiry_on = self.next_expiry_on + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "resource_ttl_days": resource_ttl_days, + "failed_resource_ttl_days": failed_resource_ttl_days, + } + ) + if next_expiry_on is not UNSET: + field_dict["next_expiry_on"] = next_expiry_on + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + + def _parse_resource_ttl_days(data: object) -> int | None: + if data is None: + return data + return cast(int | None, data) + + resource_ttl_days = _parse_resource_ttl_days(d.pop("resource_ttl_days")) + + def _parse_failed_resource_ttl_days(data: object) -> int | None: + if data is None: + return data + return cast(int | None, data) + + failed_resource_ttl_days = _parse_failed_resource_ttl_days( + d.pop("failed_resource_ttl_days") + ) + + def _parse_next_expiry_on(data: object) -> datetime.datetime | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, str): + raise TypeError() + next_expiry_on_type_0 = datetime.datetime.fromisoformat(data) + + return next_expiry_on_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(datetime.datetime | None | Unset, data) + + next_expiry_on = _parse_next_expiry_on(d.pop("next_expiry_on", UNSET)) + + usage_lifecycle = cls( + resource_ttl_days=resource_ttl_days, + failed_resource_ttl_days=failed_resource_ttl_days, + next_expiry_on=next_expiry_on, + ) + + usage_lifecycle.additional_properties = d + return usage_lifecycle + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/usage_storage.py b/fastfuels_sdk/v2/client_library/models/usage_storage.py new file mode 100644 index 0000000..b7bbc79 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/usage_storage.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Self, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="UsageStorage") + + +@_attrs_define +class UsageStorage: + """A storage usage/limit pair, in bytes. + + Attributes: + usage_bytes (int): Summed GCS artifact bytes in use. + limit_bytes (int): The storage limit, in bytes. + """ + + usage_bytes: int + limit_bytes: int + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + usage_bytes = self.usage_bytes + + limit_bytes = self.limit_bytes + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "usage_bytes": usage_bytes, + "limit_bytes": limit_bytes, + } + ) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + usage_bytes = d.pop("usage_bytes") + + limit_bytes = d.pop("limit_bytes") + + usage_storage = cls( + usage_bytes=usage_bytes, + limit_bytes=limit_bytes, + ) + + usage_storage.additional_properties = d + return usage_storage + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/user_me_response.py b/fastfuels_sdk/v2/client_library/models/user_me_response.py new file mode 100644 index 0000000..e879167 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/user_me_response.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, Self, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.user_me_response_kind import UserMeResponseKind + +if TYPE_CHECKING: + from ..models.quotas import Quotas + + +T = TypeVar("T", bound="UserMeResponse") + + +@_attrs_define +class UserMeResponse: + """The authenticated owner's identity and resolved quota configuration. + + Attributes: + id (str): The authenticated owner's unique ID. + kind (UserMeResponseKind): Whether the credential authenticated a user or an application. + tier (str): The quota tier in effect for this owner. + quotas (Quotas): Usage limits for an owner. Field defaults are the standard tier. + """ + + id: str + kind: UserMeResponseKind + tier: str + quotas: Quotas + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + id = self.id + + kind = self.kind.value + + tier = self.tier + + quotas = self.quotas.to_dict() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "id": id, + "kind": kind, + "tier": tier, + "quotas": quotas, + } + ) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + from ..models.quotas import Quotas + + d = dict(src_dict) + id = d.pop("id") + + kind = UserMeResponseKind(d.pop("kind")) + + tier = d.pop("tier") + + quotas = Quotas.from_dict(d.pop("quotas")) + + user_me_response = cls( + id=id, + kind=kind, + tier=tier, + quotas=quotas, + ) + + user_me_response.additional_properties = d + return user_me_response + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/fastfuels_sdk/v2/client_library/models/user_me_response_kind.py b/fastfuels_sdk/v2/client_library/models/user_me_response_kind.py new file mode 100644 index 0000000..a2824d3 --- /dev/null +++ b/fastfuels_sdk/v2/client_library/models/user_me_response_kind.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class UserMeResponseKind(str, Enum): + APPLICATION = "application" + USER = "user" + + def __str__(self) -> str: + return str(self.value) diff --git a/fastfuels_sdk/v2/client_library/models/validation_error.py b/fastfuels_sdk/v2/client_library/models/validation_error.py index cb0708f..7e62fb2 100644 --- a/fastfuels_sdk/v2/client_library/models/validation_error.py +++ b/fastfuels_sdk/v2/client_library/models/validation_error.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any, TypeVar, cast +from typing import Any, Self, TypeVar, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -47,7 +47,7 @@ def to_dict(self) -> dict[str, Any]: return field_dict @classmethod - def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: d = dict(src_dict) loc = [] _loc = d.pop("loc") diff --git a/fastfuels_sdk/v2/generate_client.sh b/fastfuels_sdk/v2/generate_client.sh index 819f069..e40dd04 100644 --- a/fastfuels_sdk/v2/generate_client.sh +++ b/fastfuels_sdk/v2/generate_client.sh @@ -18,16 +18,22 @@ PATCHED=$(mktemp /tmp/v2_openapi_patched.XXXXXX.json) curl -s "$URL/openapi.json" > "$SPEC" -# The spec contains two schemas titled "Feature": the FastFuels feature -# resource and geojson_pydantic's GeoJSON Feature (auto-namespaced by -# FastAPI as geojson_pydantic__features__Feature, but with title "Feature"). -# openapi-python-client names classes from titles and refuses duplicates, -# so re-title the GeoJSON one. Long-term fix: re-title the model in the -# FastFuels-API-v2 repo so the spec has no collision. +# openapi-python-client names classes from schema titles and refuses +# duplicates, dropping the endpoints that reference the loser. The spec has +# two such collisions, so re-title one side of each. Long-term fix: re-title +# the models in the FastFuels-API-v2 repo so the spec has no collision. +# +# "Feature" — the FastFuels feature resource vs geojson_pydantic's GeoJSON +# Feature (auto-namespaced by FastAPI, but still titled "Feature"). +# +# "ThreeDepCoverageResponse" — the point cloud 3DEP pre-flight check +# (point count / budget) vs the topography one (tiles / resolution). python3 - "$SPEC" "$PATCHED" <<'EOF' import json, sys spec = json.load(open(sys.argv[1])) -spec["components"]["schemas"]["geojson_pydantic__features__Feature"]["title"] = "GeoJsonFeature" +schemas = spec["components"]["schemas"] +schemas["geojson_pydantic__features__Feature"]["title"] = "GeoJsonFeature" +schemas["ThreeDepCoverageResponse"]["title"] = "PointCloudThreeDepCoverageResponse" json.dump(spec, open(sys.argv[2], "w")) EOF diff --git a/fastfuels_sdk/v2/inventories.py b/fastfuels_sdk/v2/inventories.py index 132ed69..7f1f4d4 100644 --- a/fastfuels_sdk/v2/inventories.py +++ b/fastfuels_sdk/v2/inventories.py @@ -24,7 +24,7 @@ delete_inventory, duplicate_inventory as duplicate_inventory_endpoint, get_inventory as get_inventory_endpoint, - get_inventory_data, + get_inventory_data_json, get_inventory_data_metadata, list_inventories as list_inventories_endpoint, list_inventories_cross_domain, @@ -679,7 +679,7 @@ def get_data_partition( >>> partition.num_rows 1392 """ - response = get_inventory_data.sync_detailed( + response = get_inventory_data_json.sync_detailed( self.domain_id, self.id, partition_index, diff --git a/fastfuels_sdk/v2/v2_api_design.md b/fastfuels_sdk/v2/v2_api_design.md index 1431f18..0f831f1 100644 --- a/fastfuels_sdk/v2/v2_api_design.md +++ b/fastfuels_sdk/v2/v2_api_design.md @@ -255,3 +255,94 @@ later merges). Each new resource/endpoint kept to the settled rules above. - **Uploads** — one shared `_uploads.put_upload(spec, path)` echoes the server's signed `spec.headers` verbatim; replaced the two divergent `_put_upload` copies (grids had been missing the GCS content-length-range). + +## Post-regen backlog (2026-08-04) + +Client re-synced against the live spec: 94 operations, 236 schemas. 19 +operations have no SDK surface. Ordered by what unblocks a user workflow. + +### Breaking (landed with the regen) + +- `get_inventory_data` split into `get_inventory_data_json` (adds + `json_orientation`) and `get_inventory_data_csv`; `InventoryDataFormat` is + gone. `Inventory.get_data_partition` now calls the JSON variant — behavior + unchanged. The CSV variant is unused (see *data out* below). +- Second spec title collision — the point cloud and topography 3DEP + pre-flight responses are both titled `ThreeDepCoverageResponse`, which + silently dropped the point cloud coverage endpoint from the client. + `generate_client.sh` now re-titles the point cloud one to + `PointCloudThreeDepCoverageResponse`, alongside the `Feature` patch. + Same long-term fix: re-title in FastFuels-API-v2 so the spec is clean. + +### Quotas — cross-cutting, affects every creator + +41 of 94 operations can now return **429** with a structured +`QuotaExceededDetail` (`quota`, `current`, `limit`, `window_reset_on`). +`exceptions.py` has no 429 mapping, so today it degrades to a bare +`ApiException`. Needs a `QuotaExceededException` carrying those fields — +`window_reset_on` is what a caller retries on. + +- `ff.get_quotas()` / `ff.get_usage()` ← `users/me`, `users/me/usage` + (`Quotas`, `Usage` per resource type: active/total counts, storage bytes, + weekly dispatch windows, TTL policy). Top-level functions: owner-scoped, + no held resource. + +### New resource sources + +- **3DEP point clouds** — `ff.point_clouds.create_point_cloud_from_3dep( + domain, datasets=)` + `ff.point_clouds.check_3dep_coverage(domain)` + (returns `available`, `coverage_fraction`, `estimated_point_count`, + `point_budget`, `exceeds_point_budget`, per-acquisition `datasets`). + Mirrors the existing `grids.check_3dep_coverage` pre-flight pattern; this + is the first non-upload point cloud source. +- **Point cloud → CHM** — `ff.grids.create_canopy_height_grid_from_point_cloud( + point_cloud, ...)`. First consumer of a point cloud, which closes the + 3DEP → CHM → tree inventory chain entirely inside the SDK. + +### New grid creators + +- **DUET** — `ff.grids.create_surface_fuel_grid_from_duet(source_grid, + years_since_burn=, wind_direction=, wind_variability=, bands=, + calibration=)`. Needs a `duet_calibration(...)` builder in the + `modifications.py`/`treatments.py` family: `DuetCalibration` nests + fuel_load/fuel_depth/fuel_moisture → per-fuel-type + (grass/coniferous/deciduous/litter/all) targets in three shapes + (constant / max-min / mean-sd). +- **FBFM13** — `create_fuel_model_grid_from_landfire_fbfm13` (version + 2023/2024, `remove_non_burnable=`) and + `create_fuel_grid_from_fbfm13_lookup` (9 bands), matching the FBFM40 pair. +- **FCCS lookup** — `create_fuel_grid_from_fccs_lookup` (12 bands incl. duff + and live components). The FCCS *source* creator exists; the lookup that + turns it into fuel parameters does not. +- **Compose** — `create_grid_from_compose(inputs, select=, compute=)`: grid + algebra over aliased input bands (`add`/`subtract`/`multiply`/`divide`/ + `min`/`max`/`average`, conditional `select` with `else_`). The largest + design question in this batch — it is a small DSL, not a creator with + kwargs, and it needs builders to be usable from Python. + +### New export + +- **Landscape (LCP)** — `ff.exports.create_landscape_export(...)`: 8-band + FlamMap/IFTDSS/WFDSS GeoTIFF assembled from 8 named + `LandscapeFieldSource` (grid_id + band) plus `fire_behavior_fuel_model` + (fbfm13/fbfm40) and alignment. Assembled-from-many → function, same shape + as `create_quicfire_export`. + +### Records and data out + +- `Inventory.forestry_metrics` — new `TreeForestryMetrics` on the inventory + record (tree_count, basal area/acre, TPA, QMD, dominant FIA species + groups). Read-only accessor; the parallel of `Grid.band_summary`. +- `Inventory.column_summary(column)` — `Column.summary` is new + (categorical/continuous), exactly mirroring `Band.summary`; reuse the + `band_summary` shape. +- `Inventory.to_dataframe` should fetch CSV partitions and `pd.read_csv` + them instead of rebuilding frames from JSON row lists. +- `get_chunk_metadata` / `get_grid_data_json` stay unwrapped — the binary + chunk path already covers `to_numpy`/`to_xarray`. + +### Account management (unwrapped, lowest priority) + +`applications` (6) and `keys` (4) are entirely unwrapped. These are console +concerns; wrap only if the SDK is meant to provision its own credentials. +`Application` gained `tier`/`quota_overrides`. From e32bd8ed675468f74e839149792d9736927f3c03 Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Tue, 4 Aug 2026 09:18:50 -0600 Subject: [PATCH 52/71] Raise structured quota exceptions --- docs/v2/SUMMARY.md | 1 + docs/v2/guides/quotas.md | 41 ++++++++++++ fastfuels_sdk/v2/exceptions.py | 111 ++++++++++++++++++++++++++++++++- tests/v2/test_exceptions.py | 83 ++++++++++++++++++++++++ 4 files changed, 233 insertions(+), 3 deletions(-) create mode 100644 docs/v2/guides/quotas.md create mode 100644 tests/v2/test_exceptions.py diff --git a/docs/v2/SUMMARY.md b/docs/v2/SUMMARY.md index d4ada31..fe47d95 100644 --- a/docs/v2/SUMMARY.md +++ b/docs/v2/SUMMARY.md @@ -12,4 +12,5 @@ * [Modify and treat inventories](guides/modify-treat-inventories.md) * [Point clouds](guides/point-clouds.md) * [Exports](guides/exports.md) + * [Quota rejections](guides/quotas.md) * [Reference](reference.md) diff --git a/docs/v2/guides/quotas.md b/docs/v2/guides/quotas.md new file mode 100644 index 0000000..2ba8ff4 --- /dev/null +++ b/docs/v2/guides/quotas.md @@ -0,0 +1,41 @@ +# How to Handle Quota Rejections + +!!! warning "Beta" + The v2 SDK targets the FastFuels v2 API and is under active + development. The v1 SDK remains the default — import v2 explicitly + from `fastfuels_sdk.v2`. + +## Prerequisites + +- The FastFuels SDK installed: `pip install fastfuels-sdk` +- A FastFuels API key in the `FASTFUELS_API_KEY` environment variable + +Use this guide to handle quota rejections from SDK resource creators. + +## Handle a quota rejection + +Catch `QuotaExceededException` around any operation that creates or re-derives +a resource: + +```python +import fastfuels_sdk.v2 as ff +from fastfuels_sdk.v2.exceptions import QuotaExceededException + +try: + grid = ff.grids.create_topography_grid_from_3dep( + domain, + output_resolution_m=30, + ) +except QuotaExceededException as exc: + print(exc.quota, exc.current, exc.limit) + print(exc.message) + if exc.retry_after is not None: + print(f"Retry in {exc.retry_after} seconds") +``` + +For an active-job limit, `retry_after` contains the number of seconds the API +recommends waiting before another attempt. + +For a weekly dispatch limit, `window_reset_on` contains the reset time. Count +and storage limits provide neither retry field; delete unneeded resources or +request a higher limit before retrying. diff --git a/fastfuels_sdk/v2/exceptions.py b/fastfuels_sdk/v2/exceptions.py index 25dbed0..6084c83 100644 --- a/fastfuels_sdk/v2/exceptions.py +++ b/fastfuels_sdk/v2/exceptions.py @@ -12,8 +12,11 @@ from http import HTTPStatus from typing import Any, NoReturn -from fastfuels_sdk.v2.client_library.models import HTTPValidationError -from fastfuels_sdk.v2.client_library.types import Response +from fastfuels_sdk.v2.client_library.models import ( + HTTPValidationError, + QuotaExceededDetail, +) +from fastfuels_sdk.v2.client_library.types import UNSET, Response # Implementation note: the generated client (openapi-python-client) does # not raise per-status exceptions — documented error responses are @@ -31,6 +34,7 @@ # so wrap it to tolerate undocumented body shapes; the actual detail is # re-read from ``response.content`` in ``raise_for_response()``. _generated_from_dict = HTTPValidationError.from_dict.__func__ +_generated_quota_from_dict = QuotaExceededDetail.from_dict.__func__ def _tolerant_from_dict(cls, src_dict): @@ -46,6 +50,16 @@ def _tolerant_from_dict(cls, src_dict): HTTPValidationError.from_dict = classmethod(_tolerant_from_dict) +def _quota_from_dict(cls, src_dict): + """Unwrap FastAPI's ``detail`` envelope before generated parsing.""" + if isinstance(src_dict, Mapping) and isinstance(src_dict.get("detail"), Mapping): + src_dict = src_dict["detail"] + return _generated_quota_from_dict(cls, src_dict) + + +QuotaExceededDetail.from_dict = classmethod(_quota_from_dict) + + class ApiException(Exception): """Base exception for FastFuels v2 API errors. @@ -92,6 +106,57 @@ class UnprocessableEntityException(ApiException): """ +class QuotaExceededException(ApiException): + """Raised when the API returns 429 Too Many Requests. + + Attributes + ---------- + quota : str or None + The quota field that was exceeded. + current : int or None + The owner's current usage for the quota. + limit : int or None + The quota limit that was reached. + window_reset_on : datetime.datetime or None + When a windowed quota resets, if applicable. + message : str or None + A human-readable explanation and suggested next steps. + reason : str or None + The machine-readable error reason. + retry_after : int or None + Seconds the API recommends waiting before retrying. Present only for + active-job quota rejections. + """ + + def __init__( + self, + status_code: int, + detail: Any = None, + retry_after: int | None = None, + ): + self.quota = None + self.current = None + self.limit = None + self.window_reset_on = None + self.message = None + self.reason = None + self.retry_after = retry_after + + if isinstance(detail, QuotaExceededDetail): + self.quota = detail.quota + self.current = detail.current + self.limit = detail.limit + if detail.window_reset_on is not UNSET: + self.window_reset_on = detail.window_reset_on + self.message = detail.message + if detail.reason is not UNSET: + self.reason = detail.reason + + self.status_code = status_code + self.detail = detail + Exception.__init__(self, f"({status_code}) {self.message or detail}") + + class ServiceException(ApiException): """Raised when the API returns a 5xx server error.""" @@ -101,6 +166,7 @@ class ServiceException(ApiException): HTTPStatus.UNAUTHORIZED: UnauthorizedException, HTTPStatus.FORBIDDEN: ForbiddenException, HTTPStatus.NOT_FOUND: NotFoundException, + HTTPStatus.TOO_MANY_REQUESTS: QuotaExceededException, HTTPStatus.UNPROCESSABLE_ENTITY: UnprocessableEntityException, } @@ -116,6 +182,32 @@ def _extract_detail(response: Response) -> Any: ) +def _extract_quota_detail(response: Response) -> Any: + """Return a typed quota detail, falling back to the generic detail.""" + if isinstance(response.parsed, QuotaExceededDetail): + return response.parsed + + try: + payload = json.loads(response.content) + if isinstance(payload, Mapping) and isinstance(payload.get("detail"), Mapping): + payload = payload["detail"] + if isinstance(payload, Mapping): + return QuotaExceededDetail.from_dict(payload) + except (json.JSONDecodeError, KeyError, TypeError, ValueError): + pass + + return _extract_detail(response) + + +def _extract_retry_after(response: Response) -> int | None: + """Return the delta-seconds value from ``Retry-After``, when present.""" + value = response.headers.get("Retry-After") + try: + return int(value) if value is not None else None + except ValueError: + return None + + def raise_for_response(response: Response) -> NoReturn: """Translate an error ``Response`` into a typed SDK exception. @@ -134,6 +226,8 @@ def raise_for_response(response: Response) -> NoReturn: If the response status is 403. NotFoundException If the response status is 404. + QuotaExceededException + If the response status is 429. UnprocessableEntityException If the response status is 422. ServiceException @@ -145,7 +239,18 @@ def raise_for_response(response: Response) -> NoReturn: exception_class = _STATUS_TO_EXCEPTION.get(response.status_code) if exception_class is None: exception_class = ServiceException if status_code >= 500 else ApiException - raise exception_class(status_code, _extract_detail(response)) + detail = ( + _extract_quota_detail(response) + if response.status_code == HTTPStatus.TOO_MANY_REQUESTS + else _extract_detail(response) + ) + if exception_class is QuotaExceededException: + raise exception_class( + status_code, + detail, + retry_after=_extract_retry_after(response), + ) + raise exception_class(status_code, detail) def expect(response: Response, expected_status: HTTPStatus = HTTPStatus.OK) -> Any: diff --git a/tests/v2/test_exceptions.py b/tests/v2/test_exceptions.py new file mode 100644 index 0000000..5f1c217 --- /dev/null +++ b/tests/v2/test_exceptions.py @@ -0,0 +1,83 @@ +"""Tests for v2 API error translation.""" + +import datetime +import json +from http import HTTPStatus + +import pytest + +from fastfuels_sdk.v2.client_library.models import QuotaExceededDetail +from fastfuels_sdk.v2.client_library.types import Response +from fastfuels_sdk.v2.exceptions import QuotaExceededException, expect + + +def _quota_response(detail, *, parsed=True, retry_after=None): + headers = {} + if retry_after is not None: + headers["Retry-After"] = str(retry_after) + return Response( + status_code=HTTPStatus.TOO_MANY_REQUESTS, + content=json.dumps({"detail": detail.to_dict()}).encode(), + headers=headers, + parsed=detail if parsed else None, + ) + + +def test_quota_exceeded_exception_exposes_structured_detail(): + detail = QuotaExceededDetail( + quota="max_active_grids", + current=25, + limit=25, + message="Too many active grid jobs.", + ) + + with pytest.raises(QuotaExceededException) as exc_info: + expect(_quota_response(detail, retry_after=60), HTTPStatus.CREATED) + + error = exc_info.value + assert error.status_code == HTTPStatus.TOO_MANY_REQUESTS + assert error.detail is detail + assert error.quota == "max_active_grids" + assert error.current == 25 + assert error.limit == 25 + assert error.window_reset_on is None + assert error.message == "Too many active grid jobs." + assert error.reason == "QUOTA_EXCEEDED" + assert error.retry_after == 60 + assert str(error) == "(429) Too many active grid jobs." + + +def test_quota_exceeded_exception_parses_raw_response_content(): + reset = datetime.datetime(2026, 8, 10, tzinfo=datetime.timezone.utc) + detail = QuotaExceededDetail( + quota="max_weekly_grid_dispatches", + current=500, + limit=500, + window_reset_on=reset, + message="Weekly grid dispatch quota reached.", + ) + + with pytest.raises(QuotaExceededException) as exc_info: + expect(_quota_response(detail, parsed=False), HTTPStatus.CREATED) + + error = exc_info.value + assert error.quota == "max_weekly_grid_dispatches" + assert error.window_reset_on == reset + assert error.message == "Weekly grid dispatch quota reached." + assert error.retry_after is None + + +def test_generated_quota_parser_accepts_fastapi_detail_envelope(): + detail = QuotaExceededDetail.from_dict( + { + "detail": { + "quota": "max_grids", + "current": 1000, + "limit": 1000, + "message": "Grid quota reached.", + } + } + ) + + assert detail.quota == "max_grids" + assert detail.message == "Grid quota reached." From d5bc4e304b05a6de22e0511ddc4b650fcb8d7a0c Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Tue, 4 Aug 2026 09:20:52 -0600 Subject: [PATCH 53/71] Expose owner quotas and usage --- docs/v2/SUMMARY.md | 2 +- docs/v2/guides/quotas.md | 46 +++++++++++++++++++++- fastfuels_sdk/v2/__init__.py | 5 ++- fastfuels_sdk/v2/api.py | 42 ++++++++++++++++++++ tests/v2/test_api.py | 76 ++++++++++++++++++++++++++++++++++++ 5 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 tests/v2/test_api.py diff --git a/docs/v2/SUMMARY.md b/docs/v2/SUMMARY.md index fe47d95..2e92798 100644 --- a/docs/v2/SUMMARY.md +++ b/docs/v2/SUMMARY.md @@ -12,5 +12,5 @@ * [Modify and treat inventories](guides/modify-treat-inventories.md) * [Point clouds](guides/point-clouds.md) * [Exports](guides/exports.md) - * [Quota rejections](guides/quotas.md) + * [Quotas and usage](guides/quotas.md) * [Reference](reference.md) diff --git a/docs/v2/guides/quotas.md b/docs/v2/guides/quotas.md index 2ba8ff4..9d57642 100644 --- a/docs/v2/guides/quotas.md +++ b/docs/v2/guides/quotas.md @@ -1,4 +1,4 @@ -# How to Handle Quota Rejections +# How to Check Usage and Handle Quota Rejections !!! warning "Beta" The v2 SDK targets the FastFuels v2 API and is under active @@ -10,7 +10,49 @@ - The FastFuels SDK installed: `pip install fastfuels-sdk` - A FastFuels API key in the `FASTFUELS_API_KEY` environment variable -Use this guide to handle quota rejections from SDK resource creators. +Use this guide to inspect the current owner's limits and usage and to handle +quota rejections from SDK resource creators. + +## Check your limits + +Call `get_quotas` to retrieve the limits resolved for the owner authenticated +by the current API key: + +```python +import fastfuels_sdk.v2 as ff + +quotas = ff.get_quotas() + +print(quotas.max_active_grids) +print(quotas.max_weekly_grid_dispatches) +print(quotas.max_grid_storage_bytes) +``` + +## Check your current usage + +Call `get_usage` to compare current usage with the corresponding limits: + +```python +usage = ff.get_usage() + +print(usage.grids.active.usage, usage.grids.active.limit) +print(usage.grids.total.usage, usage.grids.total.limit) +print(usage.grids.storage.usage_bytes, usage.grids.storage.limit_bytes) +``` + +Count-only resources are available through `usage.domains`, +`usage.applications`, and `usage.api_keys`. The active, total, and storage +fields are also available for exports, inventories, features, and point +clouds. + +Inspect `usage.lifecycle` for the resource-retention policy currently applied +to the owner: + +```python +print(usage.lifecycle.resource_ttl_days) +print(usage.lifecycle.failed_resource_ttl_days) +print(usage.lifecycle.next_expiry_on) +``` ## Handle a quota rejection diff --git a/fastfuels_sdk/v2/__init__.py b/fastfuels_sdk/v2/__init__.py index 5a3fdbc..1a0e58b 100644 --- a/fastfuels_sdk/v2/__init__.py +++ b/fastfuels_sdk/v2/__init__.py @@ -20,7 +20,7 @@ from fastfuels_sdk.v2 import exports, features, grids, inventories, point_clouds from fastfuels_sdk.v2._jobs import JobFailedError, wait_all -from fastfuels_sdk.v2.api import set_api_key +from fastfuels_sdk.v2.api import get_quotas, get_usage, set_api_key from fastfuels_sdk.v2.domains import Domain, list_domains, reproject_geojson from fastfuels_sdk.v2.exports import Export, get_export, list_exports from fastfuels_sdk.v2.features import Feature, get_feature, list_features @@ -49,6 +49,9 @@ "point_clouds", # Configuration "set_api_key", + # Quotas and usage + "get_quotas", + "get_usage", # Records "Domain", "Export", diff --git a/fastfuels_sdk/v2/api.py b/fastfuels_sdk/v2/api.py index 1cbaedc..70c9f90 100644 --- a/fastfuels_sdk/v2/api.py +++ b/fastfuels_sdk/v2/api.py @@ -13,7 +13,10 @@ # deployment for now — a stable domain should front it before GA (tracked in # #176); until then FASTFUELS_API_V2_URL overrides it without an SDK upgrade. from fastfuels_sdk.v2.client_library.base_url import DEFAULT_BASE_URL +from fastfuels_sdk.v2.client_library.api.users import get_me, get_me_usage from fastfuels_sdk.v2.client_library.client import AuthenticatedClient +from fastfuels_sdk.v2.client_library.models import Quotas, Usage +from fastfuels_sdk.v2.exceptions import expect _client: Optional[AuthenticatedClient] = None @@ -96,3 +99,42 @@ def ensure_client() -> AuthenticatedClient: "before making API calls" ) return client + + +def get_quotas() -> Quotas: + """Return the authenticated owner's resolved quotas. + + Returns + ------- + Quotas + Count, concurrency, storage, dispatch, and retention limits for the + owner authenticated by the current API key. + + Raises + ------ + RuntimeError + If no API key is configured. + ApiException + If the API request fails. + """ + owner = expect(get_me.sync_detailed(client=ensure_client())) + return owner.quotas + + +def get_usage() -> Usage: + """Return the authenticated owner's current usage and limits. + + Returns + ------- + Usage + Usage for job resources, count-only resources, storage, and the + owner's resource-retention policy. + + Raises + ------ + RuntimeError + If no API key is configured. + ApiException + If the API request fails. + """ + return expect(get_me_usage.sync_detailed(client=ensure_client())) diff --git a/tests/v2/test_api.py b/tests/v2/test_api.py new file mode 100644 index 0000000..ac38a3a --- /dev/null +++ b/tests/v2/test_api.py @@ -0,0 +1,76 @@ +"""Tests for v2 client configuration and owner-scoped API helpers.""" + +from http import HTTPStatus + +import fastfuels_sdk.v2 as ff +import fastfuels_sdk.v2.api as api +from fastfuels_sdk.v2.client_library.models import ( + CountUsage, + JobResourceUsage, + Quotas, + Usage, + UsageCount, + UsageLifecycle, + UsageStorage, + UserMeResponse, + UserMeResponseKind, +) +from fastfuels_sdk.v2.client_library.types import Response + + +def _response(parsed): + return Response( + status_code=HTTPStatus.OK, + content=b"", + headers={}, + parsed=parsed, + ) + + +def test_get_quotas_returns_authenticated_owner_quotas(monkeypatch): + client = object() + quotas = Quotas(max_active_grids=3) + owner = UserMeResponse( + id="owner-id", + kind=UserMeResponseKind.USER, + tier="standard", + quotas=quotas, + ) + monkeypatch.setattr(api, "ensure_client", lambda: client) + monkeypatch.setattr( + api.get_me, + "sync_detailed", + lambda *, client: _response(owner), + ) + + assert ff.get_quotas() is quotas + + +def test_get_usage_returns_authenticated_owner_usage(monkeypatch): + client = object() + count = UsageCount(usage=1, limit=10) + storage = UsageStorage(usage_bytes=1024, limit_bytes=2048) + job_usage = JobResourceUsage(active=count, total=count, storage=storage) + count_usage = CountUsage(total=count) + usage = Usage( + grids=job_usage, + exports=job_usage, + inventories=job_usage, + features=job_usage, + pointclouds=job_usage, + domains=count_usage, + applications=count_usage, + api_keys=count_usage, + lifecycle=UsageLifecycle( + resource_ttl_days=180, + failed_resource_ttl_days=14, + ), + ) + monkeypatch.setattr(api, "ensure_client", lambda: client) + monkeypatch.setattr( + api.get_me_usage, + "sync_detailed", + lambda *, client: _response(usage), + ) + + assert ff.get_usage() is usage From 44322a482084bdeb582e372bcadde684c78ba594 Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Tue, 4 Aug 2026 09:23:54 -0600 Subject: [PATCH 54/71] Add USGS 3DEP point clouds --- docs/v2/guides/point-clouds.md | 52 ++++++++++++++++---- fastfuels_sdk/v2/point_clouds.py | 82 +++++++++++++++++++++++++++++--- tests/v2/test_point_clouds.py | 74 ++++++++++++++++++++++++++++ 3 files changed, 194 insertions(+), 14 deletions(-) diff --git a/docs/v2/guides/point-clouds.md b/docs/v2/guides/point-clouds.md index a8b4420..f356cb8 100644 --- a/docs/v2/guides/point-clouds.md +++ b/docs/v2/guides/point-clouds.md @@ -5,15 +5,15 @@ development. The v1 SDK remains the default — import v2 explicitly from `fastfuels_sdk.v2`. -A point cloud is a 3D LiDAR dataset within a domain, uploaded from your own -airborne (ALS) or terrestrial (TLS) scan. For what point clouds *are* and how -they fit the platform, see the +A point cloud is a 3D LiDAR dataset within a domain, fetched from USGS 3DEP or +uploaded from your own airborne (ALS) or terrestrial (TLS) scan. For what +point clouds *are* and how they fit the platform, see the [FastFuels documentation](https://docs.fastfuels.silvxlabs.com); this guide -covers uploading and managing them from Python. +covers creating and managing them from Python. -The v2 surface is functional: you **create** a point cloud by uploading a file -to a domain, and everything you do with one you already hold is a **method** on -it. +The v2 surface is functional: you **create** a point cloud from a domain and +data source, and everything you do with one you already hold is a **method** +on it. ```python import fastfuels_sdk.v2 as ff @@ -30,7 +30,43 @@ pc.wait() - A FastFuels API key in the `FASTFUELS_API_KEY` environment variable - An existing [domain](domains.md) — the first argument is a `Domain` (or a bare domain id string) -- A local LiDAR file (`.las`/`.laz`) +- A local LiDAR file (`.las`/`.laz`) when uploading your own scan + +## Create a point cloud from USGS 3DEP + +Check coverage and the per-fetch point budget before starting the background +job: + +```python +coverage = ff.point_clouds.check_3dep_coverage(domain) + +if not coverage.available: + raise RuntimeError("No 3DEP LiDAR covers this domain") +if coverage.exceeds_point_budget: + raise RuntimeError("Shrink the domain before fetching 3DEP LiDAR") +``` + +Create the point cloud with automatic acquisition selection: + +```python +pc = ff.point_clouds.create_point_cloud_from_3dep( + domain, + name="USGS 3DEP", +) +pc.wait() +``` + +To pin specific acquisitions, pass names returned by the coverage check in +priority order: + +```python +pc = ff.point_clouds.create_point_cloud_from_3dep( + domain, + datasets=[coverage.datasets[0].name], +) +``` + +The returned point cloud is always airborne (`type_ == "als"`). ## Upload a point cloud diff --git a/fastfuels_sdk/v2/point_clouds.py b/fastfuels_sdk/v2/point_clouds.py index 7891f07..a29c332 100644 --- a/fastfuels_sdk/v2/point_clouds.py +++ b/fastfuels_sdk/v2/point_clouds.py @@ -13,6 +13,8 @@ from fastfuels_sdk.v2.api import ensure_client from fastfuels_sdk.v2.exceptions import expect from fastfuels_sdk.v2.client_library.api.point_clouds import ( + check_3dep_point_cloud_coverage, + create_3dep_point_cloud as create_3dep_point_cloud_endpoint, create_point_cloud_upload, delete_point_cloud, get_point_cloud as get_point_cloud_endpoint, @@ -23,7 +25,9 @@ from fastfuels_sdk.v2.client_library.models import ( PointCloud as PointCloudModel, CreatePointCloudUploadRequest, + CreateThreeDepPointCloudRequest, ListPointCloudsResponse, + PointCloudThreeDepCoverageResponse, PointCloudSortField, PointCloudType, SortOrder, @@ -36,6 +40,8 @@ __all__ = [ "PointCloud", + "check_3dep_coverage", + "create_point_cloud_from_3dep", "create_point_cloud_from_file", "list_point_clouds", "get_point_cloud", @@ -60,10 +66,11 @@ def _point_cloud_type(value) -> PointCloudType: class PointCloud(PointCloudModel): """Point cloud resource for the FastFuels v2 API. - A point cloud is a 3D LiDAR dataset within a domain, uploaded from your own - ALS (airborne) or TLS (terrestrial) scan. Point clouds are asynchronous job - resources — creation starts a background job and returns a *pending* record; - call :meth:`wait` to block until the uploaded file is processed. + A point cloud is a 3D LiDAR dataset within a domain, either fetched from + USGS 3DEP or uploaded from your own ALS (airborne) or TLS (terrestrial) + scan. Point clouds are asynchronous job resources — creation starts a + background job and returns a *pending* record; call :meth:`wait` to block + until the data is processed. Attributes ---------- @@ -102,7 +109,7 @@ class PointCloud(PointCloudModel): Examples -------- Upload a point cloud and wait for it to process: - >>> import fastfuels_sdk as ff + >>> import fastfuels_sdk.v2 as ff >>> pc = ff.point_clouds.create_point_cloud_from_file( ... domain, "scan.laz", point_cloud_type="als" ... ) @@ -113,6 +120,7 @@ class PointCloud(PointCloudModel): See Also -------- + create_point_cloud_from_3dep : Fetch public airborne LiDAR from USGS 3DEP. create_point_cloud_from_file : Upload a local LiDAR file. list_point_clouds : List point clouds in a domain or across all domains. """ @@ -271,10 +279,72 @@ def to_json(self) -> str: # --------------------------------------------------------------------------- -# Create a point cloud from your own file +# Create point clouds # --------------------------------------------------------------------------- +def check_3dep_coverage(domain) -> PointCloudThreeDepCoverageResponse: + """Check USGS 3DEP LiDAR coverage before creating a point cloud. + + Parameters + ---------- + domain : Domain or str + The domain (or its id) to check. + + Returns + ------- + PointCloudThreeDepCoverageResponse + Availability, coverage fraction, estimated point count and budget, + and the contributing acquisitions. + """ + response = check_3dep_point_cloud_coverage.sync_detailed( + _domain_id(domain), + client=ensure_client(), + ) + return expect(response) + + +def create_point_cloud_from_3dep( + domain, + datasets: Optional[List[str]] = None, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, +) -> PointCloud: + """Create an airborne point cloud from USGS 3DEP LiDAR. + + Parameters + ---------- + domain : Domain or str + The domain (or its id) to create the point cloud in. + datasets : List[str], optional + Acquisition names to read, in priority order. Use + :func:`check_3dep_coverage` to discover names. If omitted, the API + selects acquisitions automatically. + name, description : str, optional + Metadata for the point cloud. + tags : List[str], optional + Tags for the point cloud. + + Returns + ------- + PointCloud + The created airborne PointCloud (job status "pending" or "running"). + """ + request_body = CreateThreeDepPointCloudRequest( + datasets=_opt(datasets), + name=name, + description=description, + tags=_opt(tags), + ) + response = create_3dep_point_cloud_endpoint.sync_detailed( + _domain_id(domain), + client=ensure_client(), + body=request_body, + ) + return PointCloud._from_model(expect(response, HTTPStatus.CREATED)) + + def create_point_cloud_from_file( domain, path: str, diff --git a/tests/v2/test_point_clouds.py b/tests/v2/test_point_clouds.py index 52f045e..6de60a7 100644 --- a/tests/v2/test_point_clouds.py +++ b/tests/v2/test_point_clouds.py @@ -10,11 +10,14 @@ from fastfuels_sdk.v2.point_clouds import ( PointCloud, _point_cloud_type, + check_3dep_coverage, + create_point_cloud_from_3dep, create_point_cloud_from_file, get_point_cloud, list_point_clouds, ) from fastfuels_sdk.v2.client_library.models import JobStatus, PointCloudType +from fastfuels_sdk.v2.domains import Domain from fastfuels_sdk.v2.exceptions import NotFoundException # External imports @@ -35,6 +38,40 @@ def _placeholder_laz(directory) -> str: return str(path) +@pytest.fixture(scope="module") +def covered_3dep_domain(): + """A small Bondurant, WY domain with stable 3DEP LiDAR coverage.""" + geojson = { + "type": "FeatureCollection", + "crs": {"type": "name", "properties": {"name": "EPSG:32612"}}, + "features": [ + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [522800, 4720400], + [523300, 4720400], + [523300, 4720900], + [522800, 4720900], + [522800, 4720400], + ] + ], + }, + } + ], + } + domain = Domain.from_geojson( + geojson, + name="test_3dep_point_cloud_domain", + tags=["sdk-test"], + ) + yield domain + domain.delete(force=True) + + class TestPointCloudType: """Pure unit tests for the scan-type coercion (no API).""" @@ -50,6 +87,43 @@ def test_invalid_raises(self): _point_cloud_type("mls") +class TestCreateFrom3dep: + def test_coverage_preflight(self, covered_3dep_domain): + coverage = check_3dep_coverage(covered_3dep_domain) + + assert coverage.available is True + assert coverage.coverage_fraction == pytest.approx(1.0, abs=1e-3) + assert coverage.estimated_point_count > 0 + assert coverage.point_budget > 0 + assert coverage.exceeds_point_budget is False + assert coverage.datasets + + def test_create_with_pinned_dataset(self, covered_3dep_domain): + coverage = check_3dep_coverage(covered_3dep_domain) + dataset = coverage.datasets[0].name + point_cloud = create_point_cloud_from_3dep( + covered_3dep_domain, + datasets=[dataset], + name="throwaway_3dep_pc", + ) + try: + assert isinstance(point_cloud, PointCloud) + assert point_cloud.domain_id == covered_3dep_domain.id + assert point_cloud.type_ == PointCloudType.ALS + assert point_cloud.source["name"] == "3dep" + assert point_cloud.source["datasets"] == [dataset] + assert point_cloud.status in ( + JobStatus.PENDING, + JobStatus.RUNNING, + JobStatus.COMPLETED, + ) + finally: + try: + point_cloud.delete() + except NotFoundException: + pass + + class TestCreateAndLifecycle: @pytest.fixture(scope="class") def uploaded_point_cloud(self, test_domain, tmp_path_factory): From 800971d1db5b03e1324974eb38d27abcc63f05bd Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Tue, 4 Aug 2026 09:27:32 -0600 Subject: [PATCH 55/71] Create canopy height grids from point clouds --- docs/v2/guides/creating-grids.md | 12 +++++ docs/v2/guides/point-clouds.md | 2 + fastfuels_sdk/v2/grids.py | 83 +++++++++++++++++++++++++++++++- tests/v2/test_grids.py | 69 +++++++++++++++++++++++++- 4 files changed, 164 insertions(+), 2 deletions(-) diff --git a/docs/v2/guides/creating-grids.md b/docs/v2/guides/creating-grids.md index c02303a..836abcc 100644 --- a/docs/v2/guides/creating-grids.md +++ b/docs/v2/guides/creating-grids.md @@ -181,6 +181,18 @@ meta = ff.grids.create_canopy_height_grid_from_meta(domain, output_resolution_m= naip = ff.grids.create_canopy_height_grid_from_naip_chm(domain, output_resolution_m=1) ``` +To rasterize a completed airborne point cloud into the same `chm` band, pass +the point cloud directly. The output defaults to 1 m cells: + +```python +chm = ff.grids.create_canopy_height_grid_from_point_cloud(point_cloud) +chm.wait() +``` + +Use `output_resolution_m` or `align_to` to choose a different lattice. See the +[Point clouds guide](point-clouds.md#create-a-point-cloud-from-usgs-3dep) to +create an airborne point cloud from USGS 3DEP. + !!! tip "NAIP-CHM is a surface model" NAIP-CHM is a digital surface model and retains buildings and other infrastructure. To keep only vegetation, mask out built-up areas — see diff --git a/docs/v2/guides/point-clouds.md b/docs/v2/guides/point-clouds.md index f356cb8..2b45853 100644 --- a/docs/v2/guides/point-clouds.md +++ b/docs/v2/guides/point-clouds.md @@ -141,4 +141,6 @@ except NotFoundException: ## Next steps +- Turn a completed airborne point cloud into a canopy-height grid with + [`create_canopy_height_grid_from_point_cloud`](creating-grids.md#canopy-grids) - Full signatures — see the [Reference](../reference.md) diff --git a/fastfuels_sdk/v2/grids.py b/fastfuels_sdk/v2/grids.py index 3265791..501268f 100644 --- a/fastfuels_sdk/v2/grids.py +++ b/fastfuels_sdk/v2/grids.py @@ -5,7 +5,7 @@ # Core imports import json from http import HTTPStatus -from typing import Dict, List, Optional, Union +from typing import TYPE_CHECKING, Dict, List, Optional, Union # Internal imports from fastfuels_sdk.v2._jobs import wait as _wait @@ -26,6 +26,7 @@ create_meta_chm, create_naip_chm, create_netcdf_upload, + create_point_cloud_chm, create_resample, create_treemap, create_uniform_grid as create_uniform_grid_endpoint, @@ -49,6 +50,7 @@ CreateMetaChmRequest, CreateNaipChmRequest, CreateNetcdfUploadRequest, + CreatePointCloudChmRequest, CreateResampleRequest, CreateThreeDepTopographyRequest, CreateTreeMapRequest, @@ -88,6 +90,9 @@ import attrs import numpy as np +if TYPE_CHECKING: + from fastfuels_sdk.v2.point_clouds import PointCloud + __all__ = [ "Grid", "create_topography_grid_from_3dep", @@ -95,6 +100,7 @@ "create_canopy_fuel_grid_from_landfire", "create_canopy_height_grid_from_meta", "create_canopy_height_grid_from_naip_chm", + "create_canopy_height_grid_from_point_cloud", "create_fuel_model_grid_from_landfire_fbfm40", "create_fuel_model_grid_from_landfire_fccs", "create_pim_grid_from_treemap", @@ -1159,6 +1165,81 @@ def create_canopy_height_grid_from_naip_chm( return Grid._from_model(expect(response, HTTPStatus.CREATED)) +def create_canopy_height_grid_from_point_cloud( + point_cloud: "PointCloud", + output_resolution_m: Optional[float] = None, + align_to=None, + resampling: Optional[str] = None, + extent_buffer_cells: int = 0, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, + modifications: Optional[list] = None, +) -> Grid: + """Create a canopy height grid from a completed airborne point cloud. + + Parameters + ---------- + point_cloud : PointCloud + The completed airborne point cloud to rasterize. + output_resolution_m : float, optional + Output cell size in meters, anchored to the domain origin. Defaults to + 1 meter when no alignment target is provided. + align_to : Grid or str, optional + Match the lattice of an existing grid (or its id). + resampling : str, optional + Resampling method for the continuous canopy-height band. + extent_buffer_cells : int, optional + Result-grid cells to buffer around the domain extent (0-10, default 0). + name, description : str, optional + Metadata for the grid. + tags : List[str], optional + Tags for the grid. + modifications : list, optional + Modification rules applied after the grid is built. + + Returns + ------- + Grid + The created canopy-height Grid (job status "pending" or "running"). + + Raises + ------ + ValueError + If the point cloud is not completed or is not airborne. + """ + if point_cloud.status != JobStatus.COMPLETED: + raise ValueError( + f"Point cloud {point_cloud.id} must be completed before creating " + f"a canopy height grid (current status: {point_cloud.status.value})." + ) + if point_cloud.type_.value != "als": + raise ValueError( + f"Point cloud {point_cloud.id} must be airborne (ALS) to create a " + f"canopy height grid (got {point_cloud.type_.value!r})." + ) + + request_body = CreatePointCloudChmRequest( + source_point_cloud_id=point_cloud.id, + alignment=_build_alignment( + output_resolution_m, + align_to, + resampling=resampling, + ), + extent_buffer_cells=extent_buffer_cells, + name=name, + description=description, + tags=_opt(tags), + modifications=_opt(modifications), + ) + response = create_point_cloud_chm.sync_detailed( + point_cloud.domain_id, + client=ensure_client(), + body=request_body, + ) + return Grid._from_model(expect(response, HTTPStatus.CREATED)) + + def create_fuel_model_grid_from_landfire_fbfm40( domain, version: Optional[str] = None, diff --git a/tests/v2/test_grids.py b/tests/v2/test_grids.py index 848e39b..9ddf1bd 100644 --- a/tests/v2/test_grids.py +++ b/tests/v2/test_grids.py @@ -6,6 +6,7 @@ import inspect import json import math +from http import HTTPStatus from types import SimpleNamespace from uuid import uuid4 @@ -23,6 +24,7 @@ create_canopy_fuel_grid_from_landfire, create_canopy_height_grid_from_meta, create_canopy_height_grid_from_naip_chm, + create_canopy_height_grid_from_point_cloud, create_fuel_grid_from_fbfm40_lookup, create_fuel_model_grid_from_landfire_fbfm40, create_fuel_model_grid_from_landfire_fccs, @@ -52,11 +54,12 @@ JobStatus, Modifier, Operator, + PointCloudType, ResamplingMethod, TopographyBand, UploadBandDefinition, ) -from fastfuels_sdk.v2.client_library.types import UNSET +from fastfuels_sdk.v2.client_library.types import UNSET, Response from fastfuels_sdk.v2.exceptions import NotFoundException, expect from fastfuels_sdk.v2.modifications import mask @@ -250,6 +253,70 @@ def test_create(self, test_domain): grid.delete() +class TestCreateCanopyHeightGridFromPointCloud: + @staticmethod + def _point_cloud(status=JobStatus.COMPLETED, type_=PointCloudType.ALS): + return SimpleNamespace( + id="pc-id", + domain_id="domain-id", + status=status, + type_=type_, + ) + + def test_builds_request_from_completed_als_cloud(self, monkeypatch): + created = Grid( + id="grid-id", + domain_id="domain-id", + status=JobStatus.PENDING, + source=GridSource(), + bands=[Band(key="chm", type_=BandType.CONTINUOUS, index=0, unit="m")], + ) + captured = {} + + def fake_create(domain_id, *, client, body): + captured.update(domain_id=domain_id, client=client, body=body) + return Response( + status_code=HTTPStatus.CREATED, + content=b"", + headers={}, + parsed=created, + ) + + client = object() + monkeypatch.setattr(grids, "ensure_client", lambda: client) + monkeypatch.setattr( + grids.create_point_cloud_chm, + "sync_detailed", + fake_create, + ) + + grid = create_canopy_height_grid_from_point_cloud( + self._point_cloud(), + output_resolution_m=2, + name="Point-cloud CHM", + ) + + assert isinstance(grid, Grid) + assert grid.id == "grid-id" + assert captured["domain_id"] == "domain-id" + assert captured["client"] is client + assert captured["body"].source_point_cloud_id == "pc-id" + assert captured["body"].alignment.resolution == 2 + assert captured["body"].name == "Point-cloud CHM" + + def test_requires_completed_cloud(self): + with pytest.raises(ValueError, match="must be completed"): + create_canopy_height_grid_from_point_cloud( + self._point_cloud(status=JobStatus.PENDING) + ) + + def test_requires_airborne_cloud(self): + with pytest.raises(ValueError, match="airborne"): + create_canopy_height_grid_from_point_cloud( + self._point_cloud(type_=PointCloudType.TLS) + ) + + class TestCreateFuelModelGridFromLandfireFbfm40: def test_create(self, test_domain): # remove_non_burnable exercises the string -> enum list coercion From f3f0c7111dec08aa2302c428e529b80bdf1be44c Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Tue, 4 Aug 2026 09:35:25 -0600 Subject: [PATCH 56/71] Add DUET surface fuel grids --- docs/v2/guides/creating-grids.md | 51 ++++++++ docs/v2/reference.md | 2 + fastfuels_sdk/v2/__init__.py | 3 + fastfuels_sdk/v2/calibrations.py | 196 +++++++++++++++++++++++++++++++ fastfuels_sdk/v2/grids.py | 113 ++++++++++++++++++ tests/v2/test_calibrations.py | 84 +++++++++++++ tests/v2/test_grids.py | 170 +++++++++++++++++++++++++++ 7 files changed, 619 insertions(+) create mode 100644 fastfuels_sdk/v2/calibrations.py create mode 100644 tests/v2/test_calibrations.py diff --git a/docs/v2/guides/creating-grids.md b/docs/v2/guides/creating-grids.md index 836abcc..9f23fc4 100644 --- a/docs/v2/guides/creating-grids.md +++ b/docs/v2/guides/creating-grids.md @@ -250,6 +250,57 @@ before voxelizing (see For the concepts behind plot imputation and voxelization, see the [FastFuels documentation](https://docs.fastfuels.silvxlabs.com). +### Derive surface fuels with DUET + +To create a two-dimensional DUET surface-fuel grid, include the three DUET +input bands when voxelizing the inventory: + +```python +voxels = inventory.voxelize( + horizontal_resolution_m=2, + vertical_resolution_m=1, + bands=[ + "bulk_density.foliage.live", + "spcd", + "fuel_moisture.live", + ], +) +voxels.wait() +``` + +Build calibration targets from ordinary mappings, then pass them with the +output bands and time since fire: + +```python +calibration = ff.duet_calibration( + fuel_load={ + "grass": {"mean": 0.5, "sd": 0.25}, + "litter": {"max": 5, "min": 0}, + }, + fuel_depth={ + "grass": {"value": 0.3}, + "litter": {"value": 0.06}, + }, +) + +surface = ff.grids.create_surface_fuel_grid_from_duet( + voxels, + years_since_burn=25, + bands=[ + "fuel_load.grass", + "fuel_load.litter", + "fuel_depth.grass", + "fuel_depth.litter", + ], + calibration=calibration, +) +surface.wait() +``` + +Use a `value` target to set every occupied cell to a constant, `max` with an +optional `min` to scale by extrema, or `mean` and `sd` to scale by moments. +Omit `calibration` only when you want the raw DUET values. + ## Uniform grids To fill the whole domain with constant band values at a chosen resolution — diff --git a/docs/v2/reference.md b/docs/v2/reference.md index 2bf86c4..2fbb504 100644 --- a/docs/v2/reference.md +++ b/docs/v2/reference.md @@ -14,6 +14,8 @@ ::: fastfuels_sdk.v2.treatments +::: fastfuels_sdk.v2.calibrations + ::: fastfuels_sdk.v2._jobs ::: fastfuels_sdk.v2.api diff --git a/fastfuels_sdk/v2/__init__.py b/fastfuels_sdk/v2/__init__.py index 1a0e58b..48471d0 100644 --- a/fastfuels_sdk/v2/__init__.py +++ b/fastfuels_sdk/v2/__init__.py @@ -21,6 +21,7 @@ from fastfuels_sdk.v2 import exports, features, grids, inventories, point_clouds from fastfuels_sdk.v2._jobs import JobFailedError, wait_all from fastfuels_sdk.v2.api import get_quotas, get_usage, set_api_key +from fastfuels_sdk.v2.calibrations import duet_calibration from fastfuels_sdk.v2.domains import Domain, list_domains, reproject_geojson from fastfuels_sdk.v2.exports import Export, get_export, list_exports from fastfuels_sdk.v2.features import Feature, get_feature, list_features @@ -52,6 +53,8 @@ # Quotas and usage "get_quotas", "get_usage", + # Calibrations + "duet_calibration", # Records "Domain", "Export", diff --git a/fastfuels_sdk/v2/calibrations.py b/fastfuels_sdk/v2/calibrations.py new file mode 100644 index 0000000..4add3f6 --- /dev/null +++ b/fastfuels_sdk/v2/calibrations.py @@ -0,0 +1,196 @@ +"""Calibration builders for generated v2 request models.""" + +from collections.abc import Mapping +from numbers import Real + +from fastfuels_sdk.v2.client_library.models import ( + DuetCalibration, + DuetConstantCalibrationTarget, + DuetMaxMinCalibrationTarget, + DuetMeanSdCalibrationTarget, + DuetParameterCalibration, +) +from fastfuels_sdk.v2.client_library.types import UNSET + +__all__ = ["duet_calibration"] + +_DUET_FUEL_TYPES = {"grass", "coniferous", "deciduous", "litter", "all"} +_DUET_TARGET_TYPES = ( + DuetConstantCalibrationTarget, + DuetMaxMinCalibrationTarget, + DuetMeanSdCalibrationTarget, +) + + +def duet_calibration( + *, + fuel_load=None, + fuel_depth=None, + fuel_moisture=None, +) -> DuetCalibration: + """Build calibration targets for a DUET surface-fuel grid. + + Each argument maps fuel types (``"grass"``, ``"coniferous"``, + ``"deciduous"``, ``"litter"``, or ``"all"``) to a target. Target fields + select the calibration method: ``value`` for constant, ``max`` and + optional ``min`` for max-min, or ``mean`` and ``sd`` for mean-standard + deviation. An explicit ``method`` field is also accepted. + + Parameters + ---------- + fuel_load : mapping, optional + Per-fuel-type load targets. + fuel_depth : mapping, optional + Per-fuel-type depth targets. + fuel_moisture : mapping, optional + Per-fuel-type moisture targets. + + Returns + ------- + DuetCalibration + Calibration for + :func:`fastfuels_sdk.v2.grids.create_surface_fuel_grid_from_duet`. + + Raises + ------ + TypeError + If a parameter or target is not a mapping or generated target model. + ValueError + If no parameter is provided, a target is invalid, or mutually + exclusive fuel types are combined. + + Examples + -------- + >>> import fastfuels_sdk.v2 as ff + >>> calibration = ff.duet_calibration( + ... fuel_load={ + ... "grass": {"mean": 0.5, "sd": 0.25}, + ... "litter": {"max": 5.0, "min": 0.0}, + ... }, + ... fuel_depth={ + ... "grass": {"value": 0.3}, + ... "litter": {"value": 0.06}, + ... }, + ... ) + """ + parameters = { + "fuel_load": fuel_load, + "fuel_depth": fuel_depth, + "fuel_moisture": fuel_moisture, + } + if not any(value is not None for value in parameters.values()): + raise ValueError( + "duet_calibration requires at least one of fuel_load, fuel_depth, " + "or fuel_moisture." + ) + + return DuetCalibration( + **{ + name: _duet_parameter(value, name) if value is not None else UNSET + for name, value in parameters.items() + } + ) + + +def _duet_parameter(value, parameter: str) -> DuetParameterCalibration: + if isinstance(value, DuetParameterCalibration): + value = value.to_dict() + if not isinstance(value, Mapping): + raise TypeError(f"{parameter} must be a mapping of fuel types to targets.") + if not value: + raise ValueError(f"{parameter} requires at least one fuel-type target.") + + unknown = set(value) - _DUET_FUEL_TYPES + if unknown: + raise ValueError( + f"Unknown {parameter} fuel types: {sorted(unknown)}. Use one of " + f"{sorted(_DUET_FUEL_TYPES)}." + ) + if "all" in value and len(value) > 1: + raise ValueError( + f"{parameter} 'all' cannot be combined with per-fuel-type targets." + ) + if "litter" in value and ({"coniferous", "deciduous"} & set(value)): + raise ValueError( + f"{parameter} 'litter' cannot be combined with 'coniferous' or " + "'deciduous'." + ) + + targets = { + name: _duet_target(target, f"{parameter}.{name}") + for name, target in value.items() + } + if "all" in targets: + targets["all_"] = targets.pop("all") + return DuetParameterCalibration(**targets) + + +def _duet_target(value, path: str): + if isinstance(value, _DUET_TARGET_TYPES): + value = value.to_dict() + if not isinstance(value, Mapping): + raise TypeError(f"{path} must be a calibration-target mapping.") + + data = dict(value) + method = data.pop("method", None) + if method is None: + method = _infer_duet_method(data, path) + + if method == "constant": + _require_fields(data, path, required={"value"}) + return DuetConstantCalibrationTarget(value=_nonnegative(data["value"], path)) + if method == "maxmin": + _require_fields(data, path, required={"max"}, optional={"min"}) + maximum = _nonnegative(data["max"], f"{path}.max") + minimum = _nonnegative(data.get("min", 0.0), f"{path}.min") + if maximum < minimum: + raise ValueError(f"{path}.max must be greater than or equal to min.") + return DuetMaxMinCalibrationTarget(max_=maximum, min_=minimum) + if method == "meansd": + _require_fields(data, path, required={"mean", "sd"}) + return DuetMeanSdCalibrationTarget( + mean=_nonnegative(data["mean"], f"{path}.mean"), + sd=_nonnegative(data["sd"], f"{path}.sd"), + ) + raise ValueError( + f"Unknown calibration method {method!r} for {path}. Use 'constant', " + "'maxmin', or 'meansd'." + ) + + +def _infer_duet_method(data: Mapping, path: str) -> str: + fields = set(data) + if "value" in fields: + return "constant" + if fields & {"max", "min"}: + return "maxmin" + if fields & {"mean", "sd"}: + return "meansd" + raise ValueError( + f"Cannot infer a calibration method for {path}; provide value, max, " + "or mean and sd." + ) + + +def _require_fields( + data: Mapping, + path: str, + *, + required: set[str], + optional: set[str] | None = None, +) -> None: + optional = optional or set() + missing = required - set(data) + if missing: + raise ValueError(f"{path} is missing required fields: {sorted(missing)}.") + extra = set(data) - required - optional + if extra: + raise ValueError(f"{path} has fields not used by this method: {sorted(extra)}.") + + +def _nonnegative(value, path: str) -> float: + if isinstance(value, bool) or not isinstance(value, Real): + raise TypeError(f"{path} must be a number.") + if value < 0: + raise ValueError(f"{path} must be nonnegative.") + return float(value) diff --git a/fastfuels_sdk/v2/grids.py b/fastfuels_sdk/v2/grids.py index 501268f..082a32f 100644 --- a/fastfuels_sdk/v2/grids.py +++ b/fastfuels_sdk/v2/grids.py @@ -16,6 +16,7 @@ apply_grid_modifications as apply_grid_modifications_endpoint, check_3dep_coverage as check_3dep_coverage_endpoint, create_3dep_topography, + create_duet_grid, create_fbfm40_lookup, create_geotiff_upload, create_grid_export, @@ -41,6 +42,7 @@ from fastfuels_sdk.v2.client_library.models import ( Grid as GridModel, ApplyGridModificationsRequest, + CreateDuetRequest, CreateFbfm40LookupRequest, CreateGeoTIFFUploadRequest, CreateLandfireCanopyRequest, @@ -56,6 +58,8 @@ CreateTreeMapRequest, CreateUniformRequest, DuplicateGridRequest, + DuetBand, + DuetCalibration, ExportGridRequest, Fbfm40LookupBand, GridAlignmentDomainTarget, @@ -107,6 +111,7 @@ "create_grid_from_geotiff", "create_grid_from_netcdf", "create_uniform_grid", + "create_surface_fuel_grid_from_duet", "create_fuel_grid_from_fbfm40_lookup", "list_grids", "get_grid", @@ -1674,6 +1679,114 @@ def create_fuel_grid_from_fbfm40_lookup( return Grid._from_model(expect(response, HTTPStatus.CREATED)) +def create_surface_fuel_grid_from_duet( + source_grid: "Grid", + years_since_burn: int, + wind_direction: int = 270, + wind_variability: int = 30, + bands: Optional[list] = None, + calibration: Optional[DuetCalibration] = None, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, +) -> Grid: + """Create a 2D DUET surface-fuel grid from a 3D tree grid. + + Parameters + ---------- + source_grid : Grid + A completed 3D tree grid carrying ``bulk_density.foliage.live``, + ``spcd``, and ``fuel_moisture.live`` bands. + years_since_burn : int + Years of litter accumulation to simulate, from 1 through 100. + wind_direction : int, optional + Prevailing wind direction in whole degrees clockwise from north + (0-359, default 270). + wind_variability : int, optional + Angular spread of wind direction in whole degrees (0-180, default 30). + bands : list, optional + DUET output bands (``DuetBand`` members or string keys). Defaults to + ``fuel_load.grass`` and ``fuel_load.litter``. + calibration : DuetCalibration, optional + Per-parameter and per-fuel-type targets from + :func:`fastfuels_sdk.v2.calibrations.duet_calibration`. If omitted, + the grid stores raw DUET values. + name, description : str, optional + Metadata for the new grid. + tags : List[str], optional + Tags for the new grid. + + Returns + ------- + Grid + The new pending DUET surface-fuel Grid. + + Raises + ------ + TypeError + If a DUET time or wind parameter is not a whole number. + ValueError + If a parameter is out of range, the source is not completed, or the + source lacks a required tree band. + """ + source_grid._require_completed("create a DUET surface fuel grid from") + required_bands = { + "bulk_density.foliage.live", + "spcd", + "fuel_moisture.live", + } + band_keys = {band.key for band in source_grid.bands} + missing = sorted(required_bands - band_keys) + if missing: + raise ValueError( + f"Grid {source_grid.id} lacks DUET source bands: {missing}. " + f"Available bands: {sorted(band_keys)}." + ) + + years_since_burn = _duet_integer( + "years_since_burn", years_since_burn, minimum=1, maximum=100 + ) + wind_direction = _duet_integer( + "wind_direction", wind_direction, minimum=0, maximum=359 + ) + wind_variability = _duet_integer( + "wind_variability", wind_variability, minimum=0, maximum=180 + ) + requested_bands = _enum_list(bands, DuetBand) + if requested_bands is not UNSET: + if not requested_bands: + raise ValueError("bands must contain at least one DUET band.") + if len(set(requested_bands)) != len(requested_bands): + raise ValueError("bands contains duplicate DUET bands.") + + request_body = CreateDuetRequest( + source_grid_id=source_grid.id, + years_since_burn=years_since_burn, + wind_direction=wind_direction, + wind_variability=wind_variability, + bands=requested_bands, + calibration=_opt(calibration), + name=name, + description=description, + tags=_opt(tags), + ) + response = create_duet_grid.sync_detailed( + source_grid.domain_id, + client=ensure_client(), + body=request_body, + ) + return Grid._from_model(expect(response, HTTPStatus.CREATED)) + + +def _duet_integer(name: str, value, *, minimum: int, maximum: int) -> int: + """Validate a bounded whole-number DUET parameter.""" + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError(f"{name} must be a whole number.") + if not minimum <= value <= maximum: + raise ValueError(f"{name} must be between {minimum} and {maximum}.") + return value + + # --------------------------------------------------------------------------- # Listing, fetching, and utilities # --------------------------------------------------------------------------- diff --git a/tests/v2/test_calibrations.py b/tests/v2/test_calibrations.py new file mode 100644 index 0000000..25309ed --- /dev/null +++ b/tests/v2/test_calibrations.py @@ -0,0 +1,84 @@ +"""Tests for v2 calibration builders.""" + +import pytest + +from fastfuels_sdk.v2.calibrations import duet_calibration +from fastfuels_sdk.v2.client_library.models import ( + DuetConstantCalibrationTarget, + DuetMaxMinCalibrationTarget, + DuetMeanSdCalibrationTarget, +) + + +def test_duet_calibration_builds_each_target_method(): + calibration = duet_calibration( + fuel_load={ + "grass": {"mean": 0.5, "sd": 0.25}, + "litter": {"max": 5.0}, + }, + fuel_depth={"all": {"value": 0.3}}, + ) + + assert isinstance(calibration.fuel_load.grass, DuetMeanSdCalibrationTarget) + assert isinstance(calibration.fuel_load.litter, DuetMaxMinCalibrationTarget) + assert calibration.fuel_load.litter.min_ == 0.0 + assert isinstance(calibration.fuel_depth.all_, DuetConstantCalibrationTarget) + assert calibration.to_dict() == { + "fuel_load": { + "grass": {"method": "meansd", "mean": 0.5, "sd": 0.25}, + "litter": {"method": "maxmin", "max": 5.0, "min": 0.0}, + }, + "fuel_depth": { + "all": {"method": "constant", "value": 0.3}, + }, + } + + +def test_duet_calibration_accepts_explicit_method(): + calibration = duet_calibration( + fuel_moisture={ + "grass": {"method": "constant", "value": 12}, + } + ) + + assert calibration.fuel_moisture.grass.value == 12 + + +def test_duet_calibration_requires_a_parameter(): + with pytest.raises(ValueError, match="at least one"): + duet_calibration() + + +def test_duet_calibration_all_is_exclusive(): + with pytest.raises(ValueError, match="cannot be combined"): + duet_calibration( + fuel_load={ + "all": {"value": 1}, + "grass": {"value": 1}, + } + ) + + +def test_duet_calibration_litter_is_exclusive_of_components(): + with pytest.raises(ValueError, match="litter"): + duet_calibration( + fuel_load={ + "litter": {"value": 1}, + "coniferous": {"value": 1}, + } + ) + + +@pytest.mark.parametrize( + "target,match", + [ + ({"method": "unknown", "value": 1}, "Unknown calibration method"), + ({"mean": 1}, "missing required fields"), + ({"max": 1, "min": 2}, "greater than or equal"), + ({"value": -1}, "nonnegative"), + ({"value": 1, "max": 2}, "not used"), + ], +) +def test_duet_calibration_rejects_invalid_targets(target, match): + with pytest.raises(ValueError, match=match): + duet_calibration(fuel_load={"grass": target}) diff --git a/tests/v2/test_grids.py b/tests/v2/test_grids.py index 9ddf1bd..3537560 100644 --- a/tests/v2/test_grids.py +++ b/tests/v2/test_grids.py @@ -12,6 +12,7 @@ # Internal imports from fastfuels_sdk.v2 import grids +from fastfuels_sdk.v2.calibrations import duet_calibration from fastfuels_sdk.v2.grids import ( Grid, _build_alignment, @@ -30,6 +31,7 @@ create_fuel_model_grid_from_landfire_fccs, create_grid_from_geotiff, create_pim_grid_from_treemap, + create_surface_fuel_grid_from_duet, create_topography_grid_from_3dep, create_topography_grid_from_landfire, create_uniform_grid, @@ -42,6 +44,7 @@ Band, BandType, ContinuousBandSummary, + DuetBand, GridAlignmentDomainTarget, GridAlignmentGridTarget, GridAlignmentNativeTarget, @@ -317,6 +320,173 @@ def test_requires_airborne_cloud(self): ) +class TestCreateSurfaceFuelGridFromDuet: + @staticmethod + def _source_grid( + status=JobStatus.COMPLETED, + omit=None, + ): + required = [ + ("bulk_density.foliage.live", BandType.CONTINUOUS), + ("spcd", BandType.CATEGORICAL), + ("fuel_moisture.live", BandType.CONTINUOUS), + ] + return Grid( + id="tree-grid-id", + domain_id="domain-id", + status=status, + source=GridSource(), + bands=[ + Band(key=key, type_=type_, index=index) + for index, (key, type_) in enumerate(required) + if key != omit + ], + ) + + def test_builds_request_from_completed_tree_grid(self, monkeypatch): + created = Grid( + id="duet-grid-id", + domain_id="domain-id", + status=JobStatus.PENDING, + source=GridSource(), + bands=[], + ) + captured = {} + + def fake_create(domain_id, *, client, body): + captured.update(domain_id=domain_id, client=client, body=body) + return Response( + status_code=HTTPStatus.CREATED, + content=b"", + headers={}, + parsed=created, + ) + + client = object() + calibration = duet_calibration(fuel_load={"grass": {"mean": 0.5, "sd": 0.25}}) + monkeypatch.setattr(grids, "ensure_client", lambda: client) + monkeypatch.setattr( + grids.create_duet_grid, + "sync_detailed", + fake_create, + ) + + grid = create_surface_fuel_grid_from_duet( + self._source_grid(), + years_since_burn=20, + wind_direction=225, + wind_variability=45, + bands=["fuel_load.grass", DuetBand.FUEL_LOAD_LITTER], + calibration=calibration, + name="DUET surface fuels", + tags=["test"], + ) + + assert grid.id == "duet-grid-id" + assert captured["domain_id"] == "domain-id" + assert captured["client"] is client + assert captured["body"].source_grid_id == "tree-grid-id" + assert captured["body"].years_since_burn == 20 + assert captured["body"].wind_direction == 225 + assert captured["body"].wind_variability == 45 + assert captured["body"].bands == [ + DuetBand.FUEL_LOAD_GRASS, + DuetBand.FUEL_LOAD_LITTER, + ] + assert captured["body"].calibration is calibration + assert captured["body"].name == "DUET surface fuels" + assert captured["body"].tags == ["test"] + + def test_requires_completed_tree_grid(self): + with pytest.raises(ValueError, match=r"Call \.wait\(\)"): + create_surface_fuel_grid_from_duet( + self._source_grid(status=JobStatus.PENDING), + years_since_burn=20, + ) + + def test_requires_duet_source_bands(self): + with pytest.raises(ValueError, match="fuel_moisture.live"): + create_surface_fuel_grid_from_duet( + self._source_grid(omit="fuel_moisture.live"), + years_since_burn=20, + ) + + @pytest.mark.parametrize( + "kwargs,error", + [ + ({"years_since_burn": 0}, ValueError), + ({"years_since_burn": 101}, ValueError), + ({"years_since_burn": 1.5}, TypeError), + ({"years_since_burn": 20, "wind_direction": 360}, ValueError), + ({"years_since_burn": 20, "wind_variability": 181}, ValueError), + ({"years_since_burn": 20, "bands": []}, ValueError), + ( + { + "years_since_burn": 20, + "bands": ["fuel_load.grass", "fuel_load.grass"], + }, + ValueError, + ), + ], + ) + def test_validates_request_parameters(self, kwargs, error): + with pytest.raises(error): + create_surface_fuel_grid_from_duet(self._source_grid(), **kwargs) + + def test_create_live(self, completed_tree_inventory): + voxels = completed_tree_inventory.voxelize( + horizontal_resolution_m=2, + vertical_resolution_m=1, + bands=[ + "bulk_density.foliage.live", + "spcd", + "fuel_moisture.live", + ], + name="test_duet_source", + tags=["test"], + ) + surface = None + try: + voxels.wait() + surface = create_surface_fuel_grid_from_duet( + voxels, + years_since_burn=25, + bands=[ + "fuel_load.grass", + "fuel_load.litter", + "fuel_depth.grass", + "fuel_depth.litter", + ], + calibration=duet_calibration( + fuel_load={ + "grass": {"mean": 0.5, "sd": 0.25}, + "litter": {"max": 5, "min": 0}, + }, + fuel_depth={ + "grass": {"value": 0.3}, + "litter": {"value": 0.06}, + }, + ), + name="test_duet_surface_fuels", + tags=["test"], + ) + surface.wait() + assert surface.status == JobStatus.COMPLETED + assert {band.key for band in surface.bands} == { + "fuel_load.grass", + "fuel_load.litter", + "fuel_depth.grass", + "fuel_depth.litter", + } + grass_load = surface.to_numpy("fuel_load.grass") + assert grass_load.ndim == 2 + assert np.isfinite(grass_load).any() + finally: + if surface is not None: + surface.delete() + voxels.delete() + + class TestCreateFuelModelGridFromLandfireFbfm40: def test_create(self, test_domain): # remove_non_burnable exercises the string -> enum list coercion From 873557ff670f74080df38b7555e8918358dd7331 Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Tue, 4 Aug 2026 09:38:37 -0600 Subject: [PATCH 57/71] Add LANDFIRE FBFM13 grid support --- docs/v2/guides/creating-grids.md | 26 ++++++ fastfuels_sdk/v2/grids.py | 137 +++++++++++++++++++++++++++++++ tests/v2/conftest.py | 17 ++++ tests/v2/test_grids.py | 134 ++++++++++++++++++++++++++++++ 4 files changed, 314 insertions(+) diff --git a/docs/v2/guides/creating-grids.md b/docs/v2/guides/creating-grids.md index 9f23fc4..c9ce1da 100644 --- a/docs/v2/guides/creating-grids.md +++ b/docs/v2/guides/creating-grids.md @@ -142,6 +142,32 @@ fuels.wait() [('fuel_load.1hr', 'kg/m**2'), ('fuel_load.10hr', 'kg/m**2'), ('fuel_depth', 'm')] ``` +### Use Anderson 13 fuel models + +To create the Anderson 13 model set instead, select a LANDFIRE version and +use the FBFM13 creator: + +```python +grid = ff.grids.create_fuel_model_grid_from_landfire_fbfm13( + domain, + version="2024", + remove_non_burnable=["NB1", "NB2"], + output_resolution_m=30, +) +grid.wait() +``` + +Its categorical source band is `fbfm13`. Convert it to any of the nine +FBFM13 parameter bands with the matching lookup: + +```python +fuels = ff.grids.create_fuel_grid_from_fbfm13_lookup( + grid, + bands=["fuel_load.1hr", "fuel_load.live_foliage", "fuel_depth"], +) +fuels.wait() +``` + ### From FCCS instead Alternatively, build an already-parameterized fuels grid from the Fuel diff --git a/fastfuels_sdk/v2/grids.py b/fastfuels_sdk/v2/grids.py index 082a32f..12f1e06 100644 --- a/fastfuels_sdk/v2/grids.py +++ b/fastfuels_sdk/v2/grids.py @@ -17,10 +17,12 @@ check_3dep_coverage as check_3dep_coverage_endpoint, create_3dep_topography, create_duet_grid, + create_fbfm13_lookup, create_fbfm40_lookup, create_geotiff_upload, create_grid_export, create_landfire_canopy, + create_landfire_fbfm13, create_landfire_fbfm40, create_landfire_fccs, create_landfire_topography, @@ -43,9 +45,11 @@ Grid as GridModel, ApplyGridModificationsRequest, CreateDuetRequest, + CreateFbfm13LookupRequest, CreateFbfm40LookupRequest, CreateGeoTIFFUploadRequest, CreateLandfireCanopyRequest, + CreateLandfireFbfm13Request, CreateLandfireFbfm40Request, CreateLandfireFccsRequest, CreateLandfireTopographyRequest, @@ -61,6 +65,7 @@ DuetBand, DuetCalibration, ExportGridRequest, + Fbfm13LookupBand, Fbfm40LookupBand, GridAlignmentDomainTarget, GridAlignmentGridTarget, @@ -72,6 +77,7 @@ JobStatus, LandfireCanopyFuelBand, LandfireCanopyVersion, + LandfireFbfm13Version, LandfireFbfm40Version, LandfireFccsVersion, LandfireTopographyVersion, @@ -105,6 +111,7 @@ "create_canopy_height_grid_from_meta", "create_canopy_height_grid_from_naip_chm", "create_canopy_height_grid_from_point_cloud", + "create_fuel_model_grid_from_landfire_fbfm13", "create_fuel_model_grid_from_landfire_fbfm40", "create_fuel_model_grid_from_landfire_fccs", "create_pim_grid_from_treemap", @@ -112,6 +119,7 @@ "create_grid_from_netcdf", "create_uniform_grid", "create_surface_fuel_grid_from_duet", + "create_fuel_grid_from_fbfm13_lookup", "create_fuel_grid_from_fbfm40_lookup", "list_grids", "get_grid", @@ -1245,6 +1253,70 @@ def create_canopy_height_grid_from_point_cloud( return Grid._from_model(expect(response, HTTPStatus.CREATED)) +def create_fuel_model_grid_from_landfire_fbfm13( + domain, + version: Optional[str] = None, + remove_non_burnable: Optional[list] = None, + output_resolution_m: Optional[float] = None, + align_to=None, + align: Optional[str] = None, + resampling: Optional[str] = None, + extent_buffer_cells: int = 0, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, + modifications: Optional[list] = None, +) -> Grid: + """Create a fuel model grid from LANDFIRE Anderson 13 fuel models. + + Parameters + ---------- + domain : Domain or str + The domain (or its id) to create the grid in. + version : str, optional + LANDFIRE version (``"2023"`` or ``"2024"``). Defaults to the API's + current version. + remove_non_burnable : list, optional + Non-burnable fuel models to drop (``NonBurnableFuelModel`` members or + their string keys, e.g. ``"NB1"``, ``"NB2"``). + output_resolution_m : float, optional + Output cell size in meters, anchored to the domain origin. + align_to : Grid or str, optional + Match the lattice of an existing grid (or its id). + align : str, optional + Pass ``"native"`` to keep the source raster's pixel anchor. + resampling : str, optional + Resampling method (e.g. ``"nearest"`` for categorical fuel models). + extent_buffer_cells : int, optional + Result-grid cells to buffer around the domain extent (0-10, default 0). + name, description : str, optional + Metadata for the grid. + tags : List[str], optional + Tags for the grid. + modifications : list, optional + Modification rules applied after the grid is built. + + Returns + ------- + Grid + The created Grid object (job status ``"pending"`` or ``"running"``). + """ + request_body = CreateLandfireFbfm13Request( + version=LandfireFbfm13Version(version) if version is not None else UNSET, + remove_non_burnable=_enum_list(remove_non_burnable, NonBurnableFuelModel), + alignment=_build_alignment(output_resolution_m, align_to, align, resampling), + extent_buffer_cells=extent_buffer_cells, + name=name, + description=description, + tags=_opt(tags), + modifications=_opt(modifications), + ) + response = create_landfire_fbfm13.sync_detailed( + _domain_id(domain), client=ensure_client(), body=request_body + ) + return Grid._from_model(expect(response, HTTPStatus.CREATED)) + + def create_fuel_model_grid_from_landfire_fbfm40( domain, version: Optional[str] = None, @@ -1610,6 +1682,71 @@ def create_uniform_grid( # never appear on a grid that cannot perform them. +def create_fuel_grid_from_fbfm13_lookup( + source_grid: "Grid", + bands: list, + source_band: str = "fbfm13", + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, + modifications: Optional[list] = None, +) -> Grid: + """Create a fuel-parameter grid by looking up FBFM13 codes in a grid. + + Parameters + ---------- + source_grid : Grid + A completed grid carrying FBFM13 codes (produced by + :func:`create_fuel_model_grid_from_landfire_fbfm13`). + bands : list + The FBFM13 lookup bands to produce (``Fbfm13LookupBand`` members or + string keys such as ``"fuel_load.1hr"`` and ``"fuel_depth"``). + source_band : str, optional + The band in ``source_grid`` that holds FBFM13 codes (default + ``"fbfm13"``). + name, description : str, optional + Metadata for the new grid. + tags : List[str], optional + Tags for the new grid. + modifications : list, optional + Modification rules applied after the grid is built. + + Returns + ------- + Grid + The new pending fuel-parameter Grid. + + Raises + ------ + ValueError + If ``source_grid`` is not completed or has no ``source_band`` band. + """ + source_grid._require_completed("look up fuel parameters from") + band_keys = [band.key for band in source_grid.bands] + if source_band not in band_keys: + raise ValueError( + f"Grid {source_grid.id} has no {source_band!r} band to look up; pass " + "an FBFM13 fuel model grid (see " + f"create_fuel_model_grid_from_landfire_fbfm13). Available bands: " + f"{band_keys}." + ) + request_body = CreateFbfm13LookupRequest( + source_grid_id=source_grid.id, + bands=_enum_list(bands, Fbfm13LookupBand), + source_band=source_band, + name=name, + description=description, + tags=_opt(tags), + modifications=_opt(modifications), + ) + response = create_fbfm13_lookup.sync_detailed( + source_grid.domain_id, + client=ensure_client(), + body=request_body, + ) + return Grid._from_model(expect(response, HTTPStatus.CREATED)) + + def create_fuel_grid_from_fbfm40_lookup( source_grid: "Grid", bands: list, diff --git a/tests/v2/conftest.py b/tests/v2/conftest.py index 74efb9d..035f91a 100644 --- a/tests/v2/conftest.py +++ b/tests/v2/conftest.py @@ -21,6 +21,7 @@ create_road_feature_from_osm, ) from fastfuels_sdk.v2.grids import ( + create_fuel_model_grid_from_landfire_fbfm13, create_fuel_model_grid_from_landfire_fbfm40, create_pim_grid_from_treemap, create_topography_grid_from_3dep, @@ -99,6 +100,22 @@ def completed_fbfm40_grid(test_domain): return grid +@pytest.fixture(scope="session") +def completed_fbfm13_grid(test_domain): + """A completed LANDFIRE FBFM13 fuel model grid. READ-ONLY.""" + grid = create_fuel_model_grid_from_landfire_fbfm13( + test_domain, + version="2024", + remove_non_burnable=["NB1", "NB2"], + output_resolution_m=30, + name="test_fbfm13", + description="FBFM13 grid for testing v2 grid lookup operations", + tags=["test"], + ) + grid.wait() + return grid + + @pytest.fixture(scope="session") def completed_pim_grid(test_domain): """A completed TreeMap PIM grid. READ-ONLY: shared by every module.""" diff --git a/tests/v2/test_grids.py b/tests/v2/test_grids.py index 3537560..09208f2 100644 --- a/tests/v2/test_grids.py +++ b/tests/v2/test_grids.py @@ -26,7 +26,9 @@ create_canopy_height_grid_from_meta, create_canopy_height_grid_from_naip_chm, create_canopy_height_grid_from_point_cloud, + create_fuel_grid_from_fbfm13_lookup, create_fuel_grid_from_fbfm40_lookup, + create_fuel_model_grid_from_landfire_fbfm13, create_fuel_model_grid_from_landfire_fbfm40, create_fuel_model_grid_from_landfire_fccs, create_grid_from_geotiff, @@ -45,6 +47,7 @@ BandType, ContinuousBandSummary, DuetBand, + Fbfm13LookupBand, GridAlignmentDomainTarget, GridAlignmentGridTarget, GridAlignmentNativeTarget, @@ -502,6 +505,58 @@ def test_create(self, test_domain): grid.delete() +class TestCreateFuelModelGridFromLandfireFbfm13: + def test_builds_request(self, monkeypatch): + created = Grid( + id="fbfm13-grid-id", + domain_id="domain-id", + status=JobStatus.PENDING, + source=GridSource(), + bands=[], + ) + captured = {} + + def fake_create(domain_id, *, client, body): + captured.update(domain_id=domain_id, client=client, body=body) + return Response( + status_code=HTTPStatus.CREATED, + content=b"", + headers={}, + parsed=created, + ) + + client = object() + monkeypatch.setattr(grids, "ensure_client", lambda: client) + monkeypatch.setattr( + grids.create_landfire_fbfm13, + "sync_detailed", + fake_create, + ) + + grid = create_fuel_model_grid_from_landfire_fbfm13( + SimpleNamespace(id="domain-id"), + version="2024", + remove_non_burnable=["NB1", "NB2"], + output_resolution_m=30, + name="Anderson 13", + ) + + assert grid.id == "fbfm13-grid-id" + assert captured["domain_id"] == "domain-id" + assert captured["client"] is client + assert captured["body"].version.value == "2024" + assert [value.value for value in captured["body"].remove_non_burnable] == [ + "NB1", + "NB2", + ] + assert captured["body"].alignment.resolution == 30 + assert captured["body"].name == "Anderson 13" + + def test_completed_fixture(self, completed_fbfm13_grid): + assert completed_fbfm13_grid.status == JobStatus.COMPLETED + assert [band.key for band in completed_fbfm13_grid.bands] == ["fbfm13"] + + class TestMask: def test_mask_payload_shape(self): # Unit: a single band masks one feature to one replacement value @@ -823,6 +878,85 @@ def test_lookup_rejects_non_fbfm_grid(self, completed_topography_grid): ) +class TestFbfm13Lookup: + @staticmethod + def _source(status=JobStatus.COMPLETED, band="fbfm13"): + return Grid( + id="fbfm13-grid-id", + domain_id="domain-id", + status=status, + source=GridSource(), + bands=[Band(key=band, type_=BandType.CATEGORICAL, index=0)], + ) + + def test_builds_request(self, monkeypatch): + created = Grid( + id="fuel-grid-id", + domain_id="domain-id", + status=JobStatus.PENDING, + source=GridSource(), + bands=[], + ) + captured = {} + + def fake_create(domain_id, *, client, body): + captured.update(domain_id=domain_id, client=client, body=body) + return Response( + status_code=HTTPStatus.CREATED, + content=b"", + headers={}, + parsed=created, + ) + + client = object() + monkeypatch.setattr(grids, "ensure_client", lambda: client) + monkeypatch.setattr( + grids.create_fbfm13_lookup, + "sync_detailed", + fake_create, + ) + + result = create_fuel_grid_from_fbfm13_lookup( + self._source(), + bands=["fuel_load.1hr", Fbfm13LookupBand.FUEL_DEPTH], + name="Anderson fuel parameters", + ) + + assert result.id == "fuel-grid-id" + assert captured["body"].source_grid_id == "fbfm13-grid-id" + assert captured["body"].source_band == "fbfm13" + assert captured["body"].bands == [ + Fbfm13LookupBand.FUEL_LOAD_1HR, + Fbfm13LookupBand.FUEL_DEPTH, + ] + assert captured["body"].name == "Anderson fuel parameters" + + def test_lookup_returns_new_pending_grid(self, completed_fbfm13_grid): + fuel_grid = create_fuel_grid_from_fbfm13_lookup( + completed_fbfm13_grid, + bands=["fuel_load.1hr", "fuel_depth"], + name="throwaway_fbfm13_lookup", + ) + assert fuel_grid.id != completed_fbfm13_grid.id + assert fuel_grid.domain_id == completed_fbfm13_grid.domain_id + assert fuel_grid.status in (JobStatus.PENDING, JobStatus.RUNNING) + fuel_grid.delete() + + def test_requires_completed_source(self): + with pytest.raises(ValueError, match="look up fuel"): + create_fuel_grid_from_fbfm13_lookup( + self._source(status=JobStatus.PENDING), + bands=["fuel_load.1hr"], + ) + + def test_rejects_non_fbfm13_grid(self): + with pytest.raises(ValueError, match="fbfm13"): + create_fuel_grid_from_fbfm13_lookup( + self._source(band="elevation"), + bands=["fuel_load.1hr"], + ) + + class TestExport: def test_export_returns_pending_export(self, completed_topography_grid): # Topography is 2D, so a GeoTIFF export is valid From c81823028f881e117707f812a1081d0e6dc2cba0 Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Tue, 4 Aug 2026 09:40:07 -0600 Subject: [PATCH 58/71] Add FCCS fuel parameter lookup --- docs/v2/guides/creating-grids.md | 23 ++++++-- fastfuels_sdk/v2/grids.py | 69 ++++++++++++++++++++++++ tests/v2/conftest.py | 16 ++++++ tests/v2/test_grids.py | 90 ++++++++++++++++++++++++++++++++ 4 files changed, 195 insertions(+), 3 deletions(-) diff --git a/docs/v2/guides/creating-grids.md b/docs/v2/guides/creating-grids.md index c9ce1da..9eb2d8d 100644 --- a/docs/v2/guides/creating-grids.md +++ b/docs/v2/guides/creating-grids.md @@ -170,13 +170,30 @@ fuels.wait() ### From FCCS instead -Alternatively, build an already-parameterized fuels grid from the Fuel -Characteristic Classification System (FCCS), skipping the lookup step: +To use Fuel Characteristic Classification System (FCCS) fuelbeds, create the +categorical source grid and wait for it to complete: ```python -grid = ff.grids.create_fuel_model_grid_from_landfire_fccs( +fccs = ff.grids.create_fuel_model_grid_from_landfire_fccs( domain, remove_bare_ground=True, output_resolution_m=30 ) +fccs.wait() +``` + +Then look up any of the 12 available FCCS fuel-parameter bands, including +duff and live components: + +```python +fuels = ff.grids.create_fuel_grid_from_fccs_lookup( + fccs, + bands=[ + "fuel_load.litter", + "fuel_load.duff", + "duff_depth", + "fuel_load.live_shrub", + ], +) +fuels.wait() ``` FCCS takes the same alignment arguments as the other source grids diff --git a/fastfuels_sdk/v2/grids.py b/fastfuels_sdk/v2/grids.py index 12f1e06..926d7d9 100644 --- a/fastfuels_sdk/v2/grids.py +++ b/fastfuels_sdk/v2/grids.py @@ -17,6 +17,7 @@ check_3dep_coverage as check_3dep_coverage_endpoint, create_3dep_topography, create_duet_grid, + create_fccs_lookup, create_fbfm13_lookup, create_fbfm40_lookup, create_geotiff_upload, @@ -45,6 +46,7 @@ Grid as GridModel, ApplyGridModificationsRequest, CreateDuetRequest, + CreateFccsLookupRequest, CreateFbfm13LookupRequest, CreateFbfm40LookupRequest, CreateGeoTIFFUploadRequest, @@ -65,6 +67,7 @@ DuetBand, DuetCalibration, ExportGridRequest, + FccsLookupBand, Fbfm13LookupBand, Fbfm40LookupBand, GridAlignmentDomainTarget, @@ -119,6 +122,7 @@ "create_grid_from_netcdf", "create_uniform_grid", "create_surface_fuel_grid_from_duet", + "create_fuel_grid_from_fccs_lookup", "create_fuel_grid_from_fbfm13_lookup", "create_fuel_grid_from_fbfm40_lookup", "list_grids", @@ -1682,6 +1686,71 @@ def create_uniform_grid( # never appear on a grid that cannot perform them. +def create_fuel_grid_from_fccs_lookup( + source_grid: "Grid", + bands: list, + source_band: str = "fccs", + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, + modifications: Optional[list] = None, +) -> Grid: + """Create a fuel-parameter grid by looking up FCCS codes in a grid. + + Parameters + ---------- + source_grid : Grid + A completed grid carrying FCCS codes (produced by + :func:`create_fuel_model_grid_from_landfire_fccs`). + bands : list + The FCCS lookup bands to produce (``FccsLookupBand`` members or + string keys such as ``"fuel_load.duff"``, ``"duff_depth"``, and + ``"fuel_load.live_shrub"``). + source_band : str, optional + The band in ``source_grid`` that holds FCCS codes (default ``"fccs"``). + name, description : str, optional + Metadata for the new grid. + tags : List[str], optional + Tags for the new grid. + modifications : list, optional + Modification rules applied after the grid is built. + + Returns + ------- + Grid + The new pending fuel-parameter Grid. + + Raises + ------ + ValueError + If ``source_grid`` is not completed or has no ``source_band`` band. + """ + source_grid._require_completed("look up fuel parameters from") + band_keys = [band.key for band in source_grid.bands] + if source_band not in band_keys: + raise ValueError( + f"Grid {source_grid.id} has no {source_band!r} band to look up; pass " + "an FCCS fuel model grid (see " + f"create_fuel_model_grid_from_landfire_fccs). Available bands: " + f"{band_keys}." + ) + request_body = CreateFccsLookupRequest( + source_grid_id=source_grid.id, + bands=_enum_list(bands, FccsLookupBand), + source_band=source_band, + name=name, + description=description, + tags=_opt(tags), + modifications=_opt(modifications), + ) + response = create_fccs_lookup.sync_detailed( + source_grid.domain_id, + client=ensure_client(), + body=request_body, + ) + return Grid._from_model(expect(response, HTTPStatus.CREATED)) + + def create_fuel_grid_from_fbfm13_lookup( source_grid: "Grid", bands: list, diff --git a/tests/v2/conftest.py b/tests/v2/conftest.py index 035f91a..1df202f 100644 --- a/tests/v2/conftest.py +++ b/tests/v2/conftest.py @@ -23,6 +23,7 @@ from fastfuels_sdk.v2.grids import ( create_fuel_model_grid_from_landfire_fbfm13, create_fuel_model_grid_from_landfire_fbfm40, + create_fuel_model_grid_from_landfire_fccs, create_pim_grid_from_treemap, create_topography_grid_from_3dep, ) @@ -116,6 +117,21 @@ def completed_fbfm13_grid(test_domain): return grid +@pytest.fixture(scope="session") +def completed_fccs_grid(test_domain): + """A completed LANDFIRE FCCS fuelbed grid. READ-ONLY.""" + grid = create_fuel_model_grid_from_landfire_fccs( + test_domain, + remove_bare_ground=True, + output_resolution_m=30, + name="test_fccs", + description="FCCS grid for testing v2 grid lookup operations", + tags=["test"], + ) + grid.wait() + return grid + + @pytest.fixture(scope="session") def completed_pim_grid(test_domain): """A completed TreeMap PIM grid. READ-ONLY: shared by every module.""" diff --git a/tests/v2/test_grids.py b/tests/v2/test_grids.py index 09208f2..ff5e2f5 100644 --- a/tests/v2/test_grids.py +++ b/tests/v2/test_grids.py @@ -26,6 +26,7 @@ create_canopy_height_grid_from_meta, create_canopy_height_grid_from_naip_chm, create_canopy_height_grid_from_point_cloud, + create_fuel_grid_from_fccs_lookup, create_fuel_grid_from_fbfm13_lookup, create_fuel_grid_from_fbfm40_lookup, create_fuel_model_grid_from_landfire_fbfm13, @@ -47,6 +48,7 @@ BandType, ContinuousBandSummary, DuetBand, + FccsLookupBand, Fbfm13LookupBand, GridAlignmentDomainTarget, GridAlignmentGridTarget, @@ -957,6 +959,94 @@ def test_rejects_non_fbfm13_grid(self): ) +class TestFccsLookup: + @staticmethod + def _source(status=JobStatus.COMPLETED, band="fccs"): + return Grid( + id="fccs-grid-id", + domain_id="domain-id", + status=status, + source=GridSource(), + bands=[Band(key=band, type_=BandType.CATEGORICAL, index=0)], + ) + + def test_builds_request(self, monkeypatch): + created = Grid( + id="fuel-grid-id", + domain_id="domain-id", + status=JobStatus.PENDING, + source=GridSource(), + bands=[], + ) + captured = {} + + def fake_create(domain_id, *, client, body): + captured.update(domain_id=domain_id, client=client, body=body) + return Response( + status_code=HTTPStatus.CREATED, + content=b"", + headers={}, + parsed=created, + ) + + client = object() + monkeypatch.setattr(grids, "ensure_client", lambda: client) + monkeypatch.setattr( + grids.create_fccs_lookup, + "sync_detailed", + fake_create, + ) + + result = create_fuel_grid_from_fccs_lookup( + self._source(), + bands=["fuel_load.duff", FccsLookupBand.DUFF_DEPTH], + name="FCCS fuel parameters", + tags=["test"], + ) + + assert result.id == "fuel-grid-id" + assert captured["domain_id"] == "domain-id" + assert captured["client"] is client + assert captured["body"].source_grid_id == "fccs-grid-id" + assert captured["body"].source_band == "fccs" + assert captured["body"].bands == [ + FccsLookupBand.FUEL_LOAD_DUFF, + FccsLookupBand.DUFF_DEPTH, + ] + assert captured["body"].name == "FCCS fuel parameters" + assert captured["body"].tags == ["test"] + + def test_lookup_returns_new_pending_grid(self, completed_fccs_grid): + fuel_grid = create_fuel_grid_from_fccs_lookup( + completed_fccs_grid, + bands=[ + "fuel_load.litter", + "fuel_load.duff", + "duff_depth", + "fuel_load.live_shrub", + ], + name="throwaway_fccs_lookup", + ) + assert fuel_grid.id != completed_fccs_grid.id + assert fuel_grid.domain_id == completed_fccs_grid.domain_id + assert fuel_grid.status in (JobStatus.PENDING, JobStatus.RUNNING) + fuel_grid.delete() + + def test_requires_completed_source(self): + with pytest.raises(ValueError, match="look up fuel"): + create_fuel_grid_from_fccs_lookup( + self._source(status=JobStatus.PENDING), + bands=["fuel_load.duff"], + ) + + def test_rejects_non_fccs_grid(self): + with pytest.raises(ValueError, match="fccs"): + create_fuel_grid_from_fccs_lookup( + self._source(band="elevation"), + bands=["fuel_load.duff"], + ) + + class TestExport: def test_export_returns_pending_export(self, completed_topography_grid): # Topography is 2D, so a GeoTIFF export is valid From 137a6a2f6edfe299e0461eeba49b6b0108d5495b Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Tue, 4 Aug 2026 09:51:32 -0600 Subject: [PATCH 59/71] Add grid composition builders --- docs/v2/SUMMARY.md | 1 + docs/v2/guides/composing-grids.md | 65 ++++++ docs/v2/guides/creating-grids.md | 3 + docs/v2/reference.md | 2 + fastfuels_sdk/v2/__init__.py | 10 +- fastfuels_sdk/v2/compose.py | 326 ++++++++++++++++++++++++++++++ fastfuels_sdk/v2/grids.py | 196 ++++++++++++++++++ tests/v2/test_compose.py | 312 ++++++++++++++++++++++++++++ 8 files changed, 914 insertions(+), 1 deletion(-) create mode 100644 docs/v2/guides/composing-grids.md create mode 100644 fastfuels_sdk/v2/compose.py create mode 100644 tests/v2/test_compose.py diff --git a/docs/v2/SUMMARY.md b/docs/v2/SUMMARY.md index 2e92798..601ae6c 100644 --- a/docs/v2/SUMMARY.md +++ b/docs/v2/SUMMARY.md @@ -7,6 +7,7 @@ * [Features](guides/features.md) * Grids * [Creating grids](guides/creating-grids.md) + * [Composing grids](guides/composing-grids.md) * [Working with grids](guides/working-with-grids.md) * [Inventories](guides/inventories.md) * [Modify and treat inventories](guides/modify-treat-inventories.md) diff --git a/docs/v2/guides/composing-grids.md b/docs/v2/guides/composing-grids.md new file mode 100644 index 0000000..aa45e0d --- /dev/null +++ b/docs/v2/guides/composing-grids.md @@ -0,0 +1,65 @@ +# How to Compose Grids + +!!! warning "Beta" + The v2 SDK targets the FastFuels v2 API and is under active + development. The v1 SDK remains the default — import v2 explicitly + from `fastfuels_sdk.v2`. + +Use grid composition to copy and calculate bands from completed grids on the +same two-dimensional lattice. For creating the source grids, see +[Creating grids](creating-grids.md); for alignment options, see +[Align grids to each other](creating-grids.md#align-grids-to-each-other). + +## Prerequisites + +- The FastFuels SDK installed: `pip install fastfuels-sdk` +- A FastFuels API key in the `FASTFUELS_API_KEY` environment variable +- One or more completed 2D grids in the same domain, with the same CRS, + transform, and shape +- For the example below, a completed FBFM40 grid assigned to `fbfm40` + +## Select and calculate output bands + +Give each input grid an alias. Use that alias when referring to its bands in +select and compute operations: + +```python +import fastfuels_sdk.v2 as ff + +fuel_grid = ff.grids.create_fuel_grid_from_fbfm40_lookup( + fbfm40, + bands=["fuel_load.1hr", "fuel_depth"], +) +fuel_grid.wait() + +composed = ff.grids.create_grid_from_compose( + {"fuels": fuel_grid}, + select=[ + ff.compose.select("fuel_depth", "fuels.fuel_depth"), + ], + compute=[ + ff.compose.compute( + "fuel_load.1hr", + "multiply", + ["fuels.fuel_load.1hr", 0.5], + conditions=[ + ff.compose.condition("fuels.fuel_load.1hr", "gt", 0), + ], + else_=ff.compose.literal(0, unit="kg/m**2"), + ), + ], +) +composed.wait() +``` + +The alias mapping can contain more than one grid. Every band reference uses +the form `alias.band_key`; output names must be unique across the `select` and +`compute` lists. + +The arithmetic operators are `add`, `subtract`, `multiply`, `divide`, `min`, +`max`, and `average`. Conditions are ANDed together and require an `else_` +fallback. A fallback may be another band, a number, a typed literal, a fuel +model label, or an `ff.compose.inline_compute(...)` result. + +The API derives output units from compute operands. Pass `unit=` to +`ff.compose.compute` only to request a dimensionally compatible output unit. diff --git a/docs/v2/guides/creating-grids.md b/docs/v2/guides/creating-grids.md index 9eb2d8d..34bc29a 100644 --- a/docs/v2/guides/creating-grids.md +++ b/docs/v2/guides/creating-grids.md @@ -200,6 +200,9 @@ FCCS takes the same alignment arguments as the other source grids (`output_resolution_m`, `align_to`, `align`, `resampling`) — see [Align grids to each other](#align-grids-to-each-other). +To select and calculate bands across one or more completed grids, see +[Compose grids](composing-grids.md). + ## Canopy grids For crown-fire inputs, `create_canopy_fuel_grid_from_landfire` produces the diff --git a/docs/v2/reference.md b/docs/v2/reference.md index 2fbb504..0500363 100644 --- a/docs/v2/reference.md +++ b/docs/v2/reference.md @@ -16,6 +16,8 @@ ::: fastfuels_sdk.v2.calibrations +::: fastfuels_sdk.v2.compose + ::: fastfuels_sdk.v2._jobs ::: fastfuels_sdk.v2.api diff --git a/fastfuels_sdk/v2/__init__.py b/fastfuels_sdk/v2/__init__.py index 48471d0..3105410 100644 --- a/fastfuels_sdk/v2/__init__.py +++ b/fastfuels_sdk/v2/__init__.py @@ -18,7 +18,14 @@ ``ff.list_grids``, ``ff.wait_all``, ``ff.set_api_key``. """ -from fastfuels_sdk.v2 import exports, features, grids, inventories, point_clouds +from fastfuels_sdk.v2 import ( + compose, + exports, + features, + grids, + inventories, + point_clouds, +) from fastfuels_sdk.v2._jobs import JobFailedError, wait_all from fastfuels_sdk.v2.api import get_quotas, get_usage, set_api_key from fastfuels_sdk.v2.calibrations import duet_calibration @@ -43,6 +50,7 @@ __all__ = [ # Submodules (resource creators live here: ff.grids.create_*, ff.features.create_*) + "compose", "exports", "features", "grids", diff --git a/fastfuels_sdk/v2/compose.py b/fastfuels_sdk/v2/compose.py new file mode 100644 index 0000000..8973c9a --- /dev/null +++ b/fastfuels_sdk/v2/compose.py @@ -0,0 +1,326 @@ +"""Builders for grid-compose operations.""" + +from collections.abc import Iterable + +from fastfuels_sdk.v2.client_library.models import ( + ComposeAttributeCondition, + ComposeComparisonOperator, + ComposeCompute, + ComposeLiteral, + ComposeOperator, + ComposeSelect, + GridFeatureSpatialCondition, + GridGeometrySpatialCondition, + InlineCompute, +) +from fastfuels_sdk.v2.client_library.types import UNSET + +__all__ = ["select", "compute", "condition", "literal", "inline_compute"] + +_VARIADIC_OPERATORS = { + ComposeOperator.ADD, + ComposeOperator.AVERAGE, + ComposeOperator.MAX, + ComposeOperator.MIN, + ComposeOperator.MULTIPLY, +} +_ORDERING_OPERATORS = { + ComposeComparisonOperator.GE, + ComposeComparisonOperator.GT, + ComposeComparisonOperator.LE, + ComposeComparisonOperator.LT, +} +_CONDITION_TYPES = ( + ComposeAttributeCondition, + GridFeatureSpatialCondition, + GridGeometrySpatialCondition, +) +_ELSE_TYPES = (ComposeLiteral, InlineCompute, int, float, str) + + +def select( + output: str, + from_: str, + *, + conditions: Iterable | None = None, + else_=None, + name: str | None = None, + description: str | None = None, +) -> ComposeSelect: + """Build an operation that copies an input band into the output grid. + + Parameters + ---------- + output : str + Key for the output band. + from_ : str + Alias-qualified source band, such as ``"base.fbfm"``. + conditions : iterable, optional + Attribute or spatial conditions, all of which must match. Attribute + conditions can be built with :func:`condition`. + else_ : optional + Fallback band reference, number, typed :func:`literal`, fuel-model + label, or :func:`inline_compute`. Required when conditions are given. + name, description : str, optional + Display metadata for the output band. + + Returns + ------- + ComposeSelect + A select operation for + :func:`fastfuels_sdk.v2.grids.create_grid_from_compose`. + """ + _require_text(output, "output") + _require_band_reference(from_, "from_") + condition_list = _condition_list(conditions) + _validate_conditional_fallback(condition_list, else_) + return ComposeSelect( + output=output, + from_=from_, + conditions=condition_list, + else_=_else_value(else_), + name=_optional(name), + description=_optional(description), + ) + + +def compute( + output: str, + operator, + operands: Iterable, + *, + conditions: Iterable | None = None, + else_=None, + unit: str | None = None, + name: str | None = None, + description: str | None = None, +) -> ComposeCompute: + """Build an operation that computes an output band. + + Parameters + ---------- + output : str + Key for the output band. + operator : str or ComposeOperator + ``"add"``, ``"subtract"``, ``"multiply"``, ``"divide"``, + ``"min"``, ``"max"``, or ``"average"``. + operands : iterable + Alias-qualified band references, bare numbers, or typed literals. + At least one operand must be a band reference. + conditions : iterable, optional + Attribute or spatial conditions, all of which must match. + else_ : optional + Fallback value. Required when conditions are given. + unit : str, optional + Canonical unit in which to express the computed output. + name, description : str, optional + Display metadata for the output band. + + Returns + ------- + ComposeCompute + A compute operation for + :func:`fastfuels_sdk.v2.grids.create_grid_from_compose`. + """ + _require_text(output, "output") + operator = _operator(operator) + operand_list = _operands(operator, operands) + condition_list = _condition_list(conditions) + _validate_conditional_fallback(condition_list, else_) + return ComposeCompute( + output=output, + operator=operator, + operands=operand_list, + conditions=condition_list, + else_=_else_value(else_), + unit=_optional(unit), + name=_optional(name), + description=_optional(description), + ) + + +def condition(band: str, operator, value) -> ComposeAttributeCondition: + """Build an attribute condition for a compose operation. + + Parameters + ---------- + band : str + Alias-qualified input band to test. + operator : str or ComposeComparisonOperator + ``"eq"``, ``"ne"``, ``"gt"``, ``"lt"``, ``"ge"``, ``"le"``, + or ``"in"``. + value : int, float, str, or list + Comparison value. ``"in"`` requires a list; ordering comparisons + require a scalar. + + Returns + ------- + ComposeAttributeCondition + A condition for :func:`select` or :func:`compute`. + """ + _require_band_reference(band, "band") + try: + operator = ComposeComparisonOperator(operator) + except (TypeError, ValueError): + choices = [item.value for item in ComposeComparisonOperator] + raise ValueError( + f"Unknown compose comparison operator {operator!r}. Use one of {choices}." + ) from None + + is_list = isinstance(value, list) + values = value if is_list else [value] + if not all(_is_scalar(item) for item in values): + raise TypeError("Compose condition values must be numbers or strings.") + if operator == ComposeComparisonOperator.IN and not is_list: + raise ValueError("The 'in' compose condition requires a list value.") + if operator in _ORDERING_OPERATORS and is_list: + raise ValueError(f"The {operator.value!r} condition requires a scalar value.") + return ComposeAttributeCondition(band=band, operator=operator, value=value) + + +def literal(value, unit: str | None = None) -> ComposeLiteral: + """Build a typed literal for a compose operand or fallback. + + Parameters + ---------- + value : int, float, or str + Literal value. + unit : str, optional + Canonical unit for a numeric literal. String literals are unitless. + + Returns + ------- + ComposeLiteral + A typed compose literal. + """ + if not _is_scalar(value): + raise TypeError("Compose literal values must be numbers or strings.") + if isinstance(value, str) and unit is not None: + raise ValueError("String compose literals cannot carry a unit.") + if unit is not None and not isinstance(unit, str): + raise TypeError("unit must be a string or None.") + return ComposeLiteral(value=value, unit=_optional(unit)) + + +def inline_compute(operator, operands: Iterable) -> InlineCompute: + """Build a computation for the fallback branch of a compose operation. + + Parameters + ---------- + operator : str or ComposeOperator + Arithmetic operator. + operands : iterable + Alias-qualified band references, bare numbers, or typed literals. + + Returns + ------- + InlineCompute + A computed fallback for ``else_=``. + """ + operator = _operator(operator) + return InlineCompute(operator=operator, operands=_operands(operator, operands)) + + +def _optional(value): + return UNSET if value is None else value + + +def _require_text(value, name: str) -> None: + if not isinstance(value, str) or not value: + raise TypeError(f"{name} must be a nonempty string.") + + +def _require_band_reference(value, name: str) -> None: + _require_text(value, name) + alias, separator, band = value.partition(".") + if not separator or not alias or not band: + raise ValueError( + f"{name} must be an alias-qualified band reference such as " + "'base.fuel_load.1hr'." + ) + + +def _operator(value) -> ComposeOperator: + try: + return ComposeOperator(value) + except (TypeError, ValueError): + choices = [item.value for item in ComposeOperator] + raise ValueError( + f"Unknown compose operator {value!r}. Use one of {choices}." + ) from None + + +def _operands(operator: ComposeOperator, values: Iterable) -> list: + if isinstance(values, (str, bytes)): + raise TypeError("operands must be an iterable of compose operands.") + try: + operands = list(values) + except TypeError: + raise TypeError("operands must be an iterable of compose operands.") from None + + expected = "at least two" if operator in _VARIADIC_OPERATORS else "exactly two" + valid_arity = ( + len(operands) >= 2 if operator in _VARIADIC_OPERATORS else len(operands) == 2 + ) + if not valid_arity: + raise ValueError( + f"The {operator.value!r} operator requires {expected} operands." + ) + for operand in operands: + if isinstance(operand, str): + _require_band_reference(operand, "operand") + elif isinstance(operand, ComposeLiteral): + if isinstance(operand.value, str): + raise ValueError("String literals are not valid compute operands.") + elif not _is_number(operand): + raise TypeError( + "Compose operands must be band references, numbers, or typed literals." + ) + if not any(isinstance(operand, str) for operand in operands): + raise ValueError("A compute operation must include at least one band operand.") + return operands + + +def _condition_list(values: Iterable | None): + if values is None: + return UNSET + if isinstance(values, _CONDITION_TYPES): + raise TypeError("conditions must be an iterable of compose conditions.") + try: + conditions = list(values) + except TypeError: + raise TypeError( + "conditions must be an iterable of compose conditions." + ) from None + if not all(isinstance(item, _CONDITION_TYPES) for item in conditions): + raise TypeError( + "conditions must contain compose attribute or grid spatial conditions." + ) + return conditions + + +def _validate_conditional_fallback(conditions, else_) -> None: + has_conditions = conditions is not UNSET and bool(conditions) + if has_conditions and else_ is None: + raise ValueError("else_ is required when compose conditions are provided.") + if not has_conditions and else_ is not None: + raise ValueError("else_ requires at least one compose condition.") + + +def _else_value(value): + if value is None: + return UNSET + if isinstance(value, bool) or not isinstance(value, _ELSE_TYPES): + raise TypeError( + "else_ must be a band reference, number, fuel-model label, typed " + "literal, or inline compute." + ) + return value + + +def _is_number(value) -> bool: + return not isinstance(value, bool) and isinstance(value, (int, float)) + + +def _is_scalar(value) -> bool: + return _is_number(value) or isinstance(value, str) diff --git a/fastfuels_sdk/v2/grids.py b/fastfuels_sdk/v2/grids.py index 926d7d9..a8cfedc 100644 --- a/fastfuels_sdk/v2/grids.py +++ b/fastfuels_sdk/v2/grids.py @@ -4,6 +4,8 @@ # Core imports import json +import re +from collections.abc import Mapping from http import HTTPStatus from typing import TYPE_CHECKING, Dict, List, Optional, Union @@ -16,6 +18,7 @@ apply_grid_modifications as apply_grid_modifications_endpoint, check_3dep_coverage as check_3dep_coverage_endpoint, create_3dep_topography, + create_compose_grid, create_duet_grid, create_fccs_lookup, create_fbfm13_lookup, @@ -45,7 +48,13 @@ from fastfuels_sdk.v2.client_library.models import ( Grid as GridModel, ApplyGridModificationsRequest, + ComposeAttributeCondition, + ComposeCompute, + ComposeInput, + ComposeLiteral, + ComposeSelect, CreateDuetRequest, + CreateComposeRequest, CreateFccsLookupRequest, CreateFbfm13LookupRequest, CreateFbfm40LookupRequest, @@ -77,6 +86,7 @@ GridDataOrder, GridExportFormat, GridSortField, + InlineCompute, JobStatus, LandfireCanopyFuelBand, LandfireCanopyVersion, @@ -120,6 +130,7 @@ "create_pim_grid_from_treemap", "create_grid_from_geotiff", "create_grid_from_netcdf", + "create_grid_from_compose", "create_uniform_grid", "create_surface_fuel_grid_from_duet", "create_fuel_grid_from_fccs_lookup", @@ -1676,6 +1687,191 @@ def create_uniform_grid( return Grid._from_model(expect(response, HTTPStatus.CREATED)) +def create_grid_from_compose( + inputs: Mapping[str, "Grid"], + *, + select: Optional[list] = None, + compute: Optional[list] = None, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, + modifications: Optional[list] = None, +) -> Grid: + """Create a grid by selecting and computing bands from aligned grids. + + Parameters + ---------- + inputs : mapping of str to Grid + Alias-to-grid mapping for completed, aligned 2D grids in one domain. + Operations refer to their bands as ``"alias.band_key"``. + select : list of ComposeSelect, optional + Bands to copy, built with :func:`fastfuels_sdk.v2.compose.select`. + compute : list of ComposeCompute, optional + Bands to calculate, built with + :func:`fastfuels_sdk.v2.compose.compute`. + name, description : str, optional + Metadata for the new grid. + tags : List[str], optional + Tags for the new grid. + modifications : list, optional + Modification rules applied after the grid is composed. + + Returns + ------- + Grid + The new pending composed Grid. + + Raises + ------ + TypeError + If inputs or operations have the wrong shape. + ValueError + If inputs are empty, incomplete, duplicated, or from different + domains; if aliases or band references are invalid; or if operation + outputs are empty or duplicated. + + Examples + -------- + >>> import fastfuels_sdk.v2 as ff + >>> composed = ff.grids.create_grid_from_compose( + ... {"fuels": fuel_grid}, + ... select=[ff.compose.select("fuel_depth", "fuels.fuel_depth")], + ... compute=[ + ... ff.compose.compute( + ... "fuel_load.1hr", + ... "multiply", + ... ["fuels.fuel_load.1hr", 0.5], + ... ) + ... ], + ... ) + >>> composed.wait() + """ + if not isinstance(inputs, Mapping): + raise TypeError("inputs must be an alias-to-Grid mapping.") + if not inputs: + raise ValueError("inputs must contain at least one aliased Grid.") + + compose_inputs = [] + grids_by_alias = {} + seen_grid_ids = set() + domain_id = None + for alias, grid in inputs.items(): + if ( + not isinstance(alias, str) + or re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", alias) is None + ): + raise ValueError( + f"Invalid compose alias {alias!r}; aliases must start with a " + "letter and contain only letters, numbers, and underscores." + ) + if not isinstance(grid, Grid): + raise TypeError(f"Compose input {alias!r} must be a Grid object.") + grid._require_completed("compose") + if grid.id in seen_grid_ids: + raise ValueError(f"Grid {grid.id!r} is assigned more than one alias.") + if domain_id is None: + domain_id = grid.domain_id + elif grid.domain_id != domain_id: + raise ValueError("All compose input grids must belong to the same domain.") + seen_grid_ids.add(grid.id) + grids_by_alias[alias] = grid + compose_inputs.append(ComposeInput(grid_id=grid.id, alias=alias)) + + select_operations = _compose_operations(select, ComposeSelect, "select") + compute_operations = _compose_operations(compute, ComposeCompute, "compute") + operations = [*select_operations, *compute_operations] + if not operations: + raise ValueError("At least one select or compute operation is required.") + outputs = [operation.output for operation in operations] + if any(not isinstance(output, str) or not output for output in outputs): + raise ValueError("Every compose operation requires a nonempty output band.") + duplicates = sorted({output for output in outputs if outputs.count(output) > 1}) + if duplicates: + raise ValueError(f"Compose output bands must be unique: {duplicates}.") + for operation in operations: + _validate_compose_references(operation, grids_by_alias) + + request_body = CreateComposeRequest( + inputs=compose_inputs, + select=select_operations if select_operations else UNSET, + compute=compute_operations if compute_operations else UNSET, + name=name, + description=description, + tags=_opt(tags), + modifications=_opt(modifications), + ) + response = create_compose_grid.sync_detailed( + domain_id, + client=ensure_client(), + body=request_body, + ) + return Grid._from_model(expect(response, HTTPStatus.CREATED)) + + +def _compose_operations(value, model_type, name: str) -> list: + """Normalize and type-check one compose operation collection.""" + if value is None: + return [] + if isinstance(value, model_type): + raise TypeError(f"{name} must be a list of {model_type.__name__} objects.") + try: + operations = list(value) + except TypeError: + raise TypeError( + f"{name} must be a list of {model_type.__name__} objects." + ) from None + if not all(isinstance(operation, model_type) for operation in operations): + raise TypeError(f"{name} must contain only {model_type.__name__} objects.") + return operations + + +def _validate_compose_references(operation, grids_by_alias) -> None: + """Catch alias and band typos before dispatching a compose request.""" + if isinstance(operation, ComposeSelect): + _validate_compose_reference(operation.from_, grids_by_alias) + else: + _validate_compose_operands(operation.operands, grids_by_alias) + + conditions = operation.conditions + if conditions is not UNSET and conditions is not None: + for condition in conditions: + if isinstance(condition, ComposeAttributeCondition): + _validate_compose_reference(condition.band, grids_by_alias) + + else_value = operation.else_ + if ( + else_value is UNSET + or else_value is None + or isinstance(else_value, ComposeLiteral) + ): + return + if isinstance(else_value, InlineCompute): + _validate_compose_operands(else_value.operands, grids_by_alias) + elif isinstance(else_value, str) and "." in else_value: + _validate_compose_reference(else_value, grids_by_alias) + + +def _validate_compose_operands(operands, grids_by_alias) -> None: + for operand in operands: + if isinstance(operand, str): + _validate_compose_reference(operand, grids_by_alias) + + +def _validate_compose_reference(reference: str, grids_by_alias) -> None: + alias, separator, band_key = reference.partition(".") + if not separator or alias not in grids_by_alias or not band_key: + raise ValueError( + f"Unknown compose band reference {reference!r}; use " + "'alias.band_key' with an alias from inputs." + ) + available = [band.key for band in grids_by_alias[alias].bands] + if band_key not in available: + raise ValueError( + f"Grid {grids_by_alias[alias].id} has no {band_key!r} band for " + f"compose alias {alias!r}. Available bands: {available}." + ) + + # --------------------------------------------------------------------------- # Derive a grid from a grid you hold (source-specific transforms) # --------------------------------------------------------------------------- diff --git a/tests/v2/test_compose.py b/tests/v2/test_compose.py new file mode 100644 index 0000000..977d529 --- /dev/null +++ b/tests/v2/test_compose.py @@ -0,0 +1,312 @@ +"""Tests for v2 grid-compose builders and creator.""" + +from http import HTTPStatus + +import numpy as np +import pytest + +from fastfuels_sdk.v2 import compose, grids +from fastfuels_sdk.v2.client_library.models import ( + Band, + BandType, + ComposeComparisonOperator, + ComposeCompute, + ComposeLiteral, + ComposeOperator, + ComposeSelect, + GridSource, + InlineCompute, + JobStatus, +) +from fastfuels_sdk.v2.client_library.types import Response +from fastfuels_sdk.v2.grids import ( + Grid, + create_fuel_grid_from_fbfm40_lookup, + create_grid_from_compose, +) + + +def _grid( + id_="source-grid", + domain_id="domain-id", + status=JobStatus.COMPLETED, + bands=None, +): + return Grid( + id=id_, + domain_id=domain_id, + status=status, + source=GridSource(), + bands=bands + or [ + Band( + key="fuel_load.1hr", + type_=BandType.CONTINUOUS, + index=0, + unit="kg/m**2", + ), + Band( + key="fuel_depth", + type_=BandType.CONTINUOUS, + index=1, + unit="m", + ), + ], + ) + + +class TestComposeBuilders: + def test_select(self): + operation = compose.select("fuel_depth", "fuels.fuel_depth") + + assert isinstance(operation, ComposeSelect) + assert operation.to_dict() == { + "output": "fuel_depth", + "from": "fuels.fuel_depth", + } + + def test_compute(self): + operation = compose.compute( + "fuel_load.1hr", + "multiply", + ["fuels.fuel_load.1hr", 0.5], + unit="kg/m**2", + ) + + assert isinstance(operation, ComposeCompute) + assert operation.operator == ComposeOperator.MULTIPLY + assert operation.to_dict() == { + "operator": "multiply", + "operands": ["fuels.fuel_load.1hr", 0.5], + "output": "fuel_load.1hr", + "unit": "kg/m**2", + } + + def test_conditional_typed_literal_fallback(self): + where = compose.condition("fuels.fbfm", "in", ["GR1", "GR2"]) + fallback = compose.literal(0, unit="kg/m**2") + operation = compose.select( + "fuel_load.1hr", + "fuels.fuel_load.1hr", + conditions=[where], + else_=fallback, + ) + + assert where.operator == ComposeComparisonOperator.IN + assert isinstance(fallback, ComposeLiteral) + assert operation.to_dict()["conditions"] == [ + { + "band": "fuels.fbfm", + "operator": "in", + "value": ["GR1", "GR2"], + } + ] + assert operation.to_dict()["else"] == { + "type": "literal", + "value": 0, + "unit": "kg/m**2", + } + + def test_inline_compute(self): + fallback = compose.inline_compute( + "average", + ["base.fuel_load.1hr", "alternate.fuel_load.1hr"], + ) + + assert isinstance(fallback, InlineCompute) + assert fallback.to_dict() == { + "operator": "average", + "operands": [ + "base.fuel_load.1hr", + "alternate.fuel_load.1hr", + ], + } + + @pytest.mark.parametrize( + "operator,operands", + [ + ("add", ["fuels.fuel_load.1hr"]), + ("subtract", ["fuels.fuel_load.1hr", 1, 2]), + ("divide", [1, 2]), + ], + ) + def test_compute_rejects_invalid_operands(self, operator, operands): + with pytest.raises(ValueError): + compose.compute("output", operator, operands) + + def test_condition_in_requires_list(self): + with pytest.raises(ValueError, match="requires a list"): + compose.condition("fuels.fbfm", "in", "GR1") + + def test_condition_ordering_requires_scalar(self): + with pytest.raises(ValueError, match="requires a scalar"): + compose.condition("fuels.fuel_depth", "gt", [0, 1]) + + def test_conditions_require_fallback(self): + with pytest.raises(ValueError, match="else_"): + compose.select( + "fuel_depth", + "fuels.fuel_depth", + conditions=[compose.condition("fuels.fuel_depth", "gt", 0)], + ) + + def test_string_literal_cannot_have_unit(self): + with pytest.raises(ValueError, match="cannot carry a unit"): + compose.literal("GR1", unit="kg/m**2") + + +class TestCreateGridFromCompose: + def test_builds_request(self, monkeypatch): + source = _grid() + created = _grid(id_="composed-grid", status=JobStatus.PENDING) + captured = {} + + def fake_create(domain_id, *, client, body): + captured.update(domain_id=domain_id, client=client, body=body) + return Response( + status_code=HTTPStatus.CREATED, + content=b"", + headers={}, + parsed=created, + ) + + client = object() + monkeypatch.setattr(grids, "ensure_client", lambda: client) + monkeypatch.setattr( + grids.create_compose_grid, + "sync_detailed", + fake_create, + ) + + result = create_grid_from_compose( + {"fuels": source}, + select=[compose.select("fuel_depth", "fuels.fuel_depth")], + compute=[ + compose.compute( + "fuel_load.1hr", + "multiply", + ["fuels.fuel_load.1hr", 0.5], + ) + ], + name="Composed fuels", + tags=["test"], + ) + + assert result.id == "composed-grid" + assert captured["domain_id"] == "domain-id" + assert captured["client"] is client + assert [item.to_dict() for item in captured["body"].inputs] == [ + {"grid_id": "source-grid", "alias": "fuels"} + ] + assert captured["body"].select[0].output == "fuel_depth" + assert captured["body"].compute[0].operator == ComposeOperator.MULTIPLY + assert captured["body"].name == "Composed fuels" + assert captured["body"].tags == ["test"] + + @pytest.mark.parametrize( + "inputs,match,error", + [ + ([], "mapping", TypeError), + ({}, "at least one", ValueError), + ({"1bad": _grid()}, "Invalid compose alias", ValueError), + ( + {"a": _grid(status=JobStatus.PENDING)}, + "Cannot compose", + ValueError, + ), + ( + {"a": _grid(), "b": _grid()}, + "more than one alias", + ValueError, + ), + ( + {"a": _grid(), "b": _grid(id_="other", domain_id="other")}, + "same domain", + ValueError, + ), + ], + ) + def test_rejects_invalid_inputs(self, inputs, match, error): + with pytest.raises(error, match=match): + create_grid_from_compose( + inputs, + select=[compose.select("fuel_depth", "a.fuel_depth")], + ) + + def test_requires_an_operation(self): + with pytest.raises(ValueError, match="At least one"): + create_grid_from_compose({"fuels": _grid()}) + + def test_rejects_duplicate_outputs(self): + with pytest.raises(ValueError, match="must be unique"): + create_grid_from_compose( + {"fuels": _grid()}, + select=[compose.select("fuel", "fuels.fuel_depth")], + compute=[ + compose.compute( + "fuel", + "multiply", + ["fuels.fuel_load.1hr", 0.5], + ) + ], + ) + + def test_rejects_empty_output(self): + with pytest.raises(ValueError, match="nonempty output"): + create_grid_from_compose( + {"fuels": _grid()}, + select=[ComposeSelect(output="", from_="fuels.fuel_depth")], + ) + + @pytest.mark.parametrize( + "reference,match", + [ + ("other.fuel_depth", "Unknown compose band reference"), + ("fuels.unknown", "has no 'unknown' band"), + ], + ) + def test_rejects_unknown_references(self, reference, match): + with pytest.raises(ValueError, match=match): + create_grid_from_compose( + {"fuels": _grid()}, + select=[compose.select("fuel_depth", reference)], + ) + + def test_create_live(self, completed_fbfm40_grid): + fuel_grid = create_fuel_grid_from_fbfm40_lookup( + completed_fbfm40_grid, + bands=["fuel_load.1hr", "fuel_depth"], + name="test_compose_source", + tags=["test"], + ) + composed = None + try: + fuel_grid.wait() + composed = create_grid_from_compose( + {"fuels": fuel_grid}, + select=[compose.select("fuel_depth", "fuels.fuel_depth")], + compute=[ + compose.compute( + "fuel_load.1hr", + "multiply", + ["fuels.fuel_load.1hr", 0.5], + conditions=[compose.condition("fuels.fuel_load.1hr", "gt", 0)], + else_=compose.literal(0, unit="kg/m**2"), + ) + ], + name="test_composed_fuels", + tags=["test"], + ) + composed.wait() + assert composed.status == JobStatus.COMPLETED + assert [band.key for band in composed.bands] == [ + "fuel_depth", + "fuel_load.1hr", + ] + fuel_load = composed.to_numpy("fuel_load.1hr") + assert fuel_load.ndim == 2 + assert np.isfinite(fuel_load).any() + finally: + if composed is not None: + composed.delete() + fuel_grid.delete() From 649aa8a395a519261128691d41e664501d39f56c Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Tue, 4 Aug 2026 10:20:56 -0600 Subject: [PATCH 60/71] Add landscape grid exports --- docs/v2/guides/exports.md | 41 +++++- fastfuels_sdk/v2/exports.py | 155 ++++++++++++++++++++++- tests/v2/test_exports.py | 245 +++++++++++++++++++++++++++++++++++- 3 files changed, 432 insertions(+), 9 deletions(-) diff --git a/docs/v2/guides/exports.md b/docs/v2/guides/exports.md index e698b46..7017c96 100644 --- a/docs/v2/guides/exports.md +++ b/docs/v2/guides/exports.md @@ -8,13 +8,14 @@ An export packages a resource's data into a downloadable file: a [grid](working-with-grids.md) as GeoTIFF/NetCDF/zarr, an [inventory](inventories.md) as Parquet/CSV/GeoJSON/GeoPackage, or several -grids bundled into a QUIC-Fire-loadable archive. Exports run as background -jobs and expose a signed download URL on completion. +grids assembled into a fire-behavior landscape or QUIC-Fire-loadable archive. +Exports run as background jobs and expose a signed download URL on completion. The pattern is the same everywhere: exporting a resource you **hold** is a method on it (`grid.export(...)`, `inventory.export(...)`); the QUIC-Fire -bundle is **assembled from many** grids, so it is a module-level function -(`ff.exports.create_quicfire_export(...)`). +bundle and landscape are **assembled from many** grids, so they are +module-level functions (`ff.exports.create_quicfire_export(...)` and +`ff.exports.create_landscape_export(...)`). ## Prerequisites @@ -64,6 +65,35 @@ path = export.wait().to_file("trees.csv") 'x,y,fia_species_code,fia_status_code,dbh,height,crown_ratio' ``` +## Create a fire-behavior landscape + +To create an 8-band LANDFIRE-style GeoTIFF for FlamMap, IFTDSS, or WFDSS, +assign the terrain, fuel-model, and canopy roles to completed 30 m grids: + +```python +export = ff.exports.create_landscape_export( + domain, + fire_behavior_fuel_model="fbfm40", + elevation=(topography, "elevation"), + slope=(topography, "slope"), + aspect=(topography, "aspect"), + fuel_model=(fuel_models, "fbfm"), + canopy_cover=(canopy, "cc"), + canopy_height=(canopy, "chm"), + canopy_base_height=(canopy, "cbh"), + canopy_bulk_density=(canopy, "cbd"), + name="Landscape", +) +export.wait().to_file("landscape.tif") +``` + +Use `fire_behavior_fuel_model="fbfm13"` with an FBFM13 source. To export a +different domain-anchored cell size, pass `resolution_m=` after building all +role grids at that resolution. To preserve an existing lattice instead, pass +`align_to=`. The exporter can crop oversized inputs but does not +resample or reproject them; use the grid alignment options before exporting +if the API reports an alignment error. + ## Bundle grids for QUIC-Fire `create_quicfire_export` packages surface fuel, canopy fuel, and @@ -146,10 +176,11 @@ export = ff.get_export("4a56bae0cd5e481aa1617cb894a9a7f3") ## List exports To list your exports, optionally narrowed to a domain, a source name -(the format, or `"quicfire"` for bundles), or a tag: +(the format, `"landscape"`, or `"quicfire"`), or a tag: ```python exports = ff.list_exports(domain) +landscapes = ff.list_exports(source="landscape") bundles = ff.list_exports(source="quicfire") tagged = ff.list_exports(tag="run-42") ``` diff --git a/fastfuels_sdk/v2/exports.py b/fastfuels_sdk/v2/exports.py index ed8811b..a86d850 100644 --- a/fastfuels_sdk/v2/exports.py +++ b/fastfuels_sdk/v2/exports.py @@ -20,6 +20,7 @@ update_export, ) from fastfuels_sdk.v2.client_library.api.grids import ( + create_landscape_export as create_landscape_export_endpoint, create_quicfire_export as create_quicfire_export_endpoint, ) from fastfuels_sdk.v2.client_library.models import ( @@ -27,6 +28,11 @@ ExportSortField, FieldSource, JobStatus, + LandscapeExportAlignmentDomainTarget, + LandscapeExportAlignmentGridTarget, + LandscapeExportRequest, + LandscapeExportRequestFireBehaviorFuelModel, + LandscapeFieldSource, ListExportsResponse, QuicfireExportRequest, QuicfireExportRequestMoistMerge, @@ -43,6 +49,7 @@ __all__ = [ "Export", + "create_landscape_export", "create_quicfire_export", "list_exports", "get_export", @@ -73,6 +80,19 @@ def _field_source(value, role: str) -> FieldSource: ) +def _landscape_field_source(value, role: str) -> LandscapeFieldSource: + """Build a landscape field source from a ``(grid, band)`` pair.""" + if isinstance(value, LandscapeFieldSource): + return value + if isinstance(value, (tuple, list)) and len(value) == 2: + grid, band = value + return LandscapeFieldSource(grid_id=_domain_id(grid), band=band) + raise ValueError( + f"{role} must be a (grid, band) tuple or a LandscapeFieldSource, " + f"got {value!r}" + ) + + class Export(ExportModel): """Export resource for the FastFuels v2 API. @@ -122,6 +142,7 @@ class Export(ExportModel): See Also -------- + create_landscape_export : Assemble an 8-band fire-behavior landscape. create_quicfire_export : Bundle fuel grids into a QUIC-Fire archive. list_exports : List your exports. """ @@ -325,6 +346,136 @@ def to_json(self) -> str: # --------------------------------------------------------------------------- +def create_landscape_export( + domain, + *, + fire_behavior_fuel_model: str, + elevation: Tuple, + slope: Tuple, + aspect: Tuple, + fuel_model: Tuple, + canopy_cover: Tuple, + canopy_height: Tuple, + canopy_base_height: Tuple, + canopy_bulk_density: Tuple, + resolution_m: Optional[float] = None, + align_to=None, + expiration_days: int = 7, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, +) -> Export: + """Assemble terrain, fuel-model, and canopy grids into a landscape export. + + Produces an 8-band LANDFIRE-style GeoTIFF for FlamMap, IFTDSS, and WFDSS. + Every role is a ``(grid, band)`` pair. Role grids must already share the + selected 2D lattice and cover its full extent; the exporter never + resamples or reprojects them. + + Parameters + ---------- + domain : Domain or str + The domain (or its id) the source grids belong to. + fire_behavior_fuel_model : {"fbfm40", "fbfm13"} + How codes in ``fuel_model`` should be interpreted. + elevation, slope, aspect : tuple + ``(grid, band)`` roles for terrain elevation (m), slope (degrees), and + aspect (degrees). + fuel_model : tuple + ``(grid, band)`` for categorical FBFM40 or FBFM13 codes. + canopy_cover : tuple + ``(grid, band)`` for canopy cover (%). + canopy_height : tuple + ``(grid, band)`` for canopy height (m). + canopy_base_height : tuple + ``(grid, band)`` for canopy base height (m). + canopy_bulk_density : tuple + ``(grid, band)`` for canopy bulk density (kg/m³). + resolution_m : float, optional + Domain-anchored landscape cell size. Defaults to 30 m. Mutually + exclusive with ``align_to``. + align_to : Grid or str, optional + Match an existing grid's CRS, transform, and shape. Mutually exclusive + with ``resolution_m``. + expiration_days : int, optional + Days until the signed download URL expires (1-7, default 7). + name, description : str, optional + Metadata for the export. + tags : List[str], optional + Tags for the export. + + Returns + ------- + Export + The new pending Export. Call :meth:`Export.wait` and then + :meth:`Export.to_file` to download ``landscape.tif``. + + Raises + ------ + ValueError + If ``align_to`` and ``resolution_m`` are combined, a role is not a + field-source pair, or the fuel-model declaration is invalid. + UnprocessableEntityException + If a source band has the wrong unit or dimensionality, or a role grid + is not aligned with or does not cover the landscape lattice. + + Examples + -------- + >>> export = ff.exports.create_landscape_export( + ... domain, + ... fire_behavior_fuel_model="fbfm40", + ... elevation=(topography, "elevation"), + ... slope=(topography, "slope"), + ... aspect=(topography, "aspect"), + ... fuel_model=(fuel_models, "fbfm"), + ... canopy_cover=(canopy, "cc"), + ... canopy_height=(canopy, "chm"), + ... canopy_base_height=(canopy, "cbh"), + ... canopy_bulk_density=(canopy, "cbd"), + ... ) + >>> export.wait().to_file("landscape.tif") + """ + if align_to is not None and resolution_m is not None: + raise ValueError("Specify either align_to or resolution_m, not both.") + if align_to is not None: + alignment = LandscapeExportAlignmentGridTarget( + target="grid", grid_id=_domain_id(align_to) + ) + elif resolution_m is not None: + alignment = LandscapeExportAlignmentDomainTarget( + target="domain", resolution=resolution_m + ) + else: + alignment = UNSET + + request_body = LandscapeExportRequest( + fire_behavior_fuel_model=LandscapeExportRequestFireBehaviorFuelModel( + fire_behavior_fuel_model + ), + elevation=_landscape_field_source(elevation, "elevation"), + slope=_landscape_field_source(slope, "slope"), + aspect=_landscape_field_source(aspect, "aspect"), + fuel_model=_landscape_field_source(fuel_model, "fuel_model"), + canopy_cover=_landscape_field_source(canopy_cover, "canopy_cover"), + canopy_height=_landscape_field_source(canopy_height, "canopy_height"), + canopy_base_height=_landscape_field_source( + canopy_base_height, "canopy_base_height" + ), + canopy_bulk_density=_landscape_field_source( + canopy_bulk_density, "canopy_bulk_density" + ), + alignment=alignment, + expiration_days=expiration_days, + name=name, + description=description, + tags=_opt(tags), + ) + response = create_landscape_export_endpoint.sync_detailed( + _domain_id(domain), client=ensure_client(), body=request_body + ) + return Export._from_model(expect(response, HTTPStatus.CREATED)) + + def create_quicfire_export( domain, canopy_bulk_density: Tuple, @@ -503,8 +654,8 @@ def list_exports( Sort direction: "ascending" or "descending". source : str, optional Only return exports with this source name — the format for - single-resource exports (e.g. "geotiff", "csv") or "quicfire" - for bundles. + single-resource exports (e.g. "geotiff", "csv"), "landscape" for + landscape GeoTIFFs, or "quicfire" for bundles. tag : str, optional Only return exports carrying this tag. diff --git a/tests/v2/test_exports.py b/tests/v2/test_exports.py index 9e3f4d0..e59c654 100644 --- a/tests/v2/test_exports.py +++ b/tests/v2/test_exports.py @@ -5,24 +5,41 @@ # Core imports import json import zipfile +from http import HTTPStatus +from types import SimpleNamespace from uuid import uuid4 # Internal imports from fastfuels_sdk.v2.exports import ( Export, _field_source, + _landscape_field_source, + create_landscape_export, create_quicfire_export, get_export, list_exports, ) from fastfuels_sdk.v2.grids import ( + create_canopy_fuel_grid_from_landfire, create_fuel_grid_from_fbfm40_lookup, create_fuel_model_grid_from_landfire_fbfm40, create_topography_grid_from_3dep, create_uniform_grid, ) -from fastfuels_sdk.v2.client_library.models import FieldSource, JobStatus -from fastfuels_sdk.v2.exceptions import NotFoundException +from fastfuels_sdk.v2.client_library.models import ( + Export as ExportModel, + ExportSource, + FieldSource, + JobStatus, + LandscapeExportAlignmentDomainTarget, + LandscapeExportAlignmentGridTarget, + LandscapeFieldSource, +) +from fastfuels_sdk.v2.client_library.types import UNSET, Response +from fastfuels_sdk.v2.exceptions import ( + NotFoundException, + UnprocessableEntityException, +) # External imports import numpy as np @@ -55,6 +72,230 @@ def test_invalid_value_raises(self): _field_source("not-a-pair", "surface_moisture") +def _landscape_roles(): + return { + "elevation": ("topography-grid", "elevation"), + "slope": ("topography-grid", "slope"), + "aspect": ("topography-grid", "aspect"), + "fuel_model": ("fuel-model-grid", "fbfm"), + "canopy_cover": ("canopy-grid", "cc"), + "canopy_height": ("canopy-grid", "chm"), + "canopy_base_height": ("canopy-grid", "cbh"), + "canopy_bulk_density": ("canopy-grid", "cbd"), + } + + +def _pending_export_model(): + source = ExportSource() + source.additional_properties = {"name": "landscape"} + return ExportModel( + id="landscape-export-id", + domain_id="domain-id", + status=JobStatus.PENDING, + source=source, + ) + + +class TestLandscapeFieldSource: + def test_tuple_with_grid_object(self): + source = _landscape_field_source( + (SimpleNamespace(id="grid-id"), "elevation"), "elevation" + ) + + assert source.to_dict() == {"grid_id": "grid-id", "band": "elevation"} + + def test_model_passes_through(self): + source = LandscapeFieldSource(grid_id="grid-id", band="elevation") + assert _landscape_field_source(source, "elevation") is source + + def test_invalid_value_names_role(self): + with pytest.raises(ValueError, match="canopy_height"): + _landscape_field_source("not-a-pair", "canopy_height") + + +class TestLandscapeExport: + @staticmethod + def _mock_endpoint(monkeypatch, response=None): + captured = {} + response = response or Response( + status_code=HTTPStatus.CREATED, + content=b"", + headers={}, + parsed=_pending_export_model(), + ) + + def fake_create(domain_id, *, client, body): + captured.update(domain_id=domain_id, client=client, body=body) + return response + + client = object() + monkeypatch.setattr("fastfuels_sdk.v2.exports.ensure_client", lambda: client) + monkeypatch.setattr( + "fastfuels_sdk.v2.exports.create_landscape_export_endpoint.sync_detailed", + fake_create, + ) + return captured, client + + def test_builds_request(self, monkeypatch): + captured, client = self._mock_endpoint(monkeypatch) + + export = create_landscape_export( + SimpleNamespace(id="domain-id"), + fire_behavior_fuel_model="fbfm40", + name="Landscape", + tags=["test"], + **_landscape_roles(), + ) + + assert isinstance(export, Export) + assert export.id == "landscape-export-id" + assert captured["domain_id"] == "domain-id" + assert captured["client"] is client + assert captured["body"].alignment is UNSET + assert captured["body"].fire_behavior_fuel_model.value == "fbfm40" + assert captured["body"].elevation.to_dict() == { + "grid_id": "topography-grid", + "band": "elevation", + } + assert captured["body"].canopy_bulk_density.to_dict() == { + "grid_id": "canopy-grid", + "band": "cbd", + } + assert captured["body"].name == "Landscape" + assert captured["body"].tags == ["test"] + + def test_domain_alignment(self, monkeypatch): + captured, _ = self._mock_endpoint(monkeypatch) + + create_landscape_export( + "domain-id", + fire_behavior_fuel_model="fbfm40", + resolution_m=10, + **_landscape_roles(), + ) + + assert isinstance( + captured["body"].alignment, LandscapeExportAlignmentDomainTarget + ) + assert captured["body"].alignment.resolution == 10 + + def test_grid_alignment(self, monkeypatch): + captured, _ = self._mock_endpoint(monkeypatch) + + create_landscape_export( + "domain-id", + fire_behavior_fuel_model="fbfm13", + align_to=SimpleNamespace(id="master-grid"), + **_landscape_roles(), + ) + + assert isinstance( + captured["body"].alignment, LandscapeExportAlignmentGridTarget + ) + assert captured["body"].alignment.grid_id == "master-grid" + + def test_alignment_arguments_are_exclusive(self): + with pytest.raises(ValueError, match="not both"): + create_landscape_export( + "domain-id", + fire_behavior_fuel_model="fbfm40", + resolution_m=30, + align_to="master-grid", + **_landscape_roles(), + ) + + def test_alignment_error_is_preserved(self, monkeypatch): + response = Response( + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + content=b'{"detail":"fuel_model grid is not aligned with landscape"}', + headers={}, + parsed=None, + ) + self._mock_endpoint(monkeypatch, response=response) + + with pytest.raises(UnprocessableEntityException) as exc_info: + create_landscape_export( + "domain-id", + fire_behavior_fuel_model="fbfm40", + **_landscape_roles(), + ) + + assert exc_info.value.detail == ( + "fuel_model grid is not aligned with landscape" + ) + + @pytest.fixture(scope="class") + def topography_grid(self, test_domain): + grid = create_topography_grid_from_3dep( + test_domain, + source_resolution_m=10, + output_resolution_m=30, + bands=["elevation", "slope", "aspect"], + name="landscape_topography", + tags=["test"], + ) + grid.wait() + return grid + + @pytest.fixture(scope="class") + def canopy_grid(self, test_domain): + grid = create_canopy_fuel_grid_from_landfire( + test_domain, + output_resolution_m=30, + bands=["cc", "chm", "cbh", "cbd"], + name="landscape_canopy", + tags=["test"], + ) + grid.wait() + return grid + + def test_landscape_roundtrip( + self, + test_domain, + topography_grid, + completed_fbfm40_grid, + canopy_grid, + tmp_path, + ): + export = create_landscape_export( + test_domain, + fire_behavior_fuel_model="fbfm40", + elevation=(topography_grid, "elevation"), + slope=(topography_grid, "slope"), + aspect=(topography_grid, "aspect"), + fuel_model=(completed_fbfm40_grid, "fbfm"), + canopy_cover=(canopy_grid, "cc"), + canopy_height=(canopy_grid, "chm"), + canopy_base_height=(canopy_grid, "cbh"), + canopy_bulk_density=(canopy_grid, "cbd"), + name="test_landscape", + tags=["test"], + ) + try: + assert isinstance(export, Export) + assert export.status in (JobStatus.PENDING, JobStatus.RUNNING) + assert export.source["name"] == "landscape" + assert len(export.source["georeference"]["shape"]) == 2 + + export.wait() + destination = export.to_file(tmp_path / "landscape.tif") + with rasterio.open(destination) as dataset: + assert dataset.count == 8 + assert dataset.dtypes == ("int16",) * 8 + assert dataset.descriptions == ( + "Elevation", + "Slope", + "Aspect", + "Fuel Model", + "Canopy Cover", + "Canopy Height", + "Canopy Base Height", + "Canopy Bulk Density", + ) + finally: + export.delete() + + class TestGridExportLifecycle: @pytest.fixture(scope="class") def completed_export(self, completed_topography_grid): From 3fbd0e63302b05c84b49e6ed9ece7db467fb70c3 Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Tue, 4 Aug 2026 10:28:37 -0600 Subject: [PATCH 61/71] Document local live test authentication --- CLAUDE.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 7c6366f..cad21bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,6 +5,18 @@ Do NOT add "Co-Authored-By: Claude", "Generated with Claude Code", or any other AI-attribution lines to commit messages, PR descriptions, or GitHub issues. +## Live API testing + +- Local live-test credentials are stored in the repository-root `.env`, which + is ignored by Git. The SDK authenticates with the `FASTFUELS_API_KEY` value + from that file. +- Before running live tests, load `.env` into the current shell without + printing its contents: `set -a; source .env; set +a`. +- Check and load `.env` before concluding that live API credentials are + unavailable or asking the user to provide them. +- Never print, stage, commit, or copy credential values from `.env` into code, + documentation, logs, commit messages, pull requests, or issues. + ## Documentation FastFuels has two documentation properties that must stay in concert: From e44478c289475c5d82fc348ceb4781ae2a2fc15d Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Tue, 4 Aug 2026 10:35:06 -0600 Subject: [PATCH 62/71] Expose inventory forestry metrics --- docs/v2/guides/inventories.md | 24 ++++++++++++ fastfuels_sdk/v2/inventories.py | 11 +++++- tests/v2/test_inventories.py | 68 +++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 1 deletion(-) diff --git a/docs/v2/guides/inventories.md b/docs/v2/guides/inventories.md index ad6ef34..64bbb6a 100644 --- a/docs/v2/guides/inventories.md +++ b/docs/v2/guides/inventories.md @@ -191,6 +191,30 @@ layout first: Data is only available once the inventory is `"completed"` — earlier calls raise `UnprocessableEntityException`. +## Inspect stand-level forestry metrics + +Once a tree inventory completes, read its server-computed forestry metrics +without downloading the tree records: + +```python +metrics = inventory.forestry_metrics + +tree_count = metrics.tree_count +basal_area_per_acre = metrics.basal_area_per_area +trees_per_acre = metrics.tree_density +quadratic_mean_diameter_inches = metrics.quadratic_mean_diameter + +dominant_groups = [ + (group.spgrpcd, group.name, group.basal_area_share) + for group in metrics.dominant_species_groups +] +``` + +`dominant_species_groups` is ordered by decreasing basal-area share and +contains the leading FIA species groups. Only the leading groups are returned, +so their shares may sum to less than one. `forestry_metrics` is `None` when +metrics are unavailable, including before processing completes. + ## Reshape the trees Every creator accepts `modifications=` (rules that filter trees by conditions diff --git a/fastfuels_sdk/v2/inventories.py b/fastfuels_sdk/v2/inventories.py index 7f1f4d4..8a5729c 100644 --- a/fastfuels_sdk/v2/inventories.py +++ b/fastfuels_sdk/v2/inventories.py @@ -140,6 +140,10 @@ class Inventory(InventoryModel): Silvicultural treatments applied to the inventory. columns : List[Column], optional The columns of the inventory's tabular data. + forestry_metrics : TreeForestryMetrics, optional + Stand-level tree count, basal area per acre, trees per acre, + quadratic mean diameter, and dominant FIA species groups. Populated + when a tree inventory completes; ``None`` when unavailable. georeference : InventoryGeoreference, optional Spatial reference of the generated data; populated when the job completes. @@ -180,13 +184,18 @@ def _from_model(cls, model: InventoryModel) -> "Inventory": Round-trips through the generated to_dict/from_dict — from_dict constructs ``cls``, i.e. this subclass. """ - return cls.from_dict(model.to_dict()) + inventory = cls.from_dict(model.to_dict()) + if inventory.forestry_metrics is UNSET: + inventory.forestry_metrics = None + return inventory def _copy_fields_from(self, model: InventoryModel) -> "Inventory": """Copy all generated-model fields from `model` onto self (in-place).""" for field in attrs.fields(InventoryModel): if field.init: setattr(self, field.name, getattr(model, field.name)) + if self.forestry_metrics is UNSET: + self.forestry_metrics = None self.additional_properties = dict(model.additional_properties) return self diff --git a/tests/v2/test_inventories.py b/tests/v2/test_inventories.py index 57b1a39..68a4a5e 100644 --- a/tests/v2/test_inventories.py +++ b/tests/v2/test_inventories.py @@ -20,14 +20,20 @@ from fastfuels_sdk.v2.treatments import basal_area_treatment, diameter_treatment from fastfuels_sdk.v2.modifications import remove_trees, tree_attribute from fastfuels_sdk.v2.client_library.models import ( + FIASpeciesGroupShare, + Inventory as InventoryModel, InventoryAttribute, InventoryModification, InventoryModificationAction, InventoryModificationCondition, + InventorySource, + InventoryType, JobStatus, Modifier, Operator, + TreeForestryMetrics, ) +from fastfuels_sdk.v2.client_library.types import UNSET from fastfuels_sdk.v2.exceptions import NotFoundException # External imports @@ -75,6 +81,68 @@ def test_completed_fixture(self, completed_tree_inventory): assert completed_tree_inventory.checksum +class TestForestryMetrics: + @staticmethod + def _model(forestry_metrics=UNSET): + return InventoryModel( + id="inventory-id", + domain_id="domain-id", + type_=InventoryType.TREE, + status=JobStatus.COMPLETED, + source=InventorySource(), + forestry_metrics=forestry_metrics, + ) + + def test_wraps_metrics_record(self): + metrics = TreeForestryMetrics( + type_="tree", + tree_count=120, + basal_area_per_area=87.5, + tree_density=240.0, + quadratic_mean_diameter=9.4, + dominant_species_groups=[ + FIASpeciesGroupShare( + spgrpcd=3, + name="Douglas-fir", + basal_area_share=0.62, + ) + ], + ) + + inventory = Inventory._from_model(self._model(metrics)) + + assert isinstance(inventory.forestry_metrics, TreeForestryMetrics) + assert inventory.forestry_metrics.tree_count == 120 + assert inventory.forestry_metrics.dominant_species_groups[0].spgrpcd == 3 + + def test_normalizes_missing_metrics_to_none(self): + inventory = Inventory._from_model(self._model()) + + assert inventory.forestry_metrics is None + + def test_completed_inventory_metrics_live(self, completed_tree_inventory): + metrics = completed_tree_inventory.forestry_metrics + + assert isinstance(metrics, TreeForestryMetrics) + assert metrics.type_ == "tree" + assert ( + metrics.tree_count + == completed_tree_inventory.get_data_metadata().total_rows + ) + assert metrics.basal_area_per_area > 0 + assert metrics.tree_density > 0 + assert metrics.quadratic_mean_diameter > 0 + assert len(metrics.dominant_species_groups) > 0 + assert [ + group.basal_area_share for group in metrics.dominant_species_groups + ] == ( + sorted( + (group.basal_area_share for group in metrics.dominant_species_groups), + reverse=True, + ) + ) + + class TestCreateTreeInventoryFromFile: def test_unknown_extension_raises(self, test_domain): with pytest.raises(ValueError, match="upload format"): From 0e9d0cbc76a50b9102199acdbbec5bf2a7d3992a Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Tue, 4 Aug 2026 10:49:02 -0600 Subject: [PATCH 63/71] Add inventory column summaries --- docs/v2/guides/inventories.md | 21 +++++++++ fastfuels_sdk/v2/inventories.py | 47 +++++++++++++++++++ tests/v2/test_inventories.py | 81 +++++++++++++++++++++++++++++++++ 3 files changed, 149 insertions(+) diff --git a/docs/v2/guides/inventories.md b/docs/v2/guides/inventories.md index 64bbb6a..ada6d27 100644 --- a/docs/v2/guides/inventories.md +++ b/docs/v2/guides/inventories.md @@ -191,6 +191,27 @@ layout first: Data is only available once the inventory is `"completed"` — earlier calls raise `UnprocessableEntityException`. +## Summarize a column without downloading records + +Once an inventory completes, inspect a column's server-computed summary by +passing its key to `column_summary`: + +```python +dbh = inventory.column_summary("dbh") +mean_dbh = dbh.mean +minimum_dbh = dbh.min_ +maximum_dbh = dbh.max_ + +species = inventory.column_summary("fia_species_code") +species_count = species.unique_count +``` + +A continuous column reports `count`, `null_count`, `min_`, `max_`, `mean`, and +`std`; a categorical column reports `count`, `null_count`, and `unique_count`. +The `type_` field identifies the shape. `column_summary` returns `None` when +the requested column exists but its summary is unavailable; an unknown column +key raises `ValueError`. + ## Inspect stand-level forestry metrics Once a tree inventory completes, read its server-computed forestry metrics diff --git a/fastfuels_sdk/v2/inventories.py b/fastfuels_sdk/v2/inventories.py index 8a5729c..2b2c4f9 100644 --- a/fastfuels_sdk/v2/inventories.py +++ b/fastfuels_sdk/v2/inventories.py @@ -207,6 +207,17 @@ def _require_completed(self, action: str) -> None: "Call .wait() until it completes first." ) + def _column(self, column: str): + """Return the :class:`Column` with key ``column``, or raise if absent.""" + columns = [] if self.columns is UNSET or self.columns is None else self.columns + for inventory_column in columns: + if inventory_column.key == column: + return inventory_column + keys = [inventory_column.key for inventory_column in columns] + raise ValueError( + f"Inventory has no column {column!r}. Available columns: {keys}." + ) + @classmethod def from_id(cls, domain_id: str, inventory_id: str) -> "Inventory": """Retrieve an existing Inventory resource by its ID. @@ -654,6 +665,42 @@ def get_data_metadata(self) -> InventoryDataMetadata: ) return expect(response) + def column_summary(self, column: str): + """Return summary statistics for one column without downloading records. + + The server computes a per-column summary when an inventory completes, + so this provides a cheap overview without fetching the inventory's tree + records (unlike :meth:`to_dataframe`). + + Parameters + ---------- + column : str + The column key to summarize (see :attr:`columns` for available + keys). + + Returns + ------- + ContinuousColumnSummary or CategoricalColumnSummary or None + The column's summary, discriminated by its ``type_``: + ``"continuous"`` carries ``count``, ``null_count``, ``min_``, + ``max_``, ``mean``, and ``std``; ``"categorical"`` carries + ``count``, ``null_count``, and ``unique_count``. ``None`` until + the inventory completes (call :meth:`wait` first). + + Raises + ------ + ValueError + If ``column`` is not one of the inventory's columns. + + Examples + -------- + >>> inventory = ff.get_inventory(domain, "abc123").wait() + >>> inventory.column_summary("dbh").type_ + 'continuous' + """ + summary = self._column(column).summary + return None if summary is UNSET else summary + def get_data_partition( self, partition_index: int, columns: Optional[List[str]] = None ) -> InventoryDataResponse: diff --git a/tests/v2/test_inventories.py b/tests/v2/test_inventories.py index 68a4a5e..e8593a9 100644 --- a/tests/v2/test_inventories.py +++ b/tests/v2/test_inventories.py @@ -20,6 +20,10 @@ from fastfuels_sdk.v2.treatments import basal_area_treatment, diameter_treatment from fastfuels_sdk.v2.modifications import remove_trees, tree_attribute from fastfuels_sdk.v2.client_library.models import ( + CategoricalColumnSummary, + Column, + ColumnType, + ContinuousColumnSummary, FIASpeciesGroupShare, Inventory as InventoryModel, InventoryAttribute, @@ -143,6 +147,83 @@ def test_completed_inventory_metrics_live(self, completed_tree_inventory): ) +class TestColumnSummary: + @staticmethod + def _inventory_with_column(column): + return Inventory( + id="inventory-id", + domain_id="domain-id", + type_=InventoryType.TREE, + status=JobStatus.COMPLETED, + source=InventorySource(), + columns=[column], + ) + + def test_returns_continuous_summary(self): + summary = ContinuousColumnSummary( + type_="continuous", + count=10, + null_count=0, + min_=1.0, + max_=5.0, + mean=3.0, + std=1.0, + ) + inventory = self._inventory_with_column( + Column(key="dbh", type_=ColumnType.CONTINUOUS, summary=summary) + ) + + assert inventory.column_summary("dbh") is summary + assert inventory.column_summary("dbh").mean == 3.0 + + def test_returns_categorical_summary(self): + summary = CategoricalColumnSummary( + type_="categorical", count=10, null_count=1, unique_count=3 + ) + inventory = self._inventory_with_column( + Column( + key="fia_species_code", + type_=ColumnType.CATEGORICAL, + summary=summary, + ) + ) + + assert inventory.column_summary("fia_species_code") is summary + assert inventory.column_summary("fia_species_code").unique_count == 3 + + def test_none_when_not_computed(self): + inventory = self._inventory_with_column( + Column(key="dbh", type_=ColumnType.CONTINUOUS) + ) + + assert inventory.column_summary("dbh") is None + + def test_unknown_column_raises(self): + inventory = self._inventory_with_column( + Column(key="dbh", type_=ColumnType.CONTINUOUS) + ) + + with pytest.raises(ValueError, match="no column"): + inventory.column_summary("height") + + def test_summaries_live(self, completed_tree_inventory): + dbh = completed_tree_inventory.column_summary("dbh") + species = completed_tree_inventory.column_summary("fia_species_code") + tree_count = completed_tree_inventory.get_data_metadata().total_rows + + assert isinstance(dbh, ContinuousColumnSummary) + assert dbh.type_ == "continuous" + assert dbh.count == tree_count + assert dbh.null_count == 0 + assert dbh.mean > 0 + + assert isinstance(species, CategoricalColumnSummary) + assert species.type_ == "categorical" + assert species.count == tree_count + assert species.null_count == 0 + assert species.unique_count > 0 + + class TestCreateTreeInventoryFromFile: def test_unknown_extension_raises(self, test_domain): with pytest.raises(ValueError, match="upload format"): From 41446bb0fc6712845bf8ad7a2335606f23fc52d9 Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Tue, 4 Aug 2026 10:54:55 -0600 Subject: [PATCH 64/71] Update inventory data transports --- docs/v2/guides/inventories.md | 15 +++- fastfuels_sdk/v2/inventories.py | 35 +++++++-- tests/v2/test_inventories.py | 122 +++++++++++++++++++++++++++++++- 3 files changed, 162 insertions(+), 10 deletions(-) diff --git a/docs/v2/guides/inventories.md b/docs/v2/guides/inventories.md index ada6d27..51c6a91 100644 --- a/docs/v2/guides/inventories.md +++ b/docs/v2/guides/inventories.md @@ -170,8 +170,8 @@ Inventory 7b02df6f583a418da3ec9929037d876d: completed (5s) ## Access the tree records For most workflows, `to_dataframe()` (shown above) is all you need — it -retrieves every partition and assembles one DataFrame. Pass `columns=` to -retrieve a subset. +retrieves each partition through the CSV endpoint, parses it with pandas, and +assembles one DataFrame. Pass `columns=` to retrieve a subset. The records are served in fixed-size partitions; to control retrieval partition by partition (useful for large inventories), read the partition @@ -188,6 +188,17 @@ layout first: 34831 ``` +`get_data_partition` uses the compact `"split"` JSON layout by default, where +each row in `partition.data` follows `partition.columns`. To receive +self-describing row mappings instead, request the `"records"` layout: + +```python +partition = inventory.get_data_partition( + 0, columns=["x", "y", "height"], json_orientation="records" +) +first_tree = partition.data[0] +``` + Data is only available once the inventory is `"completed"` — earlier calls raise `UnprocessableEntityException`. diff --git a/fastfuels_sdk/v2/inventories.py b/fastfuels_sdk/v2/inventories.py index 2b2c4f9..8a57584 100644 --- a/fastfuels_sdk/v2/inventories.py +++ b/fastfuels_sdk/v2/inventories.py @@ -5,6 +5,7 @@ # Core imports import json from http import HTTPStatus +from io import StringIO from pathlib import Path from typing import List, Optional @@ -24,6 +25,7 @@ delete_inventory, duplicate_inventory as duplicate_inventory_endpoint, get_inventory as get_inventory_endpoint, + get_inventory_data_csv, get_inventory_data_json, get_inventory_data_metadata, list_inventories as list_inventories_endpoint, @@ -47,6 +49,7 @@ InventoryDataMetadata, InventoryDataResponse, InventoryExportFormat, + InventoryJsonOrientation, InventorySortField, InventoryUploadFormat, JobStatus, @@ -702,7 +705,10 @@ def column_summary(self, column: str): return None if summary is UNSET else summary def get_data_partition( - self, partition_index: int, columns: Optional[List[str]] = None + self, + partition_index: int, + columns: Optional[List[str]] = None, + json_orientation: str = "split", ) -> InventoryDataResponse: """Get one partition of the inventory's tree records. @@ -713,13 +719,17 @@ def get_data_partition( ``num_partitions`` reported by :meth:`get_data_metadata`. columns : List[str], optional Column subset to retrieve (default: all columns). + json_orientation : {"split", "records"}, optional + JSON layout. ``"split"`` (default) returns rows as lists of + values in ``partition.columns`` order; ``"records"`` returns + self-describing row mappings. Returns ------- InventoryDataResponse With attributes ``partition``, ``num_rows``, ``columns`` - (column names), and ``data`` (rows as lists of values, in - column order). + (column names), and ``data`` (row lists for ``"split"`` or row + mappings for ``"records"``). Raises ------ @@ -728,6 +738,8 @@ def get_data_partition( UnprocessableEntityException If ``partition_index`` is past the last partition, or the inventory is not in "completed" status. + ValueError + If ``json_orientation`` is not ``"split"`` or ``"records"``. Examples -------- @@ -735,11 +747,13 @@ def get_data_partition( >>> partition.num_rows 1392 """ + orientation = InventoryJsonOrientation(json_orientation) response = get_inventory_data_json.sync_detailed( self.domain_id, self.id, partition_index, client=ensure_client(), + json_orientation=orientation, columns=",".join(columns) if columns is not None else UNSET, ) return expect(response) @@ -747,8 +761,9 @@ def get_data_partition( def to_dataframe(self, columns: Optional[List[str]] = None) -> pd.DataFrame: """Retrieve the inventory's tree records as a pandas DataFrame. - Retrieves every partition and concatenates them into a single - DataFrame with one row per tree, preserving source order. + Retrieves every partition as CSV, parses it with :func:`pandas.read_csv`, + and concatenates the partitions into a single DataFrame with one row + per tree, preserving source order. Parameters ---------- @@ -776,8 +791,14 @@ def to_dataframe(self, columns: Optional[List[str]] = None) -> pd.DataFrame: metadata = self.get_data_metadata() frames = [] for partition_index in range(metadata.num_partitions): - partition = self.get_data_partition(partition_index, columns=columns) - frames.append(pd.DataFrame(partition.data, columns=partition.columns)) + response = get_inventory_data_csv.sync_detailed( + self.domain_id, + self.id, + partition_index, + client=ensure_client(), + columns=",".join(columns) if columns is not None else UNSET, + ) + frames.append(pd.read_csv(StringIO(expect(response)))) if not frames: return pd.DataFrame(columns=columns or metadata.columns) return pd.concat(frames, ignore_index=True) diff --git a/tests/v2/test_inventories.py b/tests/v2/test_inventories.py index e8593a9..7afc718 100644 --- a/tests/v2/test_inventories.py +++ b/tests/v2/test_inventories.py @@ -4,6 +4,8 @@ # Core imports import json +from http import HTTPStatus +from types import SimpleNamespace from uuid import uuid4 # Internal imports @@ -27,6 +29,8 @@ FIASpeciesGroupShare, Inventory as InventoryModel, InventoryAttribute, + InventoryDataResponse, + InventoryJsonOrientation, InventoryModification, InventoryModificationAction, InventoryModificationCondition, @@ -37,7 +41,7 @@ Operator, TreeForestryMetrics, ) -from fastfuels_sdk.v2.client_library.types import UNSET +from fastfuels_sdk.v2.client_library.types import UNSET, Response from fastfuels_sdk.v2.exceptions import NotFoundException # External imports @@ -488,6 +492,113 @@ def test_invalid_format(self, completed_tree_inventory): class TestInventoryData: + @staticmethod + def _inventory(): + return Inventory( + id="inventory-id", + domain_id="domain-id", + type_=InventoryType.TREE, + status=JobStatus.COMPLETED, + source=InventorySource(), + ) + + def test_get_data_partition_passes_json_orientation(self, monkeypatch): + captured = {} + + def fake_get(domain_id, inventory_id, partition_index, **kwargs): + captured.update( + domain_id=domain_id, + inventory_id=inventory_id, + partition_index=partition_index, + **kwargs, + ) + return Response( + status_code=HTTPStatus.OK, + content=b"", + headers={}, + parsed=InventoryDataResponse( + partition=partition_index, + num_rows=1, + columns=["height"], + data=[{"height": 12.0}], + ), + ) + + client = object() + monkeypatch.setattr( + "fastfuels_sdk.v2.inventories.ensure_client", lambda: client + ) + monkeypatch.setattr( + "fastfuels_sdk.v2.inventories.get_inventory_data_json.sync_detailed", + fake_get, + ) + + partition = self._inventory().get_data_partition( + 2, columns=["height"], json_orientation="records" + ) + + assert partition.data == [{"height": 12.0}] + assert captured == { + "domain_id": "domain-id", + "inventory_id": "inventory-id", + "partition_index": 2, + "client": client, + "json_orientation": InventoryJsonOrientation.RECORDS, + "columns": "height", + } + + def test_get_data_partition_rejects_invalid_orientation(self): + with pytest.raises(ValueError, match="not a valid InventoryJsonOrientation"): + self._inventory().get_data_partition(0, json_orientation="columns") + + def test_to_dataframe_uses_csv_partitions(self, monkeypatch): + inventory = self._inventory() + metadata = SimpleNamespace( + num_partitions=2, total_rows=3, columns=["x", "height"] + ) + csv_partitions = [ + "x,height\n1.0,10.0\n2.0,20.0\n", + "x,height\n3.0,30.0\n", + ] + captured = [] + + def fake_csv(domain_id, inventory_id, partition_index, **kwargs): + captured.append((domain_id, inventory_id, partition_index, kwargs)) + return Response( + status_code=HTTPStatus.OK, + content=csv_partitions[partition_index].encode(), + headers={}, + parsed=csv_partitions[partition_index], + ) + + def fail_json(*args, **kwargs): + raise AssertionError("to_dataframe must use the CSV endpoint") + + client = object() + monkeypatch.setattr(inventory, "get_data_metadata", lambda: metadata) + monkeypatch.setattr( + "fastfuels_sdk.v2.inventories.ensure_client", lambda: client + ) + monkeypatch.setattr( + "fastfuels_sdk.v2.inventories.get_inventory_data_csv.sync_detailed", + fake_csv, + ) + monkeypatch.setattr( + "fastfuels_sdk.v2.inventories.get_inventory_data_json.sync_detailed", + fail_json, + ) + + trees = inventory.to_dataframe(columns=["x", "height"]) + + assert trees.to_dict(orient="list") == { + "x": [1.0, 2.0, 3.0], + "height": [10.0, 20.0, 30.0], + } + assert [call[2] for call in captured] == [0, 1] + assert all( + call[3] == {"client": client, "columns": "x,height"} for call in captured + ) + def test_get_data_metadata(self, completed_tree_inventory): metadata = completed_tree_inventory.get_data_metadata() assert metadata.inventory_id == completed_tree_inventory.id @@ -506,6 +617,15 @@ def test_get_data_partition(self, completed_tree_inventory): assert "height" in partition.columns assert len(partition.data) == partition.num_rows + def test_get_data_partition_records(self, completed_tree_inventory): + partition = completed_tree_inventory.get_data_partition( + 0, columns=["height"], json_orientation="records" + ) + + assert partition.columns == ["height"] + assert len(partition.data) == partition.num_rows + assert set(partition.data[0]) == {"height"} + def test_to_dataframe(self, completed_tree_inventory): metadata = completed_tree_inventory.get_data_metadata() trees = completed_tree_inventory.to_dataframe() From dffa51b0eb6e3f53c7348162101dbc09fed6e2d8 Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Tue, 4 Aug 2026 11:01:23 -0600 Subject: [PATCH 65/71] Define account management boundary --- docs/v2/index.md | 4 ++++ fastfuels_sdk/v2/v2_api_design.md | 22 +++++++++++++++++----- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/docs/v2/index.md b/docs/v2/index.md index cbb377b..f87da7b 100644 --- a/docs/v2/index.md +++ b/docs/v2/index.md @@ -45,3 +45,7 @@ from fastfuels_sdk.v2 import Domain # v2 (Beta) ``` Coming from v1? Start with [Migrating from v1](guides/migration.md). + +The SDK authenticates with an existing API key. Creating applications and +creating, rotating, or revoking keys are account-management operations handled +outside the high-level Python SDK through the FastFuels console. diff --git a/fastfuels_sdk/v2/v2_api_design.md b/fastfuels_sdk/v2/v2_api_design.md index 0f831f1..5c38313 100644 --- a/fastfuels_sdk/v2/v2_api_design.md +++ b/fastfuels_sdk/v2/v2_api_design.md @@ -341,8 +341,20 @@ operations have no SDK surface. Ordered by what unblocks a user workflow. - `get_chunk_metadata` / `get_grid_data_json` stay unwrapped — the binary chunk path already covers `to_numpy`/`to_xarray`. -### Account management (unwrapped, lowest priority) - -`applications` (6) and `keys` (4) are entirely unwrapped. These are console -concerns; wrap only if the SDK is meant to provision its own credentials. -`Application` gained `tier`/`quota_overrides`. +### Account management (deliberately generated-client only) + +**Decision (2026-08-04): do not add high-level wrappers for `applications` +or `keys`.** The public SDK consumes an existing API key; application and key +provisioning remain account-console concerns. + +The ten generated endpoints remain available in `client_library`, including +the new `Application.tier`/`quota_overrides` fields, but they are not exported +through `fastfuels_sdk.v2`. This keeps one-time key secrets, credential +rotation/revocation, and destructive account operations out of ordinary data +workflows. It also matches v1, where these endpoints exist only in the +generated client and never gained a high-level wrapper. + +Revisit this boundary only if programmatic credential provisioning becomes a +supported SDK use case. That work should be designed as a dedicated account +module with explicit secret-handling requirements rather than added piecemeal +to the resource wrappers. From 3a85ddade2ac03441d1708c386711454c96c178d Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Tue, 4 Aug 2026 13:58:14 -0600 Subject: [PATCH 66/71] Regenerate v2 client after schema title fixes --- .../api/domains/create_domain.py | 20 ++++----- .../api/domains/preview_domain.py | 20 ++++----- .../api/domains/reproject_domain.py | 42 +++++++++---------- .../api/grids/check_3dep_coverage.py | 26 ++++++------ .../v2/client_library/models/__init__.py | 8 ++-- .../client_library/models/geo_json_feature.py | 2 +- ...body.py => geo_json_feature_collection.py} | 10 ++--- ...topography_three_dep_coverage_response.py} | 10 ++--- fastfuels_sdk/v2/domains.py | 6 +-- fastfuels_sdk/v2/generate_client.sh | 26 ++---------- fastfuels_sdk/v2/grids.py | 7 +++- fastfuels_sdk/v2/v2_api_design.md | 11 +++-- 12 files changed, 86 insertions(+), 102 deletions(-) rename fastfuels_sdk/v2/client_library/models/{create_domain_request_body.py => geo_json_feature_collection.py} (97%) rename fastfuels_sdk/v2/client_library/models/{three_dep_coverage_response.py => topography_three_dep_coverage_response.py} (92%) diff --git a/fastfuels_sdk/v2/client_library/api/domains/create_domain.py b/fastfuels_sdk/v2/client_library/api/domains/create_domain.py index 4b86c51..be87d55 100644 --- a/fastfuels_sdk/v2/client_library/api/domains/create_domain.py +++ b/fastfuels_sdk/v2/client_library/api/domains/create_domain.py @@ -5,8 +5,8 @@ from ... import errors from ...client import AuthenticatedClient, Client -from ...models.create_domain_request_body import CreateDomainRequestBody from ...models.domain import Domain +from ...models.geo_json_feature_collection import GeoJsonFeatureCollection from ...models.http_validation_error import HTTPValidationError from ...models.quota_exceeded_detail import QuotaExceededDetail from ...types import Response @@ -14,7 +14,7 @@ def _get_kwargs( *, - body: CreateDomainRequestBody, + body: GeoJsonFeatureCollection, ) -> dict[str, Any]: headers: dict[str, Any] = {} @@ -69,7 +69,7 @@ def _build_response( def sync_detailed( *, client: AuthenticatedClient, - body: CreateDomainRequestBody, + body: GeoJsonFeatureCollection, ) -> Response[Domain | HTTPValidationError | QuotaExceededDetail]: r"""Create a new domain @@ -190,7 +190,7 @@ def sync_detailed( - \"Invalid spatial extent. The domain must be entirely within CONUS.\" Args: - body (CreateDomainRequestBody): + body (GeoJsonFeatureCollection): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -214,7 +214,7 @@ def sync_detailed( def sync( *, client: AuthenticatedClient, - body: CreateDomainRequestBody, + body: GeoJsonFeatureCollection, ) -> Domain | HTTPValidationError | QuotaExceededDetail | None: r"""Create a new domain @@ -335,7 +335,7 @@ def sync( - \"Invalid spatial extent. The domain must be entirely within CONUS.\" Args: - body (CreateDomainRequestBody): + body (GeoJsonFeatureCollection): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -354,7 +354,7 @@ def sync( async def asyncio_detailed( *, client: AuthenticatedClient, - body: CreateDomainRequestBody, + body: GeoJsonFeatureCollection, ) -> Response[Domain | HTTPValidationError | QuotaExceededDetail]: r"""Create a new domain @@ -475,7 +475,7 @@ async def asyncio_detailed( - \"Invalid spatial extent. The domain must be entirely within CONUS.\" Args: - body (CreateDomainRequestBody): + body (GeoJsonFeatureCollection): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -497,7 +497,7 @@ async def asyncio_detailed( async def asyncio( *, client: AuthenticatedClient, - body: CreateDomainRequestBody, + body: GeoJsonFeatureCollection, ) -> Domain | HTTPValidationError | QuotaExceededDetail | None: r"""Create a new domain @@ -618,7 +618,7 @@ async def asyncio( - \"Invalid spatial extent. The domain must be entirely within CONUS.\" Args: - body (CreateDomainRequestBody): + body (GeoJsonFeatureCollection): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. diff --git a/fastfuels_sdk/v2/client_library/api/domains/preview_domain.py b/fastfuels_sdk/v2/client_library/api/domains/preview_domain.py index daa95a1..c7c2e5d 100644 --- a/fastfuels_sdk/v2/client_library/api/domains/preview_domain.py +++ b/fastfuels_sdk/v2/client_library/api/domains/preview_domain.py @@ -5,15 +5,15 @@ from ... import errors from ...client import AuthenticatedClient, Client -from ...models.create_domain_request_body import CreateDomainRequestBody from ...models.domain import Domain +from ...models.geo_json_feature_collection import GeoJsonFeatureCollection from ...models.http_validation_error import HTTPValidationError from ...types import Response def _get_kwargs( *, - body: CreateDomainRequestBody, + body: GeoJsonFeatureCollection, ) -> dict[str, Any]: headers: dict[str, Any] = {} @@ -63,7 +63,7 @@ def _build_response( def sync_detailed( *, client: AuthenticatedClient, - body: CreateDomainRequestBody, + body: GeoJsonFeatureCollection, ) -> Response[Domain | HTTPValidationError]: r"""Preview a domain without persisting it @@ -99,7 +99,7 @@ def sync_detailed( - \"Invalid spatial extent. The domain must be entirely within CONUS.\" Args: - body (CreateDomainRequestBody): + body (GeoJsonFeatureCollection): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -123,7 +123,7 @@ def sync_detailed( def sync( *, client: AuthenticatedClient, - body: CreateDomainRequestBody, + body: GeoJsonFeatureCollection, ) -> Domain | HTTPValidationError | None: r"""Preview a domain without persisting it @@ -159,7 +159,7 @@ def sync( - \"Invalid spatial extent. The domain must be entirely within CONUS.\" Args: - body (CreateDomainRequestBody): + body (GeoJsonFeatureCollection): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -178,7 +178,7 @@ def sync( async def asyncio_detailed( *, client: AuthenticatedClient, - body: CreateDomainRequestBody, + body: GeoJsonFeatureCollection, ) -> Response[Domain | HTTPValidationError]: r"""Preview a domain without persisting it @@ -214,7 +214,7 @@ async def asyncio_detailed( - \"Invalid spatial extent. The domain must be entirely within CONUS.\" Args: - body (CreateDomainRequestBody): + body (GeoJsonFeatureCollection): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -236,7 +236,7 @@ async def asyncio_detailed( async def asyncio( *, client: AuthenticatedClient, - body: CreateDomainRequestBody, + body: GeoJsonFeatureCollection, ) -> Domain | HTTPValidationError | None: r"""Preview a domain without persisting it @@ -272,7 +272,7 @@ async def asyncio( - \"Invalid spatial extent. The domain must be entirely within CONUS.\" Args: - body (CreateDomainRequestBody): + body (GeoJsonFeatureCollection): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. diff --git a/fastfuels_sdk/v2/client_library/api/domains/reproject_domain.py b/fastfuels_sdk/v2/client_library/api/domains/reproject_domain.py index b86f842..c79ae6d 100644 --- a/fastfuels_sdk/v2/client_library/api/domains/reproject_domain.py +++ b/fastfuels_sdk/v2/client_library/api/domains/reproject_domain.py @@ -5,14 +5,14 @@ from ... import errors from ...client import AuthenticatedClient, Client -from ...models.create_domain_request_body import CreateDomainRequestBody +from ...models.geo_json_feature_collection import GeoJsonFeatureCollection from ...models.http_validation_error import HTTPValidationError from ...types import UNSET, Response def _get_kwargs( *, - body: CreateDomainRequestBody, + body: GeoJsonFeatureCollection, target_epsg: int, ) -> dict[str, Any]: headers: dict[str, Any] = {} @@ -39,9 +39,9 @@ def _get_kwargs( def _parse_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> CreateDomainRequestBody | HTTPValidationError | None: +) -> GeoJsonFeatureCollection | HTTPValidationError | None: if response.status_code == 200: - response_200 = CreateDomainRequestBody.from_dict(response.json()) + response_200 = GeoJsonFeatureCollection.from_dict(response.json()) return response_200 @@ -58,7 +58,7 @@ def _parse_response( def _build_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[CreateDomainRequestBody | HTTPValidationError]: +) -> Response[GeoJsonFeatureCollection | HTTPValidationError]: return Response( status_code=HTTPStatus(response.status_code), content=response.content, @@ -70,9 +70,9 @@ def _build_response( def sync_detailed( *, client: AuthenticatedClient, - body: CreateDomainRequestBody, + body: GeoJsonFeatureCollection, target_epsg: int, -) -> Response[CreateDomainRequestBody | HTTPValidationError]: +) -> Response[GeoJsonFeatureCollection | HTTPValidationError]: """Reproject a FeatureCollection to a target CRS # Reproject Domain Endpoint @@ -106,14 +106,14 @@ def sync_detailed( Args: target_epsg (int): EPSG code of the target CRS. - body (CreateDomainRequestBody): + body (GeoJsonFeatureCollection): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[CreateDomainRequestBody | HTTPValidationError] + Response[GeoJsonFeatureCollection | HTTPValidationError] """ kwargs = _get_kwargs( @@ -131,9 +131,9 @@ def sync_detailed( def sync( *, client: AuthenticatedClient, - body: CreateDomainRequestBody, + body: GeoJsonFeatureCollection, target_epsg: int, -) -> CreateDomainRequestBody | HTTPValidationError | None: +) -> GeoJsonFeatureCollection | HTTPValidationError | None: """Reproject a FeatureCollection to a target CRS # Reproject Domain Endpoint @@ -167,14 +167,14 @@ def sync( Args: target_epsg (int): EPSG code of the target CRS. - body (CreateDomainRequestBody): + body (GeoJsonFeatureCollection): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - CreateDomainRequestBody | HTTPValidationError + GeoJsonFeatureCollection | HTTPValidationError """ return sync_detailed( @@ -187,9 +187,9 @@ def sync( async def asyncio_detailed( *, client: AuthenticatedClient, - body: CreateDomainRequestBody, + body: GeoJsonFeatureCollection, target_epsg: int, -) -> Response[CreateDomainRequestBody | HTTPValidationError]: +) -> Response[GeoJsonFeatureCollection | HTTPValidationError]: """Reproject a FeatureCollection to a target CRS # Reproject Domain Endpoint @@ -223,14 +223,14 @@ async def asyncio_detailed( Args: target_epsg (int): EPSG code of the target CRS. - body (CreateDomainRequestBody): + body (GeoJsonFeatureCollection): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[CreateDomainRequestBody | HTTPValidationError] + Response[GeoJsonFeatureCollection | HTTPValidationError] """ kwargs = _get_kwargs( @@ -246,9 +246,9 @@ async def asyncio_detailed( async def asyncio( *, client: AuthenticatedClient, - body: CreateDomainRequestBody, + body: GeoJsonFeatureCollection, target_epsg: int, -) -> CreateDomainRequestBody | HTTPValidationError | None: +) -> GeoJsonFeatureCollection | HTTPValidationError | None: """Reproject a FeatureCollection to a target CRS # Reproject Domain Endpoint @@ -282,14 +282,14 @@ async def asyncio( Args: target_epsg (int): EPSG code of the target CRS. - body (CreateDomainRequestBody): + body (GeoJsonFeatureCollection): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - CreateDomainRequestBody | HTTPValidationError + GeoJsonFeatureCollection | HTTPValidationError """ return ( diff --git a/fastfuels_sdk/v2/client_library/api/grids/check_3dep_coverage.py b/fastfuels_sdk/v2/client_library/api/grids/check_3dep_coverage.py index c751d4b..260887f 100644 --- a/fastfuels_sdk/v2/client_library/api/grids/check_3dep_coverage.py +++ b/fastfuels_sdk/v2/client_library/api/grids/check_3dep_coverage.py @@ -7,8 +7,10 @@ from ... import errors from ...client import AuthenticatedClient, Client from ...models.http_validation_error import HTTPValidationError -from ...models.three_dep_coverage_response import ThreeDepCoverageResponse from ...models.three_dep_resolution import ThreeDepResolution +from ...models.topography_three_dep_coverage_response import ( + TopographyThreeDepCoverageResponse, +) from ...types import UNSET, Response, Unset @@ -41,9 +43,9 @@ def _get_kwargs( def _parse_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> HTTPValidationError | ThreeDepCoverageResponse | None: +) -> HTTPValidationError | TopographyThreeDepCoverageResponse | None: if response.status_code == 200: - response_200 = ThreeDepCoverageResponse.from_dict(response.json()) + response_200 = TopographyThreeDepCoverageResponse.from_dict(response.json()) return response_200 @@ -60,7 +62,7 @@ def _parse_response( def _build_response( *, client: AuthenticatedClient | Client, response: httpx.Response -) -> Response[HTTPValidationError | ThreeDepCoverageResponse]: +) -> Response[HTTPValidationError | TopographyThreeDepCoverageResponse]: return Response( status_code=HTTPStatus(response.status_code), content=response.content, @@ -74,7 +76,7 @@ def sync_detailed( *, client: AuthenticatedClient, resolution: ThreeDepResolution | Unset = UNSET, -) -> Response[HTTPValidationError | ThreeDepCoverageResponse]: +) -> Response[HTTPValidationError | TopographyThreeDepCoverageResponse]: """Check 3DEP tile coverage for a domain # Check 3DEP Tile Coverage @@ -102,7 +104,7 @@ def sync_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[HTTPValidationError | ThreeDepCoverageResponse] + Response[HTTPValidationError | TopographyThreeDepCoverageResponse] """ kwargs = _get_kwargs( @@ -122,7 +124,7 @@ def sync( *, client: AuthenticatedClient, resolution: ThreeDepResolution | Unset = UNSET, -) -> HTTPValidationError | ThreeDepCoverageResponse | None: +) -> HTTPValidationError | TopographyThreeDepCoverageResponse | None: """Check 3DEP tile coverage for a domain # Check 3DEP Tile Coverage @@ -150,7 +152,7 @@ def sync( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - HTTPValidationError | ThreeDepCoverageResponse + HTTPValidationError | TopographyThreeDepCoverageResponse """ return sync_detailed( @@ -165,7 +167,7 @@ async def asyncio_detailed( *, client: AuthenticatedClient, resolution: ThreeDepResolution | Unset = UNSET, -) -> Response[HTTPValidationError | ThreeDepCoverageResponse]: +) -> Response[HTTPValidationError | TopographyThreeDepCoverageResponse]: """Check 3DEP tile coverage for a domain # Check 3DEP Tile Coverage @@ -193,7 +195,7 @@ async def asyncio_detailed( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - Response[HTTPValidationError | ThreeDepCoverageResponse] + Response[HTTPValidationError | TopographyThreeDepCoverageResponse] """ kwargs = _get_kwargs( @@ -211,7 +213,7 @@ async def asyncio( *, client: AuthenticatedClient, resolution: ThreeDepResolution | Unset = UNSET, -) -> HTTPValidationError | ThreeDepCoverageResponse | None: +) -> HTTPValidationError | TopographyThreeDepCoverageResponse | None: """Check 3DEP tile coverage for a domain # Check 3DEP Tile Coverage @@ -239,7 +241,7 @@ async def asyncio( httpx.TimeoutException: If the request takes longer than Client.timeout. Returns: - HTTPValidationError | ThreeDepCoverageResponse + HTTPValidationError | TopographyThreeDepCoverageResponse """ return ( diff --git a/fastfuels_sdk/v2/client_library/models/__init__.py b/fastfuels_sdk/v2/client_library/models/__init__.py index 1e2a645..1be370f 100644 --- a/fastfuels_sdk/v2/client_library/models/__init__.py +++ b/fastfuels_sdk/v2/client_library/models/__init__.py @@ -37,7 +37,6 @@ from .create_application_request import CreateApplicationRequest from .create_chm_inventory_request import CreateChmInventoryRequest from .create_compose_request import CreateComposeRequest -from .create_domain_request_body import CreateDomainRequestBody from .create_duet_request import CreateDuetRequest from .create_fbfm_13_lookup_request import CreateFbfm13LookupRequest from .create_fbfm_40_lookup_request import CreateFbfm40LookupRequest @@ -111,6 +110,7 @@ from .geo_json_crs import GeoJsonCRS from .geo_json_crs_properties import GeoJsonCRSProperties from .geo_json_feature import GeoJsonFeature +from .geo_json_feature_collection import GeoJsonFeatureCollection from .geo_json_feature_properties_type_0 import GeoJsonFeaturePropertiesType0 from .geometry_collection import GeometryCollection from .georeference import Georeference @@ -261,10 +261,10 @@ from .spatial_operator import SpatialOperator from .stem_isolation_lmf import StemIsolationLmf from .stem_isolation_vwf import StemIsolationVwf -from .three_dep_coverage_response import ThreeDepCoverageResponse from .three_dep_dataset_coverage import ThreeDepDatasetCoverage from .three_dep_resolution import ThreeDepResolution from .topography_band import TopographyBand +from .topography_three_dep_coverage_response import TopographyThreeDepCoverageResponse from .tree_band import TreeBand from .tree_forestry_metrics import TreeForestryMetrics from .tree_map_band import TreeMapBand @@ -324,7 +324,6 @@ "CreateApplicationRequest", "CreateChmInventoryRequest", "CreateComposeRequest", - "CreateDomainRequestBody", "CreateDuetRequest", "CreateFbfm13LookupRequest", "CreateFbfm40LookupRequest", @@ -394,6 +393,7 @@ "GeoJsonCRS", "GeoJsonCRSProperties", "GeoJsonFeature", + "GeoJsonFeatureCollection", "GeoJsonFeaturePropertiesType0", "GeometryCollection", "Georeference", @@ -524,10 +524,10 @@ "SpatialOperator", "StemIsolationLmf", "StemIsolationVwf", - "ThreeDepCoverageResponse", "ThreeDepDatasetCoverage", "ThreeDepResolution", "TopographyBand", + "TopographyThreeDepCoverageResponse", "TreeBand", "TreeForestryMetrics", "TreeMapBand", diff --git a/fastfuels_sdk/v2/client_library/models/geo_json_feature.py b/fastfuels_sdk/v2/client_library/models/geo_json_feature.py index 5c99ca4..5f569e0 100644 --- a/fastfuels_sdk/v2/client_library/models/geo_json_feature.py +++ b/fastfuels_sdk/v2/client_library/models/geo_json_feature.py @@ -34,7 +34,7 @@ @_attrs_define class GeoJsonFeature: - """Feature Model + """Generic GeoJSON feature with a generator-safe OpenAPI title. Attributes: type_ (Literal['Feature']): diff --git a/fastfuels_sdk/v2/client_library/models/create_domain_request_body.py b/fastfuels_sdk/v2/client_library/models/geo_json_feature_collection.py similarity index 97% rename from fastfuels_sdk/v2/client_library/models/create_domain_request_body.py rename to fastfuels_sdk/v2/client_library/models/geo_json_feature_collection.py index 185fe16..0f1039b 100644 --- a/fastfuels_sdk/v2/client_library/models/create_domain_request_body.py +++ b/fastfuels_sdk/v2/client_library/models/geo_json_feature_collection.py @@ -21,11 +21,11 @@ from ..models.geo_json_feature import GeoJsonFeature -T = TypeVar("T", bound="CreateDomainRequestBody") +T = TypeVar("T", bound="GeoJsonFeatureCollection") @_attrs_define -class CreateDomainRequestBody: +class GeoJsonFeatureCollection: """ Attributes: type_ (Literal['FeatureCollection']): @@ -231,7 +231,7 @@ def _parse_style(data: object) -> DomainStyle | None | Unset: style = _parse_style(d.pop("style", UNSET)) - create_domain_request_body = cls( + geo_json_feature_collection = cls( type_=type_, features=features, bbox=bbox, @@ -243,8 +243,8 @@ def _parse_style(data: object) -> DomainStyle | None | Unset: style=style, ) - create_domain_request_body.additional_properties = d - return create_domain_request_body + geo_json_feature_collection.additional_properties = d + return geo_json_feature_collection @property def additional_keys(self) -> list[str]: diff --git a/fastfuels_sdk/v2/client_library/models/three_dep_coverage_response.py b/fastfuels_sdk/v2/client_library/models/topography_three_dep_coverage_response.py similarity index 92% rename from fastfuels_sdk/v2/client_library/models/three_dep_coverage_response.py rename to fastfuels_sdk/v2/client_library/models/topography_three_dep_coverage_response.py index d1d8175..0ab82e1 100644 --- a/fastfuels_sdk/v2/client_library/models/three_dep_coverage_response.py +++ b/fastfuels_sdk/v2/client_library/models/topography_three_dep_coverage_response.py @@ -9,11 +9,11 @@ from ..models.three_dep_resolution import ThreeDepResolution from ..types import UNSET, Unset -T = TypeVar("T", bound="ThreeDepCoverageResponse") +T = TypeVar("T", bound="TopographyThreeDepCoverageResponse") @_attrs_define -class ThreeDepCoverageResponse: +class TopographyThreeDepCoverageResponse: """Response model for 3DEP tile coverage pre-flight check. Attributes: @@ -92,7 +92,7 @@ def _parse_acquisition_dates(data: object) -> list[str] | None | Unset: acquisition_dates = _parse_acquisition_dates(d.pop("acquisition_dates", UNSET)) - three_dep_coverage_response = cls( + topography_three_dep_coverage_response = cls( resolution=resolution, available=available, tile_count=tile_count, @@ -100,8 +100,8 @@ def _parse_acquisition_dates(data: object) -> list[str] | None | Unset: acquisition_dates=acquisition_dates, ) - three_dep_coverage_response.additional_properties = d - return three_dep_coverage_response + topography_three_dep_coverage_response.additional_properties = d + return topography_three_dep_coverage_response @property def additional_keys(self) -> list[str]: diff --git a/fastfuels_sdk/v2/domains.py b/fastfuels_sdk/v2/domains.py index acc638a..8c07855 100644 --- a/fastfuels_sdk/v2/domains.py +++ b/fastfuels_sdk/v2/domains.py @@ -23,10 +23,10 @@ ) from fastfuels_sdk.v2.client_library.models import ( Domain as DomainModel, - CreateDomainRequestBody, DomainLattice, DomainSortField, DomainSortOrder, + GeoJsonFeatureCollection, ListDomainsResponse, UpdateDomainRequestBody, ) @@ -43,7 +43,7 @@ def _build_create_request_body( description: str, tags: Optional[List[str]], pad_to_resolution: Optional[float], -) -> CreateDomainRequestBody: +) -> GeoJsonFeatureCollection: """Build a domain creation request body from GeoJSON input. The v2 API accepts FeatureCollection input only; a single Feature is @@ -62,7 +62,7 @@ def _build_create_request_body( f"got {geojson_type!r}" ) - return CreateDomainRequestBody.from_dict( + return GeoJsonFeatureCollection.from_dict( { **geojson, "name": name, diff --git a/fastfuels_sdk/v2/generate_client.sh b/fastfuels_sdk/v2/generate_client.sh index e40dd04..cf8552d 100644 --- a/fastfuels_sdk/v2/generate_client.sh +++ b/fastfuels_sdk/v2/generate_client.sh @@ -14,31 +14,11 @@ OPC_VERSION="0.29.0" URL="https://api-v2-prod-782971006568.us-west1.run.app" SPEC=$(mktemp /tmp/v2_openapi.XXXXXX.json) -PATCHED=$(mktemp /tmp/v2_openapi_patched.XXXXXX.json) -curl -s "$URL/openapi.json" > "$SPEC" - -# openapi-python-client names classes from schema titles and refuses -# duplicates, dropping the endpoints that reference the loser. The spec has -# two such collisions, so re-title one side of each. Long-term fix: re-title -# the models in the FastFuels-API-v2 repo so the spec has no collision. -# -# "Feature" — the FastFuels feature resource vs geojson_pydantic's GeoJSON -# Feature (auto-namespaced by FastAPI, but still titled "Feature"). -# -# "ThreeDepCoverageResponse" — the point cloud 3DEP pre-flight check -# (point count / budget) vs the topography one (tiles / resolution). -python3 - "$SPEC" "$PATCHED" <<'EOF' -import json, sys -spec = json.load(open(sys.argv[1])) -schemas = spec["components"]["schemas"] -schemas["geojson_pydantic__features__Feature"]["title"] = "GeoJsonFeature" -schemas["ThreeDepCoverageResponse"]["title"] = "PointCloudThreeDepCoverageResponse" -json.dump(spec, open(sys.argv[2], "w")) -EOF +curl -fsS "$URL/openapi.json" > "$SPEC" uvx "openapi-python-client@${OPC_VERSION}" generate \ - --path "$PATCHED" \ + --path "$SPEC" \ --meta none \ --output-path client_library \ --overwrite @@ -59,5 +39,5 @@ the regen script records the URL alongside the client it generates). DEFAULT_BASE_URL = "$URL" EOF -rm -f "$SPEC" "$PATCHED" +rm -f "$SPEC" echo "Done." diff --git a/fastfuels_sdk/v2/grids.py b/fastfuels_sdk/v2/grids.py index a8cfedc..07dd0a9 100644 --- a/fastfuels_sdk/v2/grids.py +++ b/fastfuels_sdk/v2/grids.py @@ -101,6 +101,7 @@ SortOrder, ThreeDepResolution, TopographyBand, + TopographyThreeDepCoverageResponse, TreeMapBand, TreeMapVersion, UniformBand, @@ -2273,7 +2274,9 @@ def get_grid(domain, grid_id: str) -> Grid: return Grid.from_id(_domain_id(domain), grid_id) -def check_3dep_coverage(domain, resolution_m: Optional[int] = None): +def check_3dep_coverage( + domain, resolution_m: Optional[int] = None +) -> TopographyThreeDepCoverageResponse: """Check USGS 3DEP tile coverage for a domain before creating a grid. Parameters @@ -2285,7 +2288,7 @@ def check_3dep_coverage(domain, resolution_m: Optional[int] = None): Returns ------- - ThreeDepCoverageResponse + TopographyThreeDepCoverageResponse Tile availability, count, URLs, and (for 1 m) acquisition dates. """ response = check_3dep_coverage_endpoint.sync_detailed( diff --git a/fastfuels_sdk/v2/v2_api_design.md b/fastfuels_sdk/v2/v2_api_design.md index 5c38313..9df1063 100644 --- a/fastfuels_sdk/v2/v2_api_design.md +++ b/fastfuels_sdk/v2/v2_api_design.md @@ -267,12 +267,11 @@ operations have no SDK surface. Ordered by what unblocks a user workflow. `json_orientation`) and `get_inventory_data_csv`; `InventoryDataFormat` is gone. `Inventory.get_data_partition` now calls the JSON variant — behavior unchanged. The CSV variant is unused (see *data out* below). -- Second spec title collision — the point cloud and topography 3DEP - pre-flight responses are both titled `ThreeDepCoverageResponse`, which - silently dropped the point cloud coverage endpoint from the client. - `generate_client.sh` now re-titles the point cloud one to - `PointCloudThreeDepCoverageResponse`, alongside the `Feature` patch. - Same long-term fix: re-title in FastFuels-API-v2 so the spec is clean. +- FastFuels-API-v2#489 resolved both schema-title collisions at the source. + `generate_client.sh` now consumes the production spec without patching it. + The generated domain request model is `GeoJsonFeatureCollection`, and the + two 3DEP coverage models are `PointCloudThreeDepCoverageResponse` and + `TopographyThreeDepCoverageResponse`. ### Quotas — cross-cutting, affects every creator From a7e8f87a320dc765d7026ebf48ee4e211c2fc7e0 Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Tue, 4 Aug 2026 14:00:21 -0600 Subject: [PATCH 67/71] Correct v2 domain response expectations --- docs/v2/guides/migration.md | 6 +++--- fastfuels_sdk/v2/domains.py | 12 ++++++------ tests/v2/test_domains.py | 9 ++++----- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/docs/v2/guides/migration.md b/docs/v2/guides/migration.md index 82a9218..dfb3426 100644 --- a/docs/v2/guides/migration.md +++ b/docs/v2/guides/migration.md @@ -46,9 +46,9 @@ Available now — see the [Domains guide](domains.md) and the `horizontal_resolution`/`vertical_resolution`. Use the optional `pad_to_resolution` argument to pad the domain bounding box outward so grids of that resolution align with it. -- **Responses are richer.** A v2 domain carries two named GeoJSON - features: `"domain"` (the projected working extent) and `"input"` - (your original geometry). +- **Responses carry the working extent.** A v2 domain contains one named + GeoJSON feature, `"domain"`: the projected bounding box used by child + resources. Keep the original input geometry separately if you need it. - **GeoDataFrame CRS is honored.** `from_geodataframe` forwards the GeoDataFrame's CRS to the API, so projected inputs (e.g. EPSG:5070) are interpreted correctly. The v1 SDK assumed EPSG:4326. diff --git a/fastfuels_sdk/v2/domains.py b/fastfuels_sdk/v2/domains.py index 8c07855..c1ec1c4 100644 --- a/fastfuels_sdk/v2/domains.py +++ b/fastfuels_sdk/v2/domains.py @@ -101,8 +101,8 @@ class Domain(DomainModel): type_ : str Always "FeatureCollection". features : List[GeoJsonFeature] - Two named GeoJSON features: "domain" (the working extent / - bounding box) and "input" (the original geometry). + One GeoJSON feature named "domain": the projected working extent / + bounding box. bbox : List[float] Bounding box of the "domain" feature. crs : GeoJsonCRS @@ -583,9 +583,8 @@ def to_json(self) -> str: def to_geodataframe(self) -> gpd.GeoDataFrame: """Convert the Domain to a GeoPandas GeoDataFrame. - The v2 API returns two named features: "domain" (the working - extent / bounding box) and "input" (the original geometry), so the - GeoDataFrame has one row per feature. + The v2 API returns one named feature, "domain": the projected working + extent / bounding box. The GeoDataFrame therefore has one row. Returns ------- @@ -596,7 +595,8 @@ def to_geodataframe(self) -> gpd.GeoDataFrame: Examples -------- >>> gdf = domain.to_geodataframe() - >>> gdf[gdf["name"] == "input"].geometry # the original geometry + >>> gdf.loc[0, "name"] + 'domain' """ feature_collection: dict[str, Any] = { "type": "FeatureCollection", diff --git a/tests/v2/test_domains.py b/tests/v2/test_domains.py index cfc2895..108854a 100644 --- a/tests/v2/test_domains.py +++ b/tests/v2/test_domains.py @@ -49,9 +49,8 @@ def assert_valid_domain( assert domain.description == self.domain_description assert domain.pad_to_resolution == self.pad_to_resolution - # v2 responses carry two named features: the working extent and - # the original input geometry - assert feature_names(domain) == {"domain", "input"} + # v2 responses carry the normalized working extent only. + assert feature_names(domain) == {"domain"} # If the input CRS is projected, the domain preserves it (echoing # the CRS name as sent, e.g. "urn:ogc:def:crs:EPSG::5070"); @@ -140,7 +139,7 @@ def test_preview(self): # A preview is never persisted; its id is the "preview" sentinel assert previewed.id == "preview" - assert feature_names(previewed) == {"domain", "input"} + assert feature_names(previewed) == {"domain"} assert previewed.pad_to_resolution == 2.0 assert len(previewed.bbox) >= 4 @@ -259,7 +258,7 @@ def test_to_geodataframe(self, test_domain): gdf = test_domain.to_geodataframe() assert len(gdf) == len(test_domain.features) - assert set(gdf["name"]) == {"domain", "input"} + assert set(gdf["name"]) == {"domain"} assert (gdf["domain_id"] == test_domain.id).all() assert ":".join(gdf.crs.to_authority()) == test_domain.crs.properties.name From e23cd056d4d233f66eb41576d4fdb16b3abd0f6c Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Tue, 4 Aug 2026 14:12:34 -0600 Subject: [PATCH 68/71] Make cross-domain grid test pagination-safe --- tests/v2/test_grids.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/tests/v2/test_grids.py b/tests/v2/test_grids.py index ff5e2f5..7b1e01e 100644 --- a/tests/v2/test_grids.py +++ b/tests/v2/test_grids.py @@ -1074,9 +1074,25 @@ def test_list_in_domain(self, test_domain, completed_topography_grid): assert completed_topography_grid.id in grid_ids def test_list_cross_domain(self, completed_topography_grid): - # No domain: list grids across all the user's domains - grid_ids = [grid.id for grid in list_grids()] - assert completed_topography_grid.id in grid_ids + # No domain: list grids across all the user's domains. The SDK returns + # one page at a time, so search subsequent pages when the account has + # more than the default page size of 100 grids. + for page in range(100): + grids = list_grids( + page=page, + size=100, + sort_by="created_on", + sort_order="descending", + ) + if completed_topography_grid.id in [grid.id for grid in grids]: + return + if len(grids) < 100: + break + + pytest.fail( + f"Grid {completed_topography_grid.id} was not found in the " + "cross-domain grid pages." + ) def test_filter_by_tag(self, test_domain, completed_topography_grid): grids_with_tag = list_grids(test_domain, tag="test") From ef883da98a3992d16f9767bc5f0f61bfee49c1a9 Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Tue, 4 Aug 2026 14:13:44 -0600 Subject: [PATCH 69/71] Update inventory modification lifecycle expectations --- docs/v2/guides/modify-treat-inventories.md | 7 +++--- fastfuels_sdk/v2/inventories.py | 11 ++++----- fastfuels_sdk/v2/v2_api_design.md | 5 +++-- tests/v2/test_inventories.py | 26 ++++++++-------------- 4 files changed, 22 insertions(+), 27 deletions(-) diff --git a/docs/v2/guides/modify-treat-inventories.md b/docs/v2/guides/modify-treat-inventories.md index 19cf6fe..b5898cb 100644 --- a/docs/v2/guides/modify-treat-inventories.md +++ b/docs/v2/guides/modify-treat-inventories.md @@ -92,9 +92,10 @@ thinned.wait() ## Apply to an inventory you hold `apply_modifications` and `apply_treatments` reshape an inventory **in place**: -the submitted rules are appended to the inventory's cumulative `modifications` -/ `treatments` list, its ID is kept, and the data is re-derived as a background -job — the inventory returns to `"pending"`, so `wait()` before using it again. +its ID is kept and the data is re-derived as a background job. The submitted +rules are queued while the inventory is `"pending"`, then appended to the +cumulative `modifications` / `treatments` list when processing completes. Call +`wait()` before using the inventory again or reading those ledgers. ```python inventory.apply_treatments([ff.basal_area_treatment("from_below", 25.0)]) diff --git a/fastfuels_sdk/v2/inventories.py b/fastfuels_sdk/v2/inventories.py index 8a57584..f523409 100644 --- a/fastfuels_sdk/v2/inventories.py +++ b/fastfuels_sdk/v2/inventories.py @@ -393,11 +393,12 @@ def duplicate( def apply_modifications(self, modifications: list) -> "Inventory": """Apply modification rules to this inventory in place. - The inventory keeps its ID; the submitted rules are appended to its - cumulative ``modifications`` list and the tree data is re-derived as - a background job — the inventory returns to "pending" status, so - call :meth:`wait` before using its data. To keep the original data, - :meth:`duplicate` first and modify the copy. + The inventory keeps its ID; the submitted rules are queued while the + tree data is re-derived as a background job. The inventory returns to + "pending" status, and the rules are appended to its cumulative + ``modifications`` list once processing completes. Call :meth:`wait` + before using its data. To keep the original data, :meth:`duplicate` + first and modify the copy. Parameters ---------- diff --git a/fastfuels_sdk/v2/v2_api_design.md b/fastfuels_sdk/v2/v2_api_design.md index 9df1063..b02b076 100644 --- a/fastfuels_sdk/v2/v2_api_design.md +++ b/fastfuels_sdk/v2/v2_api_design.md @@ -240,8 +240,9 @@ later merges). Each new resource/endpoint kept to the settled rules above. point_cloud_type=)`, `list_point_clouds`/`get_point_cloud`. Nothing else sources from a point cloud yet. - **Inventory treatments** — `Inventory.apply_treatments([...])` (in-place - re-derive; unlike `apply_modifications` it does *not* hit #333) + the - `ff.basal_area_treatment`/`ff.diameter_treatment` builders (`treatments.py`). + re-derive) + the `ff.basal_area_treatment`/`ff.diameter_treatment` builders + (`treatments.py`). The former #333 fork-safety blocker for both in-place + processing paths is fixed. - **GDAM** — `create_tree_inventory_from_gdam(domain, source_inventory, impute_columns=)`. A *create* (new inventory) → a function in the `create_tree_inventory_from_*` family, **not** a method — same call as the diff --git a/tests/v2/test_inventories.py b/tests/v2/test_inventories.py index 7afc718..996c1bc 100644 --- a/tests/v2/test_inventories.py +++ b/tests/v2/test_inventories.py @@ -9,7 +9,6 @@ from uuid import uuid4 # Internal imports -from fastfuels_sdk.v2._jobs import JobFailedError from fastfuels_sdk.v2.grids import Grid from fastfuels_sdk.v2.inventories import ( Inventory, @@ -344,17 +343,8 @@ def test_duplicate_is_a_clone(self, completed_tree_inventory): class TestApplyModifications: - @pytest.mark.xfail( - reason="v2 API bug: the in-place modifications job fails at the save " - "step (module-level gcsfs client is not fork-safe in standgen) — " - "FastFuels-API-v2#333", - raises=JobFailedError, - strict=True, - ) - def test_apply_modifications_rederives_in_place(self, completed_tree_inventory): - # Mutating, so work on a duplicate — the shared fixture is read-only - copy = completed_tree_inventory.duplicate(name="modify_test") - copy.wait() + def test_apply_modifications_rederives_in_place(self, throwaway_inventory): + copy = throwaway_inventory # Rules need at least one condition; height > 0 matches every tree modification = InventoryModification( conditions=[ @@ -377,11 +367,13 @@ def test_apply_modifications_rederives_in_place(self, completed_tree_inventory): modified = copy.apply_modifications([modification]) assert modified is copy # in place: same object, same id - assert len(copy.modifications) == 1 + assert copy.status == JobStatus.PENDING + assert copy.modifications == [] # ledger grows only after completion + assert copy.checksum != original_checksum # rotates at dispatch copy.wait() assert copy.status == JobStatus.COMPLETED + assert len(copy.modifications) == 1 assert copy.checksum != original_checksum # data was re-derived - copy.delete() def test_requires_completed_source(self, test_domain, completed_pim_grid): inventory = create_tree_inventory_from_pim_grid( @@ -427,9 +419,9 @@ class TestModificationBuilders: def test_remove_trees_at_creation( self, test_domain, completed_pim_grid, completed_tree_inventory ): - # In-place apply_modifications is #333-blocked, so verify the builders - # via the create-time path (which works). Same seed as the unmodified - # fixture, so removing dbh < 10 must yield strictly fewer trees. + # Verify the builders independently through the create-time path. Use + # the same seed as the unmodified fixture so removing dbh < 10 must + # yield strictly fewer trees. modified = create_tree_inventory_from_pim_grid( test_domain, completed_pim_grid, From e2f19fcf8792f82d38770737fe6257afc62397f2 Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Tue, 4 Aug 2026 14:14:02 -0600 Subject: [PATCH 70/71] Update inventory treatment lifecycle expectations --- fastfuels_sdk/v2/inventories.py | 11 ++++++----- tests/v2/test_inventories.py | 12 ++++++------ 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/fastfuels_sdk/v2/inventories.py b/fastfuels_sdk/v2/inventories.py index f523409..cc93434 100644 --- a/fastfuels_sdk/v2/inventories.py +++ b/fastfuels_sdk/v2/inventories.py @@ -428,11 +428,12 @@ def apply_modifications(self, modifications: list) -> "Inventory": def apply_treatments(self, treatments: list) -> "Inventory": """Apply silvicultural treatments to this inventory in place. - The inventory keeps its ID; the submitted treatments are appended to - its cumulative ``treatments`` list and the tree data is re-derived as a - background job — the inventory returns to "pending" status, so call - :meth:`wait` before using its data. To keep the original data, - :meth:`duplicate` first and treat the copy. + The inventory keeps its ID; the submitted treatments are queued while + the tree data is re-derived as a background job. The inventory returns + to "pending" status, and the treatments are appended to its cumulative + ``treatments`` list once processing completes. Call :meth:`wait` before + using its data. To keep the original data, :meth:`duplicate` first and + treat the copy. Parameters ---------- diff --git a/tests/v2/test_inventories.py b/tests/v2/test_inventories.py index 996c1bc..c7be653 100644 --- a/tests/v2/test_inventories.py +++ b/tests/v2/test_inventories.py @@ -388,20 +388,20 @@ def test_requires_completed_source(self, test_domain, completed_pim_grid): class TestApplyTreatments: - def test_apply_treatments_rederives_in_place(self, completed_tree_inventory): - # Mutating, so work on a duplicate — the shared fixture is read-only - copy = completed_tree_inventory.duplicate(name="treat_test") - copy.wait() + def test_apply_treatments_rederives_in_place(self, throwaway_inventory): + copy = throwaway_inventory original_checksum = copy.checksum treated = copy.apply_treatments([basal_area_treatment("from_below", 25.0)]) assert treated is copy # in place: same object, same id - assert len(copy.treatments) == 1 + assert copy.status == JobStatus.PENDING + assert copy.treatments == [] # ledger grows only after completion + assert copy.checksum != original_checksum # rotates at dispatch copy.wait() assert copy.status == JobStatus.COMPLETED + assert len(copy.treatments) == 1 assert copy.checksum != original_checksum # data was re-derived - copy.delete() def test_requires_completed_source(self, test_domain, completed_pim_grid): inventory = create_tree_inventory_from_pim_grid( From 7d69c9ef71571539e4f65a8ae7163a3d4c139703 Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Tue, 4 Aug 2026 14:14:17 -0600 Subject: [PATCH 71/71] Remove stale feature sorting xfail --- docs/v2/guides/features.md | 10 ++++++++++ tests/v2/test_features.py | 4 ---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/v2/guides/features.md b/docs/v2/guides/features.md index 0b66d21..ed41077 100644 --- a/docs/v2/guides/features.md +++ b/docs/v2/guides/features.md @@ -243,6 +243,16 @@ osm_features = ff.list_features(domain, product="osm") tagged = ff.list_features(domain, tag="roads") ``` +Sort a page by name, creation time, or modification time: + +```python +newest_first = ff.list_features( + domain, + sort_by="created_on", + sort_order="descending", +) +``` + ## Delete a Feature To permanently delete a feature and its generated data: diff --git a/tests/v2/test_features.py b/tests/v2/test_features.py index 75b5f90..25b633a 100644 --- a/tests/v2/test_features.py +++ b/tests/v2/test_features.py @@ -248,10 +248,6 @@ def test_filter_by_tag(self, test_domain, layerset_feature): features = list_features(test_domain, tag="layerset-test") assert [feature.id for feature in features] == [layerset_feature.id] - @pytest.mark.xfail( - reason="v2 API returns 500 for feature sort_by combinations; " - "see FastFuels-API-v2#321", - ) def test_sorting(self, test_domain, layerset_feature): features = list_features( test_domain, sort_by="created_on", sort_order="descending"