diff --git a/pysepal/message/en/locale.json b/pysepal/message/en/locale.json index 501f5c2b..d106e0f3 100644 --- a/pysepal/message/en/locale.json +++ b/pysepal/message/en/locale.json @@ -44,7 +44,8 @@ "no_access": "It seems like you do not have access to the input asset or it does not exist.", "wrong_type": "The type of the selected asset ({asset_type}) does not match authorized asset type ({allowed}).", "placeholder": "projects/{{project}}/assets/asset_name", - "no_assets": "No user assets found in: '{folder}'" + "no_assets": "No user assets found in: '{folder}'", + "list_failed": "The asset list could not be loaded: {error}" }, "load_table": { "too_small": "The provided file have less than 3 columns. Please provide a complete point file with at least ID, lattitude and longitude columns." diff --git a/pysepal/message/es/locale.json b/pysepal/message/es/locale.json index 012bccce..090a0504 100644 --- a/pysepal/message/es/locale.json +++ b/pysepal/message/es/locale.json @@ -44,7 +44,8 @@ "no_access": "Parece que no tienes acceso al asset seleccionado o este no existe.", "wrong_type": "El tipo del asset seleccionado: ({asset_type}), no coincide con ninguno de los tipos permitidos por el widget: ({allowed}).", "placeholder": "projects/{{project}}/assets/asset_name", - "no_assets": "No se encontraron assets en: '{folder}'" + "no_assets": "No se encontraron assets en: '{folder}'", + "list_failed": "No se pudo cargar la lista de assets: {error}" }, "load_table": { "too_small": "El archivo ingresado contiene menos de 3 columnas. Por favor, selecciona un archivo de puntos completo con al menos las columnas de ID, latitud y longitud." diff --git a/pysepal/message/fr/locale.json b/pysepal/message/fr/locale.json index a619bb98..6236c94c 100644 --- a/pysepal/message/fr/locale.json +++ b/pysepal/message/fr/locale.json @@ -44,7 +44,8 @@ "no_access": "Il semble que vous n'ayez pas accès à l'Asset selectionné ou qu'il n'existe pas.", "wrong_type": "Le type de l'asset selectioné ({asset_type}) ne correspond pas aux type autorisés par le widget ({allowed}).", "placeholder": "", - "no_assets": "" + "no_assets": "", + "list_failed": "La liste des assets n'a pas pu être chargée : {error}" }, "load_table": { "too_small": "Le fichier fourni comporte moins de 3 colonnes. Veuillez fournir un fichier de points complet avec au moins les colonnes ID, lattitude et longitude." diff --git a/pysepal/sepalwidgets/inputs.py b/pysepal/sepalwidgets/inputs.py index 1c2c7524..71aa3bcd 100644 --- a/pysepal/sepalwidgets/inputs.py +++ b/pysepal/sepalwidgets/inputs.py @@ -741,6 +741,7 @@ def __init__( self._loaded = False self.valid = False + self._list_error: Optional[str] = None # Use provided gee_interface or create new one from session if gee_interface: self.gee_interface = gee_interface @@ -808,7 +809,7 @@ def on_finally_validate(): self._tasks["get_items"] = self.gee_interface.create_task( func=self._get_items_async, key="get_items", - on_error=lambda x: self.alert.add_msg(f"Failed to add layer. {x}", type_="error"), + on_error=self._on_get_items_error, on_finally=on_finally_get_items, ) @@ -904,6 +905,15 @@ def _on_validation_error(self, error: Exception) -> None: self.asset_info = {} # Loading will be reset by on_finally_validate + def _on_get_items_error(self, error: Exception) -> None: + """Show a failed asset listing on the widget itself.""" + log.error( + f"[{id(self)}] Failed to list the assets of {self.folder}: {error}", exc_info=error + ) + self._list_error = msg("widgets.asset_select.list_failed", error=str(error)) + self.error_messages = self._list_error + # Loading will be reset by on_finally_get_items + # @sd.switch("loading", "disabled") async def _get_items_async(self, *args, gee_assets: List[dict] = None) -> Self: @@ -939,8 +949,11 @@ def get_log_text(init): asset_info = await self.gee_interface.get_asset_async(default) if asset_info["type"] in self.types: filtered_defaults.append(default) - except Exception: - pass + except Exception as e: + log.warning( + f"[{id(self)}] Dropped default asset {default}, its lookup failed: " + f"{type(e).__name__}: {e}" + ) if filtered_defaults: self.v_model = filtered_defaults[0] @@ -956,6 +969,11 @@ def get_log_text(init): # get_assets_async can return None for an empty/inaccessible folder; keep it iterable raw_assets = gee_assets or await self.gee_interface.get_assets_async(self.folder) or [] + # error_messages also carries validation errors, which a reload must not clear + if self._list_error is not None and self.error_messages == self._list_error: + self.error_messages = None + self._list_error = None + log.debug( f"[{id(self)}] {text} || [[[{id(self)} ]]]Already awaited for get_assets_async, current v_model is {self.v_model}" ) diff --git a/tests/test_sepalwidgets/test_AssetSelect.py b/tests/test_sepalwidgets/test_AssetSelect.py index fb0f1525..38934a39 100644 --- a/tests/test_sepalwidgets/test_AssetSelect.py +++ b/tests/test_sepalwidgets/test_AssetSelect.py @@ -1,5 +1,6 @@ """Test the AssetSelect widget.""" +import logging import time from pathlib import Path from typing import Callable, List @@ -9,6 +10,10 @@ from pysepal import sepalwidgets as sw from pysepal.message import msg +from pysepal.scripts.gee_interface import GEEInterface + +LOGGER = "sepalui.sepalwidgets.inputs" +FOLDER = "projects/tester/assets" def wait_for_async(asset_select: sw.AssetSelect, action: Callable, timeout: float = 10) -> None: @@ -175,6 +180,113 @@ def test_get_items(asset_select: sw.AssetSelect, gee_user_dir: Path) -> None: assert str(gee_user_dir / "image") in asset_select.items +class _FakeGeeInterface(GEEInterface): + """A real task loop whose asset calls answer from memory or raise on demand.""" + + def __init__(self, assets=(), list_error=None, lookups=None): + super().__init__() + self.assets = list(assets) + self.list_error = list_error + self.lookups = lookups or {} + + async def get_assets_async(self, folder=""): + if self.list_error is not None: + raise self.list_error + return self.assets + + async def get_asset_async(self, asset_id, not_exists_ok=False): + answer = self.lookups[asset_id] + if isinstance(answer, Exception): + raise answer + return answer + + +@pytest.fixture +def fake_gee(monkeypatch): + """Build fake interfaces for an AssetSelect that needs no Earth Engine credentials.""" + monkeypatch.setattr("pysepal.scripts.gee.init_ee", lambda: None) + monkeypatch.setattr(ee.data, "is_initialized", lambda: True) + interfaces = [] + + def make(**kwargs): + interfaces.append(_FakeGeeInterface(**kwargs)) + return interfaces[-1] + + yield make + for interface in interfaces: + interface.close() + + +def _wait_until(predicate: Callable[[], bool], timeout: float = 5) -> None: + start_time = time.time() + while not predicate(): + if time.time() - start_time > timeout: + raise TimeoutError("AssetSelect did not reach the expected state in time.") + time.sleep(0.05) + + +def test_a_failed_listing_is_shown_on_the_widget(fake_gee, caplog) -> None: + """The user must see why the list is empty, and the error must reach the log.""" + gee = fake_gee(list_error=RuntimeError("Earth Engine is unreachable")) + + with caplog.at_level(logging.ERROR, logger=LOGGER): + asset_select = sw.AssetSelect(folder=FOLDER, gee_interface=gee) + _wait_until(lambda: not asset_select.loading) + + assert asset_select.error_messages == msg( + "widgets.asset_select.list_failed", error="Earth Engine is unreachable" + ) + assert asset_select.disabled is False + logged = [r.getMessage() for r in caplog.records if r.name == LOGGER] + assert any("Earth Engine is unreachable" in m for m in logged), logged + + +def test_a_successful_reload_clears_the_listing_failure(fake_gee) -> None: + gee = fake_gee( + assets=[{"id": f"{FOLDER}/image", "type": "IMAGE"}], + list_error=RuntimeError("Earth Engine is unreachable"), + ) + asset_select = sw.AssetSelect(folder=FOLDER, gee_interface=gee) + _wait_until(lambda: not asset_select.loading) + assert asset_select.error_messages is not None + + gee.list_error = None + asset_select.fire_event("click:prepend-inner", None) + _wait_until(lambda: asset_select._loaded) + + assert asset_select.error_messages is None + assert f"{FOLDER}/image" in asset_select.items + + +def test_a_failed_default_lookup_is_logged(fake_gee, caplog) -> None: + """A lookup that raises drops the default with a warning; a wrong type drops it quietly.""" + denied, table, image = (f"{FOLDER}/denied", f"{FOLDER}/table", f"{FOLDER}/image") + gee = fake_gee( + lookups={ + denied: PermissionError("caller does not have permission"), + table: {"type": "TABLE"}, + image: {"type": "IMAGE"}, + } + ) + asset_select = sw.AssetSelect(folder=FOLDER, types=["IMAGE"], gee_interface=gee) + _wait_until(lambda: asset_select._loaded) + + with caplog.at_level(logging.WARNING, logger=LOGGER): + asset_select.default_asset = [denied, table, image] + _wait_until(lambda: asset_select._loaded) + + assert asset_select.v_model == image + assert image in asset_select.items + assert denied not in asset_select.items + assert table not in asset_select.items + warnings = [ + r.getMessage() for r in caplog.records if r.name == LOGGER and r.levelno >= logging.WARNING + ] + assert len(warnings) == 1, warnings + assert denied in warnings[0] + assert "caller does not have permission" in warnings[0] + + @pytest.fixture(scope="session") def default_items() -> List[str]: """Some default public data from GEE.