Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
78b8493
build: move to pysepal 4.0.0 (drop the spatial-risk fork pin)
SerafiniJose Sep 23, 2026
44d8998
style: gui/i18n.py
SerafiniJose Sep 23, 2026
1f83fcb
feat(i18n): source the locale from pysepal.i18n
SerafiniJose Sep 23, 2026
7a1c3bb
chore: PYSEPAL_DEV_AUTH replaces SOLARA_TEST
SerafiniJose Sep 23, 2026
6b40e08
fix(dev-auth): correct test to avoid grep self-match and reword prime…
SerafiniJose Sep 23, 2026
2842b23
test: pin the v4 notification-provider contract
SerafiniJose Sep 23, 2026
39762e9
fix(tests): close notification render context in test_tile_mounts_wit…
SerafiniJose Sep 23, 2026
2b32b93
refactor: let pysepal own the theme toggle
SerafiniJose Sep 23, 2026
061736d
feat(aoi): persist AoiSpec and restore through AoiView(spec=)
SerafiniJose Sep 23, 2026
1157a8d
fix(pickers): seed admin and asset pickers through v4 value/codes (dr…
SerafiniJose Sep 23, 2026
73dc910
fix(pickers): anchor SEPAL picker paths to $HOME (pysepal 4.0 gap)
SerafiniJose Sep 23, 2026
d336b73
docs: pysepal 4.0 references
SerafiniJose Sep 23, 2026
3e668ca
chore: install the pysepal plugin at project scope
SerafiniJose Sep 23, 2026
31f4483
fix(aoi): open legacy DRAW projects whose sidecar holds a date attribute
SerafiniJose Sep 23, 2026
adde2d3
chore(deps): state what the pysepal 4.0.0 pin replaces, not a fork ar…
SerafiniJose Sep 23, 2026
14d8652
fix(theme): pipeline header follows pysepal's theme state
SerafiniJose Sep 23, 2026
a1002d1
fix(aoi): heal legacy admin and asset manifests on load
SerafiniJose Sep 23, 2026
e3a6b14
fix(aoi): heal only manifests without an aoi_spec on load
SerafiniJose Sep 23, 2026
80152da
Merge branch 'main' into feat/pysepal-v4
SerafiniJose Oct 6, 2026
f2a515e
build: bump pysepal to 4.1.0
SerafiniJose Oct 6, 2026
7fb493b
test: render the new tile tests under a NotificationProvider
SerafiniJose Oct 6, 2026
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
13 changes: 13 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"extraKnownMarketplaces": {
"pysepal": {
"source": {
"source": "github",
"repo": "openforis/pysepal"
}
}
},
"enabledPlugins": {
"pysepal@pysepal": true
}
}
7 changes: 7 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,11 @@
import os
import sys

# Tests must never attempt a real SEPAL login even if the developer's shell
# exported PYSEPAL_DEV_AUTH. solara/settings.py calls dotenv.load_dotenv(),
# which loads the nearest .env walking up from cwd, and load_dotenv does not
# override existing environment variables — this guard ensures the app defaults
# to a safe state for test isolation.
os.environ["PYSEPAL_DEV_AUTH"] = "0"

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
63 changes: 44 additions & 19 deletions gui/i18n.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
"""Internationalization for the Spatial Risk GUI.

Reuses pysepal's Translator (JSON locale folders with es-ES->en fallback).
The active locale comes from the session-scoped ``LocaleState`` (pysepal),
which the ``LocaleSelect`` widget resolves in the browser (localStorage ->
legacy config seed -> navigator.language -> "en"). The translator is ALWAYS
built with an explicit target so pysepal's config-file read path
(``target=""``) is never taken — translated strings are deterministic under
pytest regardless of ~/.sepal-ui-config. Locale changes swap the translator
reactive, live-re-rendering every component that calls t() during render.
No string may be resolved at import time — always call t()/plural() inside a
component render.
The active locale comes from ``pysepal.i18n`` and is kernel-scoped: the
language selector that ``MapApp.element(locales=...)`` mounts writes it, and
persists a pick in browser localStorage; a first load with no stored pick
follows navigator.language, then English. The translator is always built with
that locale as an explicit target, and ``use_app_locale()`` (called by Page)
swaps the translator reactive when the locale changes, live-re-rendering every
component that calls t() during render. No string may be resolved at import
time — always call t()/plural() inside a component render.

Keeping the Translator-backed t() rather than pysepal's ``catalog()``/``msg()``
is the smallest migration diff: t()'s missing-key fallback and the tests that
inject ``i18n._translator`` keep working. Moving to catalog()/msg() is a
feasible follow-up (a missing key would then raise MissingMessageError).
"""

from pathlib import Path

import solara
from pysepal.i18n import current_locale
from pysepal.translator import Translator

MESSAGES_DIR = Path(__file__).parent / "messages"
Expand All @@ -24,12 +29,11 @@


def _current_locale() -> str:
from pysepal.solara.locale import resolve_locale_state

return resolve_locale_state().locale or "en"
return current_locale() or "en"


def get_translator() -> Translator:
"""Return this session's translator, building it on first use."""
if _translator.value is None:
_translator.value = Translator(MESSAGES_DIR, target=_current_locale())
return _translator.value
Expand All @@ -41,19 +45,39 @@ def set_app_locale(code: str) -> None:


def reset_translator() -> None:
"""Drop the cached translator; it lazily rebuilds from the session
LocaleState. Call from on_kernel_start."""
"""Drop the cached translator.

It lazily rebuilds in pysepal's current locale. Call from on_kernel_start.
"""
_translator.value = None


def use_app_locale() -> str:
"""Render-time hook: re-create the translator when pysepal's locale changes.

Reading ``current_locale()`` subscribes the calling component, so a switch
re-renders it and the effect swaps the translator to the new locale.
"""
locale = current_locale()

def _swap():
set_app_locale(locale)

solara.use_effect(_swap, [locale])
return locale


def t(key: str, /, **fmt) -> str:
"""Resolve a dotted key against the active catalog (es->en->key) and
str.format(**fmt). A key missing in both languages returns the key string
so a gap degrades visibly instead of crashing the GUI.
"""Resolve a dotted key against the active catalog (es->en->key).

The value is interpolated with str.format(**fmt). A key missing in both
languages returns the key string so a gap degrades visibly instead of
crashing the GUI.

``key`` is positional-only so a catalog value may carry a ``{key}``
placeholder passed as a keyword (``key=...``) without colliding with this
lookup parameter."""
lookup parameter.
"""
node = get_translator()
try:
for part in key.split("."):
Expand All @@ -68,7 +92,8 @@ def plural(n: int, one_key: str, other_key: str, /, **fmt) -> str:
"""Pick the singular vs plural key for a count and interpolate n.

The selector args are positional-only so a catalog value may carry an
``{n}``/``{one_key}``/``{other_key}`` placeholder without colliding."""
``{n}``/``{one_key}``/``{other_key}`` placeholder without colliding.
"""
fmt.setdefault("n", n)
return t(one_key if n == 1 else other_key, **fmt)

Expand Down
159 changes: 144 additions & 15 deletions gui/scripts/aoi_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@
AOI stays usable downstream without re-selection; asset AOIs remain
geometry-less on load. See ``write_aoi`` for the boundary.

The picker's own record of the selection is persisted too, as ``aoi_spec``
(pysepal's ``AoiSpec.to_dict()``), so a load restores the picker through
``AoiView(spec=)``. Manifests written before it get a spec synthesized from
their legacy fields (see ``_legacy_spec``).

Kept free of Solara/ipyvuetify so it can be unit-tested without a render
harness; pysepal/geopandas are imported lazily inside the functions.
"""
Expand All @@ -26,7 +31,7 @@
import os
import uuid
from pathlib import Path
from typing import Any, Dict, Optional
from typing import Any, Dict, Optional, Tuple

logger = logging.getLogger("spatial_risk")

Expand All @@ -41,6 +46,44 @@
_DIGEST_GRID_SIZE = 1e-6


def admin_code_chain(admin_code: Any, level: int) -> Optional[Tuple[str, ...]]:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this required?

"""Return every GAUL code from level 0 down to ``admin_code``, or None.

pysepal's admin cascade is restored from the whole chain
(``AoiSpec.admin_codes``, ``AdminLevelSelector(codes=)``), but a pre-v4
manifest (and a stored borders selection) records only the leaf. This
mirrors pysepal's own ``_derive_admin_chain``: a local pygaul parquet
lookup, no Earth Engine. At level 0 the leaf already is the whole chain,
so no lookup is made.

Args:
admin_code: The leaf GAUL 2024 code (str or int).
level: The admin level of ``admin_code`` (0, 1 or 2).

Returns:
The codes for levels ``0..level`` as strings, or None when pygaul cannot
place the code (not a GAUL 2024 code, lookup failure) — a caller then
restores without a cascade rather than failing.
"""
code = str(admin_code)
if level == 0:
return (code,)
try:
import pygaul

row = pygaul.Names(admin=code, complete=True).iloc[0]
return tuple(str(row[f"gaul{lvl}_code"]) for lvl in range(level + 1))
except Exception:
logger.warning(
"Could not derive the GAUL code chain of ADMIN%s code %s; the "
"admin picker cannot be restored to it.",
level,
code,
exc_info=True,
)
return None


def _rebuild_admin_feature_collection(admin_code: str) -> Optional[Any]:
"""Rebuild the lazy GEE FeatureCollection for a persisted admin AOI.

Expand Down Expand Up @@ -171,6 +214,17 @@ def _aoi_metadata(
meta["geometry_file"] = geometry_file
if gdf is not None:
meta["geometry_digest"] = _geometry_digest(gdf)

# The picker's own record of the selection: what load_aoi hands back to
# AoiView(spec=) so the picker shows (and re-runs) it.
spec = getattr(aoi, "spec", None)
if spec is not None:
meta["aoi_spec"] = spec.to_dict()
if spec.method == "ASSET":
# Legacy key, kept for one release so an older app still reads the
# manifest. The full dict, never spec.asset_id: the rebuild does
# ``(asset or {}).get(...)``, which a bare string silently breaks.
meta["asset"] = spec.asset_data()
return meta


Expand Down Expand Up @@ -221,11 +275,7 @@ def write_aoi(project_dir: Path, aoi: Any) -> Optional[Dict[str, Any]]:
if gdf is None:
# Metadata-only AOI (e.g. GEE admin/asset): drop any stale geometry.
sidecar.unlink(missing_ok=True)
meta = _aoi_metadata(aoi, geometry_file=None)
asset = getattr(aoi, "asset", None)
if asset and getattr(aoi, "method", None) == "ASSET":
meta["asset"] = asset
return meta
return _aoi_metadata(aoi, geometry_file=None)

# Normalize to WGS84 so the sidecar matches what zoom_bounds expects.
gdf = _to_wgs84(gdf)
Expand Down Expand Up @@ -354,9 +404,6 @@ def attach_aoi(project: Any, aoi: Any, data_dir: Path) -> bool:
expected = _aoi_metadata(
aoi, geometry_file=AOI_GEOMETRY_FILENAME if has_geometry else None, gdf=gdf
)
asset = getattr(aoi, "asset", None)
if asset and getattr(aoi, "method", None) == "ASSET":
expected["asset"] = asset

manifest_exists = manifest.exists()
committed = _read_manifest_aoi(manifest) if manifest_exists else project.aoi
Expand Down Expand Up @@ -386,6 +433,84 @@ def attach_aoi(project: Any, aoi: Any, data_dir: Path) -> bool:
return True


def _legacy_spec(metadata: Dict[str, Any], gdf: Any) -> Optional[Any]:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this looks like a backwards compatibility shim, for users that created projects with previous schemas, can you confirm if this is actually needed? this might be related to the previous comment

"""Synthesize an ``AoiSpec`` for a manifest written before specs existed.

Every project saved by the pysepal 3.x app is such a manifest, and that
app restored every method into the picker — so dropping the spec would
load them all with a blank picker. Rebuilt from what the old manifest has:

* DRAW: the drawing, from the sidecar geometry (``to_json``, not
``__geo_interface__``: its tuple coordinates would never compare equal
to the JSON lists a round-tripped spec holds). ``default=str`` because
``read_file`` parses an ISO-date-like attribute (a QGIS edit adds one)
into a ``Timestamp``, which ``json`` cannot encode.
* ASSET: the legacy ``asset`` dict, field for field.
* ADMIN0/1/2: the GAUL code chain (:func:`admin_code_chain`).
* SHAPE/POINTS: None — the file path was never stored.

Returns None whenever the inputs are not there; the AOI still loads.
"""
from pysepal.solara.components.aoi import AoiSpec

method = metadata.get("method")
if method == "DRAW" and gdf is not None:
return AoiSpec(
method="DRAW",
name=metadata.get("name") or None,
geo_json=json.loads(gdf.to_json(default=str)),
)
if method == "ASSET":
asset = metadata.get("asset")
if isinstance(asset, dict) and asset.get("asset_id"):
return AoiSpec(
method="ASSET",
asset_id=asset["asset_id"],
asset_type=asset.get("type"),
column=asset.get("column"),
value=asset.get("value"),
)
return None
admin = metadata.get("admin")
if method in _ADMIN_METHODS and admin:
codes = admin_code_chain(admin, int(method[-1]))
return AoiSpec(method=method, admin_codes=codes) if codes else None
return None


def _restore_spec(metadata: Dict[str, Any], gdf: Any) -> Optional[Any]:
"""Return the manifest's ``AoiSpec``, or a synthesized one for old manifests.

A spec this pysepal cannot read (a newer ``schema_version``, a payload with
no method) falls back to the legacy fields instead of failing the load, and
a legacy synthesis that fails returns None: a project must always open,
even if its picker comes back blank.
"""
payload = metadata.get("aoi_spec")
if payload:
from pysepal.solara.components.aoi import AoiSpec

try:
return AoiSpec.from_dict(payload)
except Exception:
logger.warning(
"Could not read the saved AOI selection (%r); rebuilding it from "
"the legacy AOI fields instead.",
payload.get("schema_version") if isinstance(payload, dict) else None,
exc_info=True,
)
try:
return _legacy_spec(metadata, gdf)
except Exception:
logger.warning(
"Could not rebuild the AOI selection from the legacy %r fields; the "
"AOI loads with a blank picker.",
metadata.get("method"),
exc_info=True,
)
return None


def load_aoi(project_dir: Path, metadata: Optional[Dict[str, Any]]) -> Optional[Any]:
"""Reconstruct an ``AoiResult`` from persisted metadata + sidecar geometry.

Expand All @@ -398,7 +523,10 @@ def load_aoi(project_dir: Path, metadata: Optional[Dict[str, Any]]) -> Optional[
Vector AOIs carry their ``gdf`` (from the sidecar). GEE admin AOIs carry
no sidecar — their lazy EE ``feature_collection`` is rebuilt from the
persisted ``admin`` code (``gdf`` stays None). ``feature_collection`` is
None only when neither applies or the rebuild fails.
None only when neither applies or the rebuild fails. ``spec`` holds the
picker's selection for ``AoiView(spec=)`` — read from the manifest, or
synthesized for a pre-spec one (see :func:`_legacy_spec`) — and is None
when there is nothing to restore the picker to.
"""
if not metadata:
return None
Expand All @@ -417,16 +545,19 @@ def load_aoi(project_dir: Path, metadata: Optional[Dict[str, Any]]) -> Optional[
if path.exists():
gdf = gpd.read_file(path)

spec = _restore_spec(metadata, gdf)

# GEE admin selections (ADMIN0/1/2) carry no geometry sidecar — only the
# GAUL ``admin`` code is persisted. Rebuild the lazy EE FeatureCollection so
# the restored AOI has usable geometry; otherwise it loads "present" but
# empty and the Variables step fails with "no usable geometry — re-select
# the area", forcing the user to reselect just to continue.
feature_collection = None
asset = spec.asset_data() if spec is not None else metadata.get("asset")
if gdf is None and gee and admin and method in _ADMIN_METHODS:
feature_collection = _rebuild_admin_feature_collection(admin)
elif gee and method == "ASSET" and metadata.get("asset"):
feature_collection = _rebuild_asset_feature_collection(metadata["asset"])
elif gee and method == "ASSET" and asset:
feature_collection = _rebuild_asset_feature_collection(asset)

return AoiResult(
method=method,
Expand All @@ -435,7 +566,5 @@ def load_aoi(project_dir: Path, metadata: Optional[Dict[str, Any]]) -> Optional[
feature_collection=feature_collection,
admin=admin,
gee=gee,
# ASSET picker inputs round-trip on the result so AoiView can restore
# the asset field on load.
asset=metadata.get("asset"),
spec=spec,
)
Loading