From a328899c722173bad1771f966f65d934a69dfd68 Mon Sep 17 00:00:00 2001 From: Akuli Date: Sun, 17 Mar 2024 01:45:24 +0200 Subject: [PATCH] Update check on startup (#1452) --- porcupine/plugins/statusbar.py | 43 +++++++++++--- porcupine/plugins/update_check.py | 95 +++++++++++++++++++++++++++++++ pyproject.toml | 3 +- requirements-dev.txt | 3 +- tests/test_update_check_plugin.py | 41 +++++++++++++ 5 files changed, 174 insertions(+), 11 deletions(-) create mode 100644 porcupine/plugins/update_check.py create mode 100644 tests/test_update_check_plugin.py diff --git a/porcupine/plugins/statusbar.py b/porcupine/plugins/statusbar.py index 7d214fefa..4b8514134 100644 --- a/porcupine/plugins/statusbar.py +++ b/porcupine/plugins/statusbar.py @@ -21,6 +21,9 @@ def __init__(self, tab: tabs.FileTab): # disappears before path truncates self.path_label = ttk.Label(self._top_frame) self.path_label.pack(side="left") + + self._showing_special_message = False + self._line_ending_button = ttk.Button( self._top_frame, command=self._choose_line_ending, style="Statusbar.TButton", width=0 ) @@ -34,7 +37,7 @@ def __init__(self, tab: tabs.FileTab): self.selection_label.pack(side="left") def update_labels(self, junk: object = None) -> None: - if not self.path_label["foreground"]: # reload warning not going on + if not self._showing_special_message: self.path_label.config(text=str(self._tab.path or "File not saved yet")) try: @@ -70,14 +73,20 @@ def update_labels(self, junk: object = None) -> None: text=self._tab.settings.get("line_ending", settings.LineEnding).name ) + def show_special_message(self, text: str) -> None: + self.path_label.config(text=text) + self._showing_special_message = True + def show_reload_warning(self, event: utils.EventWithData) -> None: if event.data_class(tabs.ReloadInfo).had_unsaved_changes: oops = utils.get_binding("<>") text = f"File was reloaded with unsaved changes. Press {oops} to get your changes back." - self.path_label.config(foreground="red", text=text) + self.show_special_message(text) + self.path_label.config(foreground="red") - def clear_reload_warning(self, junk: object) -> None: + def clear_special_message(self, junk: object) -> None: self.path_label.config(foreground="") + self._showing_special_message = False self.update_labels() def _choose_encoding(self) -> None: @@ -96,12 +105,28 @@ def _choose_line_ending(self) -> None: self._tab.settings.set("line_ending", utils.ask_line_ending(old_value)) -def on_new_filetab(tab: tabs.FileTab) -> None: +_global_message: str | None = None + + +def set_global_message(message: str | None) -> None: + """Display a message whenever a new tab is opened. + + This is currently used for notifying the user about a new Porcupine + version. Feel free to expand the status bar's API a lot beyond this + if you need something more advanced. + """ + global _global_message + _global_message = message + + +def _on_new_filetab(tab: tabs.FileTab) -> None: statusbar = StatusBar(tab) statusbar.pack(side="bottom", fill="x") + if _global_message is not None: + statusbar.show_special_message(_global_message) utils.bind_with_data(tab, "<>", statusbar.show_reload_warning, add=True) - tab.bind("<>", statusbar.clear_reload_warning, add=True) + tab.bind("<>", statusbar.clear_special_message, add=True) tab.bind("<>", statusbar.update_labels, add=True) tab.bind("<>", statusbar.update_labels, add=True) @@ -111,7 +136,7 @@ def on_new_filetab(tab: tabs.FileTab) -> None: statusbar.update_labels() -def update_button_style(junk_event: object = None) -> None: +def _update_button_style(junk_event: object = None) -> None: # https://tkdocs.com/tutorial/styles.html # tkinter's style stuff sucks get_tab_manager().tk.eval( @@ -120,7 +145,7 @@ def update_button_style(junk_event: object = None) -> None: def setup() -> None: - get_tab_manager().add_filetab_callback(on_new_filetab) + get_tab_manager().add_filetab_callback(_on_new_filetab) - get_tab_manager().bind("<>", update_button_style, add=True) - update_button_style() + get_tab_manager().bind("<>", _update_button_style, add=True) + _update_button_style() diff --git a/porcupine/plugins/update_check.py b/porcupine/plugins/update_check.py new file mode 100644 index 000000000..9ac272526 --- /dev/null +++ b/porcupine/plugins/update_check.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import datetime +import logging + +import requests + +from porcupine import __version__ as this_porcupine_version +from porcupine import settings, utils +from porcupine.plugins import statusbar +from porcupine.settings import global_settings + +log = logging.getLogger(__name__) + +# Place the update checkbox towards the end of the settings dialog +setup_after = ["restart"] + + +def x_days_ago(days: int) -> str: + days_in_year = 365.25 # good enough + days_in_month = days_in_year / 12 + months = round(days / days_in_month) + + if days == 0: + return "today" + if days == 1: + return "yesterday" + if days < 1.5 * days_in_month: + return f"{days} days ago" + + months = round(days / days_in_month) + if months < 12: + return f"about {months} months ago" + if months == 12: + return "about a year ago" + if months == 13: + return "about a year and a month ago" + if months < 24: + return f"about a year and {months - 12} months ago" + if months % 24 == 0: + return f"about {months // 12} years ago" + if months % 24 == 1: + return f"about {months // 12} years and a month ago" + return f"about {months // 12} years and {months % 12} months ago" + + +def get_date(version: str) -> datetime.date: + year, month, day = map(int, version.lstrip("v").split(".")) + return datetime.date(year, month, day) + + +def get_latest_release() -> str | None: + """Returns name of the latest release, or None if Porcupine is up to date. + + This is slow, and runs in a new thread. + """ + response = requests.get( + "https://api.github.com/repos/Akuli/porcupine/releases/latest", + headers={"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"}, + timeout=3, + ) + response.raise_for_status() + version: str = response.json()["tag_name"].lstrip("v") + + assert not version.startswith("v") + assert not this_porcupine_version.startswith("v") + + if version == this_porcupine_version: + log.debug("this is the latest version of Porcupine") + return None + + return version + + +def done_callback(success: bool, result: str | None) -> None: + if not success: + # Handle errors somewhat silently. Update checking is not very important. + log.warning("checking for updates failed") + log.info(f"full error message from update checking:\n{result}") + return + + if result is not None: + # There is a new release + some_days_ago = x_days_ago((datetime.date.today() - get_date(result)).days) + statusbar.set_global_message(f"A new version of Porcupine was released {some_days_ago}.") + + +def setup() -> None: + global_settings.add_option("update_check_on_startup", True) + settings.add_checkbutton( + "update_check_on_startup", text="Check for updates when Porcupine starts" + ) + + if global_settings.get("update_check_on_startup", bool): + utils.run_in_thread(get_latest_release, done_callback) diff --git a/pyproject.toml b/pyproject.toml index 6f4fa2eda..3c4d89b42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ dependencies = [ "psutil>=5.8.0,<6.0.0", "PyYAML>=6.0,<7", "tree-sitter-builds==2023.3.12", + "requests>=2.24.0, <3.0.0", # TODO: upgrade sv-ttk "sv-ttk==2.5.5", ] @@ -56,7 +57,6 @@ dev = [ "pyupgrade==3.9.0", "sphinx>=4.0.0, <5.0.0", "pillow>=5.4.1", - "requests>=2.24.0, <3.0.0", # type checking, exact versions to avoid "works on my computer" problems "mypy==1.1.1", @@ -70,6 +70,7 @@ dev = [ "types-PyYAML==6.0.12.8", "types-tree-sitter==0.20.1.2", "types-tree-sitter-languages==1.5.0.2", + "types-requests==2.31.0.20240311", ] [project.scripts] diff --git a/requirements-dev.txt b/requirements-dev.txt index 8001d30d9..083899f71 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -14,6 +14,7 @@ send2trash>=1.8.0,<2.0.0 psutil>=5.8.0,<6.0.0 PyYAML>=6.0,<7 tree-sitter-builds==2023.3.12 +requests>=2.24.0, <3.0.0 sv-ttk==2.5.5 pytest==6.2.5 pytest-cov==4.0.0 @@ -24,7 +25,6 @@ isort==5.12.0 pyupgrade==3.9.0 sphinx>=4.0.0, <5.0.0 pillow>=5.4.1 -requests>=2.24.0, <3.0.0 mypy==1.1.1 types-Pygments==2.14.0.6 types-docutils==0.19.1.6 @@ -36,3 +36,4 @@ types-psutil==5.9.5.10 types-PyYAML==6.0.12.8 types-tree-sitter==0.20.1.2 types-tree-sitter-languages==1.5.0.2 +types-requests==2.31.0.20240311 diff --git a/tests/test_update_check_plugin.py b/tests/test_update_check_plugin.py new file mode 100644 index 000000000..ffb83d6c1 --- /dev/null +++ b/tests/test_update_check_plugin.py @@ -0,0 +1,41 @@ +import datetime + +from porcupine.plugins import update_check + + +def test_x_days_ago(): + assert update_check.x_days_ago(0) == "today" + assert update_check.x_days_ago(1) == "yesterday" + assert update_check.x_days_ago(2) == "2 days ago" + assert update_check.x_days_ago(3) == "3 days ago" + assert update_check.x_days_ago(10) == "10 days ago" + assert update_check.x_days_ago(20) == "20 days ago" + assert update_check.x_days_ago(30) == "30 days ago" + assert update_check.x_days_ago(40) == "40 days ago" + assert update_check.x_days_ago(45) == "45 days ago" + assert update_check.x_days_ago(46) == "about 2 months ago" + assert update_check.x_days_ago(47) == "about 2 months ago" + assert update_check.x_days_ago(349) == "about 11 months ago" + assert update_check.x_days_ago(350) == "about 11 months ago" + assert update_check.x_days_ago(351) == "about a year ago" + assert update_check.x_days_ago(352) == "about a year ago" + assert update_check.x_days_ago(380) == "about a year ago" + assert update_check.x_days_ago(381) == "about a year and a month ago" + assert update_check.x_days_ago(410) == "about a year and a month ago" + assert update_check.x_days_ago(411) == "about a year and 2 months ago" + assert update_check.x_days_ago(715) == "about a year and 11 months ago" + assert update_check.x_days_ago(716) == "about 2 years ago" + assert update_check.x_days_ago(745) == "about 2 years ago" + assert update_check.x_days_ago(746) == "about 2 years and a month ago" + assert update_check.x_days_ago(776) == "about 2 years and a month ago" + assert update_check.x_days_ago(777) == "about 2 years and 2 months ago" + + +def test_the_message(mocker): + mock_datetime = mocker.patch("porcupine.plugins.update_check.datetime") + mock_datetime.date.side_effect = datetime.date + mock_datetime.date.today.return_value = datetime.date(2024, 3, 16) + mock_set_message = mocker.patch("porcupine.plugins.statusbar.set_global_message") + + update_check.done_callback(True, "v2024.03.09") + mock_set_message.assert_called_once_with("A new version of Porcupine was released 7 days ago.")