Skip to content

Commit

Permalink
add an Asset manager widget (#25)
Browse files Browse the repository at this point in the history
  • Loading branch information
12rambau authored Feb 25, 2024
2 parents bf0ae47 + 45ce0ac commit 26a17e3
Show file tree
Hide file tree
Showing 12 changed files with 251 additions and 73 deletions.
3 changes: 3 additions & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
"name": "Python 3",
"image": "mcr.microsoft.com/devcontainers/python:1-3.11-bullseye",
"features": {
"ghcr.io/devcontainers/features/python:1": {
"installJupyterlab": true
},
"ghcr.io/devcontainers-contrib/features/nox:2": {},
"ghcr.io/devcontainers-contrib/features/pre-commit:2": {}
},
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/unit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ on:
pull_request:

env:
EARTHENGINE_TOKEN: ${{ secrets.EARTHENGINE_TOKEN }}
EARTHENGINE_PROJECT: ${{ secrets.EARTHENGINE_PROJECT }}
EARTHENGINE_SERVICE_ACCOUNT: ${{ secrets.EARTHENGINE_SERVICE_ACCOUNT }}

jobs:
lint:
Expand Down
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -134,4 +134,7 @@ dmypy.json
.vscode/

# image tmp file
*Zone.Identifier
*Zone.Identifier

# experimental notebooks for visual debugging
test.ipynb
11 changes: 6 additions & 5 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,12 @@ repos:
- id: doc8
stages: [commit]

- repo: https://github.com/FHPythonUtils/LicenseCheck
rev: "2023.5.1"
hooks:
- id: licensecheck
stages: [commit]
# crash on ipyvuetify
#- repo: https://github.com/FHPythonUtils/LicenseCheck
# rev: "2023.5.1"
# hooks:
# - id: licensecheck
# stages: [commit]

- repo: https://github.com/codespell-project/codespell
rev: v2.2.4
Expand Down
43 changes: 1 addition & 42 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,7 @@
"""

# -- Path setup ----------------------------------------------------------------
import os
import re
from datetime import datetime
from pathlib import Path

import ee
import httplib2

# -- Project information -------------------------------------------------------
project = "ipygee"
Expand Down Expand Up @@ -55,7 +49,7 @@
],
}
html_context = {
"github_user": "12rambau",
"github_user": "gee-community",
"github_repo": "ipygee",
"github_version": "",
"doc_path": "docs",
Expand All @@ -70,38 +64,3 @@

# -- Options for intersphinx output --------------------------------------------
intersphinx_mapping = {}

# -- Script to authenticate to Earthengine using a token -----------------------
def gee_configure() -> None:
"""Initialize earth engine according to the environment.
It will use the creddential file if the EARTHENGINE_TOKEN env variable exist.
Otherwise it use the simple Initialize command (asking the user to register if necessary).
"""
# only do the initialization if the credential are missing
if not ee.data._credentials:

# if the credentials token is asved in the environment use it
if "EARTHENGINE_TOKEN" in os.environ:

# get the token from environment variable
ee_token = os.environ["EARTHENGINE_TOKEN"]

# as long as RDT quote the token, we need to remove the quotes before writing
# the string to the file
pattern = r"^'[^']*'$"
if re.match(pattern, ee_token) is not None:
ee_token = ee_token[1:-1]

# write the token to the appropriate folder
credential_folder_path = Path.home() / ".config" / "earthengine"
credential_folder_path.mkdir(parents=True, exist_ok=True)
credential_file_path = credential_folder_path / "credentials"
credential_file_path.write_text(ee_token)

# if the user is in local development the authentication should
# already be available
ee.Initialize(http_transport=httplib2.Http())


gee_configure()
15 changes: 0 additions & 15 deletions ipygee/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,3 @@
__version__ = "0.0.0"
__author__ = "Pierrick Rambaud"
__email__ = "pierrick.rambaud49@gmail.com"


class Hello:
"""Hello world class."""

msg = "hello world !"
"the message to print"

def hello_world(self) -> str:
"""Hello world demo method.
Returns:
the hello world string
"""
return self.msg
167 changes: 167 additions & 0 deletions ipygee/asset.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
"""The asset manager widget code and functionalities."""
from __future__ import annotations

from typing import List

import ee
import geetools # noqa
import ipyvuetify as v
import traitlets as t
from google.cloud.resourcemanager import ProjectsClient
from natsort import humansorted

from .decorator import switch

ICON_STYLE = {
"PARENT": {"color": "black", "icon": "mdi-folder-open"},
"PROJECT": {"color": "red", "icon": "mdi-google-cloud"},
"FOLDER": {"color": "grey", "icon": "mdi-folder"},
"IMAGE": {"color": "purple", "icon": "mdi-image-outline"},
"IMAGE_COLLECTION": {"color": "purple", "icon": "mdi-image-multiple-outline"},
"TABLE": {"color": "green", "icon": "mdi-table"},
"FEATURE_COLLECTION": {"color": "green", "icon": "mdi-tabe"},
}
"The style to apply to each object"


class AssetManager(v.Flex):
"""A asset manager widget."""

# -- Variables -------------------------------------------------------------

folder: t.Unicode = t.Unicode(".").tag(sync=True)
"The current folder that the user see"

selected_item: t.Unicode = t.Unicode("").tag(sync=True)
"The selected item of the asset manager"

# -- Widgets ---------------------------------------------------------------

w_new: v.Btn
"The new btn on the top of the asset manager"

w_reload: v.Btn
"The reload btn at the top of the asset manager"

w_search: v.Btn
"The search button to crowl into the existing items"

w_selected: v.TextField
"The field where the user can see the asset Id of the selected item"

w_list: v.List
"The list of items displayed in the asset manager"

w_card: v.Card
"The card hosting the list of items"

def __init__(self):
"""Initialize the class."""
# start by defining al the widgets
# We deactivated the formatting to define each one of them on 1 single line
# fmt: off

# add a line of buttons to reload and add new projects
self.w_new = v.Btn(color="error", children="NEW", elevation=2, class_="ma-1")
self.w_reload = v.Btn(children=[v.Icon(color="primary", children="mdi-reload")], elevation=2, class_="ma-1")
self.w_search = v.Btn(children=[v.Icon(color="primary", children="mdi-magnify")], elevation=2, class_="ma-1")
w_main_line = v.Flex(children=[self.w_new, self.w_reload, self.w_search])

# generate the asset selector and the CRUD buttons
self.w_selected = v.TextField(readonly=True, placeholder="Selected item", v_model="", clearable=True, outlined=True, class_="ma-1")
self.w_view = v.Btn(children=[v.Icon(color="primary", children="mdi-eye")])
self.w_copy = v.Btn(children=[v.Icon(color="primary", children="mdi-content-copy")])
self.w_move = v.Btn(children=[v.Icon(color="primary", children="mdi-file-move")])
self.w_delete = v.Btn(children=[v.Icon(color="primary", children="mdi-trash-can")])
w_btn_list = v.ItemGroup(class_="ma-1 v-btn-toggle",children=[self.w_view, self.w_copy, self.w_move, self.w_delete])
w_selected_line = v.Layout(row=True, children=[w_btn_list, self.w_selected], class_="ma-1")

# generate the initial list
w_group = v.ListItemGroup(children=self.get_items(), v_model="")
self.w_list = v.List(dense=True, v_model=True, children=[w_group], outlined=True)
self.w_card = v.Card(children=[self.w_list], outlined=True, class_="ma-1")

super().__init__(children=[w_main_line, w_selected_line, self.w_card], v_model="", class_="ma-1")
# fmt: on

# add JS behaviour
t.link((self, "selected_item"), (self, "v_model"))
self.w_list.children[0].observe(self.on_item_select, "v_model")
self.w_reload.on_event("click", self.on_reload)

def get_items(self) -> List[v.ListItem]:
"""Create the list of items inside a folder."""
# special case when we are at the root of everything
# because of the specific display of cloud projects we will store both the name and the id of everything as a dict
# for all other item types it will simply be the Name
if self.folder == ".":
list_items = [p.project_id for p in ProjectsClient().search_projects() if "earth-engine" in p.labels] # fmt: skip
list_items = [{"id": f"projects/{i}/assets", "name": i} for i in list_items]
else:
list_items = [{"id": str(i), "name": i.name} for i in ee.Asset(self.folder).iterdir()]

# split the folders and the files to display the folders first
# cloud bucket will be considered as folders
folder_list, file_list = [], [] # type: ignore[var-annotated]

# walk the list of items and generate a list of ListItem using all the appropriate features
# first we extract the type to deduce the icon and color, then we build the Items with the
# ID and the display name and finally we split them in 2 groups the folders and the files
for i in list_items:
asset = ee.Asset(i["id"])
type = "PROJECT" if asset.is_project() else asset.type
icon = ICON_STYLE[type]["icon"]
color = ICON_STYLE[type]["color"]

action = v.ListItemAction(children=[v.Icon(color=color, children=[icon])], class_="mr-1")
content = v.ListItemContent(children=[v.ListItemTitle(children=[i["name"]])])
dst_list = folder_list if type in ["FOLDER", "PROJECT"] else file_list
dst_list.append(v.ListItem(value=i["id"], children=[action, content]))

# humanly sort the 2 lists so that number are treated nicely
folder_list = humansorted(folder_list, key=lambda x: x.value)
file_list = humansorted(file_list, key=lambda x: x.value)

# add a parent items if necessary. We follow the same mechanism with specific verifications
# if the parent is a project folder or the root
if self.folder != ".":
icon = ICON_STYLE["PARENT"]["icon"]
color = ICON_STYLE["PARENT"]["color"]

asset = ee.Asset(self.folder)
parent = ee.Asset("") if asset.is_project() else asset.parent
name = parent.parts[1] if parent.is_project() else parent.name
name = name or "." # special case for the root

action = v.ListItemAction(children=[v.Icon(color=color, children=[icon])], class_="mr-1")
content = v.ListItemContent(children=[v.ListItemTitle(children=[name])])
item = v.ListItem(value=str(parent), children=[action, content])

folder_list.insert(0, item)

# return the concatenation of the 2 lists
return folder_list + file_list

@switch("loading", "disabled", member="w_card")
def on_item_select(self, change: dict):
"""Act when an item is clicked by the user."""
# exit if nothing is changed to avoid infinite loop upon loading
selected = change["new"]
if not selected:
return

# select the item in the item TextField so user can interact with it
self.w_selected.v_model = change["new"]

# reset files. This is resetting the scroll to top without using js scripts
# set the new content files and folders
ee.Asset(change["new"])
if selected == "." or ee.Asset(selected).is_project() or ee.Asset(selected).is_folder():
self.folder = selected
items = self.get_items()
self.w_list.children[0].children = [] # trick to scroll up
self.w_list.children[0].children = items

def on_reload(self, *args):
"""Reload the current folder."""
self.on_item_select(change={"new": self.folder})
52 changes: 52 additions & 0 deletions ipygee/decorator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""Decorators used in ipygee.
ported from https://github.com/12rambau/sepal_ui/blob/main/sepal_ui/scripts/decorator.py
"""
from __future__ import annotations

from functools import wraps
from typing import Any, Optional


def switch(*params, member: Optional[str] = None, debug: bool = True) -> Any:
r"""Decorator to switch the state of input boolean parameters on class widgets or the class itself.
If ``widget`` is defined, it will switch the state of every widget parameter, otherwise
it will change the state of the class (self). You can also set two decorators on the same
function, one could affect the class and other the widget.
Args:
*params: any boolean member of a Widget.
member: THe widget on which the member are switched. Default to self.
debug: Whether trigger or not an Exception if the decorated function fails.
Returns:
The return statement of the decorated method
"""

def decorator_switch(func):
@wraps(func)
def wrapper_switch(self, *args, **kwargs):

# set the widget to work with. if nothing is set it will be self
widget = getattr(self, member) if member else self

# create the list of target values based on the initial values
targets = [bool(getattr(widget, p)) for p in params]
not_targets = [not t for t in targets]

# assgn the parameters to the target inverse
[setattr(widget, p, t) for p, t in zip(params, not_targets)]

# execute the function and catch errors
try:
func(self, *args, **kwargs)
except Exception as e:
if debug:
raise e
finally:
[setattr(widget, p, t) for p, t in zip(params, targets)]

return wrapper_switch

return decorator_switch
9 changes: 9 additions & 0 deletions ipygee/type.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
""""The different types created for this specific package."""
import os
from pathlib import Path
from typing import Union

import ee
import geetools # noqa: F401

pathlike = Union[os.PathLike, Path, ee.Asset]
7 changes: 6 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ requires-python = ">=3.8"
dependencies = [
"deprecated>=1.2.14",
"earthengine-api",
"ipyvuetify",
"natsort",
]

[[project.authors]]
Expand All @@ -37,7 +39,7 @@ file = "README.rst"
content-type = "text/x-rst"

[project.urls]
Homepage = "https://github.com/12rambau/ipygee"
Homepage = "https://github.com/gee-community/ipygee"

[project.optional-dependencies]
test = [
Expand Down Expand Up @@ -113,3 +115,6 @@ warn_redundant_casts = true

[tool.licensecheck]
using = "PEP631"

[tool.black]
line-length = 105 # super small margin for items that are 2 characters too long
2 changes: 1 addition & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@

def pytest_configure():
"""Configure test environment."""
pytest_gee.init_ee_from_token()
pytest_gee.init_ee_from_service_account()
Loading

0 comments on commit 26a17e3

Please sign in to comment.