diff --git a/felt_python/__init__.py b/felt_python/__init__.py index d7e3ca9..3c4b624 100644 --- a/felt_python/__init__.py +++ b/felt_python/__init__.py @@ -67,6 +67,16 @@ update_source, delete_source, sync_source, + create_source_credential, + update_source_credential, + delete_source_credential, +) +from .components import ( + list_layer_components, + create_layer_component, + get_layer_component, + update_layer_component, + delete_layer_component, ) from .library import list_library_layers from .comments import export_comments, resolve_comment, delete_comment @@ -138,6 +148,15 @@ "update_source", "delete_source", "sync_source", + "create_source_credential", + "update_source_credential", + "delete_source_credential", + # Layer components + "list_layer_components", + "create_layer_component", + "get_layer_component", + "update_layer_component", + "delete_layer_component", # Library "list_library_layers", # Comments diff --git a/felt_python/components.py b/felt_python/components.py new file mode 100644 index 0000000..75ab7ef --- /dev/null +++ b/felt_python/components.py @@ -0,0 +1,175 @@ +"""Layer components""" + +import json + +from urllib.parse import urljoin + +from .api import make_request, BASE_URL + + +COMPONENTS = urljoin(BASE_URL, "maps/{map_id}/layers/{layer_id}/components") +COMPONENT = urljoin( + BASE_URL, "maps/{map_id}/layers/{layer_id}/components/{component_id}" +) + + +def list_layer_components(map_id: str, layer_id: str, api_token: str | None = None): + """List all components on a layer + + Args: + map_id: The ID of the map containing the layer + layer_id: The ID of the layer to list components from + api_token: Optional API token + + Returns: + List of layer components + """ + response = make_request( + url=COMPONENTS.format(map_id=map_id, layer_id=layer_id), + method="GET", + api_token=api_token, + ) + return json.load(response) + + +def create_layer_component( + map_id: str, + layer_id: str, + component_type: str, + data: dict, + title: str | None = None, + config: dict | None = None, + api_token: str | None = None, +): + """Create a component on a layer + + Args: + map_id: The ID of the map containing the layer + layer_id: The ID of the layer to create the component on + component_type: The type of component to create. Immutable after + creation. Options are "statistic", "histogram", "bar_chart", + "time_series", or "filter". + data: How the component computes its result. The expected shape + depends on the component type. For example, a "statistic" + component takes either a feature count + ({"aggregate": "count"}) or an attribute aggregation + ({"aggregate": "avg", "aggregate_by": "some_attribute"}). + title: Optional display title (max 256 characters) + config: Optional component configuration. Common keys include + "reactive" (whether the component reacts to other components' + selections) and "viewport_mode" ("global" or "viewport"). + api_token: Optional API token + + Returns: + The created layer component + """ + json_payload: dict = {"type": component_type, "data": data} + if title is not None: + json_payload["title"] = title + if config is not None: + json_payload["config"] = config + + response = make_request( + url=COMPONENTS.format(map_id=map_id, layer_id=layer_id), + method="POST", + json=json_payload, + api_token=api_token, + ) + return json.load(response) + + +def get_layer_component( + map_id: str, + layer_id: str, + component_id: str, + api_token: str | None = None, +): + """Get details of a layer component + + Args: + map_id: The ID of the map containing the layer + layer_id: The ID of the layer containing the component + component_id: The ID of the component to get details for + api_token: Optional API token + + Returns: + Layer component details + """ + response = make_request( + url=COMPONENT.format( + map_id=map_id, layer_id=layer_id, component_id=component_id + ), + method="GET", + api_token=api_token, + ) + return json.load(response) + + +def update_layer_component( + map_id: str, + layer_id: str, + component_id: str, + data: dict | None = None, + title: str | None = None, + config: dict | None = None, + api_token: str | None = None, +): + """Update a layer component + + The component's type is immutable after creation. + + Args: + map_id: The ID of the map containing the layer + layer_id: The ID of the layer containing the component + component_id: The ID of the component to update + data: Optionally change how the component computes its result. + Replaces the current value as a unit when provided; partial + updates are not supported. + title: Optional new display title (max 256 characters) + config: Optional component configuration updates. Provided keys are + updated; omitted keys keep their current value. + api_token: Optional API token + + Returns: + The updated layer component + """ + json_payload: dict = {} + if data is not None: + json_payload["data"] = data + if title is not None: + json_payload["title"] = title + if config is not None: + json_payload["config"] = config + + response = make_request( + url=COMPONENT.format( + map_id=map_id, layer_id=layer_id, component_id=component_id + ), + method="POST", + json=json_payload, + api_token=api_token, + ) + return json.load(response) + + +def delete_layer_component( + map_id: str, + layer_id: str, + component_id: str, + api_token: str | None = None, +): + """Delete a component from a layer + + Args: + map_id: The ID of the map containing the layer + layer_id: The ID of the layer containing the component + component_id: The ID of the component to delete + api_token: Optional API token + """ + make_request( + url=COMPONENT.format( + map_id=map_id, layer_id=layer_id, component_id=component_id + ), + method="DELETE", + api_token=api_token, + ) diff --git a/felt_python/layer_groups.py b/felt_python/layer_groups.py index 4dfdbfc..e5ff18c 100644 --- a/felt_python/layer_groups.py +++ b/felt_python/layer_groups.py @@ -66,7 +66,8 @@ def update_layer_groups( map_id: The ID of the map containing the layer groups layer_group_params_list: List of layer group parameters to update. Each dict must contain at least "name" key. - Optional keys include "id", "caption", "ordering_key". + Optional keys include "id", "caption", "subtitle", "ordering_key", + "legend_visibility" and "visibility_interaction". api_token: Optional API token Returns: @@ -105,7 +106,9 @@ def update_layer_group( layer_group_id: str, name: str | None = None, caption: str | None = None, + subtitle: str | None = None, ordering_key: int | None = None, + legend_visibility: str | None = None, visibility_interaction: str | None = None, api_token: str | None = None, ): @@ -116,9 +119,12 @@ def update_layer_group( layer_group_id: The ID of the layer group to update name: Optional new name for the layer group caption: Optional new caption for the layer group + subtitle: Optional new subtitle for the layer group ordering_key: Optional new ordering key for positioning + legend_visibility: Optional legend visibility setting + ("show", "hide") visibility_interaction: Optional visibility interaction setting - ("default", "slider") + ("default", "slider", "select", "multi_select") api_token: Optional API token Returns: @@ -130,8 +136,12 @@ def update_layer_group( json_payload["name"] = name if caption is not None: json_payload["caption"] = caption + if subtitle is not None: + json_payload["subtitle"] = subtitle if ordering_key is not None: json_payload["ordering_key"] = ordering_key + if legend_visibility is not None: + json_payload["legend_visibility"] = legend_visibility if visibility_interaction is not None: json_payload["visibility_interaction"] = visibility_interaction diff --git a/felt_python/layers.py b/felt_python/layers.py index 35f6d02..0d64f2e 100644 --- a/felt_python/layers.py +++ b/felt_python/layers.py @@ -298,8 +298,9 @@ def update_layers( map_id: The ID of the map containing the layers layer_params_list: List of layer parameters to update. Each dict must contain at least an "id" key. - Optional keys include "name", "caption", - "metadata", "ordering_key", "refresh_period". + Optional keys include "name", "subtitle", "caption", + "metadata", "layer_group_id", "ordering_key", "refresh_period", + "legend_display" and "legend_visibility". api_token: Optional API token Returns: @@ -371,7 +372,8 @@ def create_custom_export( map_id: The ID of the map containing the layer layer_id: The ID of the layer to export output_format: The format to export in. - Options are "csv", "gpkg", or "geojson" + Options are "csv", "gpkg", "geojson", "geotiff", "pmtiles", + "shapefile", "kml", or "geoparquet" filters: Optional list of filters in Felt Style Language filter format email_on_completion: Whether to send an email when the export completes. Defaults to True. diff --git a/felt_python/sources.py b/felt_python/sources.py index 1317b7e..f708c91 100644 --- a/felt_python/sources.py +++ b/felt_python/sources.py @@ -11,6 +11,11 @@ SOURCE = urljoin(BASE_URL, "sources/{source_id}") SOURCE_UPDATE = urljoin(BASE_URL, "sources/{source_id}/update") SOURCE_SYNC = urljoin(BASE_URL, "sources/{source_id}/sync") +SOURCE_CREDENTIALS = urljoin(BASE_URL, "sources/{source_id}/credentials") +SOURCE_CREDENTIAL = urljoin(BASE_URL, "sources/{source_id}/credentials/{credential_id}") +SOURCE_CREDENTIAL_UPDATE = urljoin( + BASE_URL, "sources/{source_id}/credentials/{credential_id}/update" +) def list_sources(workspace_id: str | None = None, api_token: str | None = None): @@ -123,3 +128,110 @@ def sync_source(source_id: str, api_token: str | None = None): api_token=api_token, ) return json.load(response) + + +def create_source_credential( + source_id: str, + name: str, + use_case: str, + credential: dict, + api_token: str | None = None, +): + """Create a credential for a source + + Args: + source_id: The ID of the source to create the credential for + name: The name of the credential + use_case: What the credential is used for. Options are + "source_authentication", "stac_api_authentication", + or "stac_asset_fetching". + credential: The credential details. Must include a "type" key + identifying the credential type along with its type-specific + fields, e.g.: + - {"type": "aws_assume_role", "role_arn": ..., "role_session_name": ...} + - {"type": "azure_storage_connection_string", "connection_string": ...} + - {"type": "custom_headers", "headers": [{"name": ..., "value": ..., + "sensitive": ...}]} + ("sensitive" is required on every header entry: a sensitive + header's value is returned as "felt:redacted" when the + credential is read back) + - {"type": "gcp_service_account_json", "service_account_filename": ..., + "service_account_json": ...} + - {"type": "key_pair", "private_key_name": ..., "private_key": ..., + "private_key_passphrase": ...} + - {"type": "snowflake_pat", "token": ...} + api_token: Optional API token + + Returns: + The created source credential + """ + response = make_request( + url=SOURCE_CREDENTIALS.format(source_id=source_id), + method="POST", + json={"name": name, "use_case": use_case, "credential": credential}, + api_token=api_token, + ) + return json.load(response) + + +def update_source_credential( + source_id: str, + credential_id: str, + name: str | None = None, + use_case: str | None = None, + credential: dict | None = None, + api_token: str | None = None, +): + """Update a source credential + + Args: + source_id: The ID of the source the credential belongs to + credential_id: The ID of the credential to update + name: Optional new name for the credential + use_case: Optional new use case. Options are + "source_authentication", "stac_api_authentication", + or "stac_asset_fetching". + credential: Optional updated credential details. Must include a + "type" key identifying the credential type along with its + type-specific fields (see create_source_credential). + api_token: Optional API token + + Returns: + The updated source credential + """ + json_payload: dict = {} + if name is not None: + json_payload["name"] = name + if use_case is not None: + json_payload["use_case"] = use_case + if credential is not None: + json_payload["credential"] = credential + + response = make_request( + url=SOURCE_CREDENTIAL_UPDATE.format( + source_id=source_id, credential_id=credential_id + ), + method="POST", + json=json_payload, + api_token=api_token, + ) + return json.load(response) + + +def delete_source_credential( + source_id: str, + credential_id: str, + api_token: str | None = None, +): + """Delete a source credential + + Args: + source_id: The ID of the source the credential belongs to + credential_id: The ID of the credential to delete + api_token: Optional API token + """ + make_request( + url=SOURCE_CREDENTIAL.format(source_id=source_id, credential_id=credential_id), + method="DELETE", + api_token=api_token, + ) diff --git a/pyproject.toml b/pyproject.toml index 1ba8ee9..3eab18f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "felt-python" -version = "0.1.3" +version = "0.2.0" authors = [ { name="Álvaro Arredondo", email="alvaro@felt.com" }, { name="Vince Foley", email="vince@felt.com" }, @@ -28,3 +28,9 @@ Documentation = "https://developers.felt.com" [tool.ruff] extend-exclude = ["*.ipynb"] + +[tool.ruff.lint] +# Pin to ruff's pre-0.16 default rules: CI installs the latest ruff, and the +# 0.16 release widened the default selection (import sorting, datetime +# timezone checks, ...) in a way that flags most of the existing codebase. +select = ["E4", "E7", "E9", "F"] diff --git a/tests/components_test.py b/tests/components_test.py new file mode 100644 index 0000000..9028582 --- /dev/null +++ b/tests/components_test.py @@ -0,0 +1,152 @@ +""" +End-to-end test for the Felt layer components functionality. +Uses the felt_python library to test creating, listing, updating and +deleting components on a layer. +""" + +import os +import sys +import unittest +import time +import datetime + +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from felt_python import ( + create_map, + delete_map, + upload_file, + get_layer, + list_layer_components, + create_layer_component, + get_layer_component, + update_layer_component, + delete_layer_component, +) + + +class FeltComponentsTest(unittest.TestCase): + """Test the Felt API layer components functionality.""" + + def setUp(self): + if not os.environ.get("FELT_API_TOKEN"): + self.skipTest("FELT_API_TOKEN environment variable not set") + + # Generate timestamp for unique resource names + self.timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + + def test_components_workflow(self): + """Test the complete workflow for layer component operations.""" + # Step 1: Create a map and upload a layer to attach components to + map_name = f"Components Test Map ({self.timestamp})" + print(f"Creating map: {map_name}...") + + response = create_map( + title=map_name, + lat=0, + lon=0, + zoom=10, + public_access="private", + ) + + self.assertIsNotNone(response) + self.assertIn("id", response) + map_id = response["id"] + print(f"Created map with ID: {map_id}") + + print("Uploading file layer...") + file_name = os.path.join( + os.path.dirname(__file__), "fixtures/null-island-points-sample.geojson" + ) + layer_resp = upload_file( + map_id=map_id, + file_name=file_name, + layer_name="Points Layer", + ) + + self.assertIsNotNone(layer_resp) + self.assertIn("layer_id", layer_resp) + layer_id = layer_resp["layer_id"] + print(f"Uploaded file layer with ID: {layer_id}") + + # Wait for layer processing to complete + print("Waiting for layer processing...") + max_wait_time = 60 # seconds + start_time = time.time() + + while time.time() - start_time < max_wait_time: + layer = get_layer(map_id, layer_id) + if layer["progress"] >= 100: + print( + f"Layer processing completed in {time.time() - start_time:.1f} seconds" + ) + break + print(f"Layer progress: {layer['progress']}%") + time.sleep(5) + + self.assertEqual(layer["progress"], 100, "Layer processing should complete") + + # Step 2: Create a statistic component (feature count) + print("Creating statistic component...") + component = create_layer_component( + map_id=map_id, + layer_id=layer_id, + component_type="statistic", + data={"aggregate": "count"}, + title="Feature count", + ) + + self.assertIsNotNone(component) + self.assertIn("id", component) + component_id = component["id"] + print(f"Created component with ID: {component_id}") + + # Step 3: List components + print("Listing components...") + components = list_layer_components(map_id, layer_id) + + self.assertIsNotNone(components) + self.assertTrue(any(c["id"] == component_id for c in components)) + print(f"Found {len(components)} components") + + # Step 4: Get component details + print("Getting component details...") + details = get_layer_component(map_id, layer_id, component_id) + + self.assertIsNotNone(details) + self.assertEqual(details["id"], component_id) + self.assertEqual(details["type"], "statistic") + + # Step 5: Update the component + updated_title = "Updated feature count" + print(f"Updating component title to: {updated_title}...") + updated = update_layer_component( + map_id=map_id, + layer_id=layer_id, + component_id=component_id, + title=updated_title, + ) + + self.assertIsNotNone(updated) + + updated_details = get_layer_component(map_id, layer_id, component_id) + self.assertEqual(updated_details["title"], updated_title) + print("Component updated successfully") + + # Step 6: Delete the component + print("Deleting component...") + delete_layer_component(map_id, layer_id, component_id) + + remaining = list_layer_components(map_id, layer_id) + self.assertFalse(any(c["id"] == component_id for c in remaining)) + print("Component deleted successfully") + + # Clean up + print("Cleaning up: deleting map...") + delete_map(map_id) + + print("\nComponents test completed successfully!") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/tests.py b/tests/tests.py index d258d91..4b02b15 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -16,6 +16,7 @@ from library_test import FeltLibraryTest from projects_test import FeltProjectsTest from sources_test import FeltSourcesTest +from components_test import FeltComponentsTest from delete_test import FeltDeleteTest @@ -39,6 +40,7 @@ FeltLibraryTest, FeltProjectsTest, FeltSourcesTest, + FeltComponentsTest, FeltDeleteTest, ]