From a9dcd5220151ec96e37d75af4be4dcd94b2ddcee Mon Sep 17 00:00:00 2001 From: Vince Foley Date: Tue, 8 Apr 2025 14:28:50 -0700 Subject: [PATCH 1/9] Update with latest Felt REST API --- .gitignore | 4 +- felt_python/__init__.py | 90 ++++++++- felt_python/comments.py | 67 +++++++ felt_python/elements.py | 97 +++++++++- felt_python/layer_groups.py | 138 +++++++++++++ felt_python/layers.py | 319 +++++++++++++++++++++++++++---- felt_python/library.py | 33 ++++ felt_python/maps.py | 178 ++++++++++++++++- felt_python/projects.py | 100 ++++++++++ felt_python/sources.py | 126 ++++++++++++ felt_python/user.py | 27 +++ felt_python/util.py | 18 ++ testing_comments_duplicate.ipynb | 301 +++++++++++++++++++++++++++++ testing_elements.ipynb | 165 ++++++++++++++-- testing_layer_groups.ipynb | 284 +++++++++++++++++++++++++++ testing_layers.ipynb | 179 ++++++++++++++++- testing_library_user.ipynb | 233 ++++++++++++++++++++++ testing_maps.ipynb | 38 ++-- testing_projects.ipynb | 211 ++++++++++++++++++++ testing_sources.ipynb | 296 ++++++++++++++++++++++++++++ 20 files changed, 2809 insertions(+), 95 deletions(-) create mode 100644 felt_python/comments.py create mode 100644 felt_python/layer_groups.py create mode 100644 felt_python/library.py create mode 100644 felt_python/projects.py create mode 100644 felt_python/sources.py create mode 100644 felt_python/user.py create mode 100644 felt_python/util.py create mode 100644 testing_comments_duplicate.ipynb create mode 100644 testing_layer_groups.ipynb create mode 100644 testing_library_user.ipynb create mode 100644 testing_projects.ipynb create mode 100644 testing_sources.ipynb diff --git a/.gitignore b/.gitignore index f875d65..1af154c 100644 --- a/.gitignore +++ b/.gitignore @@ -159,4 +159,6 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ -.DS_Store \ No newline at end of file +.DS_Store + +.python-version diff --git a/felt_python/__init__.py b/felt_python/__init__.py index a82f0e8..1cd8633 100644 --- a/felt_python/__init__.py +++ b/felt_python/__init__.py @@ -4,7 +4,9 @@ delete_map, get_map_details, update_map, - move_map + move_map, + create_embed_token, + add_source_layer, ) from .exceptions import AuthError from .layers import ( @@ -19,15 +21,50 @@ update_layer_style, get_export_link, download_layer, + update_layers, + delete_layer, + publish_layer, + create_custom_export, + get_custom_export_status, + duplicate_layers, ) from .elements import ( list_elements, list_element_groups, - list_elements_in_group, - post_elements, + upsert_elements, delete_element, + show_element_group, + create_element_groups, + # Deprecated: + post_elements, post_element_group, + list_elements_in_group, +) +from .layer_groups import ( + list_layer_groups, + get_layer_group_details, + update_layer_groups, + delete_layer_group, + publish_layer_group, +) +from .projects import ( + list_projects, + create_project, + get_project_details, + update_project, + delete_project, ) +from .sources import ( + list_sources, + create_source, + get_source_details, + update_source, + delete_source, + sync_source, +) +from .library import list_library_layers +from .comments import export_comments, resolve_comment, delete_comment +from .user import get_current_user __doc__ = """ The official Python client for the Felt API @@ -41,10 +78,15 @@ """ __all__ = [ + # Maps "create_map", "delete_map", "get_map_details", "update_map", + "move_map", + "create_embed_token", + "add_source_layer", + # Layers "list_layers", "upload_file", "upload_geodataframe", @@ -56,11 +98,49 @@ "update_layer_style", "get_export_link", "download_layer", - "AuthError", + "update_layers", + "delete_layer", + "publish_layer", + "create_custom_export", + "get_custom_export_status", + "duplicate_layers", + # Layer groups + "list_layer_groups", + "get_layer_group_details", + "update_layer_groups", + "delete_layer_group", + "publish_layer_group", + # Elements "list_elements", "list_element_groups", "list_elements_in_group", - "post_elements", + "upsert_elements", "delete_element", + "create_element_groups", + # Elements deprecated: + "post_elements", "post_element_group", + # Projects + "list_projects", + "create_project", + "get_project_details", + "update_project", + "delete_project", + # Sources + "list_sources", + "create_source", + "get_source_details", + "update_source", + "delete_source", + "sync_source", + # Library + "list_library_layers", + # Comments + "export_comments", + "resolve_comment", + "delete_comment", + # User + "get_current_user", + # Exceptions + "AuthError", ] diff --git a/felt_python/comments.py b/felt_python/comments.py new file mode 100644 index 0000000..34d3d8c --- /dev/null +++ b/felt_python/comments.py @@ -0,0 +1,67 @@ +"""Comments""" + +import json + +from urllib.parse import urljoin + +from .api import make_request, BASE_URL + + +MAP_COMMENTS_TEMPLATE = urljoin(BASE_URL, "maps/{map_id}/comments/") +MAP_COMMENT_TEMPLATE = urljoin(MAP_COMMENTS_TEMPLATE, "{comment_id}") +MAP_COMMENT_RESOLVE_TEMPLATE = urljoin(MAP_COMMENT_TEMPLATE, "/resolve") +MAP_COMMENTS_EXPORT_TEMPLATE = urljoin(MAP_COMMENTS_TEMPLATE, "export") + + +def export_comments(map_id: str, format: str = "json", api_token: str | None = None): + """Export comments from a map + + Args: + map_id: The ID of the map to export comments from + format: The format to export the comments in, either 'csv' or 'json' (default) + api_token: Optional API token + + Returns: + The exported comments in the specified format + """ + url = f"{MAP_COMMENTS_EXPORT_TEMPLATE.format(map_id=map_id)}?format={format}" + response = make_request( + url=url, + method="GET", + api_token=api_token, + ) + return json.load(response) + + +def resolve_comment(map_id: str, comment_id: str, api_token: str | None = None): + """Resolve a comment + + Args: + map_id: The ID of the map that contains the comment + comment_id: The ID of the comment to resolve + api_token: Optional API token + + Returns: + Confirmation of the resolved comment + """ + response = make_request( + url=MAP_COMMENT_RESOLVE_TEMPLATE.format(map_id=map_id, comment_id=comment_id), + method="POST", + api_token=api_token, + ) + return json.load(response) + + +def delete_comment(map_id: str, comment_id: str, api_token: str | None = None): + """Delete a comment + + Args: + map_id: The ID of the map that contains the comment + comment_id: The ID of the comment to delete + api_token: Optional API token + """ + make_request( + url=MAP_COMMENT_TEMPLATE.format(map_id=map_id, comment_id=comment_id), + method="DELETE", + api_token=api_token, + ) diff --git a/felt_python/elements.py b/felt_python/elements.py index 30ecf2b..82cf57d 100644 --- a/felt_python/elements.py +++ b/felt_python/elements.py @@ -1,10 +1,12 @@ """Elements and element groups""" import json +from typing import Dict, Any, List, Union from urllib.parse import urljoin from .api import make_request, BASE_URL +from .util import deprecated MAP_ELEMENTS_TEMPLATE = urljoin(BASE_URL, "maps/{map_id}/elements/") @@ -14,7 +16,15 @@ def list_elements(map_id: str, api_token: str | None = None): - """List all elements on a map""" + """List all elements on a map + + Args: + map_id: The ID of the map to list elements from + api_token: Optional API token + + Returns: + GeoJSON FeatureCollection of all elements + """ response = make_request( url=MAP_ELEMENTS_TEMPLATE.format(map_id=map_id), method="GET", @@ -24,7 +34,15 @@ def list_elements(map_id: str, api_token: str | None = None): def list_element_groups(map_id: str, api_token: str | None = None): - """List all element groups on a map""" + """List all element groups on a map + + Args: + map_id: The ID of the map to list element groups from + api_token: Optional API token + + Returns: + List of element groups + """ response = make_request( url=MAP_ELEMENT_GROUPS_TEMPLATE.format(map_id=map_id), method="GET", @@ -33,10 +51,19 @@ def list_element_groups(map_id: str, api_token: str | None = None): return json.load(response) -def list_elements_in_group( +def show_element_group( map_id: str, element_group_id: str, api_token: str | None = None ): - """List all elements in a group""" + """Show all elements in a group + + Args: + map_id: The ID of the map containing the group + element_group_id: The ID of the element group to list elements from + api_token: Optional API token + + Returns: + GeoJSON FeatureCollection of all elements in the group + """ response = make_request( url=ELEMENT_GROUP_TEMPLATE.format( map_id=map_id, element_group_id=element_group_id @@ -47,12 +74,36 @@ def list_elements_in_group( return json.load(response) -def post_elements(map_id: str, geojson_feature_collection: dict | str): - """Post a GeoJSON FeatureCollection +@deprecated(reason="Please use `show_element_group` instead") +def list_elements_in_group( + map_id: str, element_group_id: str, api_token: str | None = None +): + show_element_group(map_id, element_group_id, api_token) + + +@deprecated(reason="Please use `upsert_elements` instead") +def post_elements( + map_id: str, geojson_feature_collection: dict | str, api_token: str | None = None +): + upsert_elements(map_id, geojson_feature_collection, api_token) + + +def upsert_elements( + map_id: str, geojson_feature_collection: dict | str, api_token: str | None = None +): + """Create elements Each GeoJSON Feature represents an element. If a feature has an existing ID, that element will be updated. If a feature does not have an ID (or the one it has does not exist), a new element will be created. + + Args: + map_id: The ID of the map to create or update elements on + geojson_feature_collection: GeoJSON FeatureCollection as dict or JSON string + api_token: Optional API token + + Returns: + GeoJSON FeatureCollection of the created or updated elements """ if isinstance(geojson_feature_collection, str): geojson_feature_collection = json.loads(geojson_feature_collection) @@ -60,28 +111,54 @@ def post_elements(map_id: str, geojson_feature_collection: dict | str): url=MAP_ELEMENTS_TEMPLATE.format(map_id=map_id), method="POST", json=geojson_feature_collection, + api_token=api_token, ) return json.load(response) -def delete_element(map_id: str, element_id: str): - """Delete an element""" +def delete_element(map_id: str, element_id: str, api_token: str | None = None): + """Delete an element + + Args: + map_id: The ID of the map containing the element + element_id: The ID of the element to delete + api_token: Optional API token + """ make_request( url=ELEMENT_TEMPLATE.format(map_id=map_id, element_id=element_id), method="DELETE", + api_token=api_token, ) +@deprecated(reason="Please use `create_element_groups` instead") def post_element_group( map_id: str, json_element: dict | str, api_token: str | None = None, ): - """Post a new element group""" + create_element_groups(map_id, json_element, api_token) + + +def create_element_groups( + map_id: str, + element_groups: List[Dict[str, Any]], + api_token: str | None = None, +): + """Post multiple element groups + + Args: + map_id: The ID of the map to create the element groups on + element_groups: List of element group objects + api_token: Optional API token + + Returns: + The created or updated element groups + """ response = make_request( url=MAP_ELEMENT_GROUPS_TEMPLATE.format(map_id=map_id), method="POST", - json=json_element, + json=element_groups, api_token=api_token, ) return json.load(response) diff --git a/felt_python/layer_groups.py b/felt_python/layer_groups.py new file mode 100644 index 0000000..901167d --- /dev/null +++ b/felt_python/layer_groups.py @@ -0,0 +1,138 @@ +"""Layer groups""" + +import json +from typing import Dict, Any, List, Union, Optional + +from urllib.parse import urljoin + +from .api import make_request, BASE_URL + + +MAP_LAYER_GROUPS_TEMPLATE = urljoin(BASE_URL, "maps/{map_id}/layer_groups/") +LAYER_GROUP_TEMPLATE = urljoin(MAP_LAYER_GROUPS_TEMPLATE, "{layer_group_id}") +PUBLISH_LAYER_GROUP_TEMPLATE = urljoin(LAYER_GROUP_TEMPLATE, "/publish") + + +def list_layer_groups(map_id: str, api_token: str | None = None): + """List layer groups on a map + + Args: + map_id: The ID of the map to list layer groups from + api_token: Optional API token + + Returns: + List of layer groups + """ + response = make_request( + url=MAP_LAYER_GROUPS_TEMPLATE.format(map_id=map_id), + method="GET", + api_token=api_token, + ) + return json.load(response) + + +def get_layer_group_details( + map_id: str, + layer_group_id: str, + api_token: str | None = None, +): + """Get details of a layer group + + Args: + map_id: The ID of the map containing the layer group + layer_group_id: The ID of the layer group to get details for + api_token: Optional API token + + Returns: + Layer group details + """ + response = make_request( + url=LAYER_GROUP_TEMPLATE.format( + map_id=map_id, + layer_group_id=layer_group_id, + ), + method="GET", + api_token=api_token, + ) + return json.load(response) + + +def update_layer_groups( + map_id: str, + layer_group_params_list: List[Dict[str, Any]], + api_token: str | None = None, +): + """Update multiple layer groups at once + + Args: + 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" + api_token: Optional API token + + Returns: + The updated layer groups + """ + response = make_request( + url=MAP_LAYER_GROUPS_TEMPLATE.format(map_id=map_id), + method="POST", + json=layer_group_params_list, + api_token=api_token, + ) + return json.load(response) + + +def delete_layer_group( + map_id: str, + layer_group_id: str, + api_token: str | None = None, +): + """Delete a layer group from a map + + Args: + map_id: The ID of the map containing the layer group + layer_group_id: The ID of the layer group to delete + api_token: Optional API token + """ + make_request( + url=LAYER_GROUP_TEMPLATE.format( + map_id=map_id, + layer_group_id=layer_group_id, + ), + method="DELETE", + api_token=api_token, + ) + + +def publish_layer_group( + map_id: str, + layer_group_id: str, + name: str = None, + api_token: str | None = None, +): + """Publish a layer group to the Felt library + + Args: + map_id: The ID of the map containing the layer group + layer_group_id: The ID of the layer group to publish + name: Optional name to publish the layer group under + api_token: Optional API token + + Returns: + The published layer group + """ + json_payload = {} + if name is not None: + json_payload["name"] = name + + response = make_request( + url=PUBLISH_LAYER_GROUP_TEMPLATE.format( + map_id=map_id, + layer_group_id=layer_group_id, + ), + method="POST", + json=json_payload, + api_token=api_token, + ) + return json.load(response) diff --git a/felt_python/layers.py b/felt_python/layers.py index 41d5f33..9db1d83 100644 --- a/felt_python/layers.py +++ b/felt_python/layers.py @@ -7,6 +7,7 @@ import typing import urllib.request import uuid +from typing import Dict, Any, List, Union, Optional from urllib.parse import urljoin @@ -20,6 +21,10 @@ UPDATE_STYLE_TEMPLATE = urljoin(LAYER_TEMPLATE, "update_style") UPLOAD_TEMPLATE = urljoin(MAP_TEMPLATE, "upload") EXPORT_TEMPLATE = urljoin(LAYER_TEMPLATE, "get_export_link") +PUBLISH_LAYER_TEMPLATE = urljoin(LAYER_TEMPLATE, "publish") +CUSTOM_EXPORT_TEMPLATE = urljoin(LAYER_TEMPLATE, "custom_export") +CUSTOM_EXPORT_STATUS_TEMPLATE = urljoin(CUSTOM_EXPORT_TEMPLATE, "/{export_id}") +DUPLICATE_LAYERS_ENDPOINT = urljoin(BASE_URL, "duplicate_layers") def list_layers(map_id: str, api_token: str | None = None): @@ -57,50 +62,69 @@ def _multipart_request( return urllib.request.Request(url, data=body, headers=headers, method="POST") -def _request_and_upload( - url: str, +def upload_file( + map_id: str, file_name: str, - layer_name: str | None = None, + layer_name: str, + metadata: Dict[str, Any] = None, + hints: List[Dict[str, Any]] = None, + lat: float = None, + lng: float = None, + zoom: float = None, api_token: str | None = None, ): - """Upload or refresh a file - - Both upload_file and refresh_file_layer use this function to handle the - request and file upload. The only difference is that the upload endpoint - requires a layer name while the refresh endpoint does not. + """Upload a file to a Felt map + + Args: + map_id: The ID of the map to upload to + file_name: The path to the file to upload + layer_name: The display name for the new layer + metadata: Optional metadata for the layer + hints: Optional list of hints for interpreting the data in the upload + lat: Optional latitude of the image center (image uploads only) + lng: Optional longitude of the image center (image uploads only) + zoom: Optional zoom level of the image (image uploads only) + api_token: Optional API token + + Returns: + The upload response including layer ID and presigned upload details """ - json_payload = {"name": layer_name} if layer_name else None + json_payload = {"name": layer_name} + + if metadata is not None: + json_payload["metadata"] = metadata + if hints is not None: + json_payload["hints"] = hints + if lat is not None: + json_payload["lat"] = lat + if lng is not None: + json_payload["lng"] = lng + if zoom is not None: + json_payload["zoom"] = zoom + response = make_request( - url=url, method="POST", api_token=api_token, json=json_payload + url=UPLOAD_TEMPLATE.format(map_id=map_id), + method="POST", + api_token=api_token, + json=json_payload, ) presigned_upload = json.load(response) url = presigned_upload["url"] presigned_attributes = presigned_upload["presigned_attributes"] + with open(file_name, "rb") as file_obj: request = _multipart_request(url, presigned_attributes, file_obj) urllib.request.urlopen(request) - return presigned_upload - -def upload_file( - map_id: str, - file_name: str, - layer_name: str, - api_token: str | None = None, -): - """Upload a file to a Felt map""" - return _request_and_upload( - url=UPLOAD_TEMPLATE.format(map_id=map_id), - file_name=file_name, - layer_name=layer_name, - api_token=api_token, - ) + return presigned_upload def upload_dataframe( map_id: str, dataframe: "pd.DataFrame", layer_name: str, + metadata: Dict[str, Any] = None, + hints: List[Dict[str, Any]] = None, api_token: str | None = None, ): """Upload a Pandas DataFrame to a Felt map""" @@ -111,7 +135,9 @@ def upload_dataframe( map_id, file_name, layer_name, - api_token, + metadata=metadata, + hints=hints, + api_token=api_token, ) @@ -119,6 +145,8 @@ def upload_geodataframe( map_id: str, geodataframe: "gpd.GeoDataFrame", layer_name: str, + metadata: Dict[str, Any] = None, + hints: List[Dict[str, Any]] = None, api_token: str | None = None, ): """Upload a GeoPandas GeoDataFrame to a Felt map""" @@ -129,36 +157,78 @@ def upload_geodataframe( map_id, file_name, layer_name, - api_token, + metadata=metadata, + hints=hints, + api_token=api_token, ) def refresh_file_layer( map_id: str, layer_id: str, file_name: str, api_token: str | None = None ): - """Refresh a layer originated from a file upload""" - return _request_and_upload( + """Refresh a layer originated from a file upload + + Args: + map_id: The ID of the map containing the layer + layer_id: The ID of the layer to refresh + file_name: The path to the file to upload as the new data + api_token: Optional API token + + Returns: + The refresh response including presigned upload details + """ + response = make_request( url=REFRESH_TEMPLATE.format(map_id=map_id, layer_id=layer_id), - file_name=file_name, + method="POST", api_token=api_token, ) + presigned_upload = json.load(response) + url = presigned_upload["url"] + presigned_attributes = presigned_upload["presigned_attributes"] + + with open(file_name, "rb") as file_obj: + request = _multipart_request(url, presigned_attributes, file_obj) + urllib.request.urlopen(request) + + return presigned_upload def upload_url( map_id: str, layer_url: str, layer_name: str, + metadata: Dict[str, Any] = None, + hints: List[Dict[str, Any]] = None, api_token: str | None = None, ): - """Upload a URL to a Felt map""" + """Upload a URL to a Felt map + + Args: + map_id: The ID of the map to upload to + layer_url: The URL containing geodata to import + layer_name: The display name for the new layer + metadata: Optional metadata for the layer + hints: Optional list of hints for interpreting the data + api_token: Optional API token + + Returns: + The upload response + """ + json_payload = { + "import_url": layer_url, + "name": layer_name, + } + + if metadata is not None: + json_payload["metadata"] = metadata + if hints is not None: + json_payload["hints"] = hints + response = make_request( url=UPLOAD_TEMPLATE.format(map_id=map_id), method="POST", api_token=api_token, - json={ - "import_url": layer_url, - "name": layer_name, - }, + json=json_payload, ) return json.load(response) @@ -181,7 +251,7 @@ def get_layer_details( layer_id: str, api_token: str | None = None, ): - """Get style of a layer""" + """Get details of a layer""" response = make_request( url=LAYER_TEMPLATE.format( map_id=map_id, @@ -199,7 +269,7 @@ def update_layer_style( style: dict, api_token: str | None = None, ): - """Style a layer""" + """Update a layer's style""" response = make_request( url=UPDATE_STYLE_TEMPLATE.format( map_id=map_id, @@ -252,3 +322,178 @@ def download_layer( with open(file_name, "wb") as file_obj: file_obj.write(response.read()) return file_name + + +def update_layers( + map_id: str, + layer_params_list: List[Dict[str, Any]], + api_token: str | None = None, +): + """Update multiple layers at once + + Args: + 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" + api_token: Optional API token + + Returns: + The updated layers + """ + response = make_request( + url=MAP_LAYERS_TEMPLATE.format(map_id=map_id), + method="POST", + json=layer_params_list, + api_token=api_token, + ) + return json.load(response) + + +def delete_layer( + map_id: str, + layer_id: str, + api_token: str | None = None, +): + """Delete a layer from a map""" + make_request( + url=LAYER_TEMPLATE.format( + map_id=map_id, + layer_id=layer_id, + ), + method="DELETE", + api_token=api_token, + ) + + +def publish_layer( + map_id: str, + layer_id: str, + name: str = None, + api_token: str | None = None, +): + """Publish a layer to the Felt library + + Args: + map_id: The ID of the map containing the layer + layer_id: The ID of the layer to publish + name: Optional name to publish the layer under + api_token: Optional API token + + Returns: + The published layer + """ + json_payload = {} + if name is not None: + json_payload["name"] = name + + response = make_request( + url=PUBLISH_LAYER_TEMPLATE.format( + map_id=map_id, + layer_id=layer_id, + ), + method="POST", + json=json_payload, + api_token=api_token, + ) + return json.load(response) + + +def create_custom_export( + map_id: str, + layer_id: str, + output_format: str, + filters: List[Dict[str, Any]] = None, + email_on_completion: bool = True, + api_token: str | None = None, +): + """Create a custom export of a layer + + Args: + 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" + 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. + api_token: Optional API token + + Returns: + Export request details including ID and polling endpoint + """ + json_payload = { + "output_format": output_format, + "email_on_completion": email_on_completion, + } + + if filters is not None: + json_payload["filters"] = filters + + response = make_request( + url=CUSTOM_EXPORT_TEMPLATE.format( + map_id=map_id, + layer_id=layer_id, + ), + method="POST", + json=json_payload, + api_token=api_token, + ) + return json.load(response) + + +def get_custom_export_status( + map_id: str, + layer_id: str, + export_id: str, + api_token: str | None = None, +): + """Check the status of a custom export + + Args: + map_id: The ID of the map containing the layer + layer_id: The ID of the layer being exported + export_id: The ID of the export request + api_token: Optional API token + + Returns: + Export status including download URL when complete + """ + response = make_request( + url=CUSTOM_EXPORT_STATUS_TEMPLATE.format( + map_id=map_id, + layer_id=layer_id, + export_id=export_id, + ), + method="GET", + api_token=api_token, + ) + return json.load(response) + + +def duplicate_layers( + duplicate_params: List[Dict[str, str]], api_token: str | None = None +): + """Duplicate layers from one map to another + + Args: + duplicate_params: List of layer duplication parameters. Each dict must contain: + - For duplicating a single layer: + - "source_layer_id": ID of the layer to duplicate + - "destination_map_id": ID of the map to duplicate to + - For duplicating a layer group: + - "source_layer_group_id": ID of the layer group to duplicate + - "destination_map_id": ID of the map to duplicate to + api_token: Optional API token + + Returns: + The duplicated layers and layer groups + """ + response = make_request( + url=DUPLICATE_LAYERS_ENDPOINT, + method="POST", + json=duplicate_params, + api_token=api_token, + ) + return json.load(response) diff --git a/felt_python/library.py b/felt_python/library.py new file mode 100644 index 0000000..423aaeb --- /dev/null +++ b/felt_python/library.py @@ -0,0 +1,33 @@ +"""Layer library""" + +import json + +from urllib.parse import urljoin + +from .api import make_request, BASE_URL + + +LIBRARY_ENDPOINT = urljoin(BASE_URL, "library") + + +def list_library_layers(source: str = "workspace", api_token: str | None = None): + """List layers available in the layer library + + Args: + source: The source of library layers to list. + Options are: + - "workspace": list layers from your workspace library (default) + - "felt": list layers from the Felt data library + - "all": list layers from both sources + api_token: Optional API token + + Returns: + The layer library containing layers and layer groups + """ + url = f"{LIBRARY_ENDPOINT}?source={source}" + response = make_request( + url=url, + method="GET", + api_token=api_token, + ) + return json.load(response) diff --git a/felt_python/maps.py b/felt_python/maps.py index 4f9eb5a..3a6ee1e 100644 --- a/felt_python/maps.py +++ b/felt_python/maps.py @@ -1,6 +1,7 @@ """Maps""" import json +from typing import Dict, Any, List, Union, Optional from urllib.parse import urljoin @@ -11,10 +12,70 @@ MAP_TEMPLATE = urljoin(MAPS_ENDPOINT, "{map_id}/") MAP_UPDATE_TEMPLATE = urljoin(MAPS_ENDPOINT, "{map_id}/update") MAP_MOVE_TEMPLATE = urljoin(MAPS_ENDPOINT, "{map_id}/move") +MAP_EMBED_TOKEN_TEMPLATE = urljoin(MAPS_ENDPOINT, "{map_id}/embed_token") +MAP_ADD_SOURCE_LAYER_TEMPLATE = urljoin(MAPS_ENDPOINT, "{map_id}/add_source_layer") -def create_map(api_token: str | None = None, **json_args): - """Create a new Felt map""" +def create_map( + title: str = None, + description: str = None, + public_access: str = None, + basemap: str = None, + lat: float = None, + lon: float = None, + zoom: float = None, + layer_urls: List[str] = None, + workspace_id: str = None, + api_token: str = None, +): + """Create a new Felt map + + Args: + title: The title to be used for the map. Defaults to "Untitled Map" + description: A description to display in the map legend + public_access: The level of access to grant to the map. + Options are "private", "view_only", "view_and_comment", + or "view_comment_and_edit". Defaults to "view_only". + basemap: The basemap to use for the new map. Defaults to "default". + Valid values are "default", "light", "dark", "satellite", + a valid raster tile URL with {x}, {y}, and {z} parameters, + or a hex color string like #ff0000. + lat: If no data has been uploaded to the map, the initial latitude + to center the map display on. + lon: If no data has been uploaded to the map, the initial longitude + to center the map display on. + zoom: If no data has been uploaded to the map, the initial zoom level + for the map to display. + layer_urls: An array of urls to use to create layers in the map. + Only tile URLs for raster layers are supported at the moment. + workspace_id: The workspace to create the map in. + Defaults to the latest used workspace. + api_token: Optional API token + + Returns: + The created map + """ + json_args = {} + + if title is not None: + json_args["title"] = title + if description is not None: + json_args["description"] = description + if public_access is not None: + json_args["public_access"] = public_access + if basemap is not None: + json_args["basemap"] = basemap + if lat is not None: + json_args["lat"] = lat + if lon is not None: + json_args["lon"] = lon + if zoom is not None: + json_args["zoom"] = zoom + if layer_urls is not None: + json_args["layer_urls"] = layer_urls + if workspace_id is not None: + json_args["workspace_id"] = workspace_id + response = make_request( url=MAPS_ENDPOINT, method="POST", @@ -43,23 +104,124 @@ def get_map_details(map_id: str, api_token: str | None = None): return json.load(response) -def update_map(map_id: str, new_title: str, api_token: str | None = None): - """Update a map's details (title only for now)""" +def update_map( + map_id: str, + title: str = None, + description: str = None, + public_access: str = None, + api_token: str = None, +): + """Update a map's details + + Args: + map_id: The ID of the map to update + title: Optional new title for the map + description: Optional new description for the map + public_access: Optional new public access setting + Options are "private", "view_only", "view_and_comment", + or "view_comment_and_edit" + api_token: Optional API token + + Returns: + The updated map + """ + json_args = {} + if title is not None: + json_args["title"] = title + if description is not None: + json_args["description"] = description + if public_access is not None: + json_args["public_access"] = public_access + response = make_request( url=MAP_UPDATE_TEMPLATE.format(map_id=map_id), method="POST", - json={"title": new_title}, + json=json_args, api_token=api_token, ) return json.load(response) -def move_map(map_id: str, project_id: str, api_token: str | None = None): - """Move a map to a different project""" +def move_map( + map_id: str, project_id: str = None, folder_id: str = None, api_token: str = None +): + """Move a map to a different project or folder + + Args: + map_id: The ID of the map to move + project_id: The ID of the project to move the map to (mutually exclusive with folder_id) + folder_id: The ID of the folder to move the map to (mutually exclusive with project_id) + api_token: Optional API token + + Returns: + The moved map + """ + if project_id is not None and folder_id is not None: + raise ValueError("Cannot specify both project_id and folder_id") + if project_id is None and folder_id is None: + raise ValueError("Must specify either project_id or folder_id") + + json_args = {} + if project_id is not None: + json_args["project_id"] = project_id + if folder_id is not None: + json_args["folder_id"] = folder_id + response = make_request( url=MAP_MOVE_TEMPLATE.format(map_id=map_id), method="POST", - json={"project_id": project_id}, + json=json_args, + api_token=api_token, + ) + return json.load(response) + + +def create_embed_token(map_id: str, user_email: str = None, api_token: str = None): + """Create an embed token for a map + + Args: + map_id: The ID of the map to create an embed token for + user_email: Optionally assign the token to a user email address. + Providing an email will enable the viewer to export data + if the Map allows it. + api_token: Optional API token + + Returns: + The created embed token with expiration time + """ + url = MAP_EMBED_TOKEN_TEMPLATE.format(map_id=map_id) + if user_email: + url = f"{url}?user_email={user_email}" + + response = make_request( + url=url, + method="POST", + api_token=api_token, + ) + return json.load(response) + + +def add_source_layer( + map_id: str, source_layer_params: Dict[str, Any], api_token: str = None +): + """Add a layer from a source to a map + + Args: + map_id: The ID of the map to add the layer to + source_layer_params: Parameters defining the source layer to add + Must include "from" key with one of these values: + - "dataset": requires "dataset_id" + - "sql": requires "source_id" and "query" + - "stac": requires "source_id" and "stac_asset_url" + api_token: Optional API token + + Returns: + Acceptance status and links to the created resources + """ + response = make_request( + url=MAP_ADD_SOURCE_LAYER_TEMPLATE.format(map_id=map_id), + method="POST", + json=source_layer_params, api_token=api_token, ) return json.load(response) diff --git a/felt_python/projects.py b/felt_python/projects.py new file mode 100644 index 0000000..2f4f488 --- /dev/null +++ b/felt_python/projects.py @@ -0,0 +1,100 @@ +"""Projects""" + +import json + +from urllib.parse import urljoin + +from .api import make_request, BASE_URL + + +PROJECTS_ENDPOINT = urljoin(BASE_URL, "projects/") +PROJECT_TEMPLATE = urljoin(PROJECTS_ENDPOINT, "{project_id}/") +PROJECT_UPDATE_TEMPLATE = urljoin(PROJECTS_ENDPOINT, "{project_id}/update") + + +def list_projects(workspace_id: str | None = None, api_token: str | None = None): + """List all projects accessible to the authenticated user""" + url = PROJECTS_ENDPOINT + if workspace_id: + url = f"{url}?workspace_id={workspace_id}" + response = make_request( + url=url, + method="GET", + api_token=api_token, + ) + return json.load(response) + + +def create_project(name: str, visibility: str, api_token: str | None = None): + """Create a new project + + Args: + name: The name to be used for the Project + visibility: Either "workspace" (viewable by all members of the workspace) + or "private" (private to users who are invited) + api_token: Optional API token + + Returns: + The created project + """ + response = make_request( + url=PROJECTS_ENDPOINT, + method="POST", + json={"name": name, "visibility": visibility}, + api_token=api_token, + ) + return json.load(response) + + +def get_project_details(project_id: str, api_token: str | None = None): + """Get details of a project""" + response = make_request( + url=PROJECT_TEMPLATE.format(project_id=project_id), + method="GET", + api_token=api_token, + ) + return json.load(response) + + +def update_project( + project_id: str, + name: str | None = None, + visibility: str | None = None, + api_token: str | None = None, +): + """Update a project's details + + Args: + project_id: The ID of the project to update + name: Optional new name for the project + visibility: Optional new visibility setting ("workspace" or "private") + api_token: Optional API token + + Returns: + The updated project + """ + json_args = {} + if name is not None: + json_args["name"] = name + if visibility is not None: + json_args["visibility"] = visibility + + response = make_request( + url=PROJECT_UPDATE_TEMPLATE.format(project_id=project_id), + method="POST", + json=json_args, + api_token=api_token, + ) + return json.load(response) + + +def delete_project(project_id: str, api_token: str | None = None): + """Delete a project + + Note: This will delete all Folders and Maps inside the project! + """ + make_request( + url=PROJECT_TEMPLATE.format(project_id=project_id), + method="DELETE", + api_token=api_token, + ) diff --git a/felt_python/sources.py b/felt_python/sources.py new file mode 100644 index 0000000..a8630a0 --- /dev/null +++ b/felt_python/sources.py @@ -0,0 +1,126 @@ +"""Sources""" + +import json + +from urllib.parse import urljoin +from typing import Dict, Any, List, Union + +from .api import make_request, BASE_URL + + +SOURCES_ENDPOINT = urljoin(BASE_URL, "sources/") +SOURCE_TEMPLATE = urljoin(SOURCES_ENDPOINT, "{source_id}/") +SOURCE_UPDATE_TEMPLATE = urljoin(SOURCES_ENDPOINT, "{source_id}/update") +SOURCE_SYNC_TEMPLATE = urljoin(SOURCES_ENDPOINT, "{source_id}/sync") + + +def list_sources(workspace_id: str | None = None, api_token: str | None = None): + """List all sources accessible to the authenticated user""" + url = SOURCES_ENDPOINT + if workspace_id: + url = f"{url}?workspace_id={workspace_id}" + response = make_request( + url=url, + method="GET", + api_token=api_token, + ) + return json.load(response) + + +def create_source( + name: str, + connection: Dict[str, Any], + permissions: Dict[str, Any] = None, + api_token: str | None = None +): + """Create a new source + + Args: + name: The name of the source + connection: Connection details - varies by source type + permissions: Optional permissions configuration + api_token: Optional API token + + Returns: + The created source reference + """ + json_payload = {"name": name, "connection": connection} + if permissions: + json_payload["permissions"] = permissions + + response = make_request( + url=SOURCES_ENDPOINT, + method="POST", + json=json_payload, + api_token=api_token, + ) + return json.load(response) + + +def get_source_details(source_id: str, api_token: str | None = None): + """Get details of a source""" + response = make_request( + url=SOURCE_TEMPLATE.format(source_id=source_id), + method="GET", + api_token=api_token, + ) + return json.load(response) + + +def update_source( + source_id: str, + name: str | None = None, + connection: Dict[str, Any] | None = None, + permissions: Dict[str, Any] | None = None, + api_token: str | None = None +): + """Update a source's details + + Args: + source_id: The ID of the source to update + name: Optional new name for the source + connection: Optional updated connection details + permissions: Optional updated permissions configuration + api_token: Optional API token + + Returns: + The updated source reference + """ + json_payload = {} + if name is not None: + json_payload["name"] = name + if connection is not None: + json_payload["connection"] = connection + if permissions is not None: + json_payload["permissions"] = permissions + + response = make_request( + url=SOURCE_UPDATE_TEMPLATE.format(source_id=source_id), + method="POST", + json=json_payload, + api_token=api_token, + ) + return json.load(response) + + +def delete_source(source_id: str, api_token: str | None = None): + """Delete a source""" + make_request( + url=SOURCE_TEMPLATE.format(source_id=source_id), + method="DELETE", + api_token=api_token, + ) + + +def sync_source(source_id: str, api_token: str | None = None): + """Trigger synchronization of a source + + Returns: + The source reference with synchronization status + """ + response = make_request( + url=SOURCE_SYNC_TEMPLATE.format(source_id=source_id), + method="POST", + api_token=api_token, + ) + return json.load(response) diff --git a/felt_python/user.py b/felt_python/user.py new file mode 100644 index 0000000..c446273 --- /dev/null +++ b/felt_python/user.py @@ -0,0 +1,27 @@ +"""User""" + +import json + +from urllib.parse import urljoin + +from .api import make_request, BASE_URL + + +USER_ENDPOINT = urljoin(BASE_URL, "user") + + +def get_current_user(api_token: str | None = None): + """Get details of the currently authenticated user + + Args: + api_token: Optional API token + + Returns: + The user details including id, name, and email + """ + response = make_request( + url=USER_ENDPOINT, + method="GET", + api_token=api_token, + ) + return json.load(response) diff --git a/felt_python/util.py b/felt_python/util.py new file mode 100644 index 0000000..faa9b66 --- /dev/null +++ b/felt_python/util.py @@ -0,0 +1,18 @@ +import warnings +import functools + + +def deprecated(reason): + def decorator(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + warnings.warn( + f"{func.__name__} is deprecated: {reason}", + category=DeprecationWarning, + stacklevel=2, + ) + return func(*args, **kwargs) + + return wrapper + + return decorator diff --git a/testing_comments_duplicate.ipynb b/testing_comments_duplicate.ipynb new file mode 100644 index 0000000..902d2a2 --- /dev/null +++ b/testing_comments_duplicate.ipynb @@ -0,0 +1,301 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Testing Comments and Duplication" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "\n", + "from felt_python import (\n", + " create_map,\n", + " delete_map,\n", + " export_comments,\n", + " resolve_comment,\n", + " delete_comment,\n", + " duplicate_layers,\n", + " upload_file,\n", + " list_layers\n", + ")\n", + "\n", + "os.environ[\"FELT_API_TOKEN\"] = \"\"" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Create maps for testing" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Create source map\n", + "source_map = create_map(\n", + " title=\"Source Map for Testing\",\n", + " lat=40,\n", + " lon=-3,\n", + " zoom=5,\n", + " public_access=\"view_and_comment\" # Allow comments\n", + ")\n", + "source_map_id = source_map[\"id\"]\n", + "\n", + "# Create destination map (for duplication)\n", + "dest_map = create_map(\n", + " title=\"Destination Map for Testing\",\n", + " lat=40,\n", + " lon=-3,\n", + " zoom=5,\n", + " public_access=\"private\"\n", + ")\n", + "dest_map_id = dest_map[\"id\"]\n", + "\n", + "print(f\"Source map URL: {source_map['url']}\")\n", + "print(f\"Destination map URL: {dest_map['url']}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Upload a layer to the source map" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Upload a layer to the source map\n", + "layer_resp = upload_file(\n", + " map_id=source_map_id,\n", + " file_name=\"tests/fixtures/null-island-points.geojson\",\n", + " layer_name=\"Layer to duplicate\"\n", + ")\n", + "layer_id = layer_resp[\"layer_id\"]\n", + "\n", + "# Wait for layer processing\n", + "import time\n", + "print(\"Waiting for layer to process...\")\n", + "time.sleep(10)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Export comments\n", + "\n", + "Note: This example assumes there are comments on the map. If you've just created the map, \n", + "you'll need to add some comments through the Felt UI before running this code." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Export comments as JSON (default)\n", + "comments_json = export_comments(source_map_id)\n", + "print(f\"Number of comment threads: {len(comments_json)}\")\n", + "\n", + "# Export comments as CSV\n", + "# comments_csv = export_comments(source_map_id, format=\"csv\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Resolve a comment\n", + "\n", + "Note: Replace with a real comment ID from your map" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# If you have comments from the export above, you can use one of those IDs\n", + "if len(comments_json) > 0:\n", + " comment_id = comments_json[0].get(\"id\")\n", + " if comment_id:\n", + " resolve_result = resolve_comment(source_map_id, comment_id)\n", + " print(f\"Comment resolved: {resolve_result}\")\n", + "else:\n", + " print(\"No comments available to resolve. Add comments through the Felt UI.\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Delete a comment\n", + "\n", + "Note: Replace with a real comment ID from your map" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# If you have comments from the export above, you can use one of those IDs\n", + "if len(comments_json) > 1:\n", + " comment_id = comments_json[1].get(\"id\")\n", + " if comment_id:\n", + " delete_comment(source_map_id, comment_id)\n", + " print(f\"Comment deleted: {comment_id}\")\n", + "else:\n", + " print(\"Not enough comments available to delete. Add comments through the Felt UI.\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Export comments again to verify changes" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "updated_comments = export_comments(source_map_id)\n", + "print(f\"Number of comment threads after modifications: {len(updated_comments)}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Duplicate a layer from source map to destination map" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Check the layers on the source map\n", + "source_layers = list_layers(source_map_id)\n", + "print(f\"Source map has {len(source_layers)} layers\")\n", + "\n", + "if len(source_layers) > 0:\n", + " source_layer_id = source_layers[0][\"id\"]\n", + " \n", + " # Duplicate the layer\n", + " duplication_params = [\n", + " {\n", + " \"source_layer_id\": source_layer_id,\n", + " \"destination_map_id\": dest_map_id\n", + " }\n", + " ]\n", + " \n", + " duplication_result = duplicate_layers(duplication_params)\n", + " print(f\"Duplicated {len(duplication_result['layers'])} layers\")\n", + " print(f\"Duplicated {len(duplication_result['layer_groups'])} layer groups\")\n", + " \n", + " # Check if duplication worked\n", + " dest_layers = list_layers(dest_map_id)\n", + " print(f\"Destination map now has {len(dest_layers)} layers\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Duplicate a layer group\n", + "\n", + "Note: This assumes you have a layer group on your source map" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# If the source map has layer groups, you can duplicate a group\n", + "from felt_python import list_layer_groups\n", + "\n", + "source_groups = list_layer_groups(source_map_id)\n", + "print(f\"Source map has {len(source_groups)} layer groups\")\n", + "\n", + "if len(source_groups) > 0:\n", + " source_group_id = source_groups[0][\"id\"]\n", + " \n", + " # Duplicate the layer group\n", + " group_duplication_params = [\n", + " {\n", + " \"source_layer_group_id\": source_group_id,\n", + " \"destination_map_id\": dest_map_id\n", + " }\n", + " ]\n", + " \n", + " group_duplication_result = duplicate_layers(group_duplication_params)\n", + " print(f\"Duplicated {len(group_duplication_result['layers'])} layers\")\n", + " print(f\"Duplicated {len(group_duplication_result['layer_groups'])} layer groups\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Clean up" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Delete both maps\n", + "delete_map(source_map_id)\n", + "delete_map(dest_map_id)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.13" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} \ No newline at end of file diff --git a/testing_elements.ipynb b/testing_elements.ipynb index e862f74..52c0ad1 100644 --- a/testing_elements.ipynb +++ b/testing_elements.ipynb @@ -19,11 +19,11 @@ " create_map,\n", " delete_map,\n", " list_elements,\n", - " # list_element_groups,\n", - " # list_elements_in_group,\n", - " post_elements,\n", + " list_element_groups,\n", + " show_element_group,\n", + " upsert_elements,\n", " delete_element,\n", - " post_element_group,\n", + " create_element_groups\n", ")\n", "\n", "os.environ[\"FELT_API_TOKEN\"] = \"\"" @@ -48,6 +48,7 @@ " title=\"A felt-py map for testing elements\",\n", " lat=40,\n", " lon=-3,\n", + " zoom=8,\n", " public_access=\"private\",\n", ")\n", "map_id = resp[\"id\"]\n", @@ -86,7 +87,7 @@ " ],\n", "}\n", "\n", - "post_elements(map_id, geojson_feature_collection)" + "upsert_elements(map_id, geojson_feature_collection)" ] }, { @@ -102,7 +103,8 @@ "metadata": {}, "outputs": [], "source": [ - "elements = list_elements(map_id)" + "elements = list_elements(map_id)\n", + "elements" ] }, { @@ -126,16 +128,16 @@ "\n", "barcelona_element[\"properties\"][\"felt:color\"] = \"#0000FF\"\n", "barcelona_feature_collection = {\"type\": \"FeatureCollection\", \"features\": [barcelona_element]}\n", - "post_elements(map_id, barcelona_feature_collection)" + "upsert_elements(map_id, barcelona_feature_collection)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "# Make an element group\n", + "# List Element Groups\n", "\n", - "And then add the previously-created elements to the group by assigning a `felt:parentId` property" + "First, list any existing element groups (should be empty for a new map)" ] }, { @@ -144,11 +146,38 @@ "metadata": {}, "outputs": [], "source": [ - "data = [{\n", - " \"name\": \"An element group created via the API\",\n", - " \"symbol\": \"dots\",\n", - "}]\n", - "response = post_element_group(map_id, data)" + "existing_groups = list_element_groups(map_id)\n", + "existing_groups" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Create element groups" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "multiple_groups = [\n", + " {\n", + " \"name\": \"Spanish cities\",\n", + " \"symbol\": \"monument\",\n", + " \"color\": \"#A02CFA\"\n", + " },\n", + " {\n", + " \"name\": \"Parks\",\n", + " \"symbol\": \"tree\",\n", + " \"color\": \"#00AA55\"\n", + " }\n", + "]\n", + "\n", + "created_groups = create_element_groups(map_id, multiple_groups)\n", + "created_groups" ] }, { @@ -157,12 +186,96 @@ "metadata": {}, "outputs": [], "source": [ - "element_group_id = response[0][\"id\"]\n", + "# Get all element groups after creation\n", + "all_groups = list_element_groups(map_id)\n", + "all_groups" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Add previously-created elements to a group\n", "\n", + "Assign elements to the \"Spanish Cities\" group by adding `felt:parentId` property" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Get the ID of the first group (\"Spanish Cities\")\n", + "element_group_id = all_groups[0][\"id\"]\n", + "\n", + "# Add all elements to this group\n", "for feature in elements[\"features\"]:\n", " feature[\"properties\"][\"felt:parentId\"] = element_group_id\n", "\n", - "post_elements(map_id, elements)" + "# Update the elements\n", + "upsert_elements(map_id, elements)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# List elements in a specific group" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "group_elements = show_element_group(map_id, element_group_id)\n", + "group_elements" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Create more elements with group assignment\n", + "\n", + "Create elements directly assigned to a group" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Get the Parks group ID (should be the second group)\n", + "parks_group_id = all_groups[1][\"id\"]\n", + "\n", + "# Create park elements with group assignment\n", + "parks_geojson = {\n", + " \"type\": \"FeatureCollection\",\n", + " \"features\": [\n", + " {\n", + " \"type\": \"Feature\",\n", + " \"geometry\": {\"type\": \"Point\", \"coordinates\": [-3.6762, 40.4153]},\n", + " \"properties\": {\n", + " \"name\": \"Retiro Park\",\n", + " \"felt:parentId\": parks_group_id\n", + " },\n", + " },\n", + " {\n", + " \"type\": \"Feature\",\n", + " \"geometry\": {\"type\": \"Point\", \"coordinates\": [2.1526, 41.3851]},\n", + " \"properties\": {\n", + " \"name\": \"Parc de la Ciutadella\",\n", + " \"felt:parentId\": parks_group_id\n", + " },\n", + " },\n", + " ],\n", + "}\n", + "\n", + "upsert_elements(map_id, parks_geojson)" ] }, { @@ -181,6 +294,24 @@ "delete_element(map_id, barcelona_element_id)" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Verify Barcelona element is gone" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "updated_elements = list_elements(map_id)\n", + "barcelona_elements = [el for el in updated_elements[\"features\"] if el[\"properties\"].get(\"name\") == \"Barcelona\"]\n", + "print(f\"Barcelona elements found: {len(barcelona_elements)}\")" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -219,4 +350,4 @@ }, "nbformat": 4, "nbformat_minor": 4 -} +} \ No newline at end of file diff --git a/testing_layer_groups.ipynb b/testing_layer_groups.ipynb new file mode 100644 index 0000000..a926c2b --- /dev/null +++ b/testing_layer_groups.ipynb @@ -0,0 +1,284 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Testing Layer Groups" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "import time\n", + "\n", + "from felt_python import (\n", + " create_map,\n", + " delete_map,\n", + " list_layer_groups,\n", + " get_layer_group_details,\n", + " update_layer_groups,\n", + " delete_layer_group,\n", + " publish_layer_group,\n", + " upload_file\n", + ")\n", + "\n", + "os.environ[\"FELT_API_TOKEN\"] = \"\"" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Create a map for testing" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "resp = create_map(\n", + " title=\"A felt-py map for testing layer groups\",\n", + " lat=40,\n", + " lon=-3,\n", + " zoom=5,\n", + " public_access=\"private\",\n", + ")\n", + "map_id = resp[\"id\"]\n", + "resp[\"url\"]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Upload some layers to work with\n", + "\n", + "We'll upload a few layers to organize into groups" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Upload first layer\n", + "layer1_resp = upload_file(\n", + " map_id=map_id,\n", + " file_name=\"tests/fixtures/null-island-points-sample.geojson\",\n", + " layer_name=\"Points Layer\"\n", + ")\n", + "layer1_id = layer1_resp[\"layer_id\"]\n", + "\n", + "# Upload second layer\n", + "layer2_resp = upload_file(\n", + " map_id=map_id,\n", + " file_name=\"tests/fixtures/null-island-polygons.geojson\",\n", + " layer_name=\"Polygons Layer\"\n", + ")\n", + "layer2_id = layer2_resp[\"layer_id\"]\n", + "\n", + "# Wait for processing to complete\n", + "print(\"Waiting for layers to finish processing...\")\n", + "time.sleep(10)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# List existing layer groups\n", + "\n", + "Initially, there should be no layer groups" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "initial_groups = list_layer_groups(map_id)\n", + "initial_groups" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Create layer groups\n", + "\n", + "Create groups and assign layers to them" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Create two layer groups\n", + "layer_groups = [\n", + " {\n", + " \"name\": \"Vector Data\",\n", + " \"caption\": \"A collection of vector datasets\"\n", + " },\n", + " {\n", + " \"name\": \"Base Data\",\n", + " \"caption\": \"Reference layers\"\n", + " }\n", + "]\n", + "\n", + "created_groups = update_layer_groups(map_id, layer_groups)\n", + "created_groups" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Retrieve a specific layer group's details" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Get details for the first group\n", + "group_id = created_groups[0][\"id\"]\n", + "group_details = get_layer_group_details(map_id, group_id)\n", + "group_details" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Update layer groups\n", + "\n", + "Update the groups to assign layers to them and change properties" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Retrieve current groups\n", + "current_groups = list_layer_groups(map_id)\n", + "group1_id = current_groups[0][\"id\"]\n", + "group2_id = current_groups[1][\"id\"]\n", + "\n", + "# Update the groups\n", + "updated_groups = [\n", + " {\n", + " \"id\": group1_id,\n", + " \"name\": \"Vector Data (Updated)\",\n", + " \"caption\": \"A collection of vector datasets (updated)\",\n", + " \"ordering_key\": 1\n", + " },\n", + " {\n", + " \"id\": group2_id,\n", + " \"name\": \"Base Data (Updated)\",\n", + " \"caption\": \"Reference layers (updated)\",\n", + " \"ordering_key\": 2\n", + " }\n", + "]\n", + "\n", + "result = update_layer_groups(map_id, updated_groups)\n", + "result" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Publish a layer group to the library" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Publish the first group\n", + "published_group = publish_layer_group(\n", + " map_id=map_id,\n", + " layer_group_id=group1_id,\n", + " name=\"Published Vector Data\"\n", + ")\n", + "published_group" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Delete a layer group" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Delete the second group\n", + "delete_layer_group(map_id, group2_id)\n", + "\n", + "# Verify deletion\n", + "remaining_groups = list_layer_groups(map_id)\n", + "print(f\"Groups remaining: {len(remaining_groups)}\")\n", + "remaining_groups" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Clean up" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "delete_map(map_id)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.13" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} \ No newline at end of file diff --git a/testing_layers.ipynb b/testing_layers.ipynb index c11b9f4..cb96b24 100644 --- a/testing_layers.ipynb +++ b/testing_layers.ipynb @@ -30,7 +30,14 @@ " refresh_file_layer,\n", " refresh_url_layer,\n", " get_layer_details,\n", - " update_layer_style\n", + " update_layer_style,\n", + " get_export_link,\n", + " download_layer,\n", + " update_layers,\n", + " delete_layer,\n", + " publish_layer,\n", + " create_custom_export,\n", + " get_custom_export_status\n", ")\n", "\n", "os.environ[\"FELT_API_TOKEN\"] = \"\"" @@ -54,9 +61,10 @@ "outputs": [], "source": [ "resp = create_map(\n", - " title=\"A felt-py map\",\n", + " title=\"A felt-py map for testing layers\",\n", " lat=40,\n", " lon=-3,\n", + " zoom=10,\n", " public_access=\"private\",\n", ")\n", "map_id = resp[\"id\"]" @@ -97,7 +105,18 @@ "metadata": {}, "outputs": [], "source": [ - "layer_resp = upload_file(map_id, \"tests/fixtures/null-island-points-sample.geojson\", \"The Points Layer\")\n", + "metadata = {\n", + " \"attribution_text\": \"Sample Data\",\n", + " \"source_name\": \"Felt Python Library\",\n", + " \"description\": \"Sample points near Null Island\"\n", + "}\n", + "\n", + "layer_resp = upload_file(\n", + " map_id=map_id, \n", + " file_name=\"tests/fixtures/null-island-points-sample.geojson\", \n", + " layer_name=\"The Points Layer\",\n", + " metadata=metadata\n", + ")\n", "layer_id = layer_resp[\"layer_id\"]" ] }, @@ -234,6 +253,72 @@ "update_layer_style(map_id, layer_id, new_style)" ] }, + { + "cell_type": "markdown", + "id": "new-section-01", + "metadata": {}, + "source": [ + "# Update multiple layers\n", + "\n", + "You can update multiple layers at once with a single API call" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "update-layers-example", + "metadata": {}, + "outputs": [], + "source": [ + "# Get the first two layers\n", + "layers = list_layers(map_id)[:2]\n", + "\n", + "# Prepare updates for both layers\n", + "layer_updates = [\n", + " {\n", + " \"id\": layers[0][\"id\"],\n", + " \"name\": \"Updated Layer 1\",\n", + " \"caption\": \"New caption for layer 1\"\n", + " },\n", + " {\n", + " \"id\": layers[1][\"id\"],\n", + " \"name\": \"Updated Layer 2\",\n", + " \"caption\": \"New caption for layer 2\"\n", + " }\n", + "]\n", + "\n", + "# Update both layers at once\n", + "updated_layers = update_layers(map_id, layer_updates)" + ] + }, + { + "cell_type": "markdown", + "id": "publish-layer-header", + "metadata": {}, + "source": [ + "# Publish a layer to the Felt library" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "publish-layer-example", + "metadata": {}, + "outputs": [], + "source": [ + "# Wait for the layer to be fully processed first\n", + "while get_layer_details(map_id, layer_id)[\"progress\"] < 100:\n", + " print(\"Waiting for layer to finish processing...\")\n", + " time.sleep(5)\n", + "\n", + "# Publish the layer to your workspace's library\n", + "published_layer = publish_layer(\n", + " map_id=map_id,\n", + " layer_id=layer_id,\n", + " name=\"Published Points Layer\"\n", + ")" + ] + }, { "cell_type": "markdown", "id": "1649c24f-845b-4144-b710-45ff72852c18", @@ -286,6 +371,57 @@ "list_layers(map_id)" ] }, + { + "cell_type": "markdown", + "id": "custom-export-header", + "metadata": {}, + "source": [ + "# Custom layer exports\n", + "\n", + "Create a custom export with filters" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "custom-export-example", + "metadata": {}, + "outputs": [], + "source": [ + "# Custom export with filters\n", + "export_request = create_custom_export(\n", + " map_id=map_id,\n", + " layer_id=layer_id,\n", + " output_format=\"csv\", # Options: \"csv\", \"gpkg\", \"geojson\"\n", + " filters=[{\"attribute\": \"name\", \"operator\": \"=\", \"value\": \"Sample Point\"}],\n", + " email_on_completion=True\n", + ")\n", + "\n", + "print(f\"Export request ID: {export_request['export_request_id']}\")\n", + "print(f\"Polling endpoint: {export_request['poll_endpoint']}\")\n", + "\n", + "# Poll for export status\n", + "export_id = export_request['export_request_id']\n", + "\n", + "while True:\n", + " export_status = get_custom_export_status(\n", + " map_id=map_id,\n", + " layer_id=layer_id,\n", + " export_id=export_id\n", + " )\n", + " \n", + " print(f\"Export status: {export_status['status']}\")\n", + " \n", + " if export_status['status'] == 'completed':\n", + " print(f\"Download URL: {export_status['download_url']}\")\n", + " break\n", + " elif export_status['status'] == 'failed':\n", + " print(\"Export failed\")\n", + " break\n", + " \n", + " time.sleep(5)" + ] + }, { "cell_type": "markdown", "id": "6cc3e80a", @@ -303,9 +439,40 @@ "metadata": {}, "outputs": [], "source": [ - "from felt_python import download_layer\n", + "# Get just the export link\n", + "export_link = get_export_link(map_id, layer_id)\n", + "print(f\"Export link: {export_link}\")\n", + "\n", + "# Download the layer to a file\n", + "output_file = download_layer(map_id, layer_id, file_name=\"exported.gpkg\")\n", + "print(f\"Downloaded to: {output_file}\")" + ] + }, + { + "cell_type": "markdown", + "id": "delete-layer-header", + "metadata": {}, + "source": [ + "# Delete a layer" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "delete-layer-example", + "metadata": {}, + "outputs": [], + "source": [ + "# Get a list of all layers\n", + "all_layers = list_layers(map_id)\n", + "print(f\"Number of layers before deletion: {len(all_layers)}\")\n", + "\n", + "# Delete the URL layer\n", + "delete_layer(map_id, url_layer_id)\n", "\n", - "download_layer(map_id, layer_id, file_name=\"exported.gpkg\")" + "# Verify deletion\n", + "all_layers = list_layers(map_id)\n", + "print(f\"Number of layers after deletion: {len(all_layers)}\")" ] }, { @@ -348,4 +515,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file diff --git a/testing_library_user.ipynb b/testing_library_user.ipynb new file mode 100644 index 0000000..9afe68a --- /dev/null +++ b/testing_library_user.ipynb @@ -0,0 +1,233 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Testing Library and User" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "\n", + "from felt_python import (\n", + " list_library_layers,\n", + " get_current_user,\n", + " create_map,\n", + " delete_map,\n", + " upload_file,\n", + " publish_layer\n", + ")\n", + "\n", + "os.environ[\"FELT_API_TOKEN\"] = \"\"" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Get current user information" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "user = get_current_user()\n", + "print(f\"User ID: {user['id']}\")\n", + "print(f\"User Name: {user['name']}\")\n", + "print(f\"User Email: {user['email']}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# List layers in the workspace library" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "workspace_library = list_library_layers(source=\"workspace\")\n", + "\n", + "print(f\"Number of layers in workspace library: {len(workspace_library['layers'])}\")\n", + "print(f\"Number of layer groups in workspace library: {len(workspace_library['layer_groups'])}\")\n", + "\n", + "# Show first few layers if any\n", + "for i, layer in enumerate(workspace_library['layers'][:3]):\n", + " print(f\"Layer {i+1}: {layer['name']} (ID: {layer['id']})\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# List layers in the Felt data library" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "felt_library = list_library_layers(source=\"felt\")\n", + "\n", + "print(f\"Number of layers in Felt library: {len(felt_library['layers'])}\")\n", + "print(f\"Number of layer groups in Felt library: {len(felt_library['layer_groups'])}\")\n", + "\n", + "# Show first few layers\n", + "for i, layer in enumerate(felt_library['layers'][:5]):\n", + " print(f\"Layer {i+1}: {layer['name']} (ID: {layer['id']})\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Create a map with a layer and publish it to the library" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Create a map\n", + "map_resp = create_map(\n", + " title=\"Map for testing library\",\n", + " lat=40,\n", + " lon=-3,\n", + " zoom=5,\n", + " public_access=\"private\"\n", + ")\n", + "map_id = map_resp[\"id\"]\n", + "\n", + "# Upload a layer\n", + "layer_resp = upload_file(\n", + " map_id=map_id,\n", + " file_name=\"tests/fixtures/null-island-points.geojson\",\n", + " layer_name=\"Points to publish\"\n", + ")\n", + "layer_id = layer_resp[\"layer_id\"]\n", + "\n", + "# Wait for layer processing\n", + "import time\n", + "print(\"Waiting for layer to process...\")\n", + "time.sleep(10)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Publish the layer to the library" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "published = publish_layer(\n", + " map_id=map_id,\n", + " layer_id=layer_id,\n", + " name=\"Published test layer\"\n", + ")\n", + "\n", + "print(f\"Layer published: {published['name']}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Verify the layer is in the library" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "updated_library = list_library_layers(source=\"workspace\")\n", + "\n", + "print(f\"Number of layers in workspace library after publishing: {len(updated_library['layers'])}\")\n", + "\n", + "# Try to find our published layer\n", + "published_found = any(layer['name'] == \"Published test layer\" for layer in updated_library['layers'])\n", + "print(f\"Published layer found in library: {published_found}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# List all libraries (Felt and workspace)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "all_libraries = list_library_layers(source=\"all\")\n", + "\n", + "print(f\"Total number of layers in all libraries: {len(all_libraries['layers'])}\")\n", + "print(f\"Total number of layer groups in all libraries: {len(all_libraries['layer_groups'])}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Clean up" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "delete_map(map_id)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.13" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} \ No newline at end of file diff --git a/testing_maps.ipynb b/testing_maps.ipynb index ac770a2..73ad974 100644 --- a/testing_maps.ipynb +++ b/testing_maps.ipynb @@ -20,7 +20,9 @@ " delete_map,\n", " get_map_details,\n", " update_map,\n", - " move_map\n", + " move_map,\n", + " create_embed_token,\n", + " add_source_layer\n", ")\n", "\n", "os.environ[\"FELT_API_TOKEN\"] = \"\"" @@ -45,7 +47,10 @@ " title=\"A felt-py map\",\n", " lat=40,\n", " lon=-3,\n", + " zoom=9,\n", + " basemap=\"light\",\n", " public_access=\"private\",\n", + " description=\"A map created using the felt-python library\"\n", ")\n", "map_id = resp[\"id\"]" ] @@ -82,24 +87,35 @@ "outputs": [], "source": [ "resp = update_map(\n", - " new_title=\"A felt-py map with an update\",\n", - " map_id=map_id\n", + " map_id=map_id,\n", + " title=\"A felt-py map with an update\",\n", + " description=\"This map was updated through the API\",\n", + " public_access=\"view_only\"\n", ")" ] }, { - "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ - "# Moving a map\n", + "# Creating an embed token\n", "\n", - "You can move a map from one project to another.\n", + "You can create an embed token to allow embedding the map in other applications." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "embed_token = create_embed_token(\n", + " map_id=map_id,\n", + " user_email=\"your.email@example.com\" # Optional - allows exporting data if the map permits\n", + ")\n", "\n", - "resp = move_map(\n", - " project_id=\"[YOUR PROJECT ID]\",\n", - " map_id=map_id\n", - ")" + "print(f\"Token: {embed_token['token']}\")\n", + "print(f\"Expires at: {embed_token['expires_at']}\")" ] }, { @@ -140,4 +156,4 @@ }, "nbformat": 4, "nbformat_minor": 4 -} +} \ No newline at end of file diff --git a/testing_projects.ipynb b/testing_projects.ipynb new file mode 100644 index 0000000..2fd38cc --- /dev/null +++ b/testing_projects.ipynb @@ -0,0 +1,211 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Testing Projects" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "\n", + "from felt_python import (\n", + " list_projects,\n", + " create_project,\n", + " get_project_details,\n", + " update_project,\n", + " delete_project,\n", + " create_map,\n", + " move_map,\n", + " delete_map\n", + ")\n", + "\n", + "os.environ[\"FELT_API_TOKEN\"] = \"\"" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# List available projects" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "projects = list_projects()\n", + "projects" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Create a new project" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "project = create_project(\n", + " name=\"API Test Project\",\n", + " visibility=\"private\" # Options: \"workspace\" or \"private\"\n", + ")\n", + "project_id = project[\"id\"]\n", + "project" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Get project details" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "project_details = get_project_details(project_id)\n", + "project_details" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Update a project" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "updated_project = update_project(\n", + " project_id=project_id,\n", + " name=\"Updated API Test Project\",\n", + " visibility=\"workspace\" # Change visibility to workspace-wide\n", + ")\n", + "updated_project" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Create a map and move it to the project" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Create a map\n", + "map_resp = create_map(\n", + " title=\"Map for testing projects\",\n", + " lat=37.7749,\n", + " lon=-122.4194, # San Francisco\n", + " zoom=12,\n", + " public_access=\"private\"\n", + ")\n", + "map_id = map_resp[\"id\"]\n", + "\n", + "# Move the map to our new project\n", + "moved_map = move_map(\n", + " map_id=map_id,\n", + " project_id=project_id\n", + ")\n", + "moved_map" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Verify the map was moved\n", + "\n", + "Check that the map now appears in the project" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "project_with_map = get_project_details(project_id)\n", + "project_maps = project_with_map.get(\"maps\", [])\n", + "print(f\"Number of maps in project: {len(project_maps)}\")\n", + "\n", + "# Check if our map is in the project\n", + "map_in_project = any(m[\"id\"] == map_id for m in project_maps)\n", + "print(f\"Our map is in the project: {map_in_project}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Clean up\n", + "\n", + "Delete the map and project we created" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Delete the map\n", + "delete_map(map_id)\n", + "\n", + "# Delete the project\n", + "delete_project(project_id)\n", + "\n", + "# Verify project is gone\n", + "projects_after = list_projects()\n", + "print(f\"Project still exists: {any(p['id'] == project_id for p in projects_after)}\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.13" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} \ No newline at end of file diff --git a/testing_sources.ipynb b/testing_sources.ipynb new file mode 100644 index 0000000..70e3691 --- /dev/null +++ b/testing_sources.ipynb @@ -0,0 +1,296 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Testing Sources" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "\n", + "from felt_python import (\n", + " list_sources,\n", + " create_source,\n", + " get_source_details,\n", + " update_source,\n", + " delete_source,\n", + " sync_source,\n", + " create_map,\n", + " add_source_layer,\n", + " delete_map\n", + ")\n", + "\n", + "os.environ[\"FELT_API_TOKEN\"] = \"\"" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# List available sources" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "sources = list_sources()\n", + "sources" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Create a source\n", + "\n", + "Below are examples for different source types. Uncomment the one you want to use." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Example 1: Create a PostgreSQL source\n", + "\"\"\"\n", + "postgres_source = create_source(\n", + " name=\"My PostgreSQL Source\",\n", + " connection={\n", + " \"type\": \"postgresql\",\n", + " \"host\": \"your-postgres-host.example.com\",\n", + " \"port\": 5432,\n", + " \"database\": \"your_database\",\n", + " \"user\": \"your_username\",\n", + " \"password\": \"your_password\"\n", + " },\n", + " permissions={\n", + " \"type\": \"workspace_editors\" # Share with all workspace editors\n", + " }\n", + ")\n", + "source_id = postgres_source[\"id\"]\n", + "\"\"\"\n", + "\n", + "# Example 2: Create a BigQuery source\n", + "\"\"\"\n", + "bigquery_source = create_source(\n", + " name=\"My BigQuery Source\",\n", + " connection={\n", + " \"type\": \"bigquery\",\n", + " \"project\": \"your-gcp-project\",\n", + " \"dataset\": \"your_dataset\",\n", + " \"credentials\": \"BASE64_ENCODED_SERVICE_ACCOUNT_JSON\"\n", + " },\n", + " permissions={\n", + " \"type\": \"source_owner\" # Only accessible to you\n", + " }\n", + ")\n", + "source_id = bigquery_source[\"id\"]\n", + "\"\"\"\n", + "\n", + "# Example 3: Create a WMS/WMTS source\n", + "wms_source = create_source(\n", + " name=\"Public WMS Source\",\n", + " connection={\n", + " \"type\": \"wms_wmts\",\n", + " \"url\": \"https://basemap.nationalmap.gov/arcgis/services/USGSTopo/MapServer/WMSServer\"\n", + " },\n", + " permissions={\n", + " \"type\": \"workspace_editors\" # Share with all workspace editors\n", + " }\n", + ")\n", + "source_id = wms_source[\"id\"]\n", + "source_id" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Get source details" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "source_details = get_source_details(source_id)\n", + "source_details" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Update a source" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "updated_source = update_source(\n", + " source_id=source_id,\n", + " name=\"Updated WMS Source\",\n", + " # You can also update connection details if needed\n", + " connection={\n", + " \"type\": \"wms_wmts\",\n", + " \"url\": \"https://basemap.nationalmap.gov/arcgis/services/USGSTopo/MapServer/WMSServer\"\n", + " }\n", + ")\n", + "updated_source" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Synchronize a source\n", + "\n", + "This triggers a synchronization of the source to update datasets." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "synced_source = sync_source(source_id)\n", + "synced_source" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Create a map and add a layer from the source" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Create a map\n", + "map_resp = create_map(\n", + " title=\"Map with source layers\",\n", + " lat=39.8283,\n", + " lon=-98.5795, # Center of USA\n", + " zoom=4,\n", + " public_access=\"private\"\n", + ")\n", + "map_id = map_resp[\"id\"]\n", + "\n", + "# Wait for source synchronization to complete\n", + "import time\n", + "for i in range(60): # Wait up to 5 minutes\n", + " current_source = get_source_details(source_id)\n", + " if current_source[\"sync_status\"] == \"completed\":\n", + " break\n", + " print(f\"Waiting for source sync... Status: {current_source['sync_status']}\")\n", + " time.sleep(5)\n", + "\n", + "# List available datasets in the source\n", + "print(f\"Available datasets: {len(current_source.get('datasets', []))}\")\n", + "for dataset in current_source.get('datasets', [])[:5]: # Show first 5 datasets\n", + " print(f\"- {dataset.get('name')} (ID: {dataset.get('id')})\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Choose a dataset from the source\n", + "if current_source.get('datasets'):\n", + " dataset_id = current_source['datasets'][0]['id']\n", + " \n", + " # Add a layer from the source dataset\n", + " layer_result = add_source_layer(\n", + " map_id=map_id,\n", + " source_layer_params={\n", + " \"from\": \"dataset\",\n", + " \"dataset_id\": dataset_id\n", + " }\n", + " )\n", + " layer_result\n", + " \n", + "# For SQL sources, you could use SQL query:\n", + "\"\"\"\n", + "sql_layer = add_source_layer(\n", + " map_id=map_id,\n", + " source_layer_params={\n", + " \"from\": \"sql\",\n", + " \"source_id\": source_id,\n", + " \"query\": \"SELECT * FROM your_table LIMIT 100\"\n", + " }\n", + ")\n", + "\"\"\"" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Clean up\n", + "\n", + "Delete the map and source we created" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Delete the map\n", + "delete_map(map_id)\n", + "\n", + "# Delete the source\n", + "delete_source(source_id)\n", + "\n", + "# Verify source is gone\n", + "sources_after = list_sources()\n", + "print(f\"Source still exists: {any(s['id'] == source_id for s in sources_after)}\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.13" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} \ No newline at end of file From d85e48f537466077e3154fdb8acdeba77e4ed3b5 Mon Sep 17 00:00:00 2001 From: Vince Foley Date: Tue, 8 Apr 2025 16:26:56 -0700 Subject: [PATCH 2/9] Update notebooks --- README.md | 63 +--- testing_elements.ipynb => docs/elements.ipynb | 2 +- .../null-island-points-sample.geojson | 0 .../fixtures/null-island-points.geojson | 0 .../fixtures/null-island-polygons-wkt.csv | 0 .../layer_groups.ipynb | 47 ++- testing_layers.ipynb => docs/layers.ipynb | 71 +---- .../library.ipynb | 33 +- testing_maps.ipynb => docs/maps.ipynb | 68 +++- testing_projects.ipynb => docs/projects.ipynb | 2 +- testing_sources.ipynb => docs/sources.ipynb | 2 +- felt_python/layer_groups.py | 20 +- testing_comments_duplicate.ipynb | 301 ------------------ 13 files changed, 146 insertions(+), 463 deletions(-) rename testing_elements.ipynb => docs/elements.ipynb (99%) rename {tests => docs}/fixtures/null-island-points-sample.geojson (100%) rename {tests => docs}/fixtures/null-island-points.geojson (100%) rename {tests => docs}/fixtures/null-island-polygons-wkt.csv (100%) rename testing_layer_groups.ipynb => docs/layer_groups.ipynb (82%) rename testing_layers.ipynb => docs/layers.ipynb (87%) rename testing_library_user.ipynb => docs/library.ipynb (89%) rename testing_maps.ipynb => docs/maps.ipynb (64%) rename testing_projects.ipynb => docs/projects.ipynb (99%) rename testing_sources.ipynb => docs/sources.ipynb (99%) delete mode 100644 testing_comments_duplicate.ipynb diff --git a/README.md b/README.md index 8a6bff4..0e93518 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,11 @@ refreshing files and (Geo)DataFrames or updating layer styles and element proper pip install felt-python ``` -## Usage +## Documentation + +See the [docs](/docs) directory for Juypter notebooks with complete examples of using the API. + +## Basic Usage ### Authentication @@ -51,7 +55,7 @@ response = create_map( map_id = response["id"] ``` -### Uploading a file +### Upload anything ```python from felt_python import upload_file, list_layers @@ -64,60 +68,5 @@ upload = upload_file( layer_id = upload["layer_id"] ``` -### Uploading a Pandas DataFrame -```python -import pandas as pd -from felt_python import upload_dataframe - -df = pd.read_csv("path/to/file.csv") -upload_dataframe( - map_id=map_id, - dataframe=df, - layer_name="Felt <3 Pandas", -) -``` - -### Uploading a GeoPandas GeoDataFrame -```python -import geopandas as gpd -from felt_python import upload_geodataframe - -gdf = gpd.read_file("path/to/file.shp") -upload_geodataframe( - map_id=map_id, - geodataframe=gdf, - layer_name="Felt <3 GeoPandas", -) -``` - -### Refreshing a layer -```python -from felt_python import refresh_file_layer - -refresh_file_layer( - map_id=map_id, - layer_id=layer_id, - file_path="path/to/new_file.csv", -) -``` - -### Styling a layer -```python -from felt_python import get_layer_details, update_layer_style - -current_style = get_layer_details( - map_id=map_id, - layer_id=layer_id, -)["style"] -new_style = current_style.copy() -new_style["color"] = "#FF0000" -new_style["size"] = 20 -update_layer_style( - map_id=map_id, - layer_id=layer_id, - style=new_style, -) -``` - ## Support We are always eager to hear from you. Reach out to support@felt.com for all your Felt support needs. diff --git a/testing_elements.ipynb b/docs/elements.ipynb similarity index 99% rename from testing_elements.ipynb rename to docs/elements.ipynb index 52c0ad1..937c069 100644 --- a/testing_elements.ipynb +++ b/docs/elements.ipynb @@ -4,7 +4,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# Testing Elements" + "# Map Elements" ] }, { diff --git a/tests/fixtures/null-island-points-sample.geojson b/docs/fixtures/null-island-points-sample.geojson similarity index 100% rename from tests/fixtures/null-island-points-sample.geojson rename to docs/fixtures/null-island-points-sample.geojson diff --git a/tests/fixtures/null-island-points.geojson b/docs/fixtures/null-island-points.geojson similarity index 100% rename from tests/fixtures/null-island-points.geojson rename to docs/fixtures/null-island-points.geojson diff --git a/tests/fixtures/null-island-polygons-wkt.csv b/docs/fixtures/null-island-polygons-wkt.csv similarity index 100% rename from tests/fixtures/null-island-polygons-wkt.csv rename to docs/fixtures/null-island-polygons-wkt.csv diff --git a/testing_layer_groups.ipynb b/docs/layer_groups.ipynb similarity index 82% rename from testing_layer_groups.ipynb rename to docs/layer_groups.ipynb index a926c2b..952fd94 100644 --- a/testing_layer_groups.ipynb +++ b/docs/layer_groups.ipynb @@ -4,7 +4,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# Testing Layer Groups" + "# Map Layer Groups" ] }, { @@ -24,6 +24,8 @@ " update_layer_groups,\n", " delete_layer_group,\n", " publish_layer_group,\n", + " update_layers,\n", + " get_layer_details,\n", " upload_file\n", ")\n", "\n", @@ -72,7 +74,7 @@ "# Upload first layer\n", "layer1_resp = upload_file(\n", " map_id=map_id,\n", - " file_name=\"tests/fixtures/null-island-points-sample.geojson\",\n", + " file_name=\"fixtures/null-island-points-sample.geojson\",\n", " layer_name=\"Points Layer\"\n", ")\n", "layer1_id = layer1_resp[\"layer_id\"]\n", @@ -80,14 +82,19 @@ "# Upload second layer\n", "layer2_resp = upload_file(\n", " map_id=map_id,\n", - " file_name=\"tests/fixtures/null-island-polygons.geojson\",\n", + " file_name=\"fixtures/null-island-polygons-wkt.csv\",\n", " layer_name=\"Polygons Layer\"\n", ")\n", "layer2_id = layer2_resp[\"layer_id\"]\n", "\n", "# Wait for processing to complete\n", - "print(\"Waiting for layers to finish processing...\")\n", - "time.sleep(10)" + "while get_layer_details(map_id, layer1_id)[\"progress\"] < 100:\n", + " print(\"Waiting for layer to finish processing...\")\n", + " time.sleep(5)", + "while get_layer_details(map_id, layer2_id)[\"progress\"] < 100:\n", + " print(\"Waiting for layer to finish processing...\")\n", + " time.sleep(5)", + "print(\"Layers ready...\")\n" ] }, { @@ -165,7 +172,7 @@ "source": [ "# Update layer groups\n", "\n", - "Update the groups to assign layers to them and change properties" + "Update the groups to change properties" ] }, { @@ -199,6 +206,34 @@ "result" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Update layers to assign them to groups\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Prepare updates for both layers\n", + "layer_updates = [\n", + " {\n", + " \"id\": layer1_id\n", + " \"layer_group_id\": group1_id,\n", + " },\n", + " {\n", + " \"id\": layer2_id\n", + " \"layer_group_id\": group2_id,\n", + " }\n", + "]\n", + "\n", + "updated_layers = update_layers(map_id, layer_updates)" + ] + }, { "cell_type": "markdown", "metadata": {}, diff --git a/testing_layers.ipynb b/docs/layers.ipynb similarity index 87% rename from testing_layers.ipynb rename to docs/layers.ipynb index cb96b24..50e16c5 100644 --- a/testing_layers.ipynb +++ b/docs/layers.ipynb @@ -5,7 +5,7 @@ "id": "a8c2b3f0-9f10-414c-8a55-964721167c97", "metadata": {}, "source": [ - "# Testing Layers" + "# Map Layers" ] }, { @@ -35,7 +35,6 @@ " download_layer,\n", " update_layers,\n", " delete_layer,\n", - " publish_layer,\n", " create_custom_export,\n", " get_custom_export_status\n", ")\n", @@ -113,41 +112,19 @@ "\n", "layer_resp = upload_file(\n", " map_id=map_id, \n", - " file_name=\"tests/fixtures/null-island-points-sample.geojson\", \n", + " file_name=\"fixtures/null-island-points-sample.geojson\", \n", " layer_name=\"The Points Layer\",\n", " metadata=metadata\n", ")\n", "layer_id = layer_resp[\"layer_id\"]" ] }, - { - "cell_type": "markdown", - "id": "62dd0688", - "metadata": {}, - "source": [ - "# Check upload status" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "39488500", - "metadata": {}, - "outputs": [], - "source": [ - "while get_layer_details(map_id, layer_id)[\"progress\"] < 100:\n", - " print(\"Waiting for layer to finish processing...\")\n", - " time.sleep(5)" - ] - }, { "cell_type": "markdown", "id": "050e2bc0-30e4-4f4b-83cf-9e22f91df1e8", "metadata": {}, "source": [ - "### Refresh file upload\n", - "\n", - "Wait for upload to finish first" + "### Refresh file upload\n" ] }, { @@ -159,7 +136,11 @@ }, "outputs": [], "source": [ - "refresh_file_layer(map_id, layer_id, file_name=\"tests/fixtures/null-island-points.geojson\")" + "# Wait for layer processing\n", + "while get_layer_details(map_id, layer_id)[\"progress\"] < 100:\n", + " print(\"Waiting for layer to finish processing...\")\n", + " time.sleep(5)", + "refresh_file_layer(map_id, layer_id, file_name=\"fixtures/null-island-points.geojson\")" ] }, { @@ -248,8 +229,8 @@ "outputs": [], "source": [ "new_style = current_style.copy()\n", - "new_style[\"style\"][\"color\"] = \"red\"\n", - "new_style[\"style\"][\"size\"] = 20\n", + "new_style[\"color\"] = \"red\"\n", + "new_style[\"size\"] = 20\n", "update_layer_style(map_id, layer_id, new_style)" ] }, @@ -291,34 +272,6 @@ "updated_layers = update_layers(map_id, layer_updates)" ] }, - { - "cell_type": "markdown", - "id": "publish-layer-header", - "metadata": {}, - "source": [ - "# Publish a layer to the Felt library" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "publish-layer-example", - "metadata": {}, - "outputs": [], - "source": [ - "# Wait for the layer to be fully processed first\n", - "while get_layer_details(map_id, layer_id)[\"progress\"] < 100:\n", - " print(\"Waiting for layer to finish processing...\")\n", - " time.sleep(5)\n", - "\n", - "# Publish the layer to your workspace's library\n", - "published_layer = publish_layer(\n", - " map_id=map_id,\n", - " layer_id=layer_id,\n", - " name=\"Published Points Layer\"\n", - ")" - ] - }, { "cell_type": "markdown", "id": "1649c24f-845b-4144-b710-45ff72852c18", @@ -336,7 +289,7 @@ "source": [ "import pandas as pd\n", "\n", - "df = pd.read_csv(\"tests/fixtures/null-island-polygons-wkt.csv\")\n", + "df = pd.read_csv(\"fixtures/null-island-polygons-wkt.csv\")\n", "upload_dataframe(map_id, df, \"Polygons from a CSV\")" ] }, @@ -349,7 +302,7 @@ "source": [ "import geopandas as gpd\n", "\n", - "gdf = gpd.read_file('tests/fixtures/null-island-points.geojson')\n", + "gdf = gpd.read_file('fixtures/null-island-points.geojson')\n", "upload_geodataframe(map_id, gdf, layer_name=\"GeoPandas Upload\")" ] }, diff --git a/testing_library_user.ipynb b/docs/library.ipynb similarity index 89% rename from testing_library_user.ipynb rename to docs/library.ipynb index 9afe68a..52d3c09 100644 --- a/testing_library_user.ipynb +++ b/docs/library.ipynb @@ -4,7 +4,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# Testing Library and User" + "# Library" ] }, { @@ -14,38 +14,20 @@ "outputs": [], "source": [ "import os\n", + "import time\n", "\n", "from felt_python import (\n", " list_library_layers,\n", - " get_current_user,\n", " create_map,\n", " delete_map,\n", " upload_file,\n", + " get_layer_details,\n", " publish_layer\n", ")\n", "\n", "os.environ[\"FELT_API_TOKEN\"] = \"\"" ] }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Get current user information" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "user = get_current_user()\n", - "print(f\"User ID: {user['id']}\")\n", - "print(f\"User Name: {user['name']}\")\n", - "print(f\"User Email: {user['email']}\")" - ] - }, { "cell_type": "markdown", "metadata": {}, @@ -118,15 +100,16 @@ "# Upload a layer\n", "layer_resp = upload_file(\n", " map_id=map_id,\n", - " file_name=\"tests/fixtures/null-island-points.geojson\",\n", + " file_name=\"fixtures/null-island-points.geojson\",\n", " layer_name=\"Points to publish\"\n", ")\n", "layer_id = layer_resp[\"layer_id\"]\n", "\n", "# Wait for layer processing\n", - "import time\n", - "print(\"Waiting for layer to process...\")\n", - "time.sleep(10)" + "while get_layer_details(map_id, layer_id)[\"progress\"] < 100:\n", + " print(\"Waiting for layer to finish processing...\")\n", + " time.sleep(5)", + "print(\"Layer ready...\")\n" ] }, { diff --git a/testing_maps.ipynb b/docs/maps.ipynb similarity index 64% rename from testing_maps.ipynb rename to docs/maps.ipynb index 73ad974..80e2606 100644 --- a/testing_maps.ipynb +++ b/docs/maps.ipynb @@ -4,7 +4,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# Testing Maps" + "# Maps" ] }, { @@ -94,13 +94,77 @@ ")" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Export comments\n", + "\n", + "Note: This example assumes there are comments on the map. If you've just created the map, \n", + "you'll need to add some comments through the Felt UI before running this code." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Export comments as JSON (default)\n", + "comments_json = export_comments(map_id)\n", + "print(f\"Number of comment threads: {len(comments_json)}\")\n", + "\n", + "# Export comments as CSV\n", + "# comments_csv = export_comments(map_id, format=\"csv\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Resolve a comment\n", + "\n", + "Note: Replace with a real comment ID from your map" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# If you have comments from the export above, you can use one of those IDs\n", + "resolve_result = resolve_comment(map_id, comment_id)\n", + "print(f\"Comment resolved: {resolve_result}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Delete a comment\n", + "\n", + "Note: Replace with a real comment ID from your map" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# If you have comments from the export above, you can use one of those IDs\n", + "delete_comment(source_map_id, comment_id)\n", + "print(f\"Comment deleted: {comment_id}\")\n" + ] + }, { "cell_type": "markdown", "metadata": {}, "source": [ "# Creating an embed token\n", "\n", - "You can create an embed token to allow embedding the map in other applications." + "You can create an embed token to allow embedding a private map securely in other applications." ] }, { diff --git a/testing_projects.ipynb b/docs/projects.ipynb similarity index 99% rename from testing_projects.ipynb rename to docs/projects.ipynb index 2fd38cc..8c057f9 100644 --- a/testing_projects.ipynb +++ b/docs/projects.ipynb @@ -4,7 +4,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# Testing Projects" + "# Projects" ] }, { diff --git a/testing_sources.ipynb b/docs/sources.ipynb similarity index 99% rename from testing_sources.ipynb rename to docs/sources.ipynb index 70e3691..e80acaa 100644 --- a/testing_sources.ipynb +++ b/docs/sources.ipynb @@ -4,7 +4,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# Testing Sources" + "# Sources" ] }, { diff --git a/felt_python/layer_groups.py b/felt_python/layer_groups.py index 901167d..e26b3a2 100644 --- a/felt_python/layer_groups.py +++ b/felt_python/layer_groups.py @@ -15,11 +15,11 @@ def list_layer_groups(map_id: str, api_token: str | None = None): """List layer groups on a map - + Args: map_id: The ID of the map to list layer groups from api_token: Optional API token - + Returns: List of layer groups """ @@ -37,12 +37,12 @@ def get_layer_group_details( api_token: str | None = None, ): """Get details of a layer group - + Args: map_id: The ID of the map containing the layer group layer_group_id: The ID of the layer group to get details for api_token: Optional API token - + Returns: Layer group details """ @@ -63,14 +63,14 @@ def update_layer_groups( api_token: str | None = None, ): """Update multiple layer groups at once - + Args: 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" api_token: Optional API token - + Returns: The updated layer groups """ @@ -89,7 +89,7 @@ def delete_layer_group( api_token: str | None = None, ): """Delete a layer group from a map - + Args: map_id: The ID of the map containing the layer group layer_group_id: The ID of the layer group to delete @@ -112,20 +112,20 @@ def publish_layer_group( api_token: str | None = None, ): """Publish a layer group to the Felt library - + Args: map_id: The ID of the map containing the layer group layer_group_id: The ID of the layer group to publish name: Optional name to publish the layer group under api_token: Optional API token - + Returns: The published layer group """ json_payload = {} if name is not None: json_payload["name"] = name - + response = make_request( url=PUBLISH_LAYER_GROUP_TEMPLATE.format( map_id=map_id, diff --git a/testing_comments_duplicate.ipynb b/testing_comments_duplicate.ipynb deleted file mode 100644 index 902d2a2..0000000 --- a/testing_comments_duplicate.ipynb +++ /dev/null @@ -1,301 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Testing Comments and Duplication" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import os\n", - "\n", - "from felt_python import (\n", - " create_map,\n", - " delete_map,\n", - " export_comments,\n", - " resolve_comment,\n", - " delete_comment,\n", - " duplicate_layers,\n", - " upload_file,\n", - " list_layers\n", - ")\n", - "\n", - "os.environ[\"FELT_API_TOKEN\"] = \"\"" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Create maps for testing" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Create source map\n", - "source_map = create_map(\n", - " title=\"Source Map for Testing\",\n", - " lat=40,\n", - " lon=-3,\n", - " zoom=5,\n", - " public_access=\"view_and_comment\" # Allow comments\n", - ")\n", - "source_map_id = source_map[\"id\"]\n", - "\n", - "# Create destination map (for duplication)\n", - "dest_map = create_map(\n", - " title=\"Destination Map for Testing\",\n", - " lat=40,\n", - " lon=-3,\n", - " zoom=5,\n", - " public_access=\"private\"\n", - ")\n", - "dest_map_id = dest_map[\"id\"]\n", - "\n", - "print(f\"Source map URL: {source_map['url']}\")\n", - "print(f\"Destination map URL: {dest_map['url']}\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Upload a layer to the source map" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Upload a layer to the source map\n", - "layer_resp = upload_file(\n", - " map_id=source_map_id,\n", - " file_name=\"tests/fixtures/null-island-points.geojson\",\n", - " layer_name=\"Layer to duplicate\"\n", - ")\n", - "layer_id = layer_resp[\"layer_id\"]\n", - "\n", - "# Wait for layer processing\n", - "import time\n", - "print(\"Waiting for layer to process...\")\n", - "time.sleep(10)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Export comments\n", - "\n", - "Note: This example assumes there are comments on the map. If you've just created the map, \n", - "you'll need to add some comments through the Felt UI before running this code." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Export comments as JSON (default)\n", - "comments_json = export_comments(source_map_id)\n", - "print(f\"Number of comment threads: {len(comments_json)}\")\n", - "\n", - "# Export comments as CSV\n", - "# comments_csv = export_comments(source_map_id, format=\"csv\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Resolve a comment\n", - "\n", - "Note: Replace with a real comment ID from your map" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# If you have comments from the export above, you can use one of those IDs\n", - "if len(comments_json) > 0:\n", - " comment_id = comments_json[0].get(\"id\")\n", - " if comment_id:\n", - " resolve_result = resolve_comment(source_map_id, comment_id)\n", - " print(f\"Comment resolved: {resolve_result}\")\n", - "else:\n", - " print(\"No comments available to resolve. Add comments through the Felt UI.\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Delete a comment\n", - "\n", - "Note: Replace with a real comment ID from your map" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# If you have comments from the export above, you can use one of those IDs\n", - "if len(comments_json) > 1:\n", - " comment_id = comments_json[1].get(\"id\")\n", - " if comment_id:\n", - " delete_comment(source_map_id, comment_id)\n", - " print(f\"Comment deleted: {comment_id}\")\n", - "else:\n", - " print(\"Not enough comments available to delete. Add comments through the Felt UI.\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Export comments again to verify changes" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "updated_comments = export_comments(source_map_id)\n", - "print(f\"Number of comment threads after modifications: {len(updated_comments)}\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Duplicate a layer from source map to destination map" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Check the layers on the source map\n", - "source_layers = list_layers(source_map_id)\n", - "print(f\"Source map has {len(source_layers)} layers\")\n", - "\n", - "if len(source_layers) > 0:\n", - " source_layer_id = source_layers[0][\"id\"]\n", - " \n", - " # Duplicate the layer\n", - " duplication_params = [\n", - " {\n", - " \"source_layer_id\": source_layer_id,\n", - " \"destination_map_id\": dest_map_id\n", - " }\n", - " ]\n", - " \n", - " duplication_result = duplicate_layers(duplication_params)\n", - " print(f\"Duplicated {len(duplication_result['layers'])} layers\")\n", - " print(f\"Duplicated {len(duplication_result['layer_groups'])} layer groups\")\n", - " \n", - " # Check if duplication worked\n", - " dest_layers = list_layers(dest_map_id)\n", - " print(f\"Destination map now has {len(dest_layers)} layers\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Duplicate a layer group\n", - "\n", - "Note: This assumes you have a layer group on your source map" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# If the source map has layer groups, you can duplicate a group\n", - "from felt_python import list_layer_groups\n", - "\n", - "source_groups = list_layer_groups(source_map_id)\n", - "print(f\"Source map has {len(source_groups)} layer groups\")\n", - "\n", - "if len(source_groups) > 0:\n", - " source_group_id = source_groups[0][\"id\"]\n", - " \n", - " # Duplicate the layer group\n", - " group_duplication_params = [\n", - " {\n", - " \"source_layer_group_id\": source_group_id,\n", - " \"destination_map_id\": dest_map_id\n", - " }\n", - " ]\n", - " \n", - " group_duplication_result = duplicate_layers(group_duplication_params)\n", - " print(f\"Duplicated {len(group_duplication_result['layers'])} layers\")\n", - " print(f\"Duplicated {len(group_duplication_result['layer_groups'])} layer groups\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Clean up" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Delete both maps\n", - "delete_map(source_map_id)\n", - "delete_map(dest_map_id)" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.13" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} \ No newline at end of file From c796e1899216ac85ffed24f57999e93b294d397f Mon Sep 17 00:00:00 2001 From: Vince Foley Date: Tue, 8 Apr 2025 17:54:54 -0700 Subject: [PATCH 3/9] Naming convention --- docs/layer_groups.ipynb | 10 +++---- docs/layers.ipynb | 12 ++++----- docs/library.ipynb | 4 +-- docs/maps.ipynb | 8 ++++-- docs/projects.ipynb | 6 ++--- docs/sources.ipynb | 6 ++--- felt_python/__init__.py | 28 +++++++++++--------- felt_python/comments.py | 23 ++++++++-------- felt_python/elements.py | 22 +++++++-------- felt_python/layer_groups.py | 29 ++++++++------------ felt_python/layers.py | 53 +++++++++++++++++++------------------ felt_python/library.py | 10 +++---- felt_python/maps.py | 34 ++++++++++++++---------- felt_python/projects.py | 18 ++++++------- felt_python/sources.py | 48 ++++++++++++++++----------------- felt_python/user.py | 8 +++--- 16 files changed, 161 insertions(+), 158 deletions(-) diff --git a/docs/layer_groups.ipynb b/docs/layer_groups.ipynb index 952fd94..9a32b7d 100644 --- a/docs/layer_groups.ipynb +++ b/docs/layer_groups.ipynb @@ -20,12 +20,12 @@ " create_map,\n", " delete_map,\n", " list_layer_groups,\n", - " get_layer_group_details,\n", + " get_layer_group,\n", " update_layer_groups,\n", " delete_layer_group,\n", " publish_layer_group,\n", " update_layers,\n", - " get_layer_details,\n", + " get_layer,\n", " upload_file\n", ")\n", "\n", @@ -88,10 +88,10 @@ "layer2_id = layer2_resp[\"layer_id\"]\n", "\n", "# Wait for processing to complete\n", - "while get_layer_details(map_id, layer1_id)[\"progress\"] < 100:\n", + "while get_layer(map_id, layer1_id)[\"progress\"] < 100:\n", " print(\"Waiting for layer to finish processing...\")\n", " time.sleep(5)", - "while get_layer_details(map_id, layer2_id)[\"progress\"] < 100:\n", + "while get_layer(map_id, layer2_id)[\"progress\"] < 100:\n", " print(\"Waiting for layer to finish processing...\")\n", " time.sleep(5)", "print(\"Layers ready...\")\n" @@ -162,7 +162,7 @@ "source": [ "# Get details for the first group\n", "group_id = created_groups[0][\"id\"]\n", - "group_details = get_layer_group_details(map_id, group_id)\n", + "group_details = get_layer_group(map_id, group_id)\n", "group_details" ] }, diff --git a/docs/layers.ipynb b/docs/layers.ipynb index 50e16c5..7248e89 100644 --- a/docs/layers.ipynb +++ b/docs/layers.ipynb @@ -21,7 +21,7 @@ "from felt_python import (\n", " create_map,\n", " delete_map,\n", - " get_map_details,\n", + " get_map,\n", " list_layers,\n", " upload_file,\n", " upload_url,\n", @@ -29,7 +29,7 @@ " upload_geodataframe,\n", " refresh_file_layer,\n", " refresh_url_layer,\n", - " get_layer_details,\n", + " get_layer,\n", " update_layer_style,\n", " get_export_link,\n", " download_layer,\n", @@ -76,7 +76,7 @@ "metadata": {}, "outputs": [], "source": [ - "get_map_details(map_id)" + "get_map(map_id)" ] }, { @@ -137,7 +137,7 @@ "outputs": [], "source": [ "# Wait for layer processing\n", - "while get_layer_details(map_id, layer_id)[\"progress\"] < 100:\n", + "while get_layer(map_id, layer_id)[\"progress\"] < 100:\n", " print(\"Waiting for layer to finish processing...\")\n", " time.sleep(5)", "refresh_file_layer(map_id, layer_id, file_name=\"fixtures/null-island-points.geojson\")" @@ -180,7 +180,7 @@ "metadata": {}, "outputs": [], "source": [ - "while get_layer_details(map_id, url_layer_id)[\"progress\"] < 100:\n", + "while get_layer(map_id, url_layer_id)[\"progress\"] < 100:\n", " print(\"Waiting for layer to finish processing...\")\n", " time.sleep(5)\n", "refresh_url_layer(map_id, url_layer_id)" @@ -209,7 +209,7 @@ "metadata": {}, "outputs": [], "source": [ - "current_style = get_layer_details(map_id, layer_id)[\"style\"]\n", + "current_style = get_layer(map_id, layer_id)[\"style\"]\n", "current_style" ] }, diff --git a/docs/library.ipynb b/docs/library.ipynb index 52d3c09..00d5dd5 100644 --- a/docs/library.ipynb +++ b/docs/library.ipynb @@ -21,7 +21,7 @@ " create_map,\n", " delete_map,\n", " upload_file,\n", - " get_layer_details,\n", + " get_layer,\n", " publish_layer\n", ")\n", "\n", @@ -106,7 +106,7 @@ "layer_id = layer_resp[\"layer_id\"]\n", "\n", "# Wait for layer processing\n", - "while get_layer_details(map_id, layer_id)[\"progress\"] < 100:\n", + "while get_layer(map_id, layer_id)[\"progress\"] < 100:\n", " print(\"Waiting for layer to finish processing...\")\n", " time.sleep(5)", "print(\"Layer ready...\")\n" diff --git a/docs/maps.ipynb b/docs/maps.ipynb index 80e2606..4d7cc8d 100644 --- a/docs/maps.ipynb +++ b/docs/maps.ipynb @@ -18,9 +18,12 @@ "from felt_python import (\n", " create_map,\n", " delete_map,\n", - " get_map_details,\n", + " get_map,\n", " update_map,\n", " move_map,\n", + " export_comments,\n", + " resolve_comment,\n", + " delete_comment,\n", " create_embed_token,\n", " add_source_layer\n", ")\n", @@ -68,7 +71,7 @@ "metadata": {}, "outputs": [], "source": [ - "get_map_details(map_id)" + "get_map(map_id)" ] }, { @@ -113,6 +116,7 @@ "# Export comments as JSON (default)\n", "comments_json = export_comments(map_id)\n", "print(f\"Number of comment threads: {len(comments_json)}\")\n", + "comments_json\n", "\n", "# Export comments as CSV\n", "# comments_csv = export_comments(map_id, format=\"csv\")" diff --git a/docs/projects.ipynb b/docs/projects.ipynb index 8c057f9..29c6650 100644 --- a/docs/projects.ipynb +++ b/docs/projects.ipynb @@ -18,7 +18,7 @@ "from felt_python import (\n", " list_projects,\n", " create_project,\n", - " get_project_details,\n", + " get_project,\n", " update_project,\n", " delete_project,\n", " create_map,\n", @@ -80,7 +80,7 @@ "metadata": {}, "outputs": [], "source": [ - "project_details = get_project_details(project_id)\n", + "project_details = get_project(project_id)\n", "project_details" ] }, @@ -151,7 +151,7 @@ "metadata": {}, "outputs": [], "source": [ - "project_with_map = get_project_details(project_id)\n", + "project_with_map = get_project(project_id)\n", "project_maps = project_with_map.get(\"maps\", [])\n", "print(f\"Number of maps in project: {len(project_maps)}\")\n", "\n", diff --git a/docs/sources.ipynb b/docs/sources.ipynb index e80acaa..228fc89 100644 --- a/docs/sources.ipynb +++ b/docs/sources.ipynb @@ -18,7 +18,7 @@ "from felt_python import (\n", " list_sources,\n", " create_source,\n", - " get_source_details,\n", + " get_source,\n", " update_source,\n", " delete_source,\n", " sync_source,\n", @@ -126,7 +126,7 @@ "metadata": {}, "outputs": [], "source": [ - "source_details = get_source_details(source_id)\n", + "source_details = get_source(source_id)\n", "source_details" ] }, @@ -200,7 +200,7 @@ "# Wait for source synchronization to complete\n", "import time\n", "for i in range(60): # Wait up to 5 minutes\n", - " current_source = get_source_details(source_id)\n", + " current_source = get_source(source_id)\n", " if current_source[\"sync_status\"] == \"completed\":\n", " break\n", " print(f\"Waiting for source sync... Status: {current_source['sync_status']}\")\n", diff --git a/felt_python/__init__.py b/felt_python/__init__.py index 1cd8633..e695a0f 100644 --- a/felt_python/__init__.py +++ b/felt_python/__init__.py @@ -2,11 +2,13 @@ from .maps import ( create_map, delete_map, - get_map_details, + get_map, update_map, move_map, create_embed_token, add_source_layer, + # Deprecated + get_map_details, ) from .exceptions import AuthError from .layers import ( @@ -17,7 +19,7 @@ upload_url, refresh_file_layer, refresh_url_layer, - get_layer_details, + get_layer, update_layer_style, get_export_link, download_layer, @@ -42,7 +44,7 @@ ) from .layer_groups import ( list_layer_groups, - get_layer_group_details, + get_layer_group, update_layer_groups, delete_layer_group, publish_layer_group, @@ -50,14 +52,14 @@ from .projects import ( list_projects, create_project, - get_project_details, + get_project, update_project, delete_project, ) from .sources import ( list_sources, create_source, - get_source_details, + get_source, update_source, delete_source, sync_source, @@ -81,7 +83,6 @@ # Maps "create_map", "delete_map", - "get_map_details", "update_map", "move_map", "create_embed_token", @@ -94,7 +95,7 @@ "upload_url", "refresh_file_layer", "refresh_url_layer", - "get_layer_details", + "get_layer", "update_layer_style", "get_export_link", "download_layer", @@ -106,7 +107,7 @@ "duplicate_layers", # Layer groups "list_layer_groups", - "get_layer_group_details", + "get_layer_group", "update_layer_groups", "delete_layer_group", "publish_layer_group", @@ -117,19 +118,16 @@ "upsert_elements", "delete_element", "create_element_groups", - # Elements deprecated: - "post_elements", - "post_element_group", # Projects "list_projects", "create_project", - "get_project_details", + "get_project", "update_project", "delete_project", # Sources "list_sources", "create_source", - "get_source_details", + "get_source", "update_source", "delete_source", "sync_source", @@ -143,4 +141,8 @@ "get_current_user", # Exceptions "AuthError", + # Deprecated + "post_elements", + "post_element_group", + "get_map_details", ] diff --git a/felt_python/comments.py b/felt_python/comments.py index 34d3d8c..a42158c 100644 --- a/felt_python/comments.py +++ b/felt_python/comments.py @@ -7,24 +7,23 @@ from .api import make_request, BASE_URL -MAP_COMMENTS_TEMPLATE = urljoin(BASE_URL, "maps/{map_id}/comments/") -MAP_COMMENT_TEMPLATE = urljoin(MAP_COMMENTS_TEMPLATE, "{comment_id}") -MAP_COMMENT_RESOLVE_TEMPLATE = urljoin(MAP_COMMENT_TEMPLATE, "/resolve") -MAP_COMMENTS_EXPORT_TEMPLATE = urljoin(MAP_COMMENTS_TEMPLATE, "export") +COMMENT = urljoin(BASE_URL, "maps/{map_id}/comments/{comment_id}") +COMMENT_RESOLVE = urljoin(BASE_URL, "maps/{map_id}/comments/{comment_id}/resolve") +COMMENT_EXPORT = urljoin(BASE_URL, "maps/{map_id}/comments/{comment_id}/export") def export_comments(map_id: str, format: str = "json", api_token: str | None = None): """Export comments from a map - + Args: map_id: The ID of the map to export comments from format: The format to export the comments in, either 'csv' or 'json' (default) api_token: Optional API token - + Returns: The exported comments in the specified format """ - url = f"{MAP_COMMENTS_EXPORT_TEMPLATE.format(map_id=map_id)}?format={format}" + url = f"{COMMENT_EXPORT.format(map_id=map_id)}?format={format}" response = make_request( url=url, method="GET", @@ -35,17 +34,17 @@ def export_comments(map_id: str, format: str = "json", api_token: str | None = N def resolve_comment(map_id: str, comment_id: str, api_token: str | None = None): """Resolve a comment - + Args: map_id: The ID of the map that contains the comment comment_id: The ID of the comment to resolve api_token: Optional API token - + Returns: Confirmation of the resolved comment """ response = make_request( - url=MAP_COMMENT_RESOLVE_TEMPLATE.format(map_id=map_id, comment_id=comment_id), + url=COMMENT_RESOLVE.format(map_id=map_id, comment_id=comment_id), method="POST", api_token=api_token, ) @@ -54,14 +53,14 @@ def resolve_comment(map_id: str, comment_id: str, api_token: str | None = None): def delete_comment(map_id: str, comment_id: str, api_token: str | None = None): """Delete a comment - + Args: map_id: The ID of the map that contains the comment comment_id: The ID of the comment to delete api_token: Optional API token """ make_request( - url=MAP_COMMENT_TEMPLATE.format(map_id=map_id, comment_id=comment_id), + url=COMMENT.format(map_id=map_id, comment_id=comment_id), method="DELETE", api_token=api_token, ) diff --git a/felt_python/elements.py b/felt_python/elements.py index 82cf57d..b9168ab 100644 --- a/felt_python/elements.py +++ b/felt_python/elements.py @@ -9,10 +9,10 @@ from .util import deprecated -MAP_ELEMENTS_TEMPLATE = urljoin(BASE_URL, "maps/{map_id}/elements/") -ELEMENT_TEMPLATE = urljoin(MAP_ELEMENTS_TEMPLATE, "{element_id}") -MAP_ELEMENT_GROUPS_TEMPLATE = urljoin(BASE_URL, "maps/{map_id}/element_groups/") -ELEMENT_GROUP_TEMPLATE = urljoin(MAP_ELEMENT_GROUPS_TEMPLATE, "{element_group_id}") +ELEMENTS = urljoin(BASE_URL, "maps/{map_id}/elements") +ELEMENT = urljoin(BASE_URL, "maps/{map_id}/elements/{element_id}") +ELEMENT_GROUPS = urljoin(BASE_URL, "maps/{map_id}/element_groups") +ELEMENT_GROUP = urljoin(BASE_URL, "maps/{map_id}/element_groups/{element_group_id}") def list_elements(map_id: str, api_token: str | None = None): @@ -26,7 +26,7 @@ def list_elements(map_id: str, api_token: str | None = None): GeoJSON FeatureCollection of all elements """ response = make_request( - url=MAP_ELEMENTS_TEMPLATE.format(map_id=map_id), + url=ELEMENTS.format(map_id=map_id), method="GET", api_token=api_token, ) @@ -44,7 +44,7 @@ def list_element_groups(map_id: str, api_token: str | None = None): List of element groups """ response = make_request( - url=MAP_ELEMENT_GROUPS_TEMPLATE.format(map_id=map_id), + url=ELEMENT_GROUPS.format(map_id=map_id), method="GET", api_token=api_token, ) @@ -65,9 +65,7 @@ def show_element_group( GeoJSON FeatureCollection of all elements in the group """ response = make_request( - url=ELEMENT_GROUP_TEMPLATE.format( - map_id=map_id, element_group_id=element_group_id - ), + url=ELEMENT_GROUP.format(map_id=map_id, element_group_id=element_group_id), method="GET", api_token=api_token, ) @@ -108,7 +106,7 @@ def upsert_elements( if isinstance(geojson_feature_collection, str): geojson_feature_collection = json.loads(geojson_feature_collection) response = make_request( - url=MAP_ELEMENTS_TEMPLATE.format(map_id=map_id), + url=ELEMENTS.format(map_id=map_id), method="POST", json=geojson_feature_collection, api_token=api_token, @@ -125,7 +123,7 @@ def delete_element(map_id: str, element_id: str, api_token: str | None = None): api_token: Optional API token """ make_request( - url=ELEMENT_TEMPLATE.format(map_id=map_id, element_id=element_id), + url=ELEMENT.format(map_id=map_id, element_id=element_id), method="DELETE", api_token=api_token, ) @@ -156,7 +154,7 @@ def create_element_groups( The created or updated element groups """ response = make_request( - url=MAP_ELEMENT_GROUPS_TEMPLATE.format(map_id=map_id), + url=ELEMENT_GROUPS.format(map_id=map_id), method="POST", json=element_groups, api_token=api_token, diff --git a/felt_python/layer_groups.py b/felt_python/layer_groups.py index e26b3a2..c9c97d4 100644 --- a/felt_python/layer_groups.py +++ b/felt_python/layer_groups.py @@ -8,9 +8,11 @@ from .api import make_request, BASE_URL -MAP_LAYER_GROUPS_TEMPLATE = urljoin(BASE_URL, "maps/{map_id}/layer_groups/") -LAYER_GROUP_TEMPLATE = urljoin(MAP_LAYER_GROUPS_TEMPLATE, "{layer_group_id}") -PUBLISH_LAYER_GROUP_TEMPLATE = urljoin(LAYER_GROUP_TEMPLATE, "/publish") +GROUPS = urljoin(BASE_URL, "maps/{map_id}/layer_groups/") +GROUP = urljoin(BASE_URL, "maps/{map_id}/layer_groups/{layer_group_id}") +GROUPS_PUBLISH = urljoin( + BASE_URL, "maps/{map_id}/layer_groups/{layer_group_id}/publish" +) def list_layer_groups(map_id: str, api_token: str | None = None): @@ -24,14 +26,14 @@ def list_layer_groups(map_id: str, api_token: str | None = None): List of layer groups """ response = make_request( - url=MAP_LAYER_GROUPS_TEMPLATE.format(map_id=map_id), + url=GROUPS.format(map_id=map_id), method="GET", api_token=api_token, ) return json.load(response) -def get_layer_group_details( +def get_layer_group( map_id: str, layer_group_id: str, api_token: str | None = None, @@ -47,10 +49,7 @@ def get_layer_group_details( Layer group details """ response = make_request( - url=LAYER_GROUP_TEMPLATE.format( - map_id=map_id, - layer_group_id=layer_group_id, - ), + url=GROUP.format(map_id=map_id, layer_group_id=layer_group_id), method="GET", api_token=api_token, ) @@ -75,7 +74,7 @@ def update_layer_groups( The updated layer groups """ response = make_request( - url=MAP_LAYER_GROUPS_TEMPLATE.format(map_id=map_id), + url=GROUPS.format(map_id=map_id), method="POST", json=layer_group_params_list, api_token=api_token, @@ -96,10 +95,7 @@ def delete_layer_group( api_token: Optional API token """ make_request( - url=LAYER_GROUP_TEMPLATE.format( - map_id=map_id, - layer_group_id=layer_group_id, - ), + url=GROUP.format(map_id=map_id, layer_group_id=layer_group_id), method="DELETE", api_token=api_token, ) @@ -127,10 +123,7 @@ def publish_layer_group( json_payload["name"] = name response = make_request( - url=PUBLISH_LAYER_GROUP_TEMPLATE.format( - map_id=map_id, - layer_group_id=layer_group_id, - ), + url=GROUPS_PUBLISH.format(map_id=map_id, layer_group_id=layer_group_id), method="POST", json=json_payload, api_token=api_token, diff --git a/felt_python/layers.py b/felt_python/layers.py index 9db1d83..eccdc8d 100644 --- a/felt_python/layers.py +++ b/felt_python/layers.py @@ -12,25 +12,26 @@ from urllib.parse import urljoin from .api import make_request, BASE_URL -from .maps import MAP_TEMPLATE -MAP_LAYERS_TEMPLATE = urljoin(BASE_URL, "maps/{map_id}/layers/") -LAYER_TEMPLATE = urljoin(MAP_LAYERS_TEMPLATE, "{layer_id}/") -REFRESH_TEMPLATE = urljoin(LAYER_TEMPLATE, "refresh") -UPDATE_STYLE_TEMPLATE = urljoin(LAYER_TEMPLATE, "update_style") -UPLOAD_TEMPLATE = urljoin(MAP_TEMPLATE, "upload") -EXPORT_TEMPLATE = urljoin(LAYER_TEMPLATE, "get_export_link") -PUBLISH_LAYER_TEMPLATE = urljoin(LAYER_TEMPLATE, "publish") -CUSTOM_EXPORT_TEMPLATE = urljoin(LAYER_TEMPLATE, "custom_export") -CUSTOM_EXPORT_STATUS_TEMPLATE = urljoin(CUSTOM_EXPORT_TEMPLATE, "/{export_id}") -DUPLICATE_LAYERS_ENDPOINT = urljoin(BASE_URL, "duplicate_layers") +LAYERS = urljoin(BASE_URL, "maps/{map_id}/layers") +LAYER = urljoin(BASE_URL, "maps/{map_id}/layers/{layer_id}") +LAYER_REFRESH = urljoin(BASE_URL, "maps/{map_id}/layers/{layer_id}/refresh") +LAYER_UPDATE_STYLE = urljoin(BASE_URL, "maps/{map_id}/layers/{layer_id}/update_style") +LAYER_UPLOAD = urljoin(BASE_URL, "maps/{map_id}/upload") +LAYER_EXPORT = urljoin(BASE_URL, "maps/{map_id}/layers/{layer_id}/get_export_link") +LAYER_PUBLISH = urljoin(BASE_URL, "maps/{map_id}/layers/{layer_id}/publish") +LAYER_EXPORT = urljoin(BASE_URL, "maps/{map_id}/layers/{layer_id}/custom_export") +LAYER_STATUS = urljoin( + BASE_URL, "maps/{map_id}/layers/{layer_id}/custom_export/{export_id}" +) +LAYER_DUPLICATE = urljoin(BASE_URL, "duplicate_layers") def list_layers(map_id: str, api_token: str | None = None): """List layers on a map""" response = make_request( - url=MAP_LAYERS_TEMPLATE.format(map_id=map_id), + url=LAYERS.format(map_id=map_id), method="GET", api_token=api_token, ) @@ -103,7 +104,7 @@ def upload_file( json_payload["zoom"] = zoom response = make_request( - url=UPLOAD_TEMPLATE.format(map_id=map_id), + url=LAYER_UPLOAD.format(map_id=map_id), method="POST", api_token=api_token, json=json_payload, @@ -178,7 +179,7 @@ def refresh_file_layer( The refresh response including presigned upload details """ response = make_request( - url=REFRESH_TEMPLATE.format(map_id=map_id, layer_id=layer_id), + url=LAYER_REFRESH.format(map_id=map_id, layer_id=layer_id), method="POST", api_token=api_token, ) @@ -225,7 +226,7 @@ def upload_url( json_payload["hints"] = hints response = make_request( - url=UPLOAD_TEMPLATE.format(map_id=map_id), + url=LAYER_UPLOAD.format(map_id=map_id), method="POST", api_token=api_token, json=json_payload, @@ -236,7 +237,7 @@ def upload_url( def refresh_url_layer(map_id: str, layer_id: str, api_token: str | None = None): """Refresh a layer originated from a URL upload""" response = make_request( - url=REFRESH_TEMPLATE.format( + url=LAYER_REFRESH.format( map_id=map_id, layer_id=layer_id, ), @@ -246,14 +247,14 @@ def refresh_url_layer(map_id: str, layer_id: str, api_token: str | None = None): return json.load(response) -def get_layer_details( +def get_layer( map_id: str, layer_id: str, api_token: str | None = None, ): """Get details of a layer""" response = make_request( - url=LAYER_TEMPLATE.format( + url=LAYER.format( map_id=map_id, layer_id=layer_id, ), @@ -271,7 +272,7 @@ def update_layer_style( ): """Update a layer's style""" response = make_request( - url=UPDATE_STYLE_TEMPLATE.format( + url=LAYER_UPDATE_STYLE.format( map_id=map_id, layer_id=layer_id, ), @@ -292,7 +293,7 @@ def get_export_link( Vector layers will be downloaded in GPKG format. Raster layers will be GeoTIFFs. """ response = make_request( - url=EXPORT_TEMPLATE.format( + url=LAYER_EXPORT.format( map_id=map_id, layer_id=layer_id, ), @@ -343,7 +344,7 @@ def update_layers( The updated layers """ response = make_request( - url=MAP_LAYERS_TEMPLATE.format(map_id=map_id), + url=LAYERS.format(map_id=map_id), method="POST", json=layer_params_list, api_token=api_token, @@ -358,7 +359,7 @@ def delete_layer( ): """Delete a layer from a map""" make_request( - url=LAYER_TEMPLATE.format( + url=LAYER.format( map_id=map_id, layer_id=layer_id, ), @@ -389,7 +390,7 @@ def publish_layer( json_payload["name"] = name response = make_request( - url=PUBLISH_LAYER_TEMPLATE.format( + url=LAYER_PUBLISH.format( map_id=map_id, layer_id=layer_id, ), @@ -432,7 +433,7 @@ def create_custom_export( json_payload["filters"] = filters response = make_request( - url=CUSTOM_EXPORT_TEMPLATE.format( + url=LAYER_EXPORT.format( map_id=map_id, layer_id=layer_id, ), @@ -461,7 +462,7 @@ def get_custom_export_status( Export status including download URL when complete """ response = make_request( - url=CUSTOM_EXPORT_STATUS_TEMPLATE.format( + url=LAYER_STATUS.format( map_id=map_id, layer_id=layer_id, export_id=export_id, @@ -491,7 +492,7 @@ def duplicate_layers( The duplicated layers and layer groups """ response = make_request( - url=DUPLICATE_LAYERS_ENDPOINT, + url=LAYER_DUPLICATE, method="POST", json=duplicate_params, api_token=api_token, diff --git a/felt_python/library.py b/felt_python/library.py index 423aaeb..b359f4b 100644 --- a/felt_python/library.py +++ b/felt_python/library.py @@ -7,24 +7,24 @@ from .api import make_request, BASE_URL -LIBRARY_ENDPOINT = urljoin(BASE_URL, "library") +LIBRARY = urljoin(BASE_URL, "library") def list_library_layers(source: str = "workspace", api_token: str | None = None): """List layers available in the layer library - + Args: - source: The source of library layers to list. + source: The source of library layers to list. Options are: - "workspace": list layers from your workspace library (default) - "felt": list layers from the Felt data library - "all": list layers from both sources api_token: Optional API token - + Returns: The layer library containing layers and layer groups """ - url = f"{LIBRARY_ENDPOINT}?source={source}" + url = f"{LIBRARY}?source={source}" response = make_request( url=url, method="GET", diff --git a/felt_python/maps.py b/felt_python/maps.py index 3a6ee1e..c2ea335 100644 --- a/felt_python/maps.py +++ b/felt_python/maps.py @@ -6,14 +6,15 @@ from urllib.parse import urljoin from .api import make_request, BASE_URL +from .util import deprecated -MAPS_ENDPOINT = urljoin(BASE_URL, "maps/") -MAP_TEMPLATE = urljoin(MAPS_ENDPOINT, "{map_id}/") -MAP_UPDATE_TEMPLATE = urljoin(MAPS_ENDPOINT, "{map_id}/update") -MAP_MOVE_TEMPLATE = urljoin(MAPS_ENDPOINT, "{map_id}/move") -MAP_EMBED_TOKEN_TEMPLATE = urljoin(MAPS_ENDPOINT, "{map_id}/embed_token") -MAP_ADD_SOURCE_LAYER_TEMPLATE = urljoin(MAPS_ENDPOINT, "{map_id}/add_source_layer") +MAPS = urljoin(BASE_URL, "maps/") +MAP = urljoin(BASE_URL, "maps/{map_id}/") +MAP_UPDATE = urljoin(BASE_URL, "maps/{map_id}/update") +MAP_MOVE = urljoin(BASE_URL, "maps/{map_id}/move") +MAP_EMBED_TOKEN = urljoin(BASE_URL, "maps/{map_id}/embed_token") +MAP_ADD_SOURCE_LAYER = urljoin(BASE_URL, "maps/{map_id}/add_source_layer") def create_map( @@ -77,7 +78,7 @@ def create_map( json_args["workspace_id"] = workspace_id response = make_request( - url=MAPS_ENDPOINT, + url=MAPS, method="POST", json=json_args, api_token=api_token, @@ -88,22 +89,27 @@ def create_map( def delete_map(map_id: str, api_token: str | None = None): """Delete a map""" make_request( - url=MAP_TEMPLATE.format(map_id=map_id), + url=MAP.format(map_id=map_id), method="DELETE", api_token=api_token, ) -def get_map_details(map_id: str, api_token: str | None = None): +def get_map(map_id: str, api_token: str | None = None): """Get details of a map""" response = make_request( - url=MAP_TEMPLATE.format(map_id=map_id), + url=MAP.format(map_id=map_id), method="GET", api_token=api_token, ) return json.load(response) +@deprecated(reason="Please use `get_map` instead") +def get_map_details(map_id: str, api_token: str | None = None): + get_map(map_id, api_token) + + def update_map( map_id: str, title: str = None, @@ -134,7 +140,7 @@ def update_map( json_args["public_access"] = public_access response = make_request( - url=MAP_UPDATE_TEMPLATE.format(map_id=map_id), + url=MAP_UPDATE.format(map_id=map_id), method="POST", json=json_args, api_token=api_token, @@ -168,7 +174,7 @@ def move_map( json_args["folder_id"] = folder_id response = make_request( - url=MAP_MOVE_TEMPLATE.format(map_id=map_id), + url=MAP_MOVE.format(map_id=map_id), method="POST", json=json_args, api_token=api_token, @@ -189,7 +195,7 @@ def create_embed_token(map_id: str, user_email: str = None, api_token: str = Non Returns: The created embed token with expiration time """ - url = MAP_EMBED_TOKEN_TEMPLATE.format(map_id=map_id) + url = MAP_EMBED_TOKEN.format(map_id=map_id) if user_email: url = f"{url}?user_email={user_email}" @@ -219,7 +225,7 @@ def add_source_layer( Acceptance status and links to the created resources """ response = make_request( - url=MAP_ADD_SOURCE_LAYER_TEMPLATE.format(map_id=map_id), + url=MAP_ADD_SOURCE_LAYER.format(map_id=map_id), method="POST", json=source_layer_params, api_token=api_token, diff --git a/felt_python/projects.py b/felt_python/projects.py index 2f4f488..a5048be 100644 --- a/felt_python/projects.py +++ b/felt_python/projects.py @@ -7,14 +7,14 @@ from .api import make_request, BASE_URL -PROJECTS_ENDPOINT = urljoin(BASE_URL, "projects/") -PROJECT_TEMPLATE = urljoin(PROJECTS_ENDPOINT, "{project_id}/") -PROJECT_UPDATE_TEMPLATE = urljoin(PROJECTS_ENDPOINT, "{project_id}/update") +PROJECTS = urljoin(BASE_URL, "projects/") +PROJECT = urljoin(BASE_URL, "projects/{project_id}/") +PROJECT_UPDATE = urljoin(BASE_URL, "projects/{project_id}/update") def list_projects(workspace_id: str | None = None, api_token: str | None = None): """List all projects accessible to the authenticated user""" - url = PROJECTS_ENDPOINT + url = PROJECTS if workspace_id: url = f"{url}?workspace_id={workspace_id}" response = make_request( @@ -38,7 +38,7 @@ def create_project(name: str, visibility: str, api_token: str | None = None): The created project """ response = make_request( - url=PROJECTS_ENDPOINT, + url=PROJECTS, method="POST", json={"name": name, "visibility": visibility}, api_token=api_token, @@ -46,10 +46,10 @@ def create_project(name: str, visibility: str, api_token: str | None = None): return json.load(response) -def get_project_details(project_id: str, api_token: str | None = None): +def get_project(project_id: str, api_token: str | None = None): """Get details of a project""" response = make_request( - url=PROJECT_TEMPLATE.format(project_id=project_id), + url=PROJECT.format(project_id=project_id), method="GET", api_token=api_token, ) @@ -80,7 +80,7 @@ def update_project( json_args["visibility"] = visibility response = make_request( - url=PROJECT_UPDATE_TEMPLATE.format(project_id=project_id), + url=PROJECT_UPDATE.format(project_id=project_id), method="POST", json=json_args, api_token=api_token, @@ -94,7 +94,7 @@ def delete_project(project_id: str, api_token: str | None = None): Note: This will delete all Folders and Maps inside the project! """ make_request( - url=PROJECT_TEMPLATE.format(project_id=project_id), + url=PROJECT.format(project_id=project_id), method="DELETE", api_token=api_token, ) diff --git a/felt_python/sources.py b/felt_python/sources.py index a8630a0..cffe4bf 100644 --- a/felt_python/sources.py +++ b/felt_python/sources.py @@ -8,15 +8,15 @@ from .api import make_request, BASE_URL -SOURCES_ENDPOINT = urljoin(BASE_URL, "sources/") -SOURCE_TEMPLATE = urljoin(SOURCES_ENDPOINT, "{source_id}/") -SOURCE_UPDATE_TEMPLATE = urljoin(SOURCES_ENDPOINT, "{source_id}/update") -SOURCE_SYNC_TEMPLATE = urljoin(SOURCES_ENDPOINT, "{source_id}/sync") +SOURCES = urljoin(BASE_URL, "sources") +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") def list_sources(workspace_id: str | None = None, api_token: str | None = None): """List all sources accessible to the authenticated user""" - url = SOURCES_ENDPOINT + url = SOURCES if workspace_id: url = f"{url}?workspace_id={workspace_id}" response = make_request( @@ -28,28 +28,28 @@ def list_sources(workspace_id: str | None = None, api_token: str | None = None): def create_source( - name: str, - connection: Dict[str, Any], + name: str, + connection: Dict[str, Any], permissions: Dict[str, Any] = None, - api_token: str | None = None + api_token: str | None = None, ): """Create a new source - + Args: name: The name of the source connection: Connection details - varies by source type - permissions: Optional permissions configuration + permissions: Optional permissions configuration api_token: Optional API token - + Returns: The created source reference """ json_payload = {"name": name, "connection": connection} if permissions: json_payload["permissions"] = permissions - + response = make_request( - url=SOURCES_ENDPOINT, + url=SOURCES, method="POST", json=json_payload, api_token=api_token, @@ -57,10 +57,10 @@ def create_source( return json.load(response) -def get_source_details(source_id: str, api_token: str | None = None): +def get_source(source_id: str, api_token: str | None = None): """Get details of a source""" response = make_request( - url=SOURCE_TEMPLATE.format(source_id=source_id), + url=SOURCE.format(source_id=source_id), method="GET", api_token=api_token, ) @@ -68,21 +68,21 @@ def get_source_details(source_id: str, api_token: str | None = None): def update_source( - source_id: str, + source_id: str, name: str | None = None, connection: Dict[str, Any] | None = None, permissions: Dict[str, Any] | None = None, - api_token: str | None = None + api_token: str | None = None, ): """Update a source's details - + Args: source_id: The ID of the source to update name: Optional new name for the source connection: Optional updated connection details permissions: Optional updated permissions configuration api_token: Optional API token - + Returns: The updated source reference """ @@ -93,9 +93,9 @@ def update_source( json_payload["connection"] = connection if permissions is not None: json_payload["permissions"] = permissions - + response = make_request( - url=SOURCE_UPDATE_TEMPLATE.format(source_id=source_id), + url=SOURCE_UPDATE.format(source_id=source_id), method="POST", json=json_payload, api_token=api_token, @@ -106,7 +106,7 @@ def update_source( def delete_source(source_id: str, api_token: str | None = None): """Delete a source""" make_request( - url=SOURCE_TEMPLATE.format(source_id=source_id), + url=SOURCE.format(source_id=source_id), method="DELETE", api_token=api_token, ) @@ -114,12 +114,12 @@ def delete_source(source_id: str, api_token: str | None = None): def sync_source(source_id: str, api_token: str | None = None): """Trigger synchronization of a source - + Returns: The source reference with synchronization status """ response = make_request( - url=SOURCE_SYNC_TEMPLATE.format(source_id=source_id), + url=SOURCE_SYNC.format(source_id=source_id), method="POST", api_token=api_token, ) diff --git a/felt_python/user.py b/felt_python/user.py index c446273..9861a45 100644 --- a/felt_python/user.py +++ b/felt_python/user.py @@ -7,20 +7,20 @@ from .api import make_request, BASE_URL -USER_ENDPOINT = urljoin(BASE_URL, "user") +USER = urljoin(BASE_URL, "user") def get_current_user(api_token: str | None = None): """Get details of the currently authenticated user - + Args: api_token: Optional API token - + Returns: The user details including id, name, and email """ response = make_request( - url=USER_ENDPOINT, + url=USER, method="GET", api_token=api_token, ) From dd410206c3653b74e208c3bb7a493e1a28f2f2e0 Mon Sep 17 00:00:00 2001 From: Vince Foley Date: Wed, 9 Apr 2025 09:28:02 -0700 Subject: [PATCH 4/9] Add user-agent header --- README.md | 4 ++-- felt_python/api.py | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 0e93518..f2f4d3e 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ import os os.environ["FELT_API_TOKEN"] = "YOUR_API_TOKEN" ``` -### Creating a map +### Create a map ```python from felt_python import create_map @@ -58,7 +58,7 @@ map_id = response["id"] ### Upload anything ```python -from felt_python import upload_file, list_layers +from felt_python import upload_file upload = upload_file( map_id=map_id, diff --git a/felt_python/api.py b/felt_python/api.py index c9df5f5..b42ca11 100644 --- a/felt_python/api.py +++ b/felt_python/api.py @@ -5,7 +5,7 @@ import os import typing import urllib.request - +from importlib.metadata import version try: import certifi @@ -35,7 +35,10 @@ def make_request( "No API token found. Pass explicitly or set the FELT_API_TOKEN environment variable" ) from exc - data, headers = None, {"Authorization": f"Bearer {api_token}"} + data, headers = None, { + "Authorization": f"Bearer {api_token}", + "User-Agent": f"felt-python/{version('felt_python')}", + } if json is not None: data = json_.dumps(json).encode("utf8") headers["Content-Type"] = "application/json" From 14494bdd3749a41751e52a5c61817a2e6cb17f53 Mon Sep 17 00:00:00 2001 From: Vince Foley Date: Wed, 9 Apr 2025 15:22:12 -0700 Subject: [PATCH 5/9] Add tests --- docs/elements.ipynb | 2 +- docs/layer_groups.ipynb | 2 +- docs/layers.ipynb | 2 +- docs/maps.ipynb | 25 +- felt_python/api.py | 9 +- felt_python/comments.py | 2 +- felt_python/layer_groups.py | 2 +- felt_python/layers.py | 30 +- felt_python/maps.py | 4 +- tests/delete_test.py | 247 +++++++++++++++++ tests/elements_test.py | 200 ++++++++++++++ .../null-island-points-sample.geojson | 8 + tests/fixtures/null-island-points.geojson | 15 + tests/fixtures/null-island-polygons-wkt.csv | 5 + tests/layer_groups_test.py | 244 +++++++++++++++++ tests/layers_test.py | 258 ++++++++++++++++++ tests/library_test.py | 169 ++++++++++++ tests/maps_test.py | 141 ++++++++++ tests/projects_test.py | 134 +++++++++ tests/sources_test.py | 176 ++++++++++++ tests/tests.py | 53 ++++ 21 files changed, 1684 insertions(+), 44 deletions(-) create mode 100644 tests/delete_test.py create mode 100644 tests/elements_test.py create mode 100644 tests/fixtures/null-island-points-sample.geojson create mode 100644 tests/fixtures/null-island-points.geojson create mode 100644 tests/fixtures/null-island-polygons-wkt.csv create mode 100644 tests/layer_groups_test.py create mode 100644 tests/layers_test.py create mode 100644 tests/library_test.py create mode 100644 tests/maps_test.py create mode 100644 tests/projects_test.py create mode 100644 tests/sources_test.py create mode 100644 tests/tests.py diff --git a/docs/elements.ipynb b/docs/elements.ipynb index 937c069..fec30e3 100644 --- a/docs/elements.ipynb +++ b/docs/elements.ipynb @@ -45,7 +45,7 @@ "outputs": [], "source": [ "resp = create_map(\n", - " title=\"A felt-py map for testing elements\",\n", + " title=\"A felt-python map for testing elements\",\n", " lat=40,\n", " lon=-3,\n", " zoom=8,\n", diff --git a/docs/layer_groups.ipynb b/docs/layer_groups.ipynb index 9a32b7d..dead264 100644 --- a/docs/layer_groups.ipynb +++ b/docs/layer_groups.ipynb @@ -46,7 +46,7 @@ "outputs": [], "source": [ "resp = create_map(\n", - " title=\"A felt-py map for testing layer groups\",\n", + " title=\"A felt-python map for testing layer groups\",\n", " lat=40,\n", " lon=-3,\n", " zoom=5,\n", diff --git a/docs/layers.ipynb b/docs/layers.ipynb index 7248e89..3907c24 100644 --- a/docs/layers.ipynb +++ b/docs/layers.ipynb @@ -60,7 +60,7 @@ "outputs": [], "source": [ "resp = create_map(\n", - " title=\"A felt-py map for testing layers\",\n", + " title=\"A felt-python map for testing layers\",\n", " lat=40,\n", " lon=-3,\n", " zoom=10,\n", diff --git a/docs/maps.ipynb b/docs/maps.ipynb index 4d7cc8d..457cb93 100644 --- a/docs/maps.ipynb +++ b/docs/maps.ipynb @@ -47,7 +47,7 @@ "outputs": [], "source": [ "resp = create_map(\n", - " title=\"A felt-py map\",\n", + " title=\"A felt-python map\",\n", " lat=40,\n", " lon=-3,\n", " zoom=9,\n", @@ -91,7 +91,7 @@ "source": [ "resp = update_map(\n", " map_id=map_id,\n", - " title=\"A felt-py map with an update\",\n", + " title=\"A felt-python map with an update\",\n", " description=\"This map was updated through the API\",\n", " public_access=\"view_only\"\n", ")" @@ -126,9 +126,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# Resolve a comment\n", - "\n", - "Note: Replace with a real comment ID from your map" + "# Resolve a comment" ] }, { @@ -138,17 +136,16 @@ "outputs": [], "source": [ "# If you have comments from the export above, you can use one of those IDs\n", - "resolve_result = resolve_comment(map_id, comment_id)\n", - "print(f\"Comment resolved: {resolve_result}\")\n" + "if comment := comments_json[0]:\n", + " resolve_result = resolve_comment(map_id, comment['id'])\n", + " print(f\"Comment resolved: {resolve_result}\")\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "# Delete a comment\n", - "\n", - "Note: Replace with a real comment ID from your map" + "# Delete a comment" ] }, { @@ -157,9 +154,9 @@ "metadata": {}, "outputs": [], "source": [ - "# If you have comments from the export above, you can use one of those IDs\n", - "delete_comment(source_map_id, comment_id)\n", - "print(f\"Comment deleted: {comment_id}\")\n" + "if comment := comments_json[0]:\n", + " delete_comment(map_id, comment['id'])\n", + " print(f\"Comment deleted: {comment_id}\")\n" ] }, { @@ -224,4 +221,4 @@ }, "nbformat": 4, "nbformat_minor": 4 -} \ No newline at end of file +} diff --git a/felt_python/api.py b/felt_python/api.py index b42ca11..8dca941 100644 --- a/felt_python/api.py +++ b/felt_python/api.py @@ -5,7 +5,7 @@ import os import typing import urllib.request -from importlib.metadata import version +from importlib.metadata import version, PackageNotFoundError try: import certifi @@ -35,9 +35,14 @@ def make_request( "No API token found. Pass explicitly or set the FELT_API_TOKEN environment variable" ) from exc + try: + package_version = version("felt_python") + except PackageNotFoundError: + package_version = "local" + data, headers = None, { "Authorization": f"Bearer {api_token}", - "User-Agent": f"felt-python/{version('felt_python')}", + "User-Agent": f"felt-python/{package_version}", } if json is not None: data = json_.dumps(json).encode("utf8") diff --git a/felt_python/comments.py b/felt_python/comments.py index a42158c..9152b2d 100644 --- a/felt_python/comments.py +++ b/felt_python/comments.py @@ -9,7 +9,7 @@ COMMENT = urljoin(BASE_URL, "maps/{map_id}/comments/{comment_id}") COMMENT_RESOLVE = urljoin(BASE_URL, "maps/{map_id}/comments/{comment_id}/resolve") -COMMENT_EXPORT = urljoin(BASE_URL, "maps/{map_id}/comments/{comment_id}/export") +COMMENT_EXPORT = urljoin(BASE_URL, "maps/{map_id}/comments/export") def export_comments(map_id: str, format: str = "json", api_token: str | None = None): diff --git a/felt_python/layer_groups.py b/felt_python/layer_groups.py index c9c97d4..079623c 100644 --- a/felt_python/layer_groups.py +++ b/felt_python/layer_groups.py @@ -8,7 +8,7 @@ from .api import make_request, BASE_URL -GROUPS = urljoin(BASE_URL, "maps/{map_id}/layer_groups/") +GROUPS = urljoin(BASE_URL, "maps/{map_id}/layer_groups") GROUP = urljoin(BASE_URL, "maps/{map_id}/layer_groups/{layer_group_id}") GROUPS_PUBLISH = urljoin( BASE_URL, "maps/{map_id}/layer_groups/{layer_group_id}/publish" diff --git a/felt_python/layers.py b/felt_python/layers.py index eccdc8d..844ecb5 100644 --- a/felt_python/layers.py +++ b/felt_python/layers.py @@ -19,11 +19,11 @@ LAYER_REFRESH = urljoin(BASE_URL, "maps/{map_id}/layers/{layer_id}/refresh") LAYER_UPDATE_STYLE = urljoin(BASE_URL, "maps/{map_id}/layers/{layer_id}/update_style") LAYER_UPLOAD = urljoin(BASE_URL, "maps/{map_id}/upload") -LAYER_EXPORT = urljoin(BASE_URL, "maps/{map_id}/layers/{layer_id}/get_export_link") +LAYER_EXPORT_LINK = urljoin(BASE_URL, "maps/{map_id}/layers/{layer_id}/get_export_link") LAYER_PUBLISH = urljoin(BASE_URL, "maps/{map_id}/layers/{layer_id}/publish") -LAYER_EXPORT = urljoin(BASE_URL, "maps/{map_id}/layers/{layer_id}/custom_export") -LAYER_STATUS = urljoin( - BASE_URL, "maps/{map_id}/layers/{layer_id}/custom_export/{export_id}" +LAYER_CUSTOM_EXPORT = urljoin(BASE_URL, "maps/{map_id}/layers/{layer_id}/custom_export") +LAYER_CUSTOM_EXPORT_STATUS = urljoin( + BASE_URL, "maps/{map_id}/layers/{layer_id}/custom_exports/{export_id}" ) LAYER_DUPLICATE = urljoin(BASE_URL, "duplicate_layers") @@ -293,10 +293,7 @@ def get_export_link( Vector layers will be downloaded in GPKG format. Raster layers will be GeoTIFFs. """ response = make_request( - url=LAYER_EXPORT.format( - map_id=map_id, - layer_id=layer_id, - ), + url=LAYER_EXPORT_LINK.format(map_id=map_id, layer_id=layer_id), method="GET", api_token=api_token, ) @@ -359,10 +356,7 @@ def delete_layer( ): """Delete a layer from a map""" make_request( - url=LAYER.format( - map_id=map_id, - layer_id=layer_id, - ), + url=LAYER.format(map_id=map_id, layer_id=layer_id), method="DELETE", api_token=api_token, ) @@ -390,10 +384,7 @@ def publish_layer( json_payload["name"] = name response = make_request( - url=LAYER_PUBLISH.format( - map_id=map_id, - layer_id=layer_id, - ), + url=LAYER_PUBLISH.format(map_id=map_id, layer_id=layer_id), method="POST", json=json_payload, api_token=api_token, @@ -433,10 +424,7 @@ def create_custom_export( json_payload["filters"] = filters response = make_request( - url=LAYER_EXPORT.format( - map_id=map_id, - layer_id=layer_id, - ), + url=LAYER_CUSTOM_EXPORT.format(map_id=map_id, layer_id=layer_id), method="POST", json=json_payload, api_token=api_token, @@ -462,7 +450,7 @@ def get_custom_export_status( Export status including download URL when complete """ response = make_request( - url=LAYER_STATUS.format( + url=LAYER_CUSTOM_EXPORT_STATUS.format( map_id=map_id, layer_id=layer_id, export_id=export_id, diff --git a/felt_python/maps.py b/felt_python/maps.py index c2ea335..ae0eb89 100644 --- a/felt_python/maps.py +++ b/felt_python/maps.py @@ -9,8 +9,8 @@ from .util import deprecated -MAPS = urljoin(BASE_URL, "maps/") -MAP = urljoin(BASE_URL, "maps/{map_id}/") +MAPS = urljoin(BASE_URL, "maps") +MAP = urljoin(BASE_URL, "maps/{map_id}") MAP_UPDATE = urljoin(BASE_URL, "maps/{map_id}/update") MAP_MOVE = urljoin(BASE_URL, "maps/{map_id}/move") MAP_EMBED_TOKEN = urljoin(BASE_URL, "maps/{map_id}/embed_token") diff --git a/tests/delete_test.py b/tests/delete_test.py new file mode 100644 index 0000000..cd03695 --- /dev/null +++ b/tests/delete_test.py @@ -0,0 +1,247 @@ +""" +Deletion test for the Felt Python library. +Creates and then deletes all types of resources to test deletion functionality. +""" + +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 ( + # Maps + create_map, + delete_map, + # Elements + list_elements, + upsert_elements, + delete_element, + list_element_groups, + create_element_groups, + # Layers + get_layer, + upload_file, + delete_layer, + # Layer Groups + list_layer_groups, + update_layer_groups, + delete_layer_group, + # Projects + list_projects, + create_project, + delete_project, + # Sources + list_sources, + create_source, + delete_source, +) + + +class FeltDeleteTest(unittest.TestCase): + """Test the Felt API resource deletion 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_resource_deletion(self): + """Create and then delete all types of resources to test deletion functionality.""" + + # Create a source and then delete it + source_name = f"Source for deletion ({self.timestamp})" + print(f"Creating source: {source_name}...") + + source = create_source( + name=source_name, + connection={ + "type": "wms_wmts", + "url": "https://basemap.nationalmap.gov/arcgis/services/USGSTopo/MapServer/WMSServer", + }, + permissions={"type": "workspace_editors"}, + ) + + self.assertIsNotNone(source) + source_id = source["id"] + print(f"Created source with ID: {source_id}") + + # Delete the source + print(f"Deleting source: {source_id}...") + + delete_source(source_id) + + # Verify deletion by listing sources + all_sources = list_sources() + + source_exists = any(s["id"] == source_id for s in all_sources) + self.assertFalse(source_exists) + print("Source deleted successfully") + + # Create a project and then delete it + project_name = f"Project for deletion ({self.timestamp})" + print(f"Creating project: {project_name}...") + + project = create_project(name=project_name, visibility="private") + + self.assertIsNotNone(project) + project_id = project["id"] + print(f"Created project with ID: {project_id}") + + # Delete the project + print(f"Deleting project: {project_id}...") + + delete_project(project_id) + + # Verify deletion by listing projects + all_projects = list_projects() + + project_exists = any(p["id"] == project_id for p in all_projects) + self.assertFalse(project_exists) + print("Project deleted successfully") + + # Create a map with layers, layer groups, elements, element groups and delete them + map_name = f"Map for deletion ({self.timestamp})" + print(f"Creating map: {map_name}...") + + map_resp = create_map( + title=map_name, lat=40, lon=-3, zoom=8, public_access="private" + ) + + self.assertIsNotNone(map_resp) + map_id = map_resp["id"] + print(f"Created map with ID: {map_id}") + + # Upload a layer + print("Creating a 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="Layer for deletion", + ) + + self.assertIsNotNone(layer_resp) + layer_id = layer_resp["layer_id"] + print(f"Created layer with ID: {layer_id}") + + # Wait for layer processing + 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) + + # Create layer groups + print("Creating layer groups...") + layer_groups = [ + {"name": "Layer Group for deletion", "caption": "Test layer group"} + ] + + layer_group_resp = update_layer_groups(map_id, layer_groups) + + self.assertIsNotNone(layer_group_resp) + layer_group_id = layer_group_resp[0]["id"] + print(f"Created layer group with ID: {layer_group_id}") + + # Create element groups + print("Creating element groups...") + element_groups = [ + { + "name": "Element Group for deletion", + "symbol": "square", + "color": "#FF0000", + } + ] + + element_group_resp = create_element_groups(map_id, element_groups) + + self.assertIsNotNone(element_group_resp) + element_group_id = element_group_resp[0]["id"] + print(f"Created element group with ID: {element_group_id}") + + # Create elements + print("Creating elements...") + elements = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [-3.70379, 40.416775]}, + "properties": {"name": "Element for deletion"}, + } + ], + } + + elements_resp = upsert_elements(map_id, elements) + + self.assertIsNotNone(elements_resp) + element_id = elements_resp["features"][0]["properties"]["felt:id"] + print(f"Created element with ID: {element_id}") + + # Now delete each resource in reverse order + + # Delete the element + print(f"Deleting element: {element_id}...") + + delete_element(map_id, element_id) + + # Verify element deletion + all_elements = list_elements(map_id) + + element_exists = any( + el["properties"].get("felt:id") == element_id + for el in all_elements["features"] + ) + self.assertFalse(element_exists) + print("Element deleted successfully") + + # Delete the layer group + print(f"Deleting layer group: {layer_group_id}...") + + delete_layer_group(map_id, layer_group_id) + + # Verify layer group deletion + all_layer_groups = list_layer_groups(map_id) + + layer_group_exists = any(g["id"] == layer_group_id for g in all_layer_groups) + self.assertFalse(layer_group_exists) + print("Layer group deleted successfully") + + # Delete the layer + print(f"Deleting layer: {layer_id}...") + + delete_layer(map_id, layer_id) + + # Verify layer deletion by attempting to get it + try: + deleted_layer = get_layer(map_id, layer_id) + self.fail("Layer should have been deleted but was still accessible") + except Exception as e: + print("Layer deleted successfully") + + # Delete the map + print(f"Deleting map: {map_id}...") + + delete_map(map_id) + + print("Map deletion command executed successfully") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/elements_test.py b/tests/elements_test.py new file mode 100644 index 0000000..2278b03 --- /dev/null +++ b/tests/elements_test.py @@ -0,0 +1,200 @@ +""" +End-to-end test for the Felt Elements functionality. +Uses the felt_python library to test elements creation, listing, updating, and grouping operations. +""" + +import os +import sys +import unittest +import datetime + +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from felt_python import ( + create_map, + list_elements, + list_element_groups, + show_element_group, + upsert_elements, + create_element_groups, +) + + +class FeltElementsTest(unittest.TestCase): + """Test the Felt API elements 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_elements_workflow(self): + """Test the complete workflow for element operations.""" + # Step 1: Create a map to work with + map_name = f"Elements Test Map ({self.timestamp})" + print(f"Creating map: {map_name}...") + + response = create_map( + title=map_name, + lat=40, + lon=-3, + zoom=8, + public_access="private", + ) + + self.assertIsNotNone(response) + self.assertIn("id", response) + map_id = response["id"] + print(f"Created map with ID: {map_id}") + print(f"Map URL: {response['url']}") + + # Step 2: Create elements (points in Madrid and Barcelona) + print("Creating elements...") + geojson_feature_collection = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [-3.70379, 40.416775]}, + "properties": {"name": "Madrid"}, + }, + { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [2.173403, 41.385063]}, + "properties": {"name": "Barcelona"}, + }, + ], + } + + elements_response = upsert_elements(map_id, geojson_feature_collection) + + self.assertIsNotNone(elements_response) + self.assertEqual(elements_response["type"], "FeatureCollection") + self.assertEqual(len(elements_response["features"]), 2) + print(f"Created {len(elements_response['features'])} elements") + + # Step 3: List all elements + print("Listing all elements...") + + elements = list_elements(map_id) + + self.assertIsNotNone(elements) + self.assertEqual(elements["type"], "FeatureCollection") + self.assertEqual(len(elements["features"]), 2) + print(f"Found {len(elements['features'])} elements") + + # Step 4: Update an element (Barcelona with blue color) + print("Updating Barcelona element...") + barcelona_element = next( + el for el in elements["features"] if el["properties"]["name"] == "Barcelona" + ) + barcelona_element_id = barcelona_element["properties"]["felt:id"] + + barcelona_element["properties"]["felt:color"] = "#0000FF" + barcelona_feature_collection = { + "type": "FeatureCollection", + "features": [barcelona_element], + } + + update_response = upsert_elements(map_id, barcelona_feature_collection) + + self.assertIsNotNone(update_response) + + # Verify the update by listing elements again + updated_elements = list_elements(map_id) + + for element in updated_elements["features"]: + if element["properties"]["name"] == "Barcelona": + self.assertEqual(element["properties"]["felt:color"], "#0000FF") + print("Barcelona element successfully updated with blue color") + break + + # Step 5: Create element groups + print("Creating element groups...") + element_groups = [ + {"name": "Spanish cities", "symbol": "monument", "color": "#A02CFA"}, + {"name": "Parks", "symbol": "tree", "color": "#00AA55"}, + ] + + created_groups = create_element_groups(map_id, element_groups) + + self.assertIsNotNone(created_groups) + self.assertEqual(len(created_groups), 2) + print(f"Created {len(created_groups)} element groups") + + # Step 6: List element groups + print("Listing element groups...") + + all_groups = list_element_groups(map_id) + + self.assertIsNotNone(all_groups) + self.assertEqual(len(all_groups), 2) + print(f"Found {len(all_groups)} element groups") + + # Step 7: Add elements to a group + print("Adding elements to Spanish cities group...") + cities_group_id = all_groups[0]["id"] + + # Update all elements to add them to the Spanish cities group + for feature in elements["features"]: + feature["properties"]["felt:parentId"] = cities_group_id + + group_update_response = upsert_elements(map_id, elements) + + self.assertIsNotNone(group_update_response) + + # Step 8: List elements in a specific group + print(f"Listing elements in group: {cities_group_id}...") + + group_elements = show_element_group(map_id, cities_group_id) + + self.assertIsNotNone(group_elements) + self.assertEqual(len(group_elements["features"]), 2) + print(f"Found {len(group_elements['features'])} elements in the group") + + # Step 9: Create elements directly with group assignment + print("Creating park elements with direct group assignment...") + parks_group_id = all_groups[1]["id"] + + parks_geojson = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [-3.6762, 40.4153]}, + "properties": { + "name": "Retiro Park", + "felt:parentId": parks_group_id, + }, + }, + { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [2.1526, 41.3851]}, + "properties": { + "name": "Parc de la Ciutadella", + "felt:parentId": parks_group_id, + }, + }, + ], + } + + parks_response = upsert_elements(map_id, parks_geojson) + + self.assertIsNotNone(parks_response) + self.assertEqual(len(parks_response["features"]), 2) + + # Verify elements were added to the parks group + parks_group_elements = show_element_group(map_id, parks_group_id) + + self.assertEqual(len(parks_group_elements["features"]), 2) + print( + f"Created and assigned {len(parks_group_elements['features'])} elements to Parks group" + ) + + print(f"\nElements test completed successfully! Map URL: {response['url']}") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/fixtures/null-island-points-sample.geojson b/tests/fixtures/null-island-points-sample.geojson new file mode 100644 index 0000000..5cd0f18 --- /dev/null +++ b/tests/fixtures/null-island-points-sample.geojson @@ -0,0 +1,8 @@ +{ + "type": "FeatureCollection", + "features": [ + {"type": "Feature", "geometry": {"type": "Point", "coordinates": [0.0002143, 0.0002731]}, "properties": {"Integer": 3, "Float": 0.3, "String": "3", "Boolean": true}}, + {"type": "Feature", "geometry": {"type": "Point", "coordinates": [0.0000729, 0.0000983]}, "properties": {"Integer": 5, "Float": 0.5, "String": "5", "Boolean": true}}, + {"type": "Feature", "geometry": {"type": "Point", "coordinates": [0.0002533, 0.0000627]}, "properties": {"Integer": 9, "Float": 0.9, "String": "9", "Boolean": true}} + ] +} \ No newline at end of file diff --git a/tests/fixtures/null-island-points.geojson b/tests/fixtures/null-island-points.geojson new file mode 100644 index 0000000..2b7ddae --- /dev/null +++ b/tests/fixtures/null-island-points.geojson @@ -0,0 +1,15 @@ +{ + "type": "FeatureCollection", + "features": [ + {"type": "Feature", "geometry": {"type": "Point", "coordinates": [0.0002143, 0.0002731]}, "properties": {"Integer": 3, "Float": 0.3, "String": "3", "Boolean": true}}, + {"type": "Feature", "geometry": {"type": "Point", "coordinates": [0.0000729, 0.0000983]}, "properties": {"Integer": 5, "Float": 0.5, "String": "5", "Boolean": true}}, + {"type": "Feature", "geometry": {"type": "Point", "coordinates": [0.0002533, 0.0000627]}, "properties": {"Integer": 9, "Float": 0.9, "String": "9", "Boolean": true}}, + {"type": "Feature", "geometry": {"type": "Point", "coordinates": [-0.00023, 0.0001295]}, "properties": {"Integer": 4, "Float": 0.4, "String": "4", "Boolean": true}}, + {"type": "Feature", "geometry": {"type": "Point", "coordinates": [0.0000161, -0.0001956]}, "properties": {"Integer": 2, "Float": 0.2, "String": "2", "Boolean": true}}, + {"type": "Feature", "geometry": {"type": "Point", "coordinates": [0.0002644, -0.00016]}, "properties": {"Integer": 6, "Float": 0.6, "String": "6", "Boolean": true}}, + {"type": "Feature", "geometry": {"type": "Point", "coordinates": [0.0000851, -0.0000242]}, "properties": {"Integer": 8, "Float": 0.8, "String": "8", "Boolean": true}}, + {"type": "Feature", "geometry": {"type": "Point", "coordinates": [-0.0004638, -0.0000197]}, "properties": {"Integer": 1, "Float": 0.1, "String": "1", "Boolean": true}}, + {"type": "Feature", "geometry": {"type": "Point", "coordinates": [-0.0002077, -0.0000564]}, "properties": {"Integer": 7, "Float": 0.7, "String": "7", "Boolean": true}}, + {"type": "Feature", "geometry": {"type": "Point", "coordinates": [-0.0001454, -0.0001155]}, "properties": {"Integer": 10, "Float": 0.10, "String": "10", "Boolean": true}} + ] +} \ No newline at end of file diff --git a/tests/fixtures/null-island-polygons-wkt.csv b/tests/fixtures/null-island-polygons-wkt.csv new file mode 100644 index 0000000..a2bd166 --- /dev/null +++ b/tests/fixtures/null-island-polygons-wkt.csv @@ -0,0 +1,5 @@ +WKT,Integer,Float,String,Boolean +"POLYGON ((-0.000236034393311 -0.00033259391784,0.000477433204651 -0.000477433204642,0.000458657741547 0.000093877315526,-0.000093877315521 -0.000040233135223,-0.000236034393311 -0.00033259391784))","1",0.1,"1","1" +"POLYGON ((-0.000021374950266 -0.000022635473753,-0.000179708003998 0.000327229499814,0.000407695770264 0.000584721565234,0.0005042552948 0.000067055225381,0.000459841726632 0.000057850340786,0.000458657741547 0.000093877315526,-0.000021374950266 -0.000022635473753))","2",0.2,"2","1" +"MULTIPOLYGON (((0.00013152397146 0.000014475915017,0.00013152397146 0.000014475915017,0.000039784618425 -0.000007790918245,0.00013152397146 0.000014475915017)),((-0.000220607582539 -0.000300867080594,-0.000388920307159 -0.000276267528538,-0.000429153442383 0.000233352184301,-0.000164449107349 0.000293512260445,-0.000021374950266 -0.000022635473753,-0.000093877315521 -0.000040233135223,-0.000220607582539 -0.000300867080594)))","3",0.3,"3","1" +"POLYGON ((0.000203847885132 0.000555217266088,0.00020612325582 0.00049636101096,-0.000179708003998 0.000327229499814,-0.000021374950266 -0.000022635473753,-0.000164449107349 0.000293512260445,-0.000429153442383 0.000233352184301,-0.000388920307159 -0.000276267528538,-0.000220607582539 -0.000300867080594,-0.000236034393311 -0.00033259391784,0.000241921590374 -0.000429622576181,0.000244081020355 -0.000485479831694,-0.000592768192291 -0.000303089618682,-0.00061959028244 0.000233352184301,0.000203847885132 0.000555217266088))","4",0.4,"4","1" diff --git a/tests/layer_groups_test.py b/tests/layer_groups_test.py new file mode 100644 index 0000000..a85e50e --- /dev/null +++ b/tests/layer_groups_test.py @@ -0,0 +1,244 @@ +""" +End-to-end test for the Felt Layer Groups functionality. +Uses the felt_python library to test layer group creation, updating, and other operations. +""" + +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, + list_layer_groups, + get_layer_group, + update_layer_groups, + publish_layer_group, + update_layers, + get_layer, + upload_file, +) + + +class FeltLayerGroupsTest(unittest.TestCase): + """Test the Felt API layer groups 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_layer_groups_workflow(self): + """Test the complete workflow for layer groups operations.""" + # Step 1: Create a map to work with + map_name = f"Layer Groups Test Map ({self.timestamp})" + print(f"Creating map: {map_name}...") + + response = create_map( + title=map_name, + lat=40, + lon=-3, + zoom=5, + public_access="private", + ) + + self.assertIsNotNone(response) + self.assertIn("id", response) + map_id = response["id"] + print(f"Created map with ID: {map_id}") + print(f"Map URL: {response['url']}") + + # Step 2: Upload some layers to work with + print("Uploading first layer...") + file_name = os.path.join( + os.path.dirname(__file__), "fixtures/null-island-points-sample.geojson" + ) + + layer1_resp = upload_file( + map_id=map_id, file_name=file_name, layer_name="Points Layer" + ) + + self.assertIsNotNone(layer1_resp) + self.assertIn("layer_id", layer1_resp) + layer1_id = layer1_resp["layer_id"] + print(f"Uploaded first layer with ID: {layer1_id}") + + print("Uploading second layer...") + file_name = os.path.join( + os.path.dirname(__file__), "fixtures/null-island-polygons-wkt.csv" + ) + + layer2_resp = upload_file( + map_id=map_id, + file_name=file_name, + layer_name="Polygons Layer", + ) + + self.assertIsNotNone(layer2_resp) + self.assertIn("layer_id", layer2_resp) + layer2_id = layer2_resp["layer_id"] + print(f"Uploaded second layer with ID: {layer2_id}") + + # Wait for layer processing to complete + print("Waiting for layers to finish processing...") + max_wait_time = 60 # seconds + + # Wait for first layer + start_time = time.time() + while time.time() - start_time < max_wait_time: + layer = get_layer(map_id, layer1_id) + if layer["progress"] >= 100: + print( + f"First layer processing completed in {time.time() - start_time:.1f} seconds" + ) + break + print(f"First layer progress: {layer['progress']}%") + time.sleep(5) + + # Wait for second layer + start_time = time.time() + while time.time() - start_time < max_wait_time: + layer = get_layer(map_id, layer2_id) + if layer["progress"] >= 100: + print( + f"Second layer processing completed in {time.time() - start_time:.1f} seconds" + ) + break + print(f"Second layer progress: {layer['progress']}%") + time.sleep(5) + + # Step 3: List initial layer groups (should be empty) + print("Listing initial layer groups...") + + initial_groups = list_layer_groups(map_id) + + self.assertIsNotNone(initial_groups) + print(f"Found {len(initial_groups)} initial layer groups") + + # Step 4: Create layer groups + print("Creating layer groups...") + layer_groups = [ + {"name": "Vector Data", "caption": "A collection of vector datasets"}, + {"name": "Base Data", "caption": "Reference layers"}, + ] + + created_groups = update_layer_groups(map_id, layer_groups) + + self.assertIsNotNone(created_groups) + self.assertEqual(len(created_groups), 2) + print(f"Created {len(created_groups)} layer groups") + + # Step 5: Retrieve a specific layer group's details + group_id = created_groups[0]["id"] + print(f"Getting details for layer group: {group_id}...") + + group_details = get_layer_group(map_id, group_id) + + self.assertIsNotNone(group_details) + self.assertEqual(group_details["id"], group_id) + self.assertEqual(group_details["name"], "Vector Data") + print(f"Retrieved details for layer group: {group_details['name']}") + + # Step 6: Update layer groups + print("Updating layer groups...") + # Retrieve current groups + + current_groups = list_layer_groups(map_id) + + self.assertEqual(len(current_groups), 2) + + group1_id = current_groups[0]["id"] + group2_id = current_groups[1]["id"] + + # Update the groups + updated_groups = [ + { + "id": group1_id, + "name": "Vector Data (Updated)", + "caption": "A collection of vector datasets (updated)", + "ordering_key": 1, + }, + { + "id": group2_id, + "name": "Base Data (Updated)", + "caption": "Reference layers (updated)", + "ordering_key": 2, + }, + ] + + update_result = update_layer_groups(map_id, updated_groups) + + self.assertIsNotNone(update_result) + self.assertEqual(len(update_result), 2) + + # Verify updates by getting layer groups again + updated_groups_list = list_layer_groups(map_id) + + for group in updated_groups_list: + if group["id"] == group1_id: + self.assertEqual(group["name"], "Vector Data (Updated)") + self.assertEqual( + group["caption"], "A collection of vector datasets (updated)" + ) + elif group["id"] == group2_id: + self.assertEqual(group["name"], "Base Data (Updated)") + self.assertEqual(group["caption"], "Reference layers (updated)") + + print("Layer groups updated successfully") + + # Step 7: Update layers to assign them to groups + print("Updating layers to assign them to groups...") + layer_updates = [ + { + "id": layer1_id, + "layer_group_id": group1_id, + }, + { + "id": layer2_id, + "layer_group_id": group2_id, + }, + ] + + updated_layers = update_layers(map_id, layer_updates) + + self.assertIsNotNone(updated_layers) + print("Layers assigned to groups successfully") + + # Verify assignment by checking each group's contents + group1_details = get_layer_group(map_id, group1_id) + + self.assertTrue( + any(layer["id"] == layer1_id for layer in group1_details["layers"]) + ) + + group2_details = get_layer_group(map_id, group2_id) + self.assertTrue( + any(layer["id"] == layer2_id for layer in group2_details["layers"]) + ) + + # Step 8: Publish a layer group to the library + print(f"Publishing layer group: {group1_id} to the library...") + try: + published_group = publish_layer_group( + map_id=map_id, + layer_group_id=group1_id, + name="Published Vector Data Test", + ) + + self.assertIsNotNone(published_group) + print(f"Published layer group with name: {published_group['name']}") + except Exception as e: + print( + f"Publishing layer group failed (might be normal due to test data): {e}" + ) + + print(f"\nLayer groups test completed successfully! Map URL: {response['url']}") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/layers_test.py b/tests/layers_test.py new file mode 100644 index 0000000..cff2efc --- /dev/null +++ b/tests/layers_test.py @@ -0,0 +1,258 @@ +""" +End-to-end test for the Felt Layers functionality. +Uses the felt_python library to test layer creation, updating, styling and other operations. +""" + +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, + list_layers, + upload_file, + upload_url, + refresh_file_layer, + refresh_url_layer, + get_layer, + update_layer_style, + get_export_link, + update_layers, + create_custom_export, + get_custom_export_status, +) + + +class FeltLayersTest(unittest.TestCase): + """Test the Felt API layers 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_layers_workflow(self): + """Test the complete workflow for layer operations.""" + # Step 1: Create a map to work with + map_name = f"Layers Test Map ({self.timestamp})" + print(f"Creating map: {map_name}...") + + response = create_map( + title=map_name, + lat=40, + lon=-3, + 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(f"Map URL: {response['url']}") + + # Step 2: Upload a file layer + print("Uploading file layer...") + metadata = { + "attribution_text": "Sample Data", + "source_name": "Felt Python Test", + "description": "Sample points near Null Island", + } + + 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", + metadata=metadata, + ) + + 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") + self.assertEqual(layer["status"], "completed") + + # Step 3: Refresh file layer + print("Refreshing file layer...") + file_name = os.path.join( + os.path.dirname(__file__), "fixtures/null-island-points.geojson" + ) + + refresh_response = refresh_file_layer(map_id, layer_id, file_name=file_name) + + self.assertIsNotNone(refresh_response) + print("File layer refresh initiated") + + # Step 4: Upload a URL layer + print("Uploading URL layer...") + live_earthquakes_url = ( + "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_hour.geojson" + ) + + url_upload = upload_url(map_id, live_earthquakes_url, "Live Earthquakes") + + self.assertIsNotNone(url_upload) + self.assertIn("layer_id", url_upload) + url_layer_id = url_upload["layer_id"] + print(f"Uploaded URL layer with ID: {url_layer_id}") + + # Wait for URL layer processing + print("Waiting for URL layer processing...") + start_time = time.time() + + while time.time() - start_time < max_wait_time: + layer = get_layer(map_id, url_layer_id) + if layer["progress"] >= 100: + print( + f"URL layer processing completed in {time.time() - start_time:.1f} seconds" + ) + break + print(f"URL layer progress: {layer['progress']}%") + time.sleep(5) + + # Step 5: Refresh URL layer + print("Refreshing URL layer...") + + url_refresh_resp = refresh_url_layer(map_id, url_layer_id) + + self.assertIsNotNone(url_refresh_resp) + print("URL layer refresh initiated") + + # Step 6: Update layer style + print("Updating layer style...") + + current_style = get_layer(map_id, layer_id)["style"] + + self.assertIsNotNone(current_style) + + new_style = current_style.copy() + new_style["color"] = "red" + new_style["size"] = 20 + + style_update_resp = update_layer_style(map_id, layer_id, new_style) + + self.assertIsNotNone(style_update_resp) + + # Verify style update + updated_layer = get_layer(map_id, layer_id) + + self.assertEqual(updated_layer["style"]["color"], "red") + self.assertEqual(updated_layer["style"]["size"], 20) + print("Layer style updated successfully") + + # Step 7: Update multiple layers + print("Updating multiple layers...") + + all_layers = list_layers(map_id) + + self.assertGreaterEqual(len(all_layers), 2) + + # Prepare updates for both layers + layer_updates = [ + { + "id": layer_id, + "name": "Updated Points Layer", + "caption": "New caption for points layer", + }, + { + "id": url_layer_id, + "name": "Updated Earthquakes Layer", + "caption": "New caption for earthquakes layer", + }, + ] + + update_resp = update_layers(map_id, layer_updates) + + self.assertIsNotNone(update_resp) + + # Verify updates + updated_layers = list_layers(map_id) + + for layer in updated_layers: + if layer["id"] == layer_id: + self.assertEqual(layer["name"], "Updated Points Layer") + self.assertEqual(layer["caption"], "New caption for points layer") + elif layer["id"] == url_layer_id: + self.assertEqual(layer["name"], "Updated Earthquakes Layer") + self.assertEqual(layer["caption"], "New caption for earthquakes layer") + + print("Multiple layers updated successfully") + + # Step 8: Get export link + print("Getting layer export link...") + + export_link = get_export_link(map_id, layer_id) + + self.assertIsNotNone(export_link) + self.assertTrue(export_link.startswith("http")) + print(f"Export link obtained: {export_link}") + + # Step 9: Create custom export with filters + print("Creating custom export...") + + export_request = create_custom_export( + map_id=map_id, + layer_id=layer_id, + output_format="csv", + email_on_completion=False, + ) + + self.assertIsNotNone(export_request) + self.assertIn("export_request_id", export_request) + export_id = export_request["export_request_id"] + print(f"Created custom export with ID: {export_id}") + + # Poll for export status (try a few times) + max_polls = 3 + for i in range(max_polls): + print(f"Checking export status (attempt {i+1}/{max_polls})...") + + export_status = get_custom_export_status( + map_id=map_id, layer_id=layer_id, export_id=export_id + ) + + print(f"Export status: {export_status['status']}") + + if export_status["status"] == "completed": + print( + f"Export completed! Download URL: {export_status['download_url']}" + ) + break + elif export_status["status"] == "failed": + print("Export failed") + break + + if i < max_polls - 1: # Don't sleep on the last attempt + time.sleep(5) + + print(f"\nLayers test completed successfully! Map URL: {response['url']}") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/library_test.py b/tests/library_test.py new file mode 100644 index 0000000..c406894 --- /dev/null +++ b/tests/library_test.py @@ -0,0 +1,169 @@ +""" +End-to-end test for the Felt Library functionality. +Uses the felt_python library to test library functions including listing and publishing layers. +""" + +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 ( + list_library_layers, + create_map, + upload_file, + get_layer, + publish_layer, +) + + +class FeltLibraryTest(unittest.TestCase): + """Test the Felt API library 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_library_workflow(self): + """Test the complete workflow for library operations.""" + # Step 1: List layers in the workspace library + print("Listing layers in workspace library...") + + workspace_library = list_library_layers(source="workspace") + + self.assertIsNotNone(workspace_library) + self.assertIn("layers", workspace_library) + self.assertIn("layer_groups", workspace_library) + + print(f"Found {len(workspace_library['layers'])} layers in workspace library") + print( + f"Found {len(workspace_library['layer_groups'])} layer groups in workspace library" + ) + + # Show first few layers if any + for i, layer in enumerate(workspace_library["layers"][:3]): + print(f"Layer {i+1}: {layer['name']} (ID: {layer['id']})") + + # Step 2: List layers in the Felt data library + print("\nListing layers in Felt data library...") + + felt_library = list_library_layers(source="felt") + + self.assertIsNotNone(felt_library) + self.assertIn("layers", felt_library) + self.assertIn("layer_groups", felt_library) + + print(f"Found {len(felt_library['layers'])} layers in Felt library") + print(f"Found {len(felt_library['layer_groups'])} layer groups in Felt library") + + # Show first few layers + for i, layer in enumerate(felt_library["layers"][:5]): + print(f"Layer {i+1}: {layer['name']} (ID: {layer['id']})") + + # Step 3: Create a map with a layer and publish it to the library + map_name = f"Library Test Map ({self.timestamp})" + print(f"\nCreating map: {map_name}...") + + map_resp = create_map( + title=map_name, lat=40, lon=-3, zoom=5, public_access="private" + ) + + self.assertIsNotNone(map_resp) + self.assertIn("id", map_resp) + map_id = map_resp["id"] + print(f"Created map with ID: {map_id}") + + # Upload a layer + print("Uploading layer...") + file_name = os.path.join( + os.path.dirname(__file__), "fixtures/null-island-points.geojson" + ) + + layer_resp = upload_file( + map_id=map_id, + file_name=file_name, + layer_name="Points to publish", + ) + + self.assertIsNotNone(layer_resp) + self.assertIn("layer_id", layer_resp) + layer_id = layer_resp["layer_id"] + print(f"Uploaded layer with ID: {layer_id}") + + # Wait for layer processing + 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) + + print("Layer ready for publishing") + + # Step 4: Publish the layer to the library + print("Publishing layer to library...") + + published = publish_layer( + map_id=map_id, + layer_id=layer_id, + name=f"Published test layer ({self.timestamp})", + ) + + self.assertIsNotNone(published) + print(f"Layer published: {published['name']}") + + # Step 5: Verify the layer is in the library + print("Verifying layer was added to library...") + + # Wait a moment for library to update + time.sleep(5) + + updated_library = list_library_layers(source="workspace") + + self.assertIsNotNone(updated_library) + + # Try to find our published layer + published_found = any( + layer["name"] == f"Published test layer ({self.timestamp})" + for layer in updated_library["layers"] + ) + + print(f"Published layer found in library: {published_found}") + + if not published_found: + print("Note: Layer may not appear immediately in library listings") + + # Step 6: List all libraries (Felt and workspace) + print("\nListing all libraries (Felt and workspace)...") + + all_libraries = list_library_layers(source="all") + + self.assertIsNotNone(all_libraries) + self.assertIn("layers", all_libraries) + self.assertIn("layer_groups", all_libraries) + + print( + f"Total number of layers in all libraries: {len(all_libraries['layers'])}" + ) + print( + f"Total number of layer groups in all libraries: {len(all_libraries['layer_groups'])}" + ) + + print(f"\nLibrary test completed successfully! Map URL: {map_resp['url']}") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/maps_test.py b/tests/maps_test.py new file mode 100644 index 0000000..8db6092 --- /dev/null +++ b/tests/maps_test.py @@ -0,0 +1,141 @@ +""" +End-to-end test for the Felt Map functionality. +Uses the felt_python library to test the map creation, updates, and other map-related operations. +""" + +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, + get_map, + update_map, + export_comments, + resolve_comment, + delete_comment, + create_embed_token, +) + + +class FeltAPITest(unittest.TestCase): + """Test the Felt API map 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_map_workflow(self): + """Test the complete workflow for map operations.""" + # Step 1: Create a map with timestamp in name for uniqueness + map_name = f"Map Test ({self.timestamp})" + print(f"Creating map: {map_name}...") + + response = create_map( + title=map_name, + lat=40.416775, + lon=-3.70379, # Madrid coordinates + zoom=9, + basemap="light", + description=f"A test map created using the felt-python test at {self.timestamp}", + public_access="private", + ) + + self.assertIsNotNone(response) + self.assertIn("id", response) + self.assertEqual(response["title"], map_name) + + map_id = response["id"] + print(f"Map URL: {response['url']}") + + # Step 2: Get map details + print("Getting map details...") + map_details = get_map(map_id) + + self.assertIsNotNone(map_details) + self.assertEqual(map_details["id"], map_id) + self.assertEqual(map_details["title"], map_name) + self.assertEqual(map_details["public_access"], "private") + + # Step 3: Update the map + updated_name = f"Test Map Updated ({self.timestamp})" + print(f"Updating map to: {updated_name}...") + + updated_map = update_map( + map_id=map_id, + title=updated_name, + description=f"This map was updated through the API test at {self.timestamp}", + public_access="view_only", + ) + + self.assertIsNotNone(updated_map) + + # Verify update by getting map details again + updated_details = get_map(map_id) + + self.assertEqual(updated_details["title"], updated_name) + self.assertEqual(updated_details["public_access"], "view_only") + + # Step 4: Export comments + # Note: There will be no comments on a newly created map + print("Exporting comments...") + + comments = export_comments(map_id) + + self.assertIsNotNone(comments) + self.assertIsInstance(comments, list) + print(f"Found {len(comments)} comments") + + # Step 5 & 6: If there are comments, resolve and delete one + # Note: This section will only run if there are comments + if comments: + comment_id = comments[0]["id"] + + # Resolve comment + print(f"Resolving comment {comment_id}...") + resolve_result = resolve_comment(map_id, comment_id) + self.assertIsNotNone(resolve_result) + self.assertEqual(resolve_result["comment_id"], comment_id) + + # Get updated comments to check if the comment was resolved + updated_comments = export_comments(map_id) + for comment in updated_comments: + if comment["id"] == comment_id: + self.assertTrue(comment["isResolved"]) + break + + # Delete comment + print(f"Deleting comment {comment_id}...") + delete_comment(map_id, comment_id) + + # Verify comment was deleted + updated_comments = export_comments(map_id) + comment_ids = [c["id"] for c in updated_comments] + self.assertNotIn(comment_id, comment_ids) + else: + print("No comments to resolve or delete") + + # Step 7: Create an embed token + print("Creating embed token...") + + token_data = create_embed_token( + map_id=map_id, user_email=f"test.user@example.com" + ) + + self.assertIsNotNone(token_data) + self.assertIn("token", token_data) + self.assertIn("expires_at", token_data) + print(f"Created embed token that expires at {token_data['expires_at']}") + print("\nTest completed successfully!") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/projects_test.py b/tests/projects_test.py new file mode 100644 index 0000000..c8e5373 --- /dev/null +++ b/tests/projects_test.py @@ -0,0 +1,134 @@ +""" +End-to-end test for the Felt Projects functionality. +Uses the felt_python library to test project creation, updating, and related map operations. +""" + +import os +import sys +import unittest +import datetime + +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from felt_python import ( + list_projects, + create_project, + get_project, + update_project, + create_map, + move_map, +) + + +class FeltProjectsTest(unittest.TestCase): + """Test the Felt API projects 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_projects_workflow(self): + """Test the complete workflow for project operations.""" + # Step 1: List available projects + print("Listing available projects...") + + projects = list_projects() + + self.assertIsNotNone(projects) + print(f"Found {len(projects)} projects") + + # Step 2: Create a new project + project_name = f"Test Project ({self.timestamp})" + print(f"Creating project: {project_name}...") + + project = create_project( + name=project_name, visibility="private" # Options: "workspace" or "private" + ) + + self.assertIsNotNone(project) + self.assertIn("id", project) + project_id = project["id"] + print(f"Created project with ID: {project_id}") + + # Step 3: Get project details + print(f"Getting details for project: {project_id}...") + + project_details = get_project(project_id) + + self.assertIsNotNone(project_details) + self.assertEqual(project_details["id"], project_id) + self.assertEqual(project_details["name"], project_name) + self.assertEqual(project_details["visibility"], "private") + print(f"Retrieved project details: {project_details['name']}") + + # Step 4: Update a project + updated_name = f"Test Project Test Updated ({self.timestamp})" + print(f"Updating project to: {updated_name}...") + + updated_project = update_project( + project_id=project_id, + name=updated_name, + visibility="workspace", # Change visibility to workspace-wide + ) + + self.assertIsNotNone(updated_project) + + # Verify update by getting project details again + updated_details = get_project(project_id) + + self.assertEqual(updated_details["name"], updated_name) + self.assertEqual(updated_details["visibility"], "workspace") + print("Project updated successfully") + + # Step 5: Create a map and move it to the project + map_name = f"Map for testing projects ({self.timestamp})" + print(f"Creating map: {map_name}...") + + map_resp = create_map( + title=map_name, + lat=37.7749, + lon=-122.4194, # San Francisco + zoom=12, + public_access="private", + ) + + self.assertIsNotNone(map_resp) + self.assertIn("id", map_resp) + map_id = map_resp["id"] + print(f"Created map with ID: {map_id}") + + # Move the map to our new project + print(f"Moving map to project: {project_id}...") + + moved_map = move_map(map_id=map_id, project_id=project_id) + + self.assertIsNotNone(moved_map) + self.assertEqual(moved_map["id"], map_id) + self.assertEqual(moved_map["project_id"], project_id) + print("Map moved successfully") + + # Step 6: Verify the map was moved + print("Verifying map was moved to project...") + + project_with_map = get_project(project_id) + + # Check if our map is in the project + map_in_project = False + project_maps = project_with_map.get("maps", []) + for project_map in project_maps: + if project_map["id"] == map_id: + map_in_project = True + break + + self.assertTrue(map_in_project, "Map should be in the project") + print(f"Number of maps in project: {len(project_maps)}") + print(f"Our map is in the project: {map_in_project}") + + print(f"\nProjects test completed successfully! Project ID: {project_id}") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/sources_test.py b/tests/sources_test.py new file mode 100644 index 0000000..7a8db85 --- /dev/null +++ b/tests/sources_test.py @@ -0,0 +1,176 @@ +""" +End-to-end test for the Felt Sources functionality. +Uses the felt_python library to test source creation, updating, and map integration. +""" + +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 ( + list_sources, + create_source, + get_source, + update_source, + sync_source, + create_map, + add_source_layer, +) + + +class FeltSourcesTest(unittest.TestCase): + """Test the Felt API sources 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_sources_workflow(self): + """Test the complete workflow for source operations.""" + # Step 1: List available sources + print("Listing available sources...") + sources = list_sources() + + self.assertIsNotNone(sources) + print(f"Found {len(sources)} sources") + + # Step 2: Create a source (WMS/WMTS source as it's public) + source_name = f"Public WMS Source ({self.timestamp})" + print(f"Creating source: {source_name}...") + + wms_source = create_source( + name=source_name, + connection={ + "type": "wms_wmts", + "url": "https://basemap.nationalmap.gov/arcgis/services/USGSTopo/MapServer/WMSServer", + }, + permissions={ + "type": "workspace_editors" # Share with all workspace editors + }, + ) + + self.assertIsNotNone(wms_source) + self.assertIn("id", wms_source) + source_id = wms_source["id"] + print(f"Created source with ID: {source_id}") + + # Step 3: Get source details + print(f"Getting details for source: {source_id}...") + + source_details = get_source(source_id) + + self.assertIsNotNone(source_details) + self.assertEqual(source_details["id"], source_id) + self.assertEqual(source_details["name"], source_name) + print(f"Retrieved source details: {source_details['name']}") + + # Step 4: Update a source + updated_name = f"Updated WMS Source ({self.timestamp})" + print(f"Updating source to: {updated_name}...") + + updated_source = update_source( + source_id=source_id, + name=updated_name, + # Update connection details if needed + connection={ + "type": "wms_wmts", + "url": "https://basemap.nationalmap.gov/arcgis/services/USGSTopo/MapServer/WMSServer", + }, + ) + + self.assertIsNotNone(updated_source) + + # Verify update by getting source details again + updated_details = get_source(source_id) + + self.assertEqual(updated_details["name"], updated_name) + print("Source updated successfully") + + # Step 5: Synchronize a source + print(f"Synchronizing source: {source_id}...") + + synced_source = sync_source(source_id) + + self.assertIsNotNone(synced_source) + print( + f"Source sync initiated with status: {synced_source.get('sync_status', 'unknown')}" + ) + + # Step 6: Create a map and add a layer from the source + print("Creating a map for source layers...") + + map_resp = create_map( + title=f"Map with source layers ({self.timestamp})", + lat=39.8283, + lon=-98.5795, # Center of USA + zoom=4, + public_access="private", + ) + + self.assertIsNotNone(map_resp) + self.assertIn("id", map_resp) + map_id = map_resp["id"] + print(f"Created map with ID: {map_id}") + + # Wait for source synchronization to complete (or timeout) + print("Waiting for source synchronization...") + max_wait_time = 60 # seconds + start_time = time.time() + sync_completed = False + + while time.time() - start_time < max_wait_time: + current_source = get_source(source_id) + sync_status = current_source.get("sync_status") + + if sync_status == "completed": + sync_completed = True + print( + f"Source sync completed in {time.time() - start_time:.1f} seconds" + ) + break + + print(f"Waiting for source sync... Status: {sync_status}") + time.sleep(5) + + # Check for available datasets + print("Checking for available datasets...") + + current_source = get_source(source_id) + + datasets = current_source.get("datasets", []) + + print(f"Available datasets: {len(datasets)}") + for i, dataset in enumerate(datasets[:5]): # Show first 5 datasets + print( + f"- {dataset.get('name', 'Unnamed')} (ID: {dataset.get('id', 'Unknown')})" + ) + + # Step 7: Add a layer from the source (if datasets available) + if datasets: + print("Adding source layer to map...") + dataset_id = datasets[0]["id"] + + layer_result = add_source_layer( + map_id=map_id, + source_layer_params={"from": "dataset", "dataset_id": dataset_id}, + ) + + self.assertIsNotNone(layer_result) + print("Source layer added successfully") + else: + print("No datasets available to add as layers") + + print( + f"\nSources test completed successfully! Source ID: {source_id}, Map URL: {map_resp['url']}" + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/tests.py b/tests/tests.py new file mode 100644 index 0000000..d258d91 --- /dev/null +++ b/tests/tests.py @@ -0,0 +1,53 @@ +""" +Simple test runner for felt-python tests. +""" + +import os +import sys +import unittest + +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# Import test files +from maps_test import FeltAPITest +from elements_test import FeltElementsTest +from layers_test import FeltLayersTest +from layer_groups_test import FeltLayerGroupsTest +from library_test import FeltLibraryTest +from projects_test import FeltProjectsTest +from sources_test import FeltSourcesTest +from delete_test import FeltDeleteTest + + +if __name__ == "__main__": + # Check for API token + if not os.environ.get("FELT_API_TOKEN"): + print("ERROR: FELT_API_TOKEN environment variable not set") + print("export FELT_API_TOKEN='your_api_token'") + sys.exit(1) + + # Create test suite + loader = unittest.TestLoader() + suite = unittest.TestSuite() + + # Add all test classes + test_cases = [ + FeltAPITest, + FeltElementsTest, + FeltLayersTest, + FeltLayerGroupsTest, + FeltLibraryTest, + FeltProjectsTest, + FeltSourcesTest, + FeltDeleteTest, + ] + + for test_case in test_cases: + suite.addTests(loader.loadTestsFromTestCase(test_case)) + + # Run tests + runner = unittest.TextTestRunner(verbosity=2) + result = runner.run(suite) + + # Return appropriate exit code + sys.exit(not result.wasSuccessful()) From f336b3a42837812ae5b01d34715d6e02156b45b8 Mon Sep 17 00:00:00 2001 From: Vince Foley Date: Wed, 9 Apr 2025 15:50:05 -0700 Subject: [PATCH 6/9] Add CI --- .github/workflows/tests.yml | 25 +++++++++++++++++++++++++ tests/layers_test.py | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..5f22c0e --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,25 @@ +name: Felt Python Tests + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: '3.12' + + - name: Run tests + env: + FELT_API_TOKEN: ${{ secrets.FELT_API_TOKEN }} + run: | + python tests/tests.py diff --git a/tests/layers_test.py b/tests/layers_test.py index cff2efc..30c4d89 100644 --- a/tests/layers_test.py +++ b/tests/layers_test.py @@ -211,7 +211,7 @@ def test_layers_workflow(self): self.assertIsNotNone(export_link) self.assertTrue(export_link.startswith("http")) - print(f"Export link obtained: {export_link}") + print("Export link obtained") # Step 9: Create custom export with filters print("Creating custom export...") From 2b425bb74d154910fc24f6b93d4dfe0b8820e89e Mon Sep 17 00:00:00 2001 From: Vince Foley Date: Thu, 10 Apr 2025 11:35:14 -0700 Subject: [PATCH 7/9] Accept BASE_URL from env var for testing --- felt_python/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/felt_python/api.py b/felt_python/api.py index 8dca941..b9123c4 100644 --- a/felt_python/api.py +++ b/felt_python/api.py @@ -17,7 +17,7 @@ from .exceptions import AuthError -BASE_URL = "https://felt.com/api/v2/" +BASE_URL = os.getenv("FELT_BASE_URL", "https://felt.com/api/v2/") def make_request( From 94937792f490666c662ec17d2451d6ff56073d58 Mon Sep 17 00:00:00 2001 From: Vince Foley Date: Fri, 11 Apr 2025 11:52:52 -0700 Subject: [PATCH 8/9] PR feedback --- README.md | 59 +++++++++- felt_python/__init__.py | 2 +- felt_python/elements.py | 13 +-- felt_python/layer_groups.py | 3 +- felt_python/layers.py | 103 +++++++++--------- felt_python/maps.py | 5 +- felt_python/sources.py | 9 +- {docs => notebooks}/elements.ipynb | 4 +- .../null-island-points-sample.geojson | 0 .../fixtures/null-island-points.geojson | 0 .../fixtures/null-island-polygons-wkt.csv | 0 {docs => notebooks}/layer_groups.ipynb | 0 {docs => notebooks}/layers.ipynb | 0 {docs => notebooks}/library.ipynb | 0 {docs => notebooks}/maps.ipynb | 0 {docs => notebooks}/projects.ipynb | 0 {docs => notebooks}/sources.ipynb | 0 tests/elements_test.py | 6 +- 18 files changed, 124 insertions(+), 80 deletions(-) rename {docs => notebooks}/elements.ipynb (98%) rename {docs => notebooks}/fixtures/null-island-points-sample.geojson (100%) rename {docs => notebooks}/fixtures/null-island-points.geojson (100%) rename {docs => notebooks}/fixtures/null-island-polygons-wkt.csv (100%) rename {docs => notebooks}/layer_groups.ipynb (100%) rename {docs => notebooks}/layers.ipynb (100%) rename {docs => notebooks}/library.ipynb (100%) rename {docs => notebooks}/maps.ipynb (100%) rename {docs => notebooks}/projects.ipynb (100%) rename {docs => notebooks}/sources.ipynb (100%) diff --git a/README.md b/README.md index f2f4d3e..704cbcf 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,9 @@ refreshing files and (Geo)DataFrames or updating layer styles and element proper pip install felt-python ``` -## Documentation +## Notebooks -See the [docs](/docs) directory for Juypter notebooks with complete examples of using the API. +See the [notebooks](/notebooks) directory for Juypter notebooks with complete examples of using the API. ## Basic Usage @@ -68,5 +68,60 @@ upload = upload_file( layer_id = upload["layer_id"] ``` +### Uploading a Pandas DataFrame +```python +import pandas as pd +from felt_python import upload_dataframe + +df = pd.read_csv("path/to/file.csv") +upload_dataframe( + map_id=map_id, + dataframe=df, + layer_name="Felt <3 Pandas", +) +``` + +### Uploading a GeoPandas GeoDataFrame +```python +import geopandas as gpd +from felt_python import upload_geodataframe + +gdf = gpd.read_file("path/to/file.shp") +upload_geodataframe( + map_id=map_id, + geodataframe=gdf, + layer_name="Felt <3 GeoPandas", +) +``` + +### Refreshing a layer +```python +from felt_python import refresh_file_layer + +refresh_file_layer( + map_id=map_id, + layer_id=layer_id, + file_path="path/to/new_file.csv", +) +``` + +### Styling a layer +```python +from felt_python import get_layer_details, update_layer_style + +current_style = get_layer_details( + map_id=map_id, + layer_id=layer_id, +)["style"] +new_style = current_style.copy() +new_style["color"] = "#FF0000" +new_style["size"] = 20 +update_layer_style( + map_id=map_id, + layer_id=layer_id, + style=new_style, +) +``` + ## Support We are always eager to hear from you. Reach out to support@felt.com for all your Felt support needs. diff --git a/felt_python/__init__.py b/felt_python/__init__.py index e695a0f..31060f6 100644 --- a/felt_python/__init__.py +++ b/felt_python/__init__.py @@ -35,7 +35,7 @@ list_element_groups, upsert_elements, delete_element, - show_element_group, + get_element_group, create_element_groups, # Deprecated: post_elements, diff --git a/felt_python/elements.py b/felt_python/elements.py index b9168ab..6eed0ef 100644 --- a/felt_python/elements.py +++ b/felt_python/elements.py @@ -1,7 +1,6 @@ """Elements and element groups""" import json -from typing import Dict, Any, List, Union from urllib.parse import urljoin @@ -51,10 +50,8 @@ def list_element_groups(map_id: str, api_token: str | None = None): return json.load(response) -def show_element_group( - map_id: str, element_group_id: str, api_token: str | None = None -): - """Show all elements in a group +def get_element_group(map_id: str, element_group_id: str, api_token: str | None = None): + """Get contents of an element group Args: map_id: The ID of the map containing the group @@ -72,11 +69,11 @@ def show_element_group( return json.load(response) -@deprecated(reason="Please use `show_element_group` instead") +@deprecated(reason="Please use `get_element_group` instead") def list_elements_in_group( map_id: str, element_group_id: str, api_token: str | None = None ): - show_element_group(map_id, element_group_id, api_token) + get_element_group(map_id, element_group_id, api_token) @deprecated(reason="Please use `upsert_elements` instead") @@ -140,7 +137,7 @@ def post_element_group( def create_element_groups( map_id: str, - element_groups: List[Dict[str, Any]], + element_groups: list[dict[str, str]], api_token: str | None = None, ): """Post multiple element groups diff --git a/felt_python/layer_groups.py b/felt_python/layer_groups.py index 079623c..e96df51 100644 --- a/felt_python/layer_groups.py +++ b/felt_python/layer_groups.py @@ -1,7 +1,6 @@ """Layer groups""" import json -from typing import Dict, Any, List, Union, Optional from urllib.parse import urljoin @@ -58,7 +57,7 @@ def get_layer_group( def update_layer_groups( map_id: str, - layer_group_params_list: List[Dict[str, Any]], + layer_group_params_list: list[dict[str, str | int]], api_token: str | None = None, ): """Update multiple layer groups at once diff --git a/felt_python/layers.py b/felt_python/layers.py index 844ecb5..e911739 100644 --- a/felt_python/layers.py +++ b/felt_python/layers.py @@ -7,7 +7,7 @@ import typing import urllib.request import uuid -from typing import Dict, Any, List, Union, Optional +import typing from urllib.parse import urljoin @@ -38,37 +38,12 @@ def list_layers(map_id: str, api_token: str | None = None): return json.load(response) -def _multipart_request( - url: str, presigned_attributes: dict[str, str], file_obj: typing.IO[bytes] -) -> urllib.request.Request: - """Make a multipart/form-data request with the given file""" - boundary = "-" * 20 + str(uuid.uuid4()) - headers = {"Content-Type": f'multipart/form-data; boundary="{boundary}"'} - fname = os.path.basename(file_obj.name) - - data = io.BytesIO() - text = io.TextIOWrapper(data, encoding="latin-1") - for key, value in presigned_attributes.items(): - text.write(f"--{boundary}\r\n") - text.write(f'Content-Disposition: form-data; name="{key}"\r\n\r\n') - text.write(f"{value}\r\n") - text.write(f"--{boundary}\r\n") - text.write(f'Content-Disposition: form-data; name="file"; filename="{fname}"\r\n') - text.write("Content-Type: application/octet-stream\r\n\r\n") - text.flush() - data.write(file_obj.read()) - data.write(f"\r\n--{boundary}".encode("latin-1")) - body = data.getvalue() - - return urllib.request.Request(url, data=body, headers=headers, method="POST") - - def upload_file( map_id: str, file_name: str, layer_name: str, - metadata: Dict[str, Any] = None, - hints: List[Dict[str, Any]] = None, + metadata: dict[str, str] = None, + hints: list[dict[str, str]] = None, lat: float = None, lng: float = None, zoom: float = None, @@ -109,23 +84,16 @@ def upload_file( api_token=api_token, json=json_payload, ) - presigned_upload = json.load(response) - url = presigned_upload["url"] - presigned_attributes = presigned_upload["presigned_attributes"] - with open(file_name, "rb") as file_obj: - request = _multipart_request(url, presigned_attributes, file_obj) - urllib.request.urlopen(request) - - return presigned_upload + return _upload_file(json.load(response), file_name) def upload_dataframe( map_id: str, dataframe: "pd.DataFrame", layer_name: str, - metadata: Dict[str, Any] = None, - hints: List[Dict[str, Any]] = None, + metadata: dict[str, str] = None, + hints: list[dict[str, str]] = None, api_token: str | None = None, ): """Upload a Pandas DataFrame to a Felt map""" @@ -146,8 +114,8 @@ def upload_geodataframe( map_id: str, geodataframe: "gpd.GeoDataFrame", layer_name: str, - metadata: Dict[str, Any] = None, - hints: List[Dict[str, Any]] = None, + metadata: dict[str, str] = None, + hints: list[dict[str, str]] = None, api_token: str | None = None, ): """Upload a GeoPandas GeoDataFrame to a Felt map""" @@ -183,23 +151,15 @@ def refresh_file_layer( method="POST", api_token=api_token, ) - presigned_upload = json.load(response) - url = presigned_upload["url"] - presigned_attributes = presigned_upload["presigned_attributes"] - - with open(file_name, "rb") as file_obj: - request = _multipart_request(url, presigned_attributes, file_obj) - urllib.request.urlopen(request) - - return presigned_upload + return _upload_file(json.load(response), file_name) def upload_url( map_id: str, layer_url: str, layer_name: str, - metadata: Dict[str, Any] = None, - hints: List[Dict[str, Any]] = None, + metadata: dict[str, str] = None, + hints: list[dict[str, str]] = None, api_token: str | None = None, ): """Upload a URL to a Felt map @@ -324,7 +284,7 @@ def download_layer( def update_layers( map_id: str, - layer_params_list: List[Dict[str, Any]], + layer_params_list: list[dict[str, object]], api_token: str | None = None, ): """Update multiple layers at once @@ -396,7 +356,7 @@ def create_custom_export( map_id: str, layer_id: str, output_format: str, - filters: List[Dict[str, Any]] = None, + filters: list = None, email_on_completion: bool = True, api_token: str | None = None, ): @@ -462,7 +422,7 @@ def get_custom_export_status( def duplicate_layers( - duplicate_params: List[Dict[str, str]], api_token: str | None = None + duplicate_params: list[dict[str, str]], api_token: str | None = None ): """Duplicate layers from one map to another @@ -486,3 +446,38 @@ def duplicate_layers( api_token=api_token, ) return json.load(response) + + +def _upload_file(presigned_upload, file_name): + url = presigned_upload["url"] + presigned_attributes = presigned_upload["presigned_attributes"] + + with open(file_name, "rb") as file_obj: + request = _multipart_request(url, presigned_attributes, file_obj) + urllib.request.urlopen(request) + return presigned_upload + + +def _multipart_request( + url: str, presigned_attributes: dict[str, str], file_obj: typing.IO[bytes] +) -> urllib.request.Request: + """Make a multipart/form-data request with the given file""" + boundary = "-" * 20 + str(uuid.uuid4()) + headers = {"Content-Type": f'multipart/form-data; boundary="{boundary}"'} + fname = os.path.basename(file_obj.name) + + data = io.BytesIO() + text = io.TextIOWrapper(data, encoding="latin-1") + for key, value in presigned_attributes.items(): + text.write(f"--{boundary}\r\n") + text.write(f'Content-Disposition: form-data; name="{key}"\r\n\r\n') + text.write(f"{value}\r\n") + text.write(f"--{boundary}\r\n") + text.write(f'Content-Disposition: form-data; name="file"; filename="{fname}"\r\n') + text.write("Content-Type: application/octet-stream\r\n\r\n") + text.flush() + data.write(file_obj.read()) + data.write(f"\r\n--{boundary}".encode("latin-1")) + body = data.getvalue() + + return urllib.request.Request(url, data=body, headers=headers, method="POST") diff --git a/felt_python/maps.py b/felt_python/maps.py index ae0eb89..11b41ac 100644 --- a/felt_python/maps.py +++ b/felt_python/maps.py @@ -1,7 +1,6 @@ """Maps""" import json -from typing import Dict, Any, List, Union, Optional from urllib.parse import urljoin @@ -25,7 +24,7 @@ def create_map( lat: float = None, lon: float = None, zoom: float = None, - layer_urls: List[str] = None, + layer_urls: list[str] = None, workspace_id: str = None, api_token: str = None, ): @@ -208,7 +207,7 @@ def create_embed_token(map_id: str, user_email: str = None, api_token: str = Non def add_source_layer( - map_id: str, source_layer_params: Dict[str, Any], api_token: str = None + map_id: str, source_layer_params: dict[str, str], api_token: str = None ): """Add a layer from a source to a map diff --git a/felt_python/sources.py b/felt_python/sources.py index cffe4bf..c811153 100644 --- a/felt_python/sources.py +++ b/felt_python/sources.py @@ -3,7 +3,6 @@ import json from urllib.parse import urljoin -from typing import Dict, Any, List, Union from .api import make_request, BASE_URL @@ -29,8 +28,8 @@ def list_sources(workspace_id: str | None = None, api_token: str | None = None): def create_source( name: str, - connection: Dict[str, Any], - permissions: Dict[str, Any] = None, + connection: dict[str, str], + permissions: dict[str, str] = None, api_token: str | None = None, ): """Create a new source @@ -70,8 +69,8 @@ def get_source(source_id: str, api_token: str | None = None): def update_source( source_id: str, name: str | None = None, - connection: Dict[str, Any] | None = None, - permissions: Dict[str, Any] | None = None, + connection: dict[str, str] | None = None, + permissions: dict[str, str] | None = None, api_token: str | None = None, ): """Update a source's details diff --git a/docs/elements.ipynb b/notebooks/elements.ipynb similarity index 98% rename from docs/elements.ipynb rename to notebooks/elements.ipynb index fec30e3..b778a16 100644 --- a/docs/elements.ipynb +++ b/notebooks/elements.ipynb @@ -20,7 +20,7 @@ " delete_map,\n", " list_elements,\n", " list_element_groups,\n", - " show_element_group,\n", + " get_element_group,\n", " upsert_elements,\n", " delete_element,\n", " create_element_groups\n", @@ -230,7 +230,7 @@ "metadata": {}, "outputs": [], "source": [ - "group_elements = show_element_group(map_id, element_group_id)\n", + "group_elements = get_element_group(map_id, element_group_id)\n", "group_elements" ] }, diff --git a/docs/fixtures/null-island-points-sample.geojson b/notebooks/fixtures/null-island-points-sample.geojson similarity index 100% rename from docs/fixtures/null-island-points-sample.geojson rename to notebooks/fixtures/null-island-points-sample.geojson diff --git a/docs/fixtures/null-island-points.geojson b/notebooks/fixtures/null-island-points.geojson similarity index 100% rename from docs/fixtures/null-island-points.geojson rename to notebooks/fixtures/null-island-points.geojson diff --git a/docs/fixtures/null-island-polygons-wkt.csv b/notebooks/fixtures/null-island-polygons-wkt.csv similarity index 100% rename from docs/fixtures/null-island-polygons-wkt.csv rename to notebooks/fixtures/null-island-polygons-wkt.csv diff --git a/docs/layer_groups.ipynb b/notebooks/layer_groups.ipynb similarity index 100% rename from docs/layer_groups.ipynb rename to notebooks/layer_groups.ipynb diff --git a/docs/layers.ipynb b/notebooks/layers.ipynb similarity index 100% rename from docs/layers.ipynb rename to notebooks/layers.ipynb diff --git a/docs/library.ipynb b/notebooks/library.ipynb similarity index 100% rename from docs/library.ipynb rename to notebooks/library.ipynb diff --git a/docs/maps.ipynb b/notebooks/maps.ipynb similarity index 100% rename from docs/maps.ipynb rename to notebooks/maps.ipynb diff --git a/docs/projects.ipynb b/notebooks/projects.ipynb similarity index 100% rename from docs/projects.ipynb rename to notebooks/projects.ipynb diff --git a/docs/sources.ipynb b/notebooks/sources.ipynb similarity index 100% rename from docs/sources.ipynb rename to notebooks/sources.ipynb diff --git a/tests/elements_test.py b/tests/elements_test.py index 2278b03..6aa2c9f 100644 --- a/tests/elements_test.py +++ b/tests/elements_test.py @@ -14,7 +14,7 @@ create_map, list_elements, list_element_groups, - show_element_group, + get_element_group, upsert_elements, create_element_groups, ) @@ -148,7 +148,7 @@ def test_elements_workflow(self): # Step 8: List elements in a specific group print(f"Listing elements in group: {cities_group_id}...") - group_elements = show_element_group(map_id, cities_group_id) + group_elements = get_element_group(map_id, cities_group_id) self.assertIsNotNone(group_elements) self.assertEqual(len(group_elements["features"]), 2) @@ -186,7 +186,7 @@ def test_elements_workflow(self): self.assertEqual(len(parks_response["features"]), 2) # Verify elements were added to the parks group - parks_group_elements = show_element_group(map_id, parks_group_id) + parks_group_elements = get_element_group(map_id, parks_group_id) self.assertEqual(len(parks_group_elements["features"]), 2) print( From b75eb992ee3d0a751e35f85bf33cce66516e329a Mon Sep 17 00:00:00 2001 From: Vince Foley Date: Fri, 11 Apr 2025 12:28:17 -0700 Subject: [PATCH 9/9] Fix a few function names --- README.md | 4 ++-- felt_python/__init__.py | 9 ++++++--- felt_python/elements.py | 6 +++--- felt_python/layers.py | 6 ++++++ notebooks/elements.ipynb | 4 ++-- tests/delete_test.py | 4 ++-- tests/elements_test.py | 4 ++-- 7 files changed, 23 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 704cbcf..e3702d3 100644 --- a/README.md +++ b/README.md @@ -107,9 +107,9 @@ refresh_file_layer( ### Styling a layer ```python -from felt_python import get_layer_details, update_layer_style +from felt_python import get_layer, update_layer_style -current_style = get_layer_details( +current_style = get_layer( map_id=map_id, layer_id=layer_id, )["style"] diff --git a/felt_python/__init__.py b/felt_python/__init__.py index 31060f6..de5081f 100644 --- a/felt_python/__init__.py +++ b/felt_python/__init__.py @@ -1,4 +1,4 @@ -# For now, hoist all functions to the top level +# Hoist all functions to the top level from .maps import ( create_map, delete_map, @@ -29,6 +29,8 @@ create_custom_export, get_custom_export_status, duplicate_layers, + # Deprecated + get_layer_details, ) from .elements import ( list_elements, @@ -36,7 +38,7 @@ upsert_elements, delete_element, get_element_group, - create_element_groups, + upsert_element_groups, # Deprecated: post_elements, post_element_group, @@ -117,7 +119,7 @@ "list_elements_in_group", "upsert_elements", "delete_element", - "create_element_groups", + "upsert_element_groups", # Projects "list_projects", "create_project", @@ -145,4 +147,5 @@ "post_elements", "post_element_group", "get_map_details", + "get_layer_details", ] diff --git a/felt_python/elements.py b/felt_python/elements.py index 6eed0ef..cd92c68 100644 --- a/felt_python/elements.py +++ b/felt_python/elements.py @@ -126,16 +126,16 @@ def delete_element(map_id: str, element_id: str, api_token: str | None = None): ) -@deprecated(reason="Please use `create_element_groups` instead") +@deprecated(reason="Please use `upsert_element_groups` instead") def post_element_group( map_id: str, json_element: dict | str, api_token: str | None = None, ): - create_element_groups(map_id, json_element, api_token) + upsert_element_groups(map_id, json_element, api_token) -def create_element_groups( +def upsert_element_groups( map_id: str, element_groups: list[dict[str, str]], api_token: str | None = None, diff --git a/felt_python/layers.py b/felt_python/layers.py index e911739..bd52692 100644 --- a/felt_python/layers.py +++ b/felt_python/layers.py @@ -12,6 +12,7 @@ from urllib.parse import urljoin from .api import make_request, BASE_URL +from .util import deprecated LAYERS = urljoin(BASE_URL, "maps/{map_id}/layers") @@ -207,6 +208,11 @@ def refresh_url_layer(map_id: str, layer_id: str, api_token: str | None = None): return json.load(response) +@deprecated(reason="Please use `get_layer` instead") +def get_layer_details(map_id: str, api_token: str | None = None): + get_layer(map_id, api_token) + + def get_layer( map_id: str, layer_id: str, diff --git a/notebooks/elements.ipynb b/notebooks/elements.ipynb index b778a16..3cd490c 100644 --- a/notebooks/elements.ipynb +++ b/notebooks/elements.ipynb @@ -23,7 +23,7 @@ " get_element_group,\n", " upsert_elements,\n", " delete_element,\n", - " create_element_groups\n", + " upsert_element_groups\n", ")\n", "\n", "os.environ[\"FELT_API_TOKEN\"] = \"\"" @@ -176,7 +176,7 @@ " }\n", "]\n", "\n", - "created_groups = create_element_groups(map_id, multiple_groups)\n", + "created_groups = upsert_element_groups(map_id, multiple_groups)\n", "created_groups" ] }, diff --git a/tests/delete_test.py b/tests/delete_test.py index cd03695..17a574d 100644 --- a/tests/delete_test.py +++ b/tests/delete_test.py @@ -20,7 +20,7 @@ upsert_elements, delete_element, list_element_groups, - create_element_groups, + upsert_element_groups, # Layers get_layer, upload_file, @@ -169,7 +169,7 @@ def test_resource_deletion(self): } ] - element_group_resp = create_element_groups(map_id, element_groups) + element_group_resp = upsert_element_groups(map_id, element_groups) self.assertIsNotNone(element_group_resp) element_group_id = element_group_resp[0]["id"] diff --git a/tests/elements_test.py b/tests/elements_test.py index 6aa2c9f..e0c4a48 100644 --- a/tests/elements_test.py +++ b/tests/elements_test.py @@ -16,7 +16,7 @@ list_element_groups, get_element_group, upsert_elements, - create_element_groups, + upsert_element_groups, ) @@ -118,7 +118,7 @@ def test_elements_workflow(self): {"name": "Parks", "symbol": "tree", "color": "#00AA55"}, ] - created_groups = create_element_groups(map_id, element_groups) + created_groups = upsert_element_groups(map_id, element_groups) self.assertIsNotNone(created_groups) self.assertEqual(len(created_groups), 2)