Skip to content

Commit d9783f7

Browse files
authored
[BUGFIX] Remove "upgrade" button from GUI trainer, replace with a modal alerting user of new version (#633)
* [FEATURE] Implement update settings management and version check This commit introduces functionality for managing update settings within the application. It adds methods to retrieve and set the newest available version and a preference to not show update notifications again. Additionally, a new modal is implemented to inform users when an update is available, guiding them on how to upgrade. Changes: - Added update settings management in settings.py - Implemented version checking from GitHub in __init__.py - Created a modal for update notifications in the GUI This enhances user experience by providing clear update information and options. * [FEATURE] Add comprehensive tests for settings management This commit introduces a suite of tests for managing last paths and update settings within the application. It includes tests for setting and retrieving last paths with custom settings paths, ensuring that default settings remain unaffected. Additionally, it verifies the behavior of update settings when dealing with empty or missing files, and checks that partial updates preserve existing values. These enhancements improve the reliability of settings management and ensure correct functionality across various scenarios.
1 parent adf03e7 commit d9783f7

3 files changed

Lines changed: 240 additions & 83 deletions

File tree

nam/train/gui/__init__.py

Lines changed: 101 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
import re as _re
1515
import requests as _requests
1616
import tkinter as _tk
17-
import subprocess as _subprocess
1817
import sys as _sys
1918
import webbrowser as _webbrowser
2019
from dataclasses import dataclass as _dataclass
@@ -104,6 +103,35 @@ def _is_mac() -> bool:
104103
_SYSTEM_TEXT_COLOR = "systemTextColor" if _is_mac() else "black"
105104

106105

106+
def _get_latest_version_from_github() -> _Optional[_Version]:
107+
"""
108+
Fetch releases from GitHub and return the newest version, or None on error.
109+
"""
110+
url = "https://api.github.com/repos/sdatkinson/neural-amp-modeler/releases"
111+
try:
112+
response = _requests.get(url)
113+
except _requests.exceptions.ConnectionError:
114+
print("WARNING: Failed to reach the server to check for updates")
115+
return None
116+
if response.status_code != 200:
117+
print(f"Failed to fetch releases. Status code: {response.status_code}")
118+
return None
119+
releases = response.json()
120+
latest_version = None
121+
if releases:
122+
for release in releases:
123+
tag = release["tag_name"]
124+
if not tag.startswith("v"):
125+
print(f"Found invalid version {tag}")
126+
else:
127+
this_version = _Version.from_string(tag[1:])
128+
if latest_version is None or this_version > latest_version:
129+
latest_version = this_version
130+
else:
131+
print("No releases found for this repository.")
132+
return latest_version
133+
134+
107135
@_dataclass
108136
class AdvancedOptions(object):
109137
"""
@@ -392,14 +420,58 @@ def __init__(
392420
self._no.pack(side=_tk.RIGHT)
393421

394422

423+
class _UpdateAvailableModal(object):
424+
"""
425+
Modal shown when a new version is available. Message, "Do not show this again"
426+
checkbox, and Close button. Does not offer an in-GUI upgrade; instructs user to
427+
run pip install --upgrade.
428+
"""
429+
430+
def __init__(self, resume_main: _Callable[[], None], version: str):
431+
self._root = _tk.Toplevel()
432+
self._root.title("Update available")
433+
msg = (
434+
f"neural-amp-modeler v{version} is now available. To upgrade, run "
435+
"pip install --upgrade neural-amp-modeler in your terminal."
436+
)
437+
self._label = _tk.Label(
438+
self._root,
439+
text=msg,
440+
justify=_tk.LEFT,
441+
wraplength=400,
442+
)
443+
self._label.pack(padx=10, pady=10)
444+
self._never_show_var = _tk.BooleanVar(value=False)
445+
self._checkbox = _tk.Checkbutton(
446+
self._root,
447+
text="Do not show this again",
448+
variable=self._never_show_var,
449+
)
450+
self._checkbox.pack(anchor="w", padx=10, pady=5)
451+
self._close_btn = _tk.Button(
452+
self._root,
453+
text="Close",
454+
width=_BUTTON_WIDTH,
455+
height=_BUTTON_HEIGHT,
456+
command=self._on_close,
457+
)
458+
self._close_btn.pack(pady=10)
459+
self._resume_main = resume_main
460+
461+
def _on_close(self):
462+
if self._never_show_var.get():
463+
_settings.set_update_settings(never_show_again=True)
464+
self._resume_main()
465+
self._root.destroy()
466+
467+
395468
class _GUIWidgets(_Enum):
396469
INPUT_PATH = "input_path"
397470
OUTPUT_PATH = "output_path"
398471
TRAINING_DESTINATION = "training_destination"
399472
METADATA = "metadata"
400473
ADVANCED_OPTIONS = "advanced_options"
401474
TRAIN = "train"
402-
UPDATE = "update"
403475

404476

405477
@_dataclass
@@ -468,9 +540,7 @@ def __init__(self):
468540
# Last frames: advanced options & train in the SE corner:
469541
self._frame_advanced_options = _tk.Frame(self._root)
470542
self._frame_train = _tk.Frame(self._root)
471-
self._frame_update = _tk.Frame(self._root)
472543
# Pack must be in reverse order
473-
self._frame_update.pack(side=_tk.BOTTOM, anchor="e")
474544
self._frame_train.pack(side=_tk.BOTTOM, anchor="e")
475545
self._frame_advanced_options.pack(side=_tk.BOTTOM, anchor="e")
476546

@@ -505,7 +575,7 @@ def __init__(self):
505575
)
506576
self._widgets[_GUIWidgets.TRAIN].pack()
507577

508-
self._pack_update_button_if_update_is_available()
578+
self._show_update_modal_if_update_available()
509579

510580
self._check_button_states()
511581

@@ -601,78 +671,15 @@ def _open_metadata(self):
601671

602672
self._wait_while_func(lambda resume: UserMetadataGUI(resume, self))
603673

604-
def _pack_update_button(self, version_from: _Version, version_to: _Version):
605-
"""
606-
Pack a button that a user can click to update
607-
"""
608-
609-
def update_nam():
610-
result = _subprocess.run(
611-
[
612-
f"{_sys.executable}",
613-
"-m",
614-
"pip",
615-
"install",
616-
"--upgrade",
617-
"neural-amp-modeler",
618-
]
619-
)
620-
if result.returncode == 0:
621-
self._wait_while_func(
622-
(lambda resume, *args, **kwargs: _OkModal(resume, *args, **kwargs)),
623-
"Update complete! Restart NAM for changes to take effect.",
624-
)
625-
else:
626-
self._wait_while_func(
627-
(lambda resume, *args, **kwargs: _OkModal(resume, *args, **kwargs)),
628-
"Update failed! See logs.",
629-
)
630-
631-
self._widgets[_GUIWidgets.UPDATE] = _tk.Button(
632-
self._frame_update,
633-
text=f"Update ({str(version_from)} -> {str(version_to)})",
634-
width=_BUTTON_WIDTH,
635-
height=_BUTTON_HEIGHT,
636-
command=update_nam,
637-
)
638-
self._widgets[_GUIWidgets.UPDATE].pack()
639-
640-
def _pack_update_button_if_update_is_available(self):
674+
def _show_update_modal_if_update_available(self):
641675
class UpdateInfo(_NamedTuple):
642676
available: bool
643677
current_version: _Version
644678
new_version: _Optional[_Version]
645679

646680
def get_info() -> UpdateInfo:
647-
# TODO error handling
648-
url = f"https://api.github.com/repos/sdatkinson/neural-amp-modeler/releases"
649681
current_version = _get_current_version()
650-
try:
651-
response = _requests.get(url)
652-
except _requests.exceptions.ConnectionError:
653-
print("WARNING: Failed to reach the server to check for updates")
654-
return UpdateInfo(
655-
available=False, current_version=current_version, new_version=None
656-
)
657-
if response.status_code != 200:
658-
print(f"Failed to fetch releases. Status code: {response.status_code}")
659-
return UpdateInfo(
660-
available=False, current_version=current_version, new_version=None
661-
)
662-
else:
663-
releases = response.json()
664-
latest_version = None
665-
if releases:
666-
for release in releases:
667-
tag = release["tag_name"]
668-
if not tag.startswith("v"):
669-
print(f"Found invalid version {tag}")
670-
else:
671-
this_version = _Version.from_string(tag[1:])
672-
if latest_version is None or this_version > latest_version:
673-
latest_version = this_version
674-
else:
675-
print("No releases found for this repository.")
682+
latest_version = _get_latest_version_from_github()
676683
update_available = (
677684
latest_version is not None and latest_version > current_version
678685
)
@@ -683,9 +690,30 @@ def get_info() -> UpdateInfo:
683690
)
684691

685692
update_info = get_info()
686-
if update_info.available:
687-
self._pack_update_button(
688-
update_info.current_version, update_info.new_version
693+
if not update_info.available or update_info.new_version is None: # No news
694+
return
695+
# Now figure out what we've seen in the past
696+
update_settings = _settings.get_update_settings()
697+
698+
settings_version = (
699+
_Version.from_string(update_settings["newest_available_version"])
700+
if update_settings["newest_available_version"] is not None
701+
else None
702+
)
703+
# Different new version since we last checked
704+
if settings_version is None or update_info.new_version > settings_version:
705+
_settings.set_update_settings(
706+
newest_available_version=str(update_info.new_version),
707+
never_show_again=False,
708+
)
709+
update_settings = _settings.get_update_settings()
710+
if update_settings["never_show_again"]:
711+
return
712+
else:
713+
self._wait_while_func(
714+
lambda resume: _UpdateAvailableModal(
715+
resume, str(update_info.new_version)
716+
),
689717
)
690718

691719
def _resume(self):

nam/train/gui/_resources/settings.py

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
_THIS_DIR = Path(__file__).parent.resolve()
1212
_SETTINGS_JSON_PATH = Path(_THIS_DIR, "settings.json")
1313
_LAST_PATHS_KEY = "last_paths"
14+
_UPDATE_KEY = "update"
15+
_NEWEST_AVAILABLE_VERSION_KEY = "newest_available_version"
16+
_NEVER_SHOW_AGAIN_KEY = "never_show_again"
1417

1518

1619
class PathKey(Enum):
@@ -19,8 +22,10 @@ class PathKey(Enum):
1922
TRAINING_DESTINATION = "training_destination"
2023

2124

22-
def get_last_path(path_key: PathKey) -> Optional[Path]:
23-
s = _get_settings()
25+
def get_last_path(
26+
path_key: PathKey, *, settings_path: Path = _SETTINGS_JSON_PATH
27+
) -> Optional[Path]:
28+
s = _get_settings(settings_path)
2429
if _LAST_PATHS_KEY not in s:
2530
return None
2631
last_path = s[_LAST_PATHS_KEY].get(path_key.value)
@@ -30,22 +35,56 @@ def get_last_path(path_key: PathKey) -> Optional[Path]:
3035
return Path(last_path)
3136

3237

33-
def set_last_path(path_key: PathKey, path: Path):
34-
s = _get_settings()
38+
def set_last_path(
39+
path_key: PathKey, path: Path, *, settings_path: Path = _SETTINGS_JSON_PATH
40+
):
41+
s = _get_settings(settings_path)
3542
if _LAST_PATHS_KEY not in s:
3643
s[_LAST_PATHS_KEY] = {}
3744
s[_LAST_PATHS_KEY][path_key.value] = str(path)
38-
_write_settings(s)
45+
_write_settings(s, settings_path=settings_path)
3946

4047

41-
def _get_settings() -> dict:
48+
def get_update_settings(*, settings_path: Path = _SETTINGS_JSON_PATH) -> dict:
49+
"""
50+
Return update-related settings: newest_available_version (str or None),
51+
never_show_again (bool).
52+
"""
53+
s = _get_settings(settings_path)
54+
update = s.get(_UPDATE_KEY) or {}
55+
return {
56+
_NEWEST_AVAILABLE_VERSION_KEY: update.get(_NEWEST_AVAILABLE_VERSION_KEY),
57+
_NEVER_SHOW_AGAIN_KEY: bool(update.get(_NEVER_SHOW_AGAIN_KEY, False)),
58+
}
59+
60+
61+
def set_update_settings(
62+
newest_available_version: Optional[str] = None,
63+
never_show_again: Optional[bool] = None,
64+
*,
65+
settings_path: Path = _SETTINGS_JSON_PATH,
66+
):
67+
"""
68+
Update one or more update settings. Pass None for a key to leave it unchanged.
69+
"""
70+
s = _get_settings(settings_path)
71+
if _UPDATE_KEY not in s:
72+
s[_UPDATE_KEY] = {}
73+
if newest_available_version is not None:
74+
s[_UPDATE_KEY][_NEWEST_AVAILABLE_VERSION_KEY] = newest_available_version
75+
if never_show_again is not None:
76+
s[_UPDATE_KEY][_NEVER_SHOW_AGAIN_KEY] = never_show_again
77+
_write_settings(s, settings_path=settings_path)
78+
79+
80+
def _get_settings(settings_path: Path = _SETTINGS_JSON_PATH) -> dict:
4281
"""
4382
Make sure that ./settings.json exists; if it does, then read it. If not, empty dict.
4483
"""
45-
if not _SETTINGS_JSON_PATH.exists():
84+
if not settings_path.exists():
4685
return dict()
4786
else:
48-
with open(_SETTINGS_JSON_PATH, "r") as fp:
87+
with open(settings_path, "r") as fp:
4988
return json.load(fp)
5089

5190

@@ -74,6 +113,6 @@ def __call__(self, *args, **kwargs):
74113
_write_settings = _WriteSettings()
75114

76115

77-
def _write_settings_unsafe(obj: dict):
78-
with open(_SETTINGS_JSON_PATH, "w") as fp:
116+
def _write_settings_unsafe(obj: dict, settings_path: Path = _SETTINGS_JSON_PATH):
117+
with open(settings_path, "w") as fp:
79118
json.dump(obj, fp, indent=4)

0 commit comments

Comments
 (0)