From 5fc74abac216bd13cfb466c1ba7cc0da4e3e27b5 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 5 Aug 2026 16:07:34 +0000 Subject: [PATCH 1/3] Add layer components and source credentials support Audit of the client against the Felt REST API v2 OpenAPI spec found two endpoint groups with no coverage, plus some parameter drift in existing functions. New: layer components (felt_python/components.py) - list_layer_components, create_layer_component, get_layer_component, update_layer_component, delete_layer_component - Supports all component types: statistic, histogram, bar_chart, time_series, filter New: source credentials (felt_python/sources.py) - create_source_credential, update_source_credential, delete_source_credential Updated for API drift: - update_layer_group: add subtitle and legend_visibility parameters; document the select/multi_select visibility_interaction options - update_layer_groups: document subtitle, legend_visibility and visibility_interaction keys - update_layers: document subtitle, layer_group_id, legend_display and legend_visibility keys - create_custom_export: document the geotiff, pmtiles, shapefile, kml and geoparquet output formats Adds an E2E test for layer components and bumps the version to 0.2.0. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0123e8yxTXGb3tJjwHaaBZaK --- felt_python/__init__.py | 19 ++++ felt_python/components.py | 175 ++++++++++++++++++++++++++++++++++++ felt_python/layer_groups.py | 14 ++- felt_python/layers.py | 8 +- felt_python/sources.py | 108 ++++++++++++++++++++++ pyproject.toml | 2 +- tests/components_test.py | 152 +++++++++++++++++++++++++++++++ tests/tests.py | 2 + 8 files changed, 474 insertions(+), 6 deletions(-) create mode 100644 felt_python/components.py create mode 100644 tests/components_test.py 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..63f0e51 --- /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 + 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 + 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..226b37e 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,106 @@ 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": ...}]} + - {"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..0582b1e 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" }, 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, ] From fb2f7b2061465deedd598d99e7c01a320580efbb Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 5 Aug 2026 16:13:47 +0000 Subject: [PATCH 2/3] Pin ruff lint rules to pre-0.16 defaults CI installs the latest ruff on every run, and ruff 0.16 widened the default rule selection (import sorting, datetime timezone checks, blind except, __all__ sorting), which flags 41 pre-existing violations on main and turns lint red for every branch. Pin the selected rules to the previous defaults so lint results are deterministic across ruff releases. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0123e8yxTXGb3tJjwHaaBZaK --- pyproject.toml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 0582b1e..3eab18f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] From 62bda716a1dbef624ae2754f4fdc9684a0bb5af4 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 5 Aug 2026 19:06:45 +0000 Subject: [PATCH 3/3] Fix custom_headers docstring and document component title cap The custom_headers credential example was missing the required "sensitive" field on header entries; document its redaction behavior. Also note the 256-character limit on component titles, which lives in the backend changeset rather than the OpenAPI schema. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0123e8yxTXGb3tJjwHaaBZaK --- felt_python/components.py | 4 ++-- felt_python/sources.py | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/felt_python/components.py b/felt_python/components.py index 63f0e51..75ab7ef 100644 --- a/felt_python/components.py +++ b/felt_python/components.py @@ -54,7 +54,7 @@ def create_layer_component( component takes either a feature count ({"aggregate": "count"}) or an attribute aggregation ({"aggregate": "avg", "aggregate_by": "some_attribute"}). - title: Optional display title + 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"). @@ -125,7 +125,7 @@ def update_layer_component( 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 + 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 diff --git a/felt_python/sources.py b/felt_python/sources.py index 226b37e..f708c91 100644 --- a/felt_python/sources.py +++ b/felt_python/sources.py @@ -150,7 +150,11 @@ def create_source_credential( 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": ...}]} + - {"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": ...,