diff --git a/pyproject.toml b/pyproject.toml
index 4cc5e3b7..989740b3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -85,6 +85,7 @@ test = [
"Flake8-pyproject",
"nbmake",
"pytest-regressions",
+ "tomli",
]
doc = [
"sphinx<7",
@@ -95,6 +96,7 @@ doc = [
"m2r2>=0.3.3",
"sphinxcontrib-autoprogram",
"sphinx-favicon>=1.0.1",
+ "tomli",
]
[project.scripts]
diff --git a/sepal_ui/message/en/locale.json b/sepal_ui/message/en/locale.json
index b65a232d..4b08e551 100644
--- a/sepal_ui/message/en/locale.json
+++ b/sepal_ui/message/en/locale.json
@@ -23,7 +23,12 @@
"navdrawer": {
"code": "Source code",
"wiki": "Wiki",
- "bug": "Bug report"
+ "bug": "Bug report",
+ "changelog": {
+ "version": "Version: {}",
+ "title": "Changelog",
+ "close_btn": "Close"
+ }
},
"asset_select": {
"types": {
diff --git a/sepal_ui/scripts/utils.py b/sepal_ui/scripts/utils.py
index 34925cb8..de2f128b 100644
--- a/sepal_ui/scripts/utils.py
+++ b/sepal_ui/scripts/utils.py
@@ -1,5 +1,6 @@
"""All the helper function of sepal-ui."""
+import configparser
import math
import os
import random
@@ -7,12 +8,14 @@
import string
import warnings
from pathlib import Path
-from typing import Any, Sequence, Union
+from typing import Any, Sequence, Tuple, Union
from urllib.parse import urlparse
import ee
import httplib2
import ipyvuetify as v
+import requests
+import tomli
from anyascii import anyascii
from deprecated.sphinx import deprecated, versionadded
from matplotlib import colors as c
@@ -23,6 +26,9 @@
from sepal_ui.scripts import decorator as sd
from sepal_ui.scripts.warning import SepalWarning
+# Types
+Pathlike = Union[str, Path]
+
def hide_component(widget: v.VuetifyWidget) -> v.VuetifyWidget:
"""Hide a vuetify based component.
@@ -60,7 +66,7 @@ def show_component(widget: v.VuetifyWidget) -> v.VuetifyWidget:
return widget
-def create_download_link(pathname: Union[str, Path]) -> str:
+def create_download_link(pathname: Pathlike) -> str:
"""Create a clickable link to download the pathname target.
Args:
@@ -102,7 +108,7 @@ def random_string(string_length: int = 3) -> str:
return "".join(random.choice(letters) for i in range(string_length))
-def get_file_size(filename: Union[str, Path]) -> str:
+def get_file_size(filename: Pathlike) -> str:
"""Get the file size as string of 2 digit in the adapted scale (B, KB, MB....).
Args:
@@ -128,10 +134,8 @@ def init_ee() -> None:
"""
# 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:
-
# write the token to the appropriate folder
ee_token = os.environ["EARTHENGINE_TOKEN"]
credential_folder_path = Path.home() / ".config" / "earthengine"
@@ -183,7 +187,6 @@ def to_colors(
out_color = "#000000" # default black color
if isinstance(in_color, tuple) and len(in_color) == 3:
-
# rescale color if necessary
if all(isinstance(item, int) for item in in_color):
in_color = [c / 255.0 for c in in_color]
@@ -191,7 +194,6 @@ def to_colors(
return transform(in_color)
else:
-
# try to guess the color system
try:
return transform(in_color)
@@ -375,6 +377,92 @@ def check_input(input_: Any, msg: str = ms.utils.check_input.error) -> bool:
return init
+def get_app_version(repo_folder: Pathlike = Path.cwd()) -> str:
+ """Get the current version of the a github project using the pyproject.toml file in the root.
+
+ Returns:
+ the version of the repository
+ """
+ # get the path to the pyproject.toml file
+ pyproject_path = repo_folder / "pyproject.toml"
+
+ # check if the file exist
+ if pyproject_path.exists():
+ # read the file using tomli
+ pyproject = tomli.loads(pyproject_path.read_text())
+
+ # get the version
+ return pyproject.get("project", {}).get("version", None)
+
+ return None
+
+
+def get_repo_info(repo_folder: Pathlike = Path.cwd()) -> Tuple[str, str]:
+ """Get the repository name and owner from the git config file."""
+ config = configparser.ConfigParser()
+ git_config_path = Path(repo_folder) / ".git/config"
+ config.read(git_config_path)
+
+ try:
+ remote_url = config.get('remote "origin"', "url")
+ except (configparser.NoSectionError, configparser.NoOptionError):
+ return "", ""
+
+ # Check if URL is likely SSH
+ if "git@" in remote_url:
+ match = re.search(r":(.*?)/(.*?)(?:\.git)?$", remote_url)
+
+ # Assume URL is HTTPS otherwise
+ else:
+ match = re.search(r"github\.com/(.*?)/(.*?)(?:\.git)?$", remote_url)
+
+ if match:
+ return match.groups()
+
+ else:
+ return "", ""
+
+
+def get_changelog(repo_folder: Pathlike = Path.cwd()) -> str:
+ """Check if the repository contains a changelog file and/or a remote release and return its content.
+
+ Returns:
+ str: the content of the release and/or changelog file
+ """
+ changelog_text, release_text = "", ""
+ repo_owner, repo_name = get_repo_info(repo_folder)
+
+ release_url = (
+ f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases/latest"
+ )
+
+ response = requests.get(release_url)
+ if all([repo_owner, repo_name]) and response.status_code == 200:
+ response_json = response.json()
+ name = response_json.get("name")
+
+ if name == f"v_{get_app_version()}":
+ release_text = response_json.get("body")
+
+ url_pattern = r"https://github\.com/[^ ]+/pull/\d+"
+
+ # Replace URLs with tags
+ def wrap_url_in_a_tag(match):
+ url = match.group(0)
+ return f'{url}'
+
+ release_text = re.sub(url_pattern, wrap_url_in_a_tag, release_text)
+
+ # get the path to the pyproject.toml file
+ changelog_path = Path(repo_folder) / "CHANGELOG.md"
+
+ # check if the file exist
+ if changelog_path.exists():
+ changelog_text = changelog_path.read_text()
+
+ return release_text, changelog_text
+
+
################################################################################
# the soon to be deprecated decorators
#
diff --git a/sepal_ui/sepalwidgets/app.py b/sepal_ui/sepalwidgets/app.py
index 59f9f397..893fec86 100644
--- a/sepal_ui/sepalwidgets/app.py
+++ b/sepal_ui/sepalwidgets/app.py
@@ -32,6 +32,7 @@
from sepal_ui.scripts import utils as su
from sepal_ui.sepalwidgets.alert import Banner
from sepal_ui.sepalwidgets.sepalwidget import SepalWidget
+from sepal_ui.sepalwidgets.widget import Markdown
from sepal_ui.translator import Translator
__all__ = [
@@ -46,7 +47,6 @@
class LocaleSelect(v.Menu, SepalWidget):
-
COUNTRIES: pd.DataFrame = pd.read_parquet(
Path(__file__).parents[1] / "data" / "locale.parquet"
)
@@ -140,7 +140,6 @@ def _get_country_items(self, locales: list) -> List[str]:
country_list = []
filtered_countries = self.COUNTRIES[self.COUNTRIES.code.isin(locales)]
for r in filtered_countries.itertuples(index=False):
-
attr = {**self.ATTR, "src": self.FLAG.format(r.flag), "alt": r.name}
children = [
@@ -177,7 +176,6 @@ def _on_locale_select(self, change: dict) -> None:
class ThemeSelect(v.Btn, SepalWidget):
-
THEME_ICONS: dict = {"dark": "fa-solid fa-moon", "light": "fa-solid fa-sun"}
"the dictionnry of icons to use for each theme (used as keys)"
@@ -232,7 +230,6 @@ def toggle_theme(self, *args) -> None:
class AppBar(v.AppBar, SepalWidget):
-
toogle_button: Optional[v.Btn]
"The btn to display or hide the drawer to the user"
@@ -297,7 +294,6 @@ def set_title(self, title: str) -> Self:
class DrawerItem(v.ListItem, SepalWidget):
-
rt: Optional[ResizeTrigger] = None
"The trigger to resize maps and other javascript object when jumping from a tile to another"
@@ -415,7 +411,6 @@ def display_tile(self, tiles: List[v.Card]) -> Self:
return self
def _on_click(self, *args) -> Self:
-
for tile in self.tiles:
show = self._metadata["card_id"] == tile._metadata["mount_id"]
tile.viz = show
@@ -433,7 +428,6 @@ def _on_click(self, *args) -> Self:
class NavDrawer(v.NavigationDrawer, SepalWidget):
-
items: List[DrawerItem] = []
"the list of all the drawerItem to display in the drawer"
@@ -443,6 +437,7 @@ def __init__(
code: str = "",
wiki: str = "",
issue: str = "",
+ repo_folder: su.Pathlike = "",
**kwargs,
) -> None:
"""Custom NavDrawer using the different DrawerItems of the user.
@@ -454,16 +449,22 @@ def __init__(
code: the absolute link to the source code
wiki: the absolute link the the wiki page
issue: the absolute link to the issue tracker
+ repo_folder: the path to the github repository folder where the changelog and version are stored. Default to the current working directory.
kwargs (optional) any parameter from a v.NavigationDrawer. If set, 'app' and 'children' will be overwritten.
"""
self.items = items
+ repo_folder = Path(repo_folder) if repo_folder else Path.cwd()
+
+ v_slots = []
+
code_link = []
if code:
item_code = DrawerItem(
ms.widgets.navdrawer.code, icon="fa-regular fa-file-code", href=code
)
code_link.append(item_code)
+
if wiki:
item_wiki = DrawerItem(
ms.widgets.navdrawer.wiki, icon="fa-solid fa-book-open", href=wiki
@@ -475,6 +476,10 @@ def __init__(
)
code_link.append(item_bug)
+ version_card = VersionCard(repo_folder=repo_folder)
+ if version_card:
+ v_slots = [{"name": "append", "children": [version_card]}]
+
children = [
v.List(dense=True, children=self.items),
v.Divider(),
@@ -486,6 +491,7 @@ def __init__(
kwargs["app"] = True
kwargs.setdefault("color", color.darker)
kwargs["children"] = children
+ kwargs.setdefault("v_slots", v_slots)
# call the constructor
super().__init__(**kwargs)
@@ -544,7 +550,6 @@ def __init__(self, text: str = "", **kwargs) -> None:
class App(v.App, SepalWidget):
-
tiles: List[v.Card] = []
"the tiles of the app"
@@ -732,7 +737,6 @@ def _remove_banner(self, change: dict) -> None:
Adapt the banner display so that the first one is the only one shown displaying the number of other banner in the queue
"""
if change["new"] is False:
-
# extract the banner from the app children
children, banner_list = [], []
for e in self.content.children.copy():
@@ -752,3 +756,72 @@ def _remove_banner(self, change: dict) -> None:
self.content.children = banner_list + children
return
+
+
+def VersionCard(repo_folder: str = Path.cwd()) -> Optional[v.Card]:
+ """Returns a card with the current version of the app and a changelog dialog.
+
+ Args:
+ github_url: the url of the github repository of the app
+ """
+ app_version = su.get_app_version(repo_folder)
+ if not app_version:
+ return None
+
+ release_text, changelog_text = su.get_changelog(repo_folder)
+
+ content = []
+
+ if release_text:
+ content.append(Markdown(release_text))
+
+ if changelog_text:
+ content.append(v.Divider())
+ content.append(Markdown(changelog_text))
+
+ btn_close = v.Btn(
+ color="primary",
+ children=[ms.widgets.navdrawer.changelog.close_btn],
+ on_event=[
+ (
+ "click",
+ lambda *_: setattr(w_changelog, "v_model", False),
+ )
+ ],
+ )
+
+ w_changelog = v.Dialog(
+ v_model=False,
+ max_width=750,
+ children=[
+ v.Card(
+ children=[
+ v.CardTitle(
+ class_="headline",
+ children=[ms.widgets.navdrawer.changelog.title],
+ ),
+ v.CardText(children=content),
+ v.CardActions(children=[v.Spacer(), btn_close]),
+ ]
+ )
+ ],
+ )
+
+ w_version = v.Card(
+ class_="text-center",
+ tile=True,
+ color=color.main,
+ children=[
+ v.CardText(
+ children=[
+ ms.widgets.navdrawer.changelog.version.format(app_version),
+ w_changelog,
+ ]
+ ),
+ ],
+ )
+
+ w_version.on_event("click", lambda *_: setattr(w_changelog, "v_model", True))
+ btn_close.on_event("click", lambda *_: setattr(w_changelog, "v_model", False))
+
+ return w_version
diff --git a/sepal_ui/templates/panel_app/CHANGELOG.md b/sepal_ui/templates/panel_app/CHANGELOG.md
new file mode 100644
index 00000000..28d25065
--- /dev/null
+++ b/sepal_ui/templates/panel_app/CHANGELOG.md
@@ -0,0 +1,5 @@
+## v_1.1.1 (2023-09-26)
+
+### Feature
+
+- create a dummy changelog
\ No newline at end of file
diff --git a/sepal_ui/templates/panel_app/pyproject.toml b/sepal_ui/templates/panel_app/pyproject.toml
index 5ef98514..6a02b77d 100644
--- a/sepal_ui/templates/panel_app/pyproject.toml
+++ b/sepal_ui/templates/panel_app/pyproject.toml
@@ -1,3 +1,6 @@
+[project]
+version = "1.1.1"
+
[sepal-ui]
init-notebook = "ui.ipynb"
diff --git a/tests/conftest.py b/tests/conftest.py
index f2686d5d..80a99a5c 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -335,3 +335,13 @@ def cred() -> list:
credentials = json.loads(os.getenv("PLANET_API_CREDENTIALS"))
return list(credentials.values())
+
+
+@pytest.fixture(scope="session")
+def repo_dir(tmp_path_factory: pytest.TempPathFactory) -> Path:
+ """Create a dummy repo directory.
+
+ Returns:
+ Path to the repo dir
+ """
+ return tmp_path_factory.mktemp("repo_dir")
diff --git a/tests/test_scripts/test_utils.py b/tests/test_scripts/test_utils.py
index 9681840f..090899ab 100644
--- a/tests/test_scripts/test_utils.py
+++ b/tests/test_scripts/test_utils.py
@@ -273,3 +273,94 @@ def test_check_input() -> None:
su.check_input([])
return
+
+
+def test_get_app_version(repo_dir):
+ """Test if the function gets the pyproject version."""
+ dummy_repo = repo_dir / "dummy_repo"
+ dummy_repo.mkdir(exist_ok=True, parents=True)
+ pyproject_file = dummy_repo / "pyproject.toml"
+
+ # create a temporary pyproject.toml file
+ with open(pyproject_file, "w") as f:
+ f.write("[project]\nversion = '1.0.0'")
+
+ # test the function
+ version = su.get_app_version(dummy_repo)
+ assert version == "1.0.0"
+
+ # remove the temporary file
+ pyproject_file.unlink()
+
+ # test the function with a non-existing pyproject.toml file
+ version = su.get_app_version(dummy_repo)
+ assert version is None
+
+
+def test_get_repo_info(repo_dir):
+ """Test if the function returns repo_owner and repo_name correctly."""
+ # test the function with a known repository URL
+ # Create a temporary .git folder inside the temporary directory
+
+ # repo_dir = Path("delete_mi")
+ git_folder = repo_dir / ".git"
+ git_folder.mkdir(exist_ok=True, parents=True)
+
+ expected_owner = "12rambau"
+ expected_repo = "sepal_ui"
+
+ config = ConfigParser()
+ config.add_section('remote "origin"')
+ config.set(
+ 'remote "origin"', "url", f"git@github.com:{expected_owner}/{expected_repo}.git"
+ )
+ with open(git_folder / "config", "w") as f:
+ config.write(f)
+
+ repo_owner, repo_name = su.get_repo_info(repo_folder=repo_dir)
+
+ assert repo_owner == expected_owner
+ assert repo_name == expected_repo
+
+ config.set(
+ 'remote "origin"',
+ "url",
+ f"https://github.com/{expected_owner}/{expected_repo}.git",
+ )
+ with open(git_folder / "config", "w") as f:
+ config.write(f)
+
+ repo_owner, repo_name = su.get_repo_info(repo_folder=repo_dir)
+
+ assert repo_owner == expected_owner
+ assert repo_name == expected_repo
+
+ # Test the function with a non-existing matching repository URL
+ config.set('remote "origin"', "url", "toto")
+ with open(git_folder / "config", "w") as f:
+ config.write(f)
+
+ repo_info = su.get_repo_info(repo_folder=repo_dir)
+
+ assert repo_info == ("", "")
+
+
+def test_get_changelog(repo_dir):
+ """Test if the function returns the changelog correctly."""
+ # Create a dummy directory with a changelog file
+
+ # Create a dummy changelog file and write some text in it
+ changelog_file = repo_dir / "CHANGELOG.md"
+ changelog_file.touch()
+ changelog_file.write_text("# Changelog")
+
+ # Test the function
+ changelog = su.get_changelog(repo_folder=repo_dir)
+
+ assert changelog == ("", "# Changelog")
+
+ # assume that this is executed in this git repository
+ release_text, changelog_text = su.get_changelog()
+
+ assert release_text is not None
+ assert changelog_text is not None
diff --git a/tests/test_sepalwidgets/test_App.py b/tests/test_sepalwidgets/test_App.py
index fe65c5c0..6a517e67 100644
--- a/tests/test_sepalwidgets/test_App.py
+++ b/tests/test_sepalwidgets/test_App.py
@@ -1,5 +1,7 @@
"""Test the App widget."""
+import os
+
import ipyvuetify as v
import pytest
@@ -132,6 +134,45 @@ def test_close_banner(app: sw.App) -> None:
return
+def test_version_card(repo_dir) -> None:
+ """Test the drawer of the app."""
+ # arrange
+ app_version = "999.999.1"
+ changelog_text = "# Changelog"
+
+ # Change current working directory to dummy repo
+ os.chdir(repo_dir)
+
+ # Check that if there is no pyproject.toml file, the version card is not present
+ navigation_drawer = sw.NavDrawer([], repo_folder=repo_dir)
+
+ assert navigation_drawer.v_slots == []
+
+ # Create a pyproject.toml file and a changelog
+ pyproject_file = repo_dir / "pyproject.toml"
+
+ # create a temporary pyproject.toml file
+ with open(pyproject_file, "w") as f:
+ f.write(f"[project]\nversion = '{app_version}'")
+
+ # Create a dummy changelog file and write some text in it
+ changelog_file = repo_dir / "CHANGELOG.md"
+ changelog_file.touch()
+ changelog_file.write_text(f"{changelog_text}")
+
+ navigation_drawer = sw.NavDrawer([], repo_folder=repo_dir)
+
+ # Check if the version card is present
+
+ assert len(navigation_drawer.v_slots) == 1
+
+ version_card = navigation_drawer.v_slots[0]["children"][0]
+
+ # Check if the version card has the right content
+ displayed_version = version_card.children[0].children[0]
+ assert displayed_version == f"Version: {app_version}"
+
+
@pytest.fixture(scope="function")
def app() -> sw.App:
"""Create a default App.