From 8b19561ed677ece27e25335c459248e30354799c Mon Sep 17 00:00:00 2001 From: Nathaniel Schmitz Date: Wed, 6 Nov 2024 21:43:18 -0500 Subject: [PATCH] Separate model and view code in the inspector tree --- geemap/coreutils.py | 218 ++++++++++++++++---------- tests/data/feature_tree.json | 50 ++++++ tests/data/image_collection_tree.json | 208 ++++++++++++++++++++++++ tests/data/image_tree.json | 182 +++++++++++++++++++++ tests/fake_ee.py | 41 ++++- tests/test_coreutils.py | 33 ++++ tests/test_map_widgets.py | 6 +- 7 files changed, 646 insertions(+), 92 deletions(-) create mode 100644 tests/data/feature_tree.json create mode 100644 tests/data/image_collection_tree.json create mode 100644 tests/data/image_tree.json diff --git a/geemap/coreutils.py b/geemap/coreutils.py index 0b5e8f0163..c502171145 100644 --- a/geemap/coreutils.py +++ b/geemap/coreutils.py @@ -2,11 +2,11 @@ import os import sys import zipfile +from typing import Any, Dict, List, Optional, Tuple, Union import ee import ipywidgets as widgets from ipytree import Node, Tree -from typing import Union, List, Dict, Optional, Tuple, Any try: from IPython.display import display, Javascript @@ -114,13 +114,82 @@ def ee_initialize( ee.Initialize(**kwargs) -def get_info( +def _new_tree_node( + label: str, + children: Optional[list[dict[str, Any]]] = None, + expanded: bool = False, + top_level: bool = False, +) -> Dict[str, Any]: + """Returns node JSON for an interactive representation of an EE ComputedObject.""" + return { + "label": label, + "children": children or [], + "expanded": expanded, + "topLevel": top_level, + } + + +def _order_items(item_dict: dict[str, Any], ordering_list: list[str]) -> dict[str, Any]: + """Orders dictionary items in a specified order. + + Adapted from https://github.com/google/earthengine-jupyter. + """ + # Keys consist of: + # - keys in ordering_list first, in the correct order; and then + # - keys not in ordering_list, sorted. + keys = [x for x in ordering_list if x in item_dict.keys()] + sorted( + [x for x in item_dict.keys() if x not in ordering_list] + ) + return dict([(key, item_dict[key]) for key in keys]) + + +def _walk_tree( + info: Union[list[Any], dict[str, Any]], opened: bool +) -> list[dict[str, Any]]: + node_list = [] + if isinstance(info, list): + for index, item in enumerate(info): + node_name = f"{index}: {item}" + children = [] + if isinstance(item, dict): + node_name = f"{index}: " + extensions = [] + if "id" in item: + extensions.append(f"\"{item['id']}\"") + if "data_type" in item: + extensions.append(str(item["data_type"]["precision"])) + if "crs" in item: + extensions.append(str(item["crs"])) + if "dimensions" in item: + dimensions = item["dimensions"] + extensions.append(f"{dimensions[0]}x{dimensions[1]} px") + node_name += ", ".join(extensions) + children = _walk_tree(item, opened) + node_list.append(_new_tree_node(node_name, children, expanded=opened)) + elif isinstance(info, dict): + for k, v in info.items(): + if isinstance(v, (list, dict)): + if k == "properties": + k = f"properties: Object ({len(v)} properties)" + elif k == "bands": + k = f"bands: List ({len(v)} elements)" + node_list.append( + _new_tree_node(f"{k}", _walk_tree(v, opened), expanded=opened) + ) + else: + node_list.append(_new_tree_node(f"{k}: {v}", expanded=opened)) + else: + node_list.append(_new_tree_node(f"{info}", expanded=opened)) + return node_list + + +def build_computed_object_tree( ee_object: Union[ee.FeatureCollection, ee.Image, ee.Geometry, ee.Feature], layer_name: str = "", opened: bool = False, - return_node: bool = False, -) -> Union[Node, Tree, None]: - """Print out the information for an Earth Engine object using a tree structure. +) -> dict[str, Any]: + """Return a tree structure representing an EE object. + The source code was adapted from https://github.com/google/earthengine-jupyter. Credits to Tyler Erickson. @@ -129,102 +198,81 @@ def get_info( The Earth Engine object. layer_name (str, optional): The name of the layer. Defaults to "". opened (bool, optional): Whether to expand the tree. Defaults to False. - return_node (bool, optional): Whether to return the widget as ipytree.Node. - If False, returns the widget as ipytree.Tree. Defaults to False. Returns: - Union[Node, Tree, None]: The tree or node representing the Earth Engine - object information. + dict[str, Any]: The node representing the Earth Engine object information. """ - def _order_items(item_dict, ordering_list): - """Orders dictionary items in a specified order. - Adapted from https://github.com/google/earthengine-jupyter. - """ - list_of_tuples = [ - (key, item_dict[key]) - for key in [x for x in ordering_list if x in item_dict.keys()] - ] - - return dict(list_of_tuples) - - def _process_info(info): - node_list = [] - if isinstance(info, list): - for count, item in enumerate(info): - if isinstance(item, (list, dict)): - if "id" in item: - count = f"{count}: \"{item['id']}\"" - if "data_type" in item: - count = f"{count}, {item['data_type']['precision']}" - if "crs" in item: - count = f"{count}, {item['crs']}" - if "dimensions" in item: - dimensions = item["dimensions"] - count = f"{count}, {dimensions[0]}x{dimensions[1]} px" - node_list.append( - Node(f"{count}", nodes=_process_info(item), opened=opened) - ) - else: - node_list.append(Node(f"{count}: {item}", icon="file")) - elif isinstance(info, dict): - for k, v in info.items(): - if isinstance(v, (list, dict)): - if k == "properties": - k = f"properties: Object ({len(v)} properties)" - elif k == "bands": - k = f"bands: List ({len(v)} elements)" - node_list.append( - Node(f"{k}", nodes=_process_info(v), opened=opened) - ) - else: - node_list.append(Node(f"{k}: {v}", icon="file")) - else: - node_list.append(Node(f"{info}", icon="file")) - return node_list - + # Convert EE object props to dicts. It's easier to traverse the nested structure. if isinstance(ee_object, ee.FeatureCollection): ee_object = ee_object.map(lambda f: ee.Feature(None, f.toDictionary())) + layer_info = ee_object.getInfo() if not layer_info: - return None + return {} - props = layer_info.get("properties", {}) - layer_info["properties"] = dict(sorted(props.items())) + # Strip geometries because they're slow to render as text. + if "geometry" in layer_info: + layer_info.pop("geometry") - ordering_list = [] - if "type" in layer_info: - ordering_list.append("type") - ee_type = layer_info["type"] - else: - ee_type = "" + # Sort the keys in layer_info and the nested properties. + if properties := layer_info.get("properties"): + layer_info["properties"] = dict(sorted(properties.items())) + ordering_list = ["type", "id", "version", "bands", "properties"] + layer_info = _order_items(layer_info, ordering_list) - if "id" in layer_info: - ordering_list.append("id") - ee_id = layer_info["id"] - else: - ee_id = "" + ee_type = layer_info.get("type", ee_object.__class__.__name__) - ordering_list.append("version") - ordering_list.append("bands") - ordering_list.append("properties") + band_info = "" + if bands := layer_info.get("bands"): + band_info = f" ({len(bands)} bands)" + if layer_name: + layer_name = f"{layer_name}: " - layer_info = _order_items(layer_info, ordering_list) - nodes = _process_info(layer_info) + return _new_tree_node( + f"{layer_name}{ee_type}{band_info}", + _walk_tree(layer_info, opened), + expanded=opened, + ) - if len(layer_name) > 0: - layer_name = f"{layer_name}: " - if "bands" in layer_info: - band_info = f' ({len(layer_info["bands"])} bands)' - else: - band_info = "" - root_node = Node(f"{layer_name}{ee_type} {band_info}", nodes=nodes, opened=opened) - # root_node.open_icon = "plus-square" - # root_node.open_icon_style = "success" - # root_node.close_icon = "minus-square" - # root_node.close_icon_style = "info" +def get_info( + ee_object: Union[ee.FeatureCollection, ee.Image, ee.Geometry, ee.Feature], + layer_name: str = "", + opened: bool = False, + return_node: bool = False, +) -> Union[Node, Tree, None]: + """Print out the information for an Earth Engine object using a tree structure. + The source code was adapted from https://github.com/google/earthengine-jupyter. + Credits to Tyler Erickson. + + Args: + ee_object (Union[ee.FeatureCollection, ee.Image, ee.Geometry, ee.Feature]): + The Earth Engine object. + layer_name (str, optional): The name of the layer. Defaults to "". + opened (bool, optional): Whether to expand the tree. Defaults to False. + return_node (bool, optional): Whether to return the widget as ipytree.Node. + If False, returns the widget as ipytree.Tree. Defaults to False. + + Returns: + Union[Node, Tree, None]: The tree or node representing the Earth Engine + object information. + """ + + tree_json = build_computed_object_tree(ee_object, layer_name, opened) + + def _create_node(data): + """Create a widget for the computed object tree.""" + node = Node(data.get("label", "Node"), opened=data.get("expanded", False)) + if children := data.get("children"): + for child in children: + node.add_node(_create_node(child)) + else: + node.icon = "file" + node.value = str(data) # Store the entire data as a string + return node + root_node = _create_node(tree_json) if return_node: return root_node else: diff --git a/tests/data/feature_tree.json b/tests/data/feature_tree.json new file mode 100644 index 0000000000..00d5d7bb1d --- /dev/null +++ b/tests/data/feature_tree.json @@ -0,0 +1,50 @@ +{ + "label": "Feature", + "children": [ + { + "label": "type: Feature", + "children": [], + "expanded": false, + "topLevel": false + }, + { + "label": "id: 00000000000000000001", + "children": [], + "expanded": false, + "topLevel": false + }, + { + "label": "properties: Object (4 properties)", + "children": [ + { + "label": "fullname: some-full-name", + "children": [], + "expanded": false, + "topLevel": false + }, + { + "label": "linearid: 110469267091", + "children": [], + "expanded": false, + "topLevel": false + }, + { + "label": "mtfcc: S1400", + "children": [], + "expanded": false, + "topLevel": false + }, + { + "label": "rttyp: some-rttyp", + "children": [], + "expanded": false, + "topLevel": false + } + ], + "expanded": false, + "topLevel": false + } + ], + "expanded": false, + "topLevel": false +} diff --git a/tests/data/image_collection_tree.json b/tests/data/image_collection_tree.json new file mode 100644 index 0000000000..cfdb17e49f --- /dev/null +++ b/tests/data/image_collection_tree.json @@ -0,0 +1,208 @@ +{ + "label": "ImageCollection", + "children": [ + { + "label": "type: ImageCollection", + "children": [], + "expanded": false, + "topLevel": false + }, + { + "label": "bands: List (0 elements)", + "children": [], + "expanded": false, + "topLevel": false + }, + { + "label": "features", + "children": [ + { + "label": "0: \"some/image/id\"", + "children": [ + { + "label": "type: Image", + "children": [], + "expanded": false, + "topLevel": false + }, + { + "label": "bands: List (1 elements)", + "children": [ + { + "label": "0: \"band-1\", int, EPSG:4326, 4x2 px", + "children": [ + { + "label": "id: band-1", + "children": [], + "expanded": false, + "topLevel": false + }, + { + "label": "data_type", + "children": [ + { + "label": "type: PixelType", + "children": [], + "expanded": false, + "topLevel": false + }, + { + "label": "precision: int", + "children": [], + "expanded": false, + "topLevel": false + }, + { + "label": "min: -2", + "children": [], + "expanded": false, + "topLevel": false + }, + { + "label": "max: 2", + "children": [], + "expanded": false, + "topLevel": false + } + ], + "expanded": false, + "topLevel": false + }, + { + "label": "dimensions", + "children": [ + { + "label": "0: 4", + "children": [], + "expanded": false, + "topLevel": false + }, + { + "label": "1: 2", + "children": [], + "expanded": false, + "topLevel": false + } + ], + "expanded": false, + "topLevel": false + }, + { + "label": "crs: EPSG:4326", + "children": [], + "expanded": false, + "topLevel": false + }, + { + "label": "crs_transform", + "children": [ + { + "label": "0: 1", + "children": [], + "expanded": false, + "topLevel": false + }, + { + "label": "1: 0", + "children": [], + "expanded": false, + "topLevel": false + }, + { + "label": "2: -180", + "children": [], + "expanded": false, + "topLevel": false + }, + { + "label": "3: 0", + "children": [], + "expanded": false, + "topLevel": false + }, + { + "label": "4: -1", + "children": [], + "expanded": false, + "topLevel": false + }, + { + "label": "5: 84", + "children": [], + "expanded": false, + "topLevel": false + } + ], + "expanded": false, + "topLevel": false + } + ], + "expanded": false, + "topLevel": false + } + ], + "expanded": false, + "topLevel": false + }, + { + "label": "version: 42", + "children": [], + "expanded": false, + "topLevel": false + }, + { + "label": "id: some/image/id", + "children": [], + "expanded": false, + "topLevel": false + }, + { + "label": "properties: Object (3 properties)", + "children": [ + { + "label": "type_name: Image", + "children": [], + "expanded": false, + "topLevel": false + }, + { + "label": "keywords", + "children": [ + { + "label": "0: keyword-1", + "children": [], + "expanded": false, + "topLevel": false + }, + { + "label": "1: keyword-2", + "children": [], + "expanded": false, + "topLevel": false + } + ], + "expanded": false, + "topLevel": false + }, + { + "label": "thumb: https://some-thumbnail.png", + "children": [], + "expanded": false, + "topLevel": false + } + ], + "expanded": false, + "topLevel": false + } + ], + "expanded": false, + "topLevel": false + } + ], + "expanded": false, + "topLevel": false + } + ], + "expanded": false, + "topLevel": false +} diff --git a/tests/data/image_tree.json b/tests/data/image_tree.json new file mode 100644 index 0000000000..f0c7024995 --- /dev/null +++ b/tests/data/image_tree.json @@ -0,0 +1,182 @@ +{ + "label": "Image (1 bands)", + "children": [ + { + "label": "type: Image", + "children": [], + "expanded": false, + "topLevel": false + }, + { + "label": "id: some/image/id", + "children": [], + "expanded": false, + "topLevel": false + }, + { + "label": "version: 42", + "children": [], + "expanded": false, + "topLevel": false + }, + { + "label": "bands: List (1 elements)", + "children": [ + { + "label": "0: \"band-1\", int, EPSG:4326, 4x2 px", + "children": [ + { + "label": "id: band-1", + "children": [], + "expanded": false, + "topLevel": false + }, + { + "label": "data_type", + "children": [ + { + "label": "type: PixelType", + "children": [], + "expanded": false, + "topLevel": false + }, + { + "label": "precision: int", + "children": [], + "expanded": false, + "topLevel": false + }, + { + "label": "min: -2", + "children": [], + "expanded": false, + "topLevel": false + }, + { + "label": "max: 2", + "children": [], + "expanded": false, + "topLevel": false + } + ], + "expanded": false, + "topLevel": false + }, + { + "label": "dimensions", + "children": [ + { + "label": "0: 4", + "children": [], + "expanded": false, + "topLevel": false + }, + { + "label": "1: 2", + "children": [], + "expanded": false, + "topLevel": false + } + ], + "expanded": false, + "topLevel": false + }, + { + "label": "crs: EPSG:4326", + "children": [], + "expanded": false, + "topLevel": false + }, + { + "label": "crs_transform", + "children": [ + { + "label": "0: 1", + "children": [], + "expanded": false, + "topLevel": false + }, + { + "label": "1: 0", + "children": [], + "expanded": false, + "topLevel": false + }, + { + "label": "2: -180", + "children": [], + "expanded": false, + "topLevel": false + }, + { + "label": "3: 0", + "children": [], + "expanded": false, + "topLevel": false + }, + { + "label": "4: -1", + "children": [], + "expanded": false, + "topLevel": false + }, + { + "label": "5: 84", + "children": [], + "expanded": false, + "topLevel": false + } + ], + "expanded": false, + "topLevel": false + } + ], + "expanded": false, + "topLevel": false + } + ], + "expanded": false, + "topLevel": false + }, + { + "label": "properties: Object (3 properties)", + "children": [ + { + "label": "keywords", + "children": [ + { + "label": "0: keyword-1", + "children": [], + "expanded": false, + "topLevel": false + }, + { + "label": "1: keyword-2", + "children": [], + "expanded": false, + "topLevel": false + } + ], + "expanded": false, + "topLevel": false + }, + { + "label": "thumb: https://some-thumbnail.png", + "children": [], + "expanded": false, + "topLevel": false + }, + { + "label": "type_name: Image", + "children": [], + "expanded": false, + "topLevel": false + } + ], + "expanded": false, + "topLevel": false + } + ], + "expanded": false, + "topLevel": false +} diff --git a/tests/fake_ee.py b/tests/fake_ee.py index 6ec46c39fa..15eb95ee95 100644 --- a/tests/fake_ee.py +++ b/tests/fake_ee.py @@ -26,6 +26,32 @@ def bandNames(self, *_, **__): def reduceRegion(self, *_, **__): return Dictionary({"B1": 42, "B2": 3.14}) + def getInfo(self): + return { + "type": "Image", + "bands": [ + { + "id": "band-1", + "data_type": { + "type": "PixelType", + "precision": "int", + "min": -2, + "max": 2, + }, + "dimensions": [4, 2], + "crs": "EPSG:4326", + "crs_transform": [1, 0, -180, 0, -1, 84], + }, + ], + "version": 42, + "id": "some/image/id", + "properties": { + "type_name": "Image", + "keywords": ["keyword-1", "keyword-2"], + "thumb": "https://some-thumbnail.png", + }, + } + class List: def __init__(self, items, *_, **__): @@ -164,10 +190,10 @@ def getInfo(self, *_, **__): }, "id": "00000000000000000001", "properties": { - "fullname": "", + "fullname": "some-full-name", "linearid": "110469267091", "mtfcc": "S1400", - "rttyp": "", + "rttyp": "some-rttyp", }, } @@ -178,12 +204,19 @@ def __eq__(self, other: object): class ImageCollection: - def __init__(self, *_, **__): - pass + def __init__(self, images: list[Image], *_, **__): + self.images = images def mosaic(self, *_, **__): return Image() + def getInfo(self): + return { + "type": "ImageCollection", + "bands": [], + "features": [f.getInfo() for f in self.images], + } + class Reducer: @classmethod diff --git a/tests/test_coreutils.py b/tests/test_coreutils.py index be08ae3e82..955141538d 100644 --- a/tests/test_coreutils.py +++ b/tests/test_coreutils.py @@ -1,12 +1,17 @@ #!/usr/bin/env python """Tests for `coreutils` module.""" +import json import os import sys +from typing import Any, Dict import unittest from unittest import mock +import ee + from geemap import coreutils +from tests import fake_ee class FakeSecretNotFoundError(Exception): @@ -17,6 +22,16 @@ class FakeNotebookAccessError(Exception): """google.colab.userdata.NotebookAccessError fake.""" +def _read_json_file(path: str) -> dict[str, Any]: + script_dir = os.path.dirname(os.path.abspath(__file__)) + file_path = os.path.join(script_dir, f"data/{path}") + with open(file_path, "r", encoding="utf-8") as f: + return json.load(f) + + +@mock.patch.object(ee, "Feature", fake_ee.Feature) +@mock.patch.object(ee, "Image", fake_ee.Image) +@mock.patch.object(ee, "ImageCollection", fake_ee.ImageCollection) class TestCoreUtils(unittest.TestCase): """Tests for core utilss.""" @@ -54,3 +69,21 @@ def test_get_env_var_colab_fails_fallback_to_env(self): mock_colab.userdata.get.side_effect = FakeNotebookAccessError() self.assertEqual(coreutils.get_env_var("key"), "environ-value") + + def test_build_computed_object_tree_feature(self): + """Tests building a JSON computed object tree for a Feature.""" + tree = coreutils.build_computed_object_tree(ee.Feature({})) + expected = _read_json_file("feature_tree.json") + self.assertEqual(tree, expected) + + def test_build_computed_object_tree_image(self): + """Tests building a JSON computed object tree for an Image.""" + tree = coreutils.build_computed_object_tree(ee.Image(0)) + expected = _read_json_file("image_tree.json") + self.assertEqual(tree, expected) + + def test_build_computed_object_tree_image_collection(self): + """Tests building a JSON computed object tree for an ImageCollection.""" + tree = coreutils.build_computed_object_tree(ee.ImageCollection([ee.Image(0)])) + expected = _read_json_file("image_collection_tree.json") + self.assertEqual(tree, expected) diff --git a/tests/test_map_widgets.py b/tests/test_map_widgets.py index 446723f74c..4a09922f98 100644 --- a/tests/test_map_widgets.py +++ b/tests/test_map_widgets.py @@ -429,14 +429,14 @@ def test_map_click(self): objects_root = self._query_node(self.inspector, "Objects") self.assertIsNotNone(objects_root) - layer_3_root = self._query_node(objects_root, "test-map-3: Feature ") + layer_3_root = self._query_node(objects_root, "test-map-3: Feature") self.assertIsNotNone(layer_3_root) self.assertIsNotNone(self._query_node(layer_3_root, "type: Feature")) self.assertIsNotNone(self._query_node(layer_3_root, "id: 00000000000000000001")) - self.assertIsNotNone(self._query_node(layer_3_root, "fullname: ")) + self.assertIsNotNone(self._query_node(layer_3_root, "fullname: some-full-name")) self.assertIsNotNone(self._query_node(layer_3_root, "linearid: 110469267091")) self.assertIsNotNone(self._query_node(layer_3_root, "mtfcc: S1400")) - self.assertIsNotNone(self._query_node(layer_3_root, "rttyp: ")) + self.assertIsNotNone(self._query_node(layer_3_root, "rttyp: some-rttyp")) def test_map_click_twice(self): """Tests that clicking the map a second time removes the original output."""