Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion pysepal/message/en/locale.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
3 changes: 2 additions & 1 deletion pysepal/message/es/locale.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
3 changes: 2 additions & 1 deletion pysepal/message/fr/locale.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
24 changes: 21 additions & 3 deletions pysepal/sepalwidgets/inputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
)

Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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]
Expand All @@ -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}"
)
Expand Down
112 changes: 112 additions & 0 deletions tests/test_sepalwidgets/test_AssetSelect.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Test the AssetSelect widget."""

import logging
import time
from pathlib import Path
from typing import Callable, List
Expand All @@ -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:
Expand Down Expand Up @@ -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.
Expand Down
Loading