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.