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 18 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()
137 changes: 137 additions & 0 deletions porcupine/plugins/update_check.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
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 1 year ago"
Akuli marked this conversation as resolved.
Show resolved Hide resolved
if months == 13:
return "about 1 year and 1 month ago"
if months < 24:
return f"about 1 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 1 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 fetch_release_creator(version: str) -> str | None:
Akuli marked this conversation as resolved.
Show resolved Hide resolved
"""Find out who created a release.

Unfortunately the releases appear as being created by the GitHub Actions
bot account, so we look for a commit created by a script that Porcupine
maintainers run locally.
"""

# Commit date may be off by a day because time zones
start_time = (get_date(version) - datetime.timedelta(days=1)).isoformat() + ":00:00:00Z"
end_time = (get_date(version) + datetime.timedelta(days=1)).isoformat() + ":23:59:59Z"

try:
response = requests.get(
"https://api.github.com/repos/Akuli/porcupine/commits",
params={"since": start_time, "until": end_time},
headers={"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"},
timeout=3,
)
response.raise_for_status()
except requests.RequestException:
log.info(f"error fetching commits around release date {version}", exc_info=True)
return None

for commit in response.json():
if commit["commit"]["message"] == f"Version v{version.lstrip('v')}":
return commit["author"]["login"]

# script no longer used in a future version of Porcupine?
return None


def fetch_release_info() -> tuple[str, str | None] | None:
"""Returns (when_released, who_released) for the latest release.

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, fetch_release_creator(version))


def format_new_release_message(version: str, who_released: str | None) -> str:
some_days_ago = x_days_ago((datetime.date.today() - get_date(version)).days)
if who_released is None:
return f"A new version of Porcupine was released {some_days_ago}."
else:
return f"{who_released} released a new version of Porcupine {some_days_ago}."


def check_for_updates_in_background() -> None:
def done_callback(success: bool, result: str | tuple[str, str | None] | 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

assert not isinstance(result, str)
if result is not None:
# There is a new release
statusbar.set_global_message(format_new_release_message(*result))

utils.run_in_thread(fetch_release_info, done_callback)


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):
check_for_updates_in_background()
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
4 changes: 4 additions & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,11 @@ def main():
subprocess.check_call(
["git", "add", "porcupine/__init__.py", "README.md", "CHANGELOG.md", "pyproject.toml"]
)

# Avoid changing this commit message. It is used in the update_check plugin to figure
# out who ran this script to create a release.
subprocess.check_call(["git", "commit", "-m", f"Version {TAG_FORMAT % new_info}"])

subprocess.check_call(["git", "tag", TAG_FORMAT % new_info])
subprocess.check_call(["git", "push", "origin", branch])
subprocess.check_call(["git", "push", "--tags", "origin", branch])
Expand Down
61 changes: 61 additions & 0 deletions tests/test_update_check_plugin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import datetime
import os
import sys

import pytest

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 1 year ago"
assert update_check.x_days_ago(352) == "about 1 year ago"
assert update_check.x_days_ago(380) == "about 1 year ago"
assert update_check.x_days_ago(381) == "about 1 year and 1 month ago"
assert update_check.x_days_ago(410) == "about 1 year and 1 month ago"
assert update_check.x_days_ago(411) == "about 1 year and 2 months ago"
assert update_check.x_days_ago(715) == "about 1 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 1 month ago"
assert update_check.x_days_ago(776) == "about 2 years and 1 month ago"
assert update_check.x_days_ago(777) == "about 2 years and 2 months ago"


@pytest.mark.skipif(
sys.platform == "macos" and os.environ.get("GITHUB_ACTIONS") == "true",
reason="failed MacOS CI in github actions, don't know why",
)
def test_fetching_release_creator():
# I don't want to know which way it calls this function.
# Let's just make it work in both cases.
assert update_check.fetch_release_creator("2024.03.09") == "Akuli"
assert update_check.fetch_release_creator("v2024.03.09") == "Akuli"


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)

assert (
update_check.format_new_release_message("v2024.03.09", None)
== "A new version of Porcupine was released 7 days ago."
)
assert (
update_check.format_new_release_message("v2024.03.09", "Akuli")
== "Akuli released a new version of Porcupine 7 days ago."
)