Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update check on startup #1452

Merged
merged 20 commits into from
Mar 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
43 changes: 34 additions & 9 deletions porcupine/plugins/statusbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand All @@ -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:
Expand Down Expand Up @@ -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("<<Undo>>")
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:
Expand All @@ -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, "<<Reloaded>>", statusbar.show_reload_warning, add=True)
tab.bind("<<AfterSave>>", statusbar.clear_reload_warning, add=True)
tab.bind("<<AfterSave>>", statusbar.clear_special_message, add=True)

tab.bind("<<PathChanged>>", statusbar.update_labels, add=True)
tab.bind("<<TabSettingChanged:encoding>>", statusbar.update_labels, add=True)
Expand All @@ -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(
Expand All @@ -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("<<ThemeChanged>>", update_button_style, add=True)
update_button_style()
get_tab_manager().bind("<<ThemeChanged>>", _update_button_style, add=True)
_update_button_style()
95 changes: 95 additions & 0 deletions porcupine/plugins/update_check.py
Original file line number Diff line number Diff line change
@@ -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)
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
Expand All @@ -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",
Expand All @@ -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]
Expand Down
3 changes: 2 additions & 1 deletion requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
41 changes: 41 additions & 0 deletions tests/test_update_check_plugin.py
Original file line number Diff line number Diff line change
@@ -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.")
Loading