From bb24303469449fa95ce6e03af90a4fc01a1a3b92 Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Wed, 17 Dec 2025 10:58:00 -0600 Subject: [PATCH 01/50] Adds "Version" option in Settings; pulls git branch or hash in local dev --- src/seedsigner/controller.py | 30 ++++++++++++++- .../gui/screens/settings_screens.py | 38 +++++++++++++++++++ src/seedsigner/views/screensaver.py | 8 +++- src/seedsigner/views/settings_views.py | 20 ++++++++++ 4 files changed, 92 insertions(+), 4 deletions(-) diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py index 1e2f6d4f..a151d3f9 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -1,4 +1,5 @@ import logging +import os import time import traceback @@ -21,6 +22,9 @@ from seedsigner.views.view import Destination logger = logging.getLogger(__name__) +VERSION = "0.8.6" + + class BackStack(list[Destination]): def __repr__(self): @@ -100,8 +104,6 @@ class Controller(Singleton): rather than at the top in order avoid circular imports. """ - VERSION = "0.8.6" - # Declare class member vars with type hints to enable richer IDE support throughout # the code. _storage: SeedStorage = None # TODO: Rename "storage" to something more indicative of its temp, in-memory state @@ -475,3 +477,27 @@ class Controller(Singleton): exception_msg, ] return Destination(UnhandledExceptionView, view_args={"error": error}, clear_history=True) + + + def get_display_version(self) -> str: + """ + Returns a user-friendly version string for display in the UI. + """ + name = f"v{VERSION}" + if Settings.HOSTNAME == Settings.SEEDSIGNER_OS: + return name + + # We're in local dev; pull info from .git/HEAD if we can + git_HEAD_path = os.path.join(os.getcwd(), "..", ".git", "HEAD") + + if os.path.exists(git_HEAD_path): + with open(git_HEAD_path, "r") as f: + git_ref = f.read().strip() + if git_ref.startswith("ref:"): + # get the branch name + name = git_ref.split("/")[-1] + else: + # get the commit hash + name = git_ref[:7] + + return name diff --git a/src/seedsigner/gui/screens/settings_screens.py b/src/seedsigner/gui/screens/settings_screens.py index 48cdc7d3..9d26cc0e 100644 --- a/src/seedsigner/gui/screens/settings_screens.py +++ b/src/seedsigner/gui/screens/settings_screens.py @@ -310,6 +310,44 @@ class DonateScreen(BaseTopNavScreen): +@dataclass +class VersionScreen(BaseTopNavScreen): + version: str = None + + def __post_init__(self): + self.title = _("Version") + super().__post_init__() + + font_name = GUIConstants.FIXED_WIDTH_FONT_NAME + font_size = GUIConstants.get_top_nav_title_font_size() + 6 + font = Fonts.get_font(font_name, font_size) + (left, char_height, char_width, bottom) = font.getbbox("X", anchor="ls") + + if len(self.version) * char_width > self.canvas_width - 2*GUIConstants.EDGE_PADDING: + max_chars_width = int((self.canvas_width - 2*GUIConstants.EDGE_PADDING) / char_width) + # Add as many line breaks as needed for the version string to fit + wrapped_version = [] + for i in range(0, len(self.version), max_chars_width): + if i + max_chars_width < len(self.version): + wrapped_version.append(self.version[i:i+max_chars_width]) + else: + wrapped_version.append(self.version[i:]) + self.version = "\n".join(wrapped_version) + + # Center the version vertically + char_height *= -1 # due to the "ls" (baseline) anchor, height is negative + screen_y = int((self.canvas_height - self.top_nav.height) / 2) + self.top_nav.height - char_height*len(wrapped_version) + + self.components.append(TextArea( + text=self.version, + font_name=font_name, + font_size=font_size, + font_color=GUIConstants.ACCENT_COLOR, + screen_y=screen_y, + )) + + + @dataclass class SettingsQRConfirmationScreen(ButtonListScreen): config_name: str = None diff --git a/src/seedsigner/views/screensaver.py b/src/seedsigner/views/screensaver.py index 8b2970da..c8c51c70 100644 --- a/src/seedsigner/views/screensaver.py +++ b/src/seedsigner/views/screensaver.py @@ -97,13 +97,17 @@ class OpeningSplashScreen(LogoScreen): # Display version num below SeedSigner logo font = Fonts.get_font(GUIConstants.get_body_font_name(), GUIConstants.get_top_nav_title_font_size()) - version = f"v{controller.VERSION}" + version = controller.get_display_version() # The logo png is 240x240, but the actual logo is 70px tall, vertically centered logo_height = 70 version_x = int(self.renderer.canvas_width/2) version_y = int(self.canvas_height/2) + int(logo_height/2) + logo_offset_y + GUIConstants.COMPONENT_PADDING - self.renderer.draw.text(xy=(version_x, version_y), text=version, font=font, fill=GUIConstants.ACCENT_COLOR, anchor="mt") + version_max_chars = 20 + self.renderer.draw.text(xy=(version_x, version_y), text=version[:version_max_chars], font=font, fill=GUIConstants.ACCENT_COLOR, anchor="mt") + if len(version) > version_max_chars: + # Squeeze a second version display line in if needed + self.renderer.draw.text(xy=(version_x, version_y + GUIConstants.get_top_nav_title_font_size()), text=version[version_max_chars:], font=font, fill=GUIConstants.ACCENT_COLOR, anchor="mt") if not self.renderer.is_screenshot_generator: self.renderer.show_image() diff --git a/src/seedsigner/views/settings_views.py b/src/seedsigner/views/settings_views.py index cbf66e08..df03e436 100644 --- a/src/seedsigner/views/settings_views.py +++ b/src/seedsigner/views/settings_views.py @@ -17,6 +17,7 @@ class SettingsMenuView(View): HARDWARE = ButtonOption("Hardware", right_icon_name=SeedSignerIconConstants.CHEVRON_RIGHT) IO_TEST = ButtonOption("I/O test") DONATE = ButtonOption("Donate") + VERSION = ButtonOption("Version") def __init__(self, visibility: str = SettingsConstants.VISIBILITY__GENERAL, selected_attr: str = None, initial_scroll: int = 0): super().__init__() @@ -49,6 +50,7 @@ class SettingsMenuView(View): button_data.append(self.IO_TEST) button_data.append(self.DONATE) + button_data.append(self.VERSION) elif self.visibility == SettingsConstants.VISIBILITY__ADVANCED: title = _("Advanced") @@ -96,6 +98,9 @@ class SettingsMenuView(View): elif button_data[selected_menu_num] == self.DONATE: return Destination(DonateView) + + elif button_data[selected_menu_num] == self.VERSION: + return Destination(VersionView) elif settings_entries[selected_menu_num].attr_name == SettingsConstants.SETTING__LOCALE: return Destination(LocaleSelectionView) @@ -315,3 +320,18 @@ class DonateView(View): self.run_screen(settings_screens.DonateScreen) return Destination(SettingsMenuView) + + + +class VersionView(View): + def run(self): + from seedsigner.controller import Controller + controller = Controller.get_instance() + version = controller.get_display_version() + + self.run_screen( + settings_screens.VersionScreen, + version=version + ) + + return Destination(SettingsMenuView) \ No newline at end of file From 3a06333435bb94ca35f42c606e79e35f13c81c66 Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Wed, 17 Dec 2025 14:04:35 -0600 Subject: [PATCH 02/50] Add get_last_src_edit --- src/seedsigner/controller.py | 39 +++++++++++++++++-- .../gui/screens/settings_screens.py | 16 +++++++- src/seedsigner/views/screensaver.py | 2 +- src/seedsigner/views/settings_views.py | 4 +- tests/screenshot_generator/generator.py | 1 + 5 files changed, 54 insertions(+), 8 deletions(-) diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py index a151d3f9..6284a460 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -1,3 +1,4 @@ +from datetime import datetime import logging import os import time @@ -479,16 +480,23 @@ class Controller(Singleton): return Destination(UnhandledExceptionView, view_args={"error": error}, clear_history=True) - def get_display_version(self) -> str: + def get_version(self) -> str: """ - Returns a user-friendly version string for display in the UI. + Returns either the hard-coded Controller.VERSION or a dynamically detected + version string for display in the UI. In local dev will return either the + current git branch name or commit hash. """ name = f"v{VERSION}" if Settings.HOSTNAME == Settings.SEEDSIGNER_OS: return name - + # We're in local dev; pull info from .git/HEAD if we can - git_HEAD_path = os.path.join(os.getcwd(), "..", ".git", "HEAD") + git_HEAD_dir = os.getcwd() + + # Main app runs from src/ dir, tests and screenshot generator from project root + if "src" in git_HEAD_dir: + git_HEAD_dir = os.path.join(git_HEAD_dir, "..") + git_HEAD_path = os.path.join(git_HEAD_dir, ".git", "HEAD") if os.path.exists(git_HEAD_path): with open(git_HEAD_path, "r") as f: @@ -501,3 +509,26 @@ class Controller(Singleton): name = git_ref[:7] return name + + + def get_last_src_edit(self) -> datetime: + """ + Recursively scan the src/ directory for the most recent python file edit time and + return it. + """ + src_path = os.getcwd() + print(f"{src_path=}") + if "src" not in src_path: + # Screenshot generator runs from the project root + src_path = os.path.join(src_path, "src") + + print(f"{src_path=}") + latest_mtime = 0 + for dirpath, dirnames, filenames in os.walk(src_path): + for filename in filenames: + if filename.endswith(".py"): + filepath = os.path.join(dirpath, filename) + mtime = os.path.getmtime(filepath) + if mtime > latest_mtime: + latest_mtime = mtime + return datetime.fromtimestamp(latest_mtime) \ No newline at end of file diff --git a/src/seedsigner/gui/screens/settings_screens.py b/src/seedsigner/gui/screens/settings_screens.py index 9d26cc0e..14e27d24 100644 --- a/src/seedsigner/gui/screens/settings_screens.py +++ b/src/seedsigner/gui/screens/settings_screens.py @@ -1,3 +1,4 @@ +import datetime import time from dataclasses import dataclass @@ -313,6 +314,7 @@ class DonateScreen(BaseTopNavScreen): @dataclass class VersionScreen(BaseTopNavScreen): version: str = None + last_edit: datetime = None def __post_init__(self): self.title = _("Version") @@ -323,6 +325,7 @@ class VersionScreen(BaseTopNavScreen): font = Fonts.get_font(font_name, font_size) (left, char_height, char_width, bottom) = font.getbbox("X", anchor="ls") + num_version_lines = 1 if len(self.version) * char_width > self.canvas_width - 2*GUIConstants.EDGE_PADDING: max_chars_width = int((self.canvas_width - 2*GUIConstants.EDGE_PADDING) / char_width) # Add as many line breaks as needed for the version string to fit @@ -333,10 +336,14 @@ class VersionScreen(BaseTopNavScreen): else: wrapped_version.append(self.version[i:]) self.version = "\n".join(wrapped_version) + num_version_lines = len(wrapped_version) # Center the version vertically char_height *= -1 # due to the "ls" (baseline) anchor, height is negative - screen_y = int((self.canvas_height - self.top_nav.height) / 2) + self.top_nav.height - char_height*len(wrapped_version) + + # Note: we roughly account for the last edit display's vertical space by adding to + # num_version_lines. + screen_y = int((self.canvas_height - self.top_nav.height) / 2) + self.top_nav.height - char_height*(num_version_lines + 1) self.components.append(TextArea( text=self.version, @@ -346,6 +353,13 @@ class VersionScreen(BaseTopNavScreen): screen_y=screen_y, )) + last_edit_str = self.last_edit.strftime("%Y-%m-%d\n%H:%M:%S UTC") + self.components.append(TextArea( + text=last_edit_str, + is_text_centered=True, + screen_y=self.components[-1].screen_y + self.components[-1].height + 2*GUIConstants.COMPONENT_PADDING, + )) + @dataclass diff --git a/src/seedsigner/views/screensaver.py b/src/seedsigner/views/screensaver.py index c8c51c70..25be5ccf 100644 --- a/src/seedsigner/views/screensaver.py +++ b/src/seedsigner/views/screensaver.py @@ -97,7 +97,7 @@ class OpeningSplashScreen(LogoScreen): # Display version num below SeedSigner logo font = Fonts.get_font(GUIConstants.get_body_font_name(), GUIConstants.get_top_nav_title_font_size()) - version = controller.get_display_version() + version = controller.get_version() # The logo png is 240x240, but the actual logo is 70px tall, vertically centered logo_height = 70 diff --git a/src/seedsigner/views/settings_views.py b/src/seedsigner/views/settings_views.py index df03e436..9bc2ff63 100644 --- a/src/seedsigner/views/settings_views.py +++ b/src/seedsigner/views/settings_views.py @@ -327,11 +327,11 @@ class VersionView(View): def run(self): from seedsigner.controller import Controller controller = Controller.get_instance() - version = controller.get_display_version() self.run_screen( settings_screens.VersionScreen, - version=version + version=controller.get_version(), + last_edit=controller.get_last_src_edit(), ) return Destination(SettingsMenuView) \ No newline at end of file diff --git a/tests/screenshot_generator/generator.py b/tests/screenshot_generator/generator.py index e45373fc..3d32678b 100644 --- a/tests/screenshot_generator/generator.py +++ b/tests/screenshot_generator/generator.py @@ -425,6 +425,7 @@ def generate_screenshots(locale): "Settings Views": settings_views_list + [ ScreenshotConfig(settings_views.IOTestView), ScreenshotConfig(settings_views.DonateView), + ScreenshotConfig(settings_views.VersionView), ScreenshotConfig(settings_views.SettingsIngestSettingsQRView, dict(data=settingsqr_data_persistent), screenshot_name="SettingsIngestSettingsQRView_persistent"), ScreenshotConfig(settings_views.SettingsIngestSettingsQRView, dict(data=settingsqr_data_not_persistent), screenshot_name="SettingsIngestSettingsQRView_not_persistent"), ], From 7c7ac3a6db877a58d3d569618267bb0202bed6d3 Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Wed, 17 Dec 2025 14:24:51 -0600 Subject: [PATCH 03/50] comments updates --- src/seedsigner/controller.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py index 6284a460..f325a346 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -482,15 +482,13 @@ class Controller(Singleton): def get_version(self) -> str: """ - Returns either the hard-coded Controller.VERSION or a dynamically detected - version string for display in the UI. In local dev will return either the - current git branch name or commit hash. + Will attempt to read the current git branch name or commit hash from + .git/HEAD. But if there's no git info available, it will fall back to the + hard-coded Controller.VERSION. """ name = f"v{VERSION}" - if Settings.HOSTNAME == Settings.SEEDSIGNER_OS: - return name - # We're in local dev; pull info from .git/HEAD if we can + # .git/HEAD will be in the project root, if it exists git_HEAD_dir = os.getcwd() # Main app runs from src/ dir, tests and screenshot generator from project root @@ -502,10 +500,11 @@ class Controller(Singleton): with open(git_HEAD_path, "r") as f: git_ref = f.read().strip() if git_ref.startswith("ref:"): - # get the branch name + # HEAD format: "ref: refs/heads/some_branch_name" name = git_ref.split("/")[-1] else: - # get the commit hash + # If we're on a detached HEAD, the contents will just be the current + # commit hash. name = git_ref[:7] return name From 91dd5ff23ce193b565ed6672f3f216c8d8aeadcb Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Wed, 17 Dec 2025 15:21:09 -0600 Subject: [PATCH 04/50] Match git tag name to current commit; clean up latest src edit scan --- src/seedsigner/controller.py | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py index f325a346..e542213e 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -471,7 +471,7 @@ class Controller(Singleton): if ", line " in traceback_line: line_info = traceback_line.split("/")[-1].replace("\"", "").replace("line ", "") break - + error = [ exception_type, line_info, @@ -507,6 +507,19 @@ class Controller(Singleton): # commit hash. name = git_ref[:7] + # Check the .git/refs/tags dir for a tag matching this commit hash + git_refs_tags_dir = os.path.join(git_HEAD_dir, ".git", "refs", "tags") + if os.path.exists(git_refs_tags_dir): + for tag_filename in os.listdir(git_refs_tags_dir): + tag_path = os.path.join(git_refs_tags_dir, tag_filename) + with open(tag_path, "r") as tag_file: + # Tag files just contain their associated commit hash + tag_commit_hash = tag_file.read().strip() + if tag_commit_hash.startswith(git_ref): + # Filename is the tag name + name = f"v{tag_filename}" + break + return name @@ -516,18 +529,19 @@ class Controller(Singleton): return it. """ src_path = os.getcwd() - print(f"{src_path=}") if "src" not in src_path: # Screenshot generator runs from the project root src_path = os.path.join(src_path, "src") - - print(f"{src_path=}") - latest_mtime = 0 + + latest_edit = 0 for dirpath, dirnames, filenames in os.walk(src_path): + if "__pycache__" in dirpath: + continue for filename in filenames: if filename.endswith(".py"): filepath = os.path.join(dirpath, filename) mtime = os.path.getmtime(filepath) - if mtime > latest_mtime: - latest_mtime = mtime - return datetime.fromtimestamp(latest_mtime) \ No newline at end of file + if mtime > latest_edit: + latest_edit = mtime + + return datetime.fromtimestamp(latest_edit) \ No newline at end of file From 6229f0270a0891b679fca2a275e9ec189618418c Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Wed, 17 Dec 2025 15:24:54 -0600 Subject: [PATCH 05/50] Minor edit --- src/seedsigner/controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py index e542213e..877e3481 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -515,7 +515,7 @@ class Controller(Singleton): with open(tag_path, "r") as tag_file: # Tag files just contain their associated commit hash tag_commit_hash = tag_file.read().strip() - if tag_commit_hash.startswith(git_ref): + if tag_commit_hash == git_ref: # Filename is the tag name name = f"v{tag_filename}" break From c5bff2ae80a987c389fec8846ffddb1f5e5f563a Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Wed, 17 Dec 2025 17:28:57 -0600 Subject: [PATCH 06/50] Create a 2nd screenshot for the bottom part of the main Settings menu --- src/seedsigner/views/settings_views.py | 19 +++++++++++-------- tests/screenshot_generator/generator.py | 3 +++ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/seedsigner/views/settings_views.py b/src/seedsigner/views/settings_views.py index 9bc2ff63..a35631d6 100644 --- a/src/seedsigner/views/settings_views.py +++ b/src/seedsigner/views/settings_views.py @@ -19,10 +19,11 @@ class SettingsMenuView(View): DONATE = ButtonOption("Donate") VERSION = ButtonOption("Version") - def __init__(self, visibility: str = SettingsConstants.VISIBILITY__GENERAL, selected_attr: str = None, initial_scroll: int = 0): + def __init__(self, visibility: str = SettingsConstants.VISIBILITY__GENERAL, selected_attr: str = None, selected_button_option: ButtonOption = None, initial_scroll: int = 0): super().__init__() self.visibility = visibility self.selected_attr = selected_attr + self.selected_button_option = selected_button_option # Used to preserve the rendering position in the list self.initial_scroll = initial_scroll @@ -34,13 +35,6 @@ class SettingsMenuView(View): ) button_data: list[ButtonOption] = [ButtonOption(e.display_name) for e in settings_entries] - selected_button = 0 - if self.selected_attr: - for i, entry in enumerate(settings_entries): - if entry.attr_name == self.selected_attr: - selected_button = i - break - if self.visibility == SettingsConstants.VISIBILITY__GENERAL: title = _("Settings") @@ -67,6 +61,15 @@ class SettingsMenuView(View): title = _("Dev Options") next_destination = None + selected_button = 0 + if self.selected_button_option: + selected_button = button_data.index(self.selected_button_option) + elif self.selected_attr: + for i, entry in enumerate(settings_entries): + if entry.attr_name == self.selected_attr: + selected_button = i + break + selected_menu_num = self.run_screen( ButtonListScreen, title=title, diff --git a/tests/screenshot_generator/generator.py b/tests/screenshot_generator/generator.py index 3d32678b..8ac7f53b 100644 --- a/tests/screenshot_generator/generator.py +++ b/tests/screenshot_generator/generator.py @@ -211,6 +211,9 @@ def generate_screenshots(locale): # Add the top level "General" settings menu and entries settings_views_list.append(ScreenshotConfig(settings_views.SettingsMenuView)) + + # Scroll the general settings to the bottom + settings_views_list.append(ScreenshotConfig(settings_views.SettingsMenuView, dict(selected_button_option=settings_views.SettingsMenuView.VERSION), screenshot_name="SettingsMenuView_2")) add_settings_entries(SettingsConstants.VISIBILITY__GENERAL) # Add the "Advanced" menu... From 60e11e9e3c377671cb97663d30693f3f38bfc33c Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Thu, 18 Dec 2025 11:03:31 -0600 Subject: [PATCH 07/50] Move version functionality to its own `helpers/version.py` file --- src/seedsigner/controller.py | 70 ---------- .../gui/screens/settings_screens.py | 13 +- src/seedsigner/helpers/version.py | 129 ++++++++++++++++++ src/seedsigner/views/screensaver.py | 3 +- src/seedsigner/views/settings_views.py | 7 +- 5 files changed, 141 insertions(+), 81 deletions(-) create mode 100644 src/seedsigner/helpers/version.py diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py index 877e3481..c2ecf967 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -23,9 +23,6 @@ from seedsigner.views.view import Destination logger = logging.getLogger(__name__) -VERSION = "0.8.6" - - class BackStack(list[Destination]): def __repr__(self): @@ -478,70 +475,3 @@ class Controller(Singleton): exception_msg, ] return Destination(UnhandledExceptionView, view_args={"error": error}, clear_history=True) - - - def get_version(self) -> str: - """ - Will attempt to read the current git branch name or commit hash from - .git/HEAD. But if there's no git info available, it will fall back to the - hard-coded Controller.VERSION. - """ - name = f"v{VERSION}" - - # .git/HEAD will be in the project root, if it exists - git_HEAD_dir = os.getcwd() - - # Main app runs from src/ dir, tests and screenshot generator from project root - if "src" in git_HEAD_dir: - git_HEAD_dir = os.path.join(git_HEAD_dir, "..") - git_HEAD_path = os.path.join(git_HEAD_dir, ".git", "HEAD") - - if os.path.exists(git_HEAD_path): - with open(git_HEAD_path, "r") as f: - git_ref = f.read().strip() - if git_ref.startswith("ref:"): - # HEAD format: "ref: refs/heads/some_branch_name" - name = git_ref.split("/")[-1] - else: - # If we're on a detached HEAD, the contents will just be the current - # commit hash. - name = git_ref[:7] - - # Check the .git/refs/tags dir for a tag matching this commit hash - git_refs_tags_dir = os.path.join(git_HEAD_dir, ".git", "refs", "tags") - if os.path.exists(git_refs_tags_dir): - for tag_filename in os.listdir(git_refs_tags_dir): - tag_path = os.path.join(git_refs_tags_dir, tag_filename) - with open(tag_path, "r") as tag_file: - # Tag files just contain their associated commit hash - tag_commit_hash = tag_file.read().strip() - if tag_commit_hash == git_ref: - # Filename is the tag name - name = f"v{tag_filename}" - break - - return name - - - def get_last_src_edit(self) -> datetime: - """ - Recursively scan the src/ directory for the most recent python file edit time and - return it. - """ - src_path = os.getcwd() - if "src" not in src_path: - # Screenshot generator runs from the project root - src_path = os.path.join(src_path, "src") - - latest_edit = 0 - for dirpath, dirnames, filenames in os.walk(src_path): - if "__pycache__" in dirpath: - continue - for filename in filenames: - if filename.endswith(".py"): - filepath = os.path.join(dirpath, filename) - mtime = os.path.getmtime(filepath) - if mtime > latest_edit: - latest_edit = mtime - - return datetime.fromtimestamp(latest_edit) \ No newline at end of file diff --git a/src/seedsigner/gui/screens/settings_screens.py b/src/seedsigner/gui/screens/settings_screens.py index 14e27d24..2c1ed82d 100644 --- a/src/seedsigner/gui/screens/settings_screens.py +++ b/src/seedsigner/gui/screens/settings_screens.py @@ -353,12 +353,13 @@ class VersionScreen(BaseTopNavScreen): screen_y=screen_y, )) - last_edit_str = self.last_edit.strftime("%Y-%m-%d\n%H:%M:%S UTC") - self.components.append(TextArea( - text=last_edit_str, - is_text_centered=True, - screen_y=self.components[-1].screen_y + self.components[-1].height + 2*GUIConstants.COMPONENT_PADDING, - )) + if self.last_edit: + last_edit_str = self.last_edit.strftime("%Y-%m-%d\n%H:%M:%S UTC") + self.components.append(TextArea( + text=last_edit_str, + is_text_centered=True, + screen_y=self.components[-1].screen_y + self.components[-1].height + 2*GUIConstants.COMPONENT_PADDING, + )) diff --git a/src/seedsigner/helpers/version.py b/src/seedsigner/helpers/version.py new file mode 100644 index 00000000..db1d4ab2 --- /dev/null +++ b/src/seedsigner/helpers/version.py @@ -0,0 +1,129 @@ +import logging +import os +from datetime import datetime + + +logger = logging.getLogger(__name__) + + + +class Version: + """ + Utility class to detect the current version and the last edit time of the source code. + + Version detection attempts to read the current git branch, commit hash, or tag but + will fall back to the hard-coded VERSION constant if no git info is available. + + Internal utility functions are separated out as class methods for easier mocking in tests. + """ + + VERSION = "0.8.6" + + + @classmethod + def _get_dot_git_dir(cls) -> str: + # .git will be in the project root, if it exists + git_HEAD_dir = os.getcwd() + + # Main app runs from src/ dir, tests and screenshot generator from project root + if "src" in git_HEAD_dir: + git_HEAD_dir = git_HEAD_dir.rsplit("src", 1)[0] + + return os.path.join(git_HEAD_dir, ".git") + + + @classmethod + def _read_HEAD_file(cls) -> tuple[str,str]: + git_HEAD_path = os.path.join(cls._get_dot_git_dir(), "HEAD") + + branch_name = None + commit_hash = None + if os.path.exists(git_HEAD_path): + with open(git_HEAD_path, "r") as f: + git_ref = f.read().strip() + if git_ref.startswith("ref:"): + # HEAD format: "ref: refs/heads/some_branch_name" + branch_name = git_ref.split("/")[-1] + else: + # If we're on a detached HEAD, the contents will just be the current + # commit hash. + commit_hash = git_ref + return (branch_name, commit_hash) + + + @classmethod + def _get_matching_tag(cls, commit_hash: str) -> str: + # Check the .git/refs/tags dir for a tag matching this commit hash + git_refs_tags_dir = os.path.join(cls._get_dot_git_dir(), "refs", "tags") + if os.path.exists(git_refs_tags_dir): + for tag_filename in os.listdir(git_refs_tags_dir): + tag_path = os.path.join(git_refs_tags_dir, tag_filename) + with open(tag_path, "r") as tag_file: + # Tag files just contain their associated commit hash + tag_commit_hash = tag_file.read().strip() + if tag_commit_hash == commit_hash: + # Filename is the tag name + return tag_filename + return None + + + @classmethod + def get_version(cls) -> str: + """ + Will attempt to read the current git branch name or commit hash from + .git/HEAD. But if there's no git info available, it will fall back to the + hard-coded VERSION constant. + """ + name = f"v{cls.VERSION}" + + branch_name, commit_hash = cls._read_HEAD_file() + if branch_name: + name = branch_name + elif commit_hash: + # See if this commit_hash matches a tag + matching_tag = cls._get_matching_tag(commit_hash) + if matching_tag: + name = matching_tag + else: + name = commit_hash[:7] # short commit hash + + return name + + + @classmethod + def get_last_src_edit(cls) -> datetime: + """ + Recursively scan the src/ directory for the most recent python file edit time. + """ + try: + src_path = os.getcwd() + if "src" not in src_path: + # Screenshot generator runs from the project root + src_path = os.path.join(src_path, "src") + + last_modified = 0.0 + num_files = 0 + for dirpath, dirnames, filenames in os.walk(src_path): + if "__pycache__" in dirpath: + continue + for filename in filenames: + if filename.endswith(".py"): + num_files += 1 + filepath = os.path.join(dirpath, filename) + + # getmtime + file_mtime = os.path.getmtime(filepath) + last_modified = max(file_mtime, last_modified) + + # Sanity check + if num_files == 0: + raise Exception(f"No python source files found in {src_path}") + + return datetime.fromtimestamp(last_modified) + + except Exception as e: + # Catch and log any unexpected errors but this isn't a mission-critical + # function so return gracefully. + import traceback + logger.error(traceback.format_exc()) + return None \ No newline at end of file diff --git a/src/seedsigner/views/screensaver.py b/src/seedsigner/views/screensaver.py index 25be5ccf..3160626a 100644 --- a/src/seedsigner/views/screensaver.py +++ b/src/seedsigner/views/screensaver.py @@ -8,6 +8,7 @@ from gettext import gettext as _ from seedsigner.gui.components import Fonts, GUIConstants, load_image from seedsigner.gui.screens.screen import BaseScreen +from seedsigner.helpers.version import Version from seedsigner.models.settings import Settings from seedsigner.models.settings_definition import SettingsConstants from seedsigner.views.view import View @@ -97,7 +98,7 @@ class OpeningSplashScreen(LogoScreen): # Display version num below SeedSigner logo font = Fonts.get_font(GUIConstants.get_body_font_name(), GUIConstants.get_top_nav_title_font_size()) - version = controller.get_version() + version = Version.get_version() # The logo png is 240x240, but the actual logo is 70px tall, vertically centered logo_height = 70 diff --git a/src/seedsigner/views/settings_views.py b/src/seedsigner/views/settings_views.py index a35631d6..85b446e9 100644 --- a/src/seedsigner/views/settings_views.py +++ b/src/seedsigner/views/settings_views.py @@ -328,13 +328,12 @@ class DonateView(View): class VersionView(View): def run(self): - from seedsigner.controller import Controller - controller = Controller.get_instance() + from seedsigner.helpers.version import Version self.run_screen( settings_screens.VersionScreen, - version=controller.get_version(), - last_edit=controller.get_last_src_edit(), + version=Version.get_version(), + last_edit=Version.get_last_src_edit(), ) return Destination(SettingsMenuView) \ No newline at end of file From 3f0ece384d738be269e9c8bac00855b854df718b Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Thu, 18 Dec 2025 11:04:31 -0600 Subject: [PATCH 08/50] Tests fail w/root `pytest` but succeed if test file is run directly. --- tests/test_version.py | 84 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 tests/test_version.py diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 00000000..8006966f --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,84 @@ +from datetime import datetime +import os +from unittest import mock + +# Must import this before any SeedSigner imports +from base import BaseTest + +from seedsigner.helpers.version import Version + + + +class TestVersion(BaseTest): + def test_version_with_no_git_head(self): + """ + If there is no .git/HEAD file, the hard-coded VERSION constant should be returned. + """ + # mock out the os.path.exists call to always return False + with mock.patch("os.path.exists", return_value=False): + version = Version.get_version() + assert version == f"v{Version.VERSION}" + + + def test_version_with_actual_git_head(self): + """ + If the .git dir exists on our actual filesystem right now, we should get a version + based on its contents. + + NOTE: This test is potentially fragile as it depends on the test runner system's actual + git state. The get_version() call should be able to handle all possible git states, but + this does create the possibility of external variability. + """ + fake_hardcoded_version = "fake.version.123" + with mock.patch.object(Version, 'VERSION', fake_hardcoded_version): + git_dot_dir = Version._get_dot_git_dir() + if os.path.exists(git_dot_dir): + assert Version.get_version() != f"v{fake_hardcoded_version}" + else: + # if there's no .git dir, mark this test as skipped + self.skipTest(f"No .git dir found at {git_dot_dir}, skipping test.") + + + def test_version_with_mocked_git_head(self): + """ + If there is a .git/HEAD file, the version should report the current git branch + name, commit hash, or a matching tag. + """ + with mock.patch("os.path.exists", return_value=True): + # Mock the HEAD file read in _read_HEAD_file to return our fake branch name + branch_name = "my_feature_branch" + git_HEAD_content = f"ref: refs/heads/{branch_name}" + with mock.patch("builtins.open", mock.mock_open(read_data=git_HEAD_content)): + version = Version.get_version() + assert version == branch_name + + # Mock the HEAD file read in _read_HEAD_file to return a fake commit hash + commit_hash = "abcdef1234567890" + with mock.patch("builtins.open", mock.mock_open(read_data=commit_hash)): + # Mock that there are no matching tags for this commit hash + with mock.patch.object(Version, '_get_matching_tag', return_value=None): + version = Version.get_version() + assert version == commit_hash[:7] # short commit hash + + # Now mock that there is a matching tag for this commit hash + tag_name = "v1.2.3" + with mock.patch.object(Version, '_get_matching_tag', return_value=tag_name): + version = Version.get_version() + assert version == tag_name + + + def test_get_last_src_edit(self): + """ + Test that get_last_src_edit returns a sane datetime object. Assumes the system + running this test has a reasonably correct system time. + """ + last_edit = Version.get_last_src_edit() + assert isinstance(last_edit, datetime) + + # Has to be more recent than the first SeedSigner v0.0.1 release + known_past = datetime(2020, 12, 13) + assert last_edit > known_past + + # Could not have happened tomorrow + known_future = datetime.now().replace(year=datetime.now().year + 1) + assert last_edit < known_future \ No newline at end of file From 28c1d92889154349037aaaf9bfa4b9d5d7275c99 Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Thu, 18 Dec 2025 11:52:00 -0600 Subject: [PATCH 09/50] Cleaner dir path approach --- src/seedsigner/helpers/version.py | 19 +++++++------------ tests/test_version.py | 2 +- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/src/seedsigner/helpers/version.py b/src/seedsigner/helpers/version.py index db1d4ab2..a8075545 100644 --- a/src/seedsigner/helpers/version.py +++ b/src/seedsigner/helpers/version.py @@ -22,14 +22,11 @@ class Version: @classmethod def _get_dot_git_dir(cls) -> str: - # .git will be in the project root, if it exists - git_HEAD_dir = os.getcwd() + # If it exists, the .git dir will be in the project root + path = os.path.dirname(os.path.abspath(__file__)) + project_root = path.rsplit("/src", 1)[0] - # Main app runs from src/ dir, tests and screenshot generator from project root - if "src" in git_HEAD_dir: - git_HEAD_dir = git_HEAD_dir.rsplit("src", 1)[0] - - return os.path.join(git_HEAD_dir, ".git") + return os.path.join(project_root, ".git") @classmethod @@ -96,10 +93,8 @@ class Version: Recursively scan the src/ directory for the most recent python file edit time. """ try: - src_path = os.getcwd() - if "src" not in src_path: - # Screenshot generator runs from the project root - src_path = os.path.join(src_path, "src") + path = os.path.dirname(os.path.abspath(__file__)) + src_path = path.rsplit("/src", 1)[0] + "/src" last_modified = 0.0 num_files = 0 @@ -111,7 +106,7 @@ class Version: num_files += 1 filepath = os.path.join(dirpath, filename) - # getmtime + # getmtime returns the file's last modified time file_mtime = os.path.getmtime(filepath) last_modified = max(file_mtime, last_modified) diff --git a/tests/test_version.py b/tests/test_version.py index 8006966f..4d450d65 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -65,7 +65,7 @@ class TestVersion(BaseTest): with mock.patch.object(Version, '_get_matching_tag', return_value=tag_name): version = Version.get_version() assert version == tag_name - + def test_get_last_src_edit(self): """ From 31d2d8bfe5c74425f332631c12968e4b0ea937d9 Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Thu, 18 Dec 2025 11:56:07 -0600 Subject: [PATCH 10/50] Bugfix: `Mock` persisted beyond intended test case --- tests/test_settings_definition.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/test_settings_definition.py b/tests/test_settings_definition.py index f95a6c8b..794bdba0 100644 --- a/tests/test_settings_definition.py +++ b/tests/test_settings_definition.py @@ -1,5 +1,5 @@ import os -from unittest.mock import Mock +from unittest.mock import patch from base import BaseTest from seedsigner.models.settings_definition import SettingsConstants @@ -31,8 +31,7 @@ class TestSettingsDefinition(BaseTest): # We're going to mock the `root` results to include the absent language code's .mo file mocked_results = [(os.path.join(root, "en", "LC_MESSAGES"), [], ["messages.po", "messages.mo"])] mocked_results.append((os.path.join(root, absent_language_code, "LC_MESSAGES"), [], ["messages.po", "messages.mo"])) - os.walk = Mock(return_value=mocked_results) - - # Recheck w/our mocked dir listing: - detected_languages = [lang_tuple[0] for lang_tuple in SettingsConstants.get_detected_languages()] - assert absent_language_code in detected_languages + with patch("os.walk", return_value=mocked_results): + # Recheck w/our mocked dir listing: + detected_languages = [lang_tuple[0] for lang_tuple in SettingsConstants.get_detected_languages()] + assert absent_language_code in detected_languages From 483cb814d63814e20f960bf179043a02cc6e3434 Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Thu, 18 Dec 2025 12:09:07 -0600 Subject: [PATCH 11/50] Fix path construction to be compatible with CI --- src/seedsigner/helpers/version.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/seedsigner/helpers/version.py b/src/seedsigner/helpers/version.py index a8075545..71363589 100644 --- a/src/seedsigner/helpers/version.py +++ b/src/seedsigner/helpers/version.py @@ -24,7 +24,9 @@ class Version: def _get_dot_git_dir(cls) -> str: # If it exists, the .git dir will be in the project root path = os.path.dirname(os.path.abspath(__file__)) - project_root = path.rsplit("/src", 1)[0] + + # Have to back out of "helpers" and "seedsigner" and "src" dirs + project_root = os.path.join(path, "..", "..", "..") return os.path.join(project_root, ".git") @@ -94,7 +96,9 @@ class Version: """ try: path = os.path.dirname(os.path.abspath(__file__)) - src_path = path.rsplit("/src", 1)[0] + "/src" + + # Have to back out of "helpers" and "seedsigner" dirs + src_path = os.path.join(path, "..", "..") last_modified = 0.0 num_files = 0 From b1877e64b2072136a12d09ffb3044e6fe25c5c11 Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Thu, 18 Dec 2025 12:13:09 -0600 Subject: [PATCH 12/50] Fix "skip test" call --- tests/test_version.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_version.py b/tests/test_version.py index 4d450d65..cc99ef3d 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -1,5 +1,6 @@ -from datetime import datetime import os +import pytest +from datetime import datetime from unittest import mock # Must import this before any SeedSigner imports @@ -35,8 +36,8 @@ class TestVersion(BaseTest): if os.path.exists(git_dot_dir): assert Version.get_version() != f"v{fake_hardcoded_version}" else: - # if there's no .git dir, mark this test as skipped - self.skipTest(f"No .git dir found at {git_dot_dir}, skipping test.") + # If there's no .git dir, mark this test as skipped + pytest.skip(f"No .git dir found at {git_dot_dir}, skipping test.") def test_version_with_mocked_git_head(self): From 9b7176244bc18265556a5bf730bb90226664e1ec Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Sat, 20 Dec 2025 09:11:55 -0600 Subject: [PATCH 13/50] Write src/version.json --- src/seedsigner/helpers/version.py | 58 ++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/src/seedsigner/helpers/version.py b/src/seedsigner/helpers/version.py index 71363589..b5f70683 100644 --- a/src/seedsigner/helpers/version.py +++ b/src/seedsigner/helpers/version.py @@ -1,3 +1,4 @@ +import json import logging import os from datetime import datetime @@ -19,6 +20,8 @@ class Version: VERSION = "0.8.6" + VERSION_FILENAME = "version.json" + @classmethod def _get_dot_git_dir(cls) -> str: @@ -64,6 +67,23 @@ class Version: # Filename is the tag name return tag_filename return None + + + @classmethod + def _get_version_file(cls) -> dict | None: + """ + Attempts to read the VERSION_FILENAME and return its contents as a dict. + """ + version_file_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", cls.VERSION_FILENAME) + if os.path.exists(version_file_path): + try: + with open(version_file_path, "r") as f: + return json.load(f) + except Exception as e: + # In local dev we don't expect/want this file to exist + pass + return None + @classmethod @@ -73,8 +93,7 @@ class Version: .git/HEAD. But if there's no git info available, it will fall back to the hard-coded VERSION constant. """ - name = f"v{cls.VERSION}" - + name = None branch_name, commit_hash = cls._read_HEAD_file() if branch_name: name = branch_name @@ -85,6 +104,16 @@ class Version: name = matching_tag else: name = commit_hash[:7] # short commit hash + + if name is None: + # Try reading from version.json file + version_file_data = cls._get_version_file() + if version_file_data: + name = version_file_data.get("version") + + if name is None: + # Fallback to hard-coded version + name = f"v{cls.VERSION}" return name @@ -114,9 +143,12 @@ class Version: file_mtime = os.path.getmtime(filepath) last_modified = max(file_mtime, last_modified) - # Sanity check if num_files == 0: - raise Exception(f"No python source files found in {src_path}") + # Fallback to reading from the version file + version_file_data = cls._get_version_file() + if version_file_data: + last_src_edit_str = version_file_data.get("last_src_edit") + return datetime.fromisoformat(last_src_edit_str) return datetime.fromtimestamp(last_modified) @@ -125,4 +157,20 @@ class Version: # function so return gracefully. import traceback logger.error(traceback.format_exc()) - return None \ No newline at end of file + return None + + +if __name__ == "__main__": + """ + CLI to extract the current version and last edit time and write to `src/version.json`. + """ + version_info = dict(version=Version.get_version()) + last_edit_dt = Version.get_last_src_edit() + if last_edit_dt: + version_info["last_src_edit"] = last_edit_dt.isoformat() + + with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", Version.VERSION_FILENAME), "w") as f: + json.dump(version_info, f, indent=4) + + print("Wrote version info to src/version.json:") + print(json.dumps(version_info, indent=4)) \ No newline at end of file From 722d99863bfc2636f8fd5bdead8c014a84bf10aa Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Sat, 20 Dec 2025 09:23:38 -0600 Subject: [PATCH 14/50] dir handling improvements --- src/seedsigner/helpers/version.py | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/src/seedsigner/helpers/version.py b/src/seedsigner/helpers/version.py index b5f70683..f56f45ea 100644 --- a/src/seedsigner/helpers/version.py +++ b/src/seedsigner/helpers/version.py @@ -67,25 +67,29 @@ class Version: # Filename is the tag name return tag_filename return None - + + + @classmethod + def _get_version_file_path(cls) -> str: + # Have to back out of "helpers" dir to the main "seedsigner" dir + return os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", cls.VERSION_FILENAME) + @classmethod def _get_version_file(cls) -> dict | None: """ Attempts to read the VERSION_FILENAME and return its contents as a dict. """ - version_file_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", cls.VERSION_FILENAME) - if os.path.exists(version_file_path): - try: - with open(version_file_path, "r") as f: - return json.load(f) - except Exception as e: - # In local dev we don't expect/want this file to exist - pass + version_file_path = cls._get_version_file_path() + try: + with open(version_file_path, "r") as f: + return json.load(f) + except Exception as e: + # In local dev we don't expect/want this file to exist + pass return None - @classmethod def get_version(cls) -> str: """ @@ -162,15 +166,15 @@ class Version: if __name__ == "__main__": """ - CLI to extract the current version and last edit time and write to `src/version.json`. + CLI to extract the current version and last edit time and write to `src/seedsigner/version.json`. """ version_info = dict(version=Version.get_version()) last_edit_dt = Version.get_last_src_edit() if last_edit_dt: version_info["last_src_edit"] = last_edit_dt.isoformat() - with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", Version.VERSION_FILENAME), "w") as f: + with open(Version._get_version_file_path(), "w") as f: json.dump(version_info, f, indent=4) - print("Wrote version info to src/version.json:") + print("Wrote version info to src/seedsigner/version.json:") print(json.dumps(version_info, indent=4)) \ No newline at end of file From 5eb8d08b6da9f4fac7efd603fa4f165016563726 Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Sat, 20 Dec 2025 09:33:06 -0600 Subject: [PATCH 15/50] better path handling --- src/seedsigner/helpers/version.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/seedsigner/helpers/version.py b/src/seedsigner/helpers/version.py index f56f45ea..86dfeeed 100644 --- a/src/seedsigner/helpers/version.py +++ b/src/seedsigner/helpers/version.py @@ -72,7 +72,7 @@ class Version: @classmethod def _get_version_file_path(cls) -> str: # Have to back out of "helpers" dir to the main "seedsigner" dir - return os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", cls.VERSION_FILENAME) + return os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", cls.VERSION_FILENAME)) @classmethod @@ -173,8 +173,9 @@ if __name__ == "__main__": if last_edit_dt: version_info["last_src_edit"] = last_edit_dt.isoformat() - with open(Version._get_version_file_path(), "w") as f: + version_file_path = Version._get_version_file_path() + with open(version_file_path, "w") as f: json.dump(version_info, f, indent=4) - print("Wrote version info to src/seedsigner/version.json:") + print(f"Wrote version info to: {version_file_path}") print(json.dumps(version_info, indent=4)) \ No newline at end of file From d0b29d222dc9c2c973fc1ddac66bf76801997183 Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Sat, 20 Dec 2025 10:39:48 -0600 Subject: [PATCH 16/50] Fix inadvertantly persistent test artifacts --- tests/test_flows_seed.py | 2 -- tests/test_flows_view.py | 14 +++++++------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py index 2b2339f1..98c5048a 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -61,7 +61,6 @@ class TestSeedFlows(FlowTest): the SeedOptionsView. """ def test_with_mnemonic(mnemonic): - Settings.HOSTNAME = "not seedsigner-os" sequence = [ FlowStep(MainMenuView, button_data_selection=MainMenuView.SEEDS), FlowStep(seed_views.SeedsMenuView, is_redirect=True), # When no seeds are loaded it auto-redirects to LoadSeedView @@ -128,7 +127,6 @@ class TestSeedFlows(FlowTest): Most BIP-39 mnemonics should generate an error if entered as Electrum seeds. """ def test_with_mnemonic(mnemonic: list[str], custom_extension: str = None, expects_electrum_seed_is_valid: bool = True): - Settings.HOSTNAME = "not seedsigner-os" settings = Settings.get_instance() settings.set_value(SettingsConstants.SETTING__ELECTRUM_SEEDS, SettingsConstants.OPTION__ENABLED) diff --git a/tests/test_flows_view.py b/tests/test_flows_view.py index 2e034cb8..bdb14104 100644 --- a/tests/test_flows_view.py +++ b/tests/test_flows_view.py @@ -30,13 +30,13 @@ class TestViewFlows(FlowTest): """ Basic flow from MainMenuView to PowerOffView """ - Settings.HOSTNAME = Settings.SEEDSIGNER_OS - self.run_sequence([ - FlowStep(MainMenuView, screen_return_value=RET_CODE__POWER_BUTTON), - FlowStep(PowerOptionsView, button_data_selection=PowerOptionsView.POWER_OFF), - FlowStep(PowerOffView), # returns BackStackView - FlowStep(PowerOptionsView), - ]) + with patch.object(Settings, 'HOSTNAME', return_value=Settings.SEEDSIGNER_OS): + self.run_sequence([ + FlowStep(MainMenuView, screen_return_value=RET_CODE__POWER_BUTTON), + FlowStep(PowerOptionsView, button_data_selection=PowerOptionsView.POWER_OFF), + FlowStep(PowerOffView), # returns BackStackView + FlowStep(PowerOptionsView), + ]) def test_not_yet_implemented_flow(self): From 5ff2e25d1fa6f3963d9abcfb71df7a70d8bca278 Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Sat, 20 Dec 2025 10:39:58 -0600 Subject: [PATCH 17/50] Remove hard-coded VERSION --- src/seedsigner/helpers/version.py | 61 ++++++++++++++++++++++--------- tests/test_version.py | 48 ++++++++++++------------ 2 files changed, 68 insertions(+), 41 deletions(-) diff --git a/src/seedsigner/helpers/version.py b/src/seedsigner/helpers/version.py index 86dfeeed..76be03a4 100644 --- a/src/seedsigner/helpers/version.py +++ b/src/seedsigner/helpers/version.py @@ -3,6 +3,8 @@ import logging import os from datetime import datetime +from seedsigner.models.settings import Settings + logger = logging.getLogger(__name__) @@ -17,9 +19,6 @@ class Version: Internal utility functions are separated out as class methods for easier mocking in tests. """ - - VERSION = "0.8.6" - VERSION_FILENAME = "version.json" @@ -98,6 +97,31 @@ class Version: hard-coded VERSION constant. """ name = None + + def _prefix_version_name(version: str) -> str: + """ + Ensure version strings are prefixed with 'v' if they look like semantic + versions. Only checks that the first part is numeric in order to be compatible + with minor versions like an "rc1". + """ + if not version.startswith("v") and version.count(".") >= 1 and version.split(".")[0].isnumeric(): + return f"v{version}" + return version + + print(f"{Settings.HOSTNAME=}") + + if Settings.HOSTNAME == Settings.SEEDSIGNER_OS: + # The SeedSigner OS build process generates the version.json file for the tag, + # branch, or commit hash the image is targeting. + try: + name = cls._get_version_file()["version"] + except Exception: + # Shouldn't be possible. Raise an exception to alert testers before this + # image goes out. + raise Exception("Could not read the version from the version.json file.") + return _prefix_version_name(name) + + # In local dev we dynamically read from the .git/HEAD file branch_name, commit_hash = cls._read_HEAD_file() if branch_name: name = branch_name @@ -108,18 +132,11 @@ class Version: name = matching_tag else: name = commit_hash[:7] # short commit hash - - if name is None: - # Try reading from version.json file - version_file_data = cls._get_version_file() - if version_file_data: - name = version_file_data.get("version") if name is None: - # Fallback to hard-coded version - name = f"v{cls.VERSION}" + raise Exception("Could not determine version from git info.") - return name + return _prefix_version_name(name) @classmethod @@ -127,6 +144,17 @@ class Version: """ Recursively scan the src/ directory for the most recent python file edit time. """ + if Settings.HOSTNAME == Settings.SEEDSIGNER_OS: + # The SeedSigner OS build process generates the version.json file which will + # already contain the last edit time. + try: + last_src_edit_str = cls._get_version_file()["last_src_edit"] + return datetime.fromisoformat(last_src_edit_str) + except Exception as e: + # Shouldn't be possible. Raise an exception to alert testers before this + # image goes out. + raise Exception("Could not read the last_src_edit from the version.json file.") + try: path = os.path.dirname(os.path.abspath(__file__)) @@ -148,11 +176,8 @@ class Version: last_modified = max(file_mtime, last_modified) if num_files == 0: - # Fallback to reading from the version file - version_file_data = cls._get_version_file() - if version_file_data: - last_src_edit_str = version_file_data.get("last_src_edit") - return datetime.fromisoformat(last_src_edit_str) + # Shouldn't be possible + raise Exception("No python source files found in src/ directory") return datetime.fromtimestamp(last_modified) @@ -167,6 +192,8 @@ class Version: if __name__ == "__main__": """ CLI to extract the current version and last edit time and write to `src/seedsigner/version.json`. + + Used by the SeedSigner OS build process to generate the version.json file. """ version_info = dict(version=Version.get_version()) last_edit_dt = Version.get_last_src_edit() diff --git a/tests/test_version.py b/tests/test_version.py index cc99ef3d..3819c6aa 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -11,33 +11,33 @@ from seedsigner.helpers.version import Version class TestVersion(BaseTest): - def test_version_with_no_git_head(self): - """ - If there is no .git/HEAD file, the hard-coded VERSION constant should be returned. - """ - # mock out the os.path.exists call to always return False - with mock.patch("os.path.exists", return_value=False): - version = Version.get_version() - assert version == f"v{Version.VERSION}" + # def test_version_with_no_git_head(self): + # """ + # If there is no .git/HEAD file, the hard-coded VERSION constant should be returned. + # """ + # # mock out the os.path.exists call to always return False + # with mock.patch("os.path.exists", return_value=False): + # version = Version.get_version() + # assert version == f"v{Version.VERSION}" - def test_version_with_actual_git_head(self): - """ - If the .git dir exists on our actual filesystem right now, we should get a version - based on its contents. + # def test_version_with_actual_git_head(self): + # """ + # If the .git dir exists on our actual filesystem right now, we should get a version + # based on its contents. - NOTE: This test is potentially fragile as it depends on the test runner system's actual - git state. The get_version() call should be able to handle all possible git states, but - this does create the possibility of external variability. - """ - fake_hardcoded_version = "fake.version.123" - with mock.patch.object(Version, 'VERSION', fake_hardcoded_version): - git_dot_dir = Version._get_dot_git_dir() - if os.path.exists(git_dot_dir): - assert Version.get_version() != f"v{fake_hardcoded_version}" - else: - # If there's no .git dir, mark this test as skipped - pytest.skip(f"No .git dir found at {git_dot_dir}, skipping test.") + # NOTE: This test is potentially fragile as it depends on the test runner system's actual + # git state. The get_version() call should be able to handle all possible git states, but + # this does create the possibility of external variability. + # """ + # fake_hardcoded_version = "fake.version.123" + # with mock.patch.object(Version, 'VERSION', fake_hardcoded_version): + # git_dot_dir = Version._get_dot_git_dir() + # if os.path.exists(git_dot_dir): + # assert Version.get_version() != f"v{fake_hardcoded_version}" + # else: + # # If there's no .git dir, mark this test as skipped + # pytest.skip(f"No .git dir found at {git_dot_dir}, skipping test.") def test_version_with_mocked_git_head(self): From 3f7803fa15006bc792ed7c6dfbba2fa51ea2d092 Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Sat, 20 Dec 2025 11:04:02 -0600 Subject: [PATCH 18/50] Use `git log` for most recent commit's edit time when writing version.json --- src/seedsigner/helpers/version.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/seedsigner/helpers/version.py b/src/seedsigner/helpers/version.py index 76be03a4..5ae8f07d 100644 --- a/src/seedsigner/helpers/version.py +++ b/src/seedsigner/helpers/version.py @@ -140,7 +140,7 @@ class Version: @classmethod - def get_last_src_edit(cls) -> datetime: + def get_last_edit(cls) -> datetime: """ Recursively scan the src/ directory for the most recent python file edit time. """ @@ -194,11 +194,17 @@ if __name__ == "__main__": CLI to extract the current version and last edit time and write to `src/seedsigner/version.json`. Used by the SeedSigner OS build process to generate the version.json file. + + Uses the last git commit time (via `git log`) as the last edit time. """ version_info = dict(version=Version.get_version()) - last_edit_dt = Version.get_last_src_edit() - if last_edit_dt: - version_info["last_src_edit"] = last_edit_dt.isoformat() + + # Run `git log` in the shell to get the last commit time + try: + last_commit = os.popen("git log -1 --format=%cI").read().strip() + version_info["last_src_edit"] = last_commit + except Exception as e: + raise Exception("Could not get last commit time from git log.") from e version_file_path = Version._get_version_file_path() with open(version_file_path, "w") as f: From 657167ac61aeab9a85200bb5138c621395452df7 Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Sat, 20 Dec 2025 11:07:46 -0600 Subject: [PATCH 19/50] rename and bugfix --- src/seedsigner/helpers/version.py | 2 +- src/seedsigner/views/settings_views.py | 2 +- tests/test_version.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/seedsigner/helpers/version.py b/src/seedsigner/helpers/version.py index 5ae8f07d..834dc076 100644 --- a/src/seedsigner/helpers/version.py +++ b/src/seedsigner/helpers/version.py @@ -140,7 +140,7 @@ class Version: @classmethod - def get_last_edit(cls) -> datetime: + def get_last_edit_timestamp(cls) -> datetime: """ Recursively scan the src/ directory for the most recent python file edit time. """ diff --git a/src/seedsigner/views/settings_views.py b/src/seedsigner/views/settings_views.py index 85b446e9..03b5f178 100644 --- a/src/seedsigner/views/settings_views.py +++ b/src/seedsigner/views/settings_views.py @@ -333,7 +333,7 @@ class VersionView(View): self.run_screen( settings_screens.VersionScreen, version=Version.get_version(), - last_edit=Version.get_last_src_edit(), + last_edit=Version.get_last_edit_timestamp(), ) return Destination(SettingsMenuView) \ No newline at end of file diff --git a/tests/test_version.py b/tests/test_version.py index 3819c6aa..f398d2de 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -68,12 +68,12 @@ class TestVersion(BaseTest): assert version == tag_name - def test_get_last_src_edit(self): + def test_get_last_edit(self): """ Test that get_last_src_edit returns a sane datetime object. Assumes the system running this test has a reasonably correct system time. """ - last_edit = Version.get_last_src_edit() + last_edit = Version.get_last_edit_timestamp() assert isinstance(last_edit, datetime) # Has to be more recent than the first SeedSigner v0.0.1 release From d02e980596cf66a32331586ef27dca87203ab60a Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Sat, 20 Dec 2025 12:38:04 -0600 Subject: [PATCH 20/50] Use `git` directly to get branch or tag name --- src/seedsigner/helpers/version.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/seedsigner/helpers/version.py b/src/seedsigner/helpers/version.py index 834dc076..ee3c6029 100644 --- a/src/seedsigner/helpers/version.py +++ b/src/seedsigner/helpers/version.py @@ -107,8 +107,6 @@ class Version: if not version.startswith("v") and version.count(".") >= 1 and version.split(".")[0].isnumeric(): return f"v{version}" return version - - print(f"{Settings.HOSTNAME=}") if Settings.HOSTNAME == Settings.SEEDSIGNER_OS: # The SeedSigner OS build process generates the version.json file for the tag, @@ -206,6 +204,15 @@ if __name__ == "__main__": except Exception as e: raise Exception("Could not get last commit time from git log.") from e + try: + version_name = os.popen("git branch --show-current").read().strip() + if not version_name: + # If we're on a tag, there won't be a current branch. Instead, try to get the + # current tag. + version_name = os.popen("git describe --tags --abbrev=0").read().strip() + except Exception as e: + raise Exception("Could not get version name from git.") from e + version_file_path = Version._get_version_file_path() with open(version_file_path, "w") as f: json.dump(version_info, f, indent=4) From 37531c03014e55ac95b1c2f8baa5a24f91198737 Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Sat, 20 Dec 2025 14:29:06 -0600 Subject: [PATCH 21/50] Use SeedSigner OS env var; enforce utc time --- src/seedsigner/helpers/version.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/seedsigner/helpers/version.py b/src/seedsigner/helpers/version.py index ee3c6029..6e65ca86 100644 --- a/src/seedsigner/helpers/version.py +++ b/src/seedsigner/helpers/version.py @@ -1,7 +1,7 @@ import json import logging import os -from datetime import datetime +from datetime import datetime, timezone from seedsigner.models.settings import Settings @@ -200,18 +200,25 @@ if __name__ == "__main__": # Run `git log` in the shell to get the last commit time try: last_commit = os.popen("git log -1 --format=%cI").read().strip() - version_info["last_src_edit"] = last_commit + # Parse the timestamp, ensure that it's in UTC, and omit tz info + version_info["last_src_edit"] = datetime.fromisoformat(last_commit).astimezone(timezone.utc).replace(tzinfo=None).isoformat() except Exception as e: raise Exception("Could not get last commit time from git log.") from e try: - version_name = os.popen("git branch --show-current").read().strip() + version_name = None + + # If we're currently building SeedSigner OS, check the env var + version_name = os.getenv("SEEDSIGNER_VERSION_NAME") + if not version_name: - # If we're on a tag, there won't be a current branch. Instead, try to get the - # current tag. - version_name = os.popen("git describe --tags --abbrev=0").read().strip() + version_name = os.popen("git branch --show-current").read().strip() + if not version_name: + # If we're on a tag, there won't be a current branch. Instead, try to get the + # current tag. + version_name = os.popen("git describe --tags --abbrev=0").read().strip() except Exception as e: - raise Exception("Could not get version name from git.") from e + raise Exception("Could not get version name from SeedSigner OS env var nor git.") from e version_file_path = Version._get_version_file_path() with open(version_file_path, "w") as f: From 97386ffffe2ba30f0a8d95dc651391961c42c228 Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Sat, 20 Dec 2025 14:33:48 -0600 Subject: [PATCH 22/50] Try to pull version name from CI env vars --- src/seedsigner/helpers/version.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/seedsigner/helpers/version.py b/src/seedsigner/helpers/version.py index 6e65ca86..0fe1de13 100644 --- a/src/seedsigner/helpers/version.py +++ b/src/seedsigner/helpers/version.py @@ -132,7 +132,12 @@ class Version: name = commit_hash[:7] # short commit hash if name is None: - raise Exception("Could not determine version from git info.") + # If we're running in the Github Actions CI, we'll have env vars we can use + if os.getenv("CI") == "true": + name = os.getenv("GITHUB_REF_NAME") or os.getenv("GITHUB_SHA")[:7] + + if name is None: + raise Exception("Could not determine version from git info nor CI env vars.") return _prefix_version_name(name) From 906f21cca9da2d3898ad3d30ac11b16d299c159c Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Sat, 20 Dec 2025 14:43:19 -0600 Subject: [PATCH 23/50] debugging --- src/seedsigner/helpers/version.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/seedsigner/helpers/version.py b/src/seedsigner/helpers/version.py index 0fe1de13..e7b5880d 100644 --- a/src/seedsigner/helpers/version.py +++ b/src/seedsigner/helpers/version.py @@ -215,6 +215,7 @@ if __name__ == "__main__": # If we're currently building SeedSigner OS, check the env var version_name = os.getenv("SEEDSIGNER_VERSION_NAME") + print(f"SEEDSIGNER_VERSION_NAME={version_name}") if not version_name: version_name = os.popen("git branch --show-current").read().strip() From 516a76baf113d45daaab33ce2176af2ebf807b0e Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Sat, 20 Dec 2025 14:43:55 -0600 Subject: [PATCH 24/50] Add version.json to .gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 9c70709f..eeca72e0 100644 --- a/.gitignore +++ b/.gitignore @@ -3,9 +3,10 @@ __pycache__/ src/seedsigner.egg-info/ .nova .vscode +src/seedsigner/version.json src/seedsigner/models/settings_definition.json .idea .coverage* *.po -*.mo \ No newline at end of file +*.mo From 272db5dfd807b669e8e1bb0332d7108da617a9d1 Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Sat, 20 Dec 2025 14:56:52 -0600 Subject: [PATCH 25/50] bugfix --- src/seedsigner/helpers/version.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/seedsigner/helpers/version.py b/src/seedsigner/helpers/version.py index e7b5880d..0f1b3561 100644 --- a/src/seedsigner/helpers/version.py +++ b/src/seedsigner/helpers/version.py @@ -200,7 +200,7 @@ if __name__ == "__main__": Uses the last git commit time (via `git log`) as the last edit time. """ - version_info = dict(version=Version.get_version()) + version_info = dict() # Run `git log` in the shell to get the last commit time try: @@ -211,18 +211,25 @@ if __name__ == "__main__": raise Exception("Could not get last commit time from git log.") from e try: - version_name = None - # If we're currently building SeedSigner OS, check the env var version_name = os.getenv("SEEDSIGNER_VERSION_NAME") - print(f"SEEDSIGNER_VERSION_NAME={version_name}") if not version_name: version_name = os.popen("git branch --show-current").read().strip() - if not version_name: - # If we're on a tag, there won't be a current branch. Instead, try to get the - # current tag. - version_name = os.popen("git describe --tags --abbrev=0").read().strip() + + if not version_name: + # If we're on a tag, there won't be a current branch. Instead, try to get the + # current tag. + version_name = os.popen("git describe --tags --abbrev=0").read().strip() + + if not version_name: + # Fallback to commit hash + version_name = os.popen("git rev-parse --short HEAD").read().strip() + + if not version_name: + raise Exception("No git info found for version name.") + + version_info["version"] = version_name except Exception as e: raise Exception("Could not get version name from SeedSigner OS env var nor git.") from e From 47212c98f1bf948e9918b672c4bb88b1c965aff4 Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Mon, 22 Dec 2025 08:40:36 -0600 Subject: [PATCH 26/50] Huge refactor; interim commit --- .../gui/screens/settings_screens.py | 110 ++- src/seedsigner/helpers/version.py | 682 ++++++++++++++---- src/seedsigner/views/screensaver.py | 2 +- src/seedsigner/views/settings_views.py | 16 +- tests/screenshot_generator/generator.py | 18 + tests/test_version.py | 8 +- tools/write_versionfile.py | 63 ++ 7 files changed, 737 insertions(+), 162 deletions(-) create mode 100644 tools/write_versionfile.py diff --git a/src/seedsigner/gui/screens/settings_screens.py b/src/seedsigner/gui/screens/settings_screens.py index 2c1ed82d..67835faf 100644 --- a/src/seedsigner/gui/screens/settings_screens.py +++ b/src/seedsigner/gui/screens/settings_screens.py @@ -313,53 +313,105 @@ class DonateScreen(BaseTopNavScreen): @dataclass class VersionScreen(BaseTopNavScreen): - version: str = None - last_edit: datetime = None + version_name: str = None + version_fork: str = None + version_timestamp: datetime = None + version_commit_hash: str = None def __post_init__(self): self.title = _("Version") super().__post_init__() - font_name = GUIConstants.FIXED_WIDTH_FONT_NAME - font_size = GUIConstants.get_top_nav_title_font_size() + 6 - font = Fonts.get_font(font_name, font_size) - (left, char_height, char_width, bottom) = font.getbbox("X", anchor="ls") + version_name_font_name = GUIConstants.FIXED_WIDTH_FONT_NAME + version_name_font_size = GUIConstants.get_top_nav_title_font_size() + 6 + version_name_font = Fonts.get_font(version_name_font_name, version_name_font_size) + (left, version_name_char_height, version_name_char_width, bottom) = version_name_font.getbbox("X", anchor="ls") - num_version_lines = 1 - if len(self.version) * char_width > self.canvas_width - 2*GUIConstants.EDGE_PADDING: - max_chars_width = int((self.canvas_width - 2*GUIConstants.EDGE_PADDING) / char_width) + if len(self.version_name) * version_name_char_width > self.canvas_width - 2*GUIConstants.EDGE_PADDING: + max_chars_width = int((self.canvas_width - 2*GUIConstants.EDGE_PADDING) / version_name_char_width) # Add as many line breaks as needed for the version string to fit wrapped_version = [] - for i in range(0, len(self.version), max_chars_width): - if i + max_chars_width < len(self.version): - wrapped_version.append(self.version[i:i+max_chars_width]) + for i in range(0, len(self.version_name), max_chars_width): + if i + max_chars_width < len(self.version_name): + wrapped_version.append(self.version_name[i:i+max_chars_width]) else: - wrapped_version.append(self.version[i:]) - self.version = "\n".join(wrapped_version) - num_version_lines = len(wrapped_version) - - # Center the version vertically - char_height *= -1 # due to the "ls" (baseline) anchor, height is negative + wrapped_version.append(self.version_name[i:]) + self.version_name = "\n".join(wrapped_version) - # Note: we roughly account for the last edit display's vertical space by adding to - # num_version_lines. - screen_y = int((self.canvas_height - self.top_nav.height) / 2) + self.top_nav.height - char_height*(num_version_lines + 1) + timestamp_font_name = GUIConstants.get_body_font_name() + timestamp_font_size = GUIConstants.get_body_font_size() + timestamp_font = Fonts.get_font(timestamp_font_name, timestamp_font_size) + (left, timestamp_char_height, timestamp_char_width, bottom) = timestamp_font.getbbox("UTC", anchor="ls") + + screen_y = self.top_nav.height + GUIConstants.COMPONENT_PADDING * 2 + if not self.version_fork and not self.version_commit_hash: + # Center the version name if there's no fork/commit info + screen_y = int(self.canvas_height / 2) - (-1 * version_name_char_height) # char_height is negative self.components.append(TextArea( - text=self.version, - font_name=font_name, - font_size=font_size, + text=self.version_name, + font_name=version_name_font_name, + font_size=version_name_font_size, font_color=GUIConstants.ACCENT_COLOR, screen_y=screen_y, )) - if self.last_edit: - last_edit_str = self.last_edit.strftime("%Y-%m-%d\n%H:%M:%S UTC") + screen_y = self.components[-1].screen_y + self.components[-1].height + 3*GUIConstants.COMPONENT_PADDING + + label_font_name = GUIConstants.get_body_font_name() + label_font_size = GUIConstants.get_body_font_size() + label_font = Fonts.get_font(label_font_name, label_font_size) + (left, label_height, fork_label_width, bottom) = label_font.getbbox("fork: ", anchor="ls") + (left, label_height, commit_label_width, bottom) = label_font.getbbox("commit: ", anchor="ls") + + if self.version_fork: + screen_x = 0 + if self.version_commit_hash: + # right-align the labels + screen_x = commit_label_width - fork_label_width self.components.append(TextArea( - text=last_edit_str, - is_text_centered=True, - screen_y=self.components[-1].screen_y + self.components[-1].height + 2*GUIConstants.COMPONENT_PADDING, + text=f"fork: ", + is_text_centered=False, + font_color=GUIConstants.LABEL_FONT_COLOR, + screen_x=screen_x, + screen_y=screen_y, )) + self.components.append(TextArea( + text=self.version_fork, + is_text_centered=False, + font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME, + font_size=GUIConstants.get_top_nav_title_font_size(), + edge_padding=0, + screen_x=screen_x + fork_label_width + GUIConstants.COMPONENT_PADDING, + screen_y=screen_y, + )) + screen_y = self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING + + if self.version_commit_hash: + self.components.append(TextArea( + text=f"commit: ", + is_text_centered=False, + font_color=GUIConstants.LABEL_FONT_COLOR, + screen_y=screen_y, + )) + self.components.append(TextArea( + text=self.version_commit_hash, + is_text_centered=False, + font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME, + font_size=GUIConstants.get_top_nav_title_font_size(), + edge_padding=0, + screen_x=commit_label_width + GUIConstants.COMPONENT_PADDING, + screen_y=screen_y, + )) + + # Pin the timestamp centered to the bottom of the screen + last_edit_str = self.version_timestamp.strftime("%Y-%m-%d %H:%M:%S UTC") + self.components.append(TextArea( + text=last_edit_str, + font_name=timestamp_font_name, + font_size=timestamp_font_size, + screen_y=self.canvas_height - (-1 * timestamp_char_height) - GUIConstants.EDGE_PADDING, + )) diff --git a/src/seedsigner/helpers/version.py b/src/seedsigner/helpers/version.py index 0f1b3561..4365f159 100644 --- a/src/seedsigner/helpers/version.py +++ b/src/seedsigner/helpers/version.py @@ -4,37 +4,392 @@ import os from datetime import datetime, timezone from seedsigner.models.settings import Settings +from seedsigner.models.singleton import Singleton logger = logging.getLogger(__name__) -class Version: - """ - Utility class to detect the current version and the last edit time of the source code. +# Note: If this exception and its associated decorator end up being useful elsewhere, move +# them to a more general location (e.g. create a helpers/exceptions.py). +class NotAllowedInSeedSignerOS(Exception): + pass - Version detection attempts to read the current git branch, commit hash, or tag but - will fall back to the hard-coded VERSION constant if no git info is available. - Internal utility functions are separated out as class methods for easier mocking in tests. +def fail_if_running_seedsigner_os(func: callable): + """ Simple decorator to enforce SeedSigner OS restrictions. """ + def wrapper_func(*args, **kwargs): + if Settings.HOSTNAME == Settings.SEEDSIGNER_OS: + raise NotAllowedInSeedSignerOS(f"Cannot run `{func.__name__}` in SeedSigner OS.") + + # Now run the target function and return its results + return func(*args, **kwargs) + return wrapper_func + + + + +class Version(Singleton): """ - VERSION_FILENAME = "version.json" + Utility class to report the current version and the last edit time of the source code. + + On SeedSigner OS: + * Version data is written to `src/seedsigner/version.json` during the build + process via tools/write_versionfile.py. + * version_name: copied from the command used to build the SeedSigner OS image: + --app-branch (the target git branch OR tag) or + --app-commit-id (target commit hash) + * version_timestamp: the last git commit time for the target branch, tag, or + commit hash. + + In local dev: + * version_name: read dynamically from a few possible sources. In order: + * SEEDSIGNER_VERSION_NAME env var, if available. + * Shell `git` calls (e.g. `git branch --show-current`). + * Directly parsing the .git/HEAD file and possibly .git/refs/tags. + * Note: we avoid reading from the version.json file as it may be out of date + and could lead to confusion. + * version_timestamp: determined by scanning the src/ directory for the most + recently modified python file. + + In Github Actions CI: + * version_name: read from GITHUB_REF_NAME or GITHUB_SHA env vars. + * version_timestamp: TODO + + This `Version` class defines the limited methods that are meant to be used elsewhere + in the SeedSigner codebase. The misc utility functions in `VersionUtils` were + explicitly isolated because they should not be used elsewhere in the codebase. + """ + _version_name: str = None + _version_fork: str = None + _version_timestamp: datetime = None + _version_commit_hash: str = None @classmethod + def get_instance(cls): + # This is the only way to access the one and only instance + if cls._instance: + return cls._instance + else: + # Instantiate the one and only Version instance + return cls.configure_instance() + + + @classmethod + def configure_instance(cls): + # Must be called before the first get_instance() call + if cls._instance: + raise Exception("Instance already configured") + + # Create the one and only Version instance + version = cls.__new__(cls) + cls._instance = version + + # Populate version data + version._version_name = VersionUtils.get_version_name() + version._version_fork = VersionUtils.get_version_fork() + version._version_timestamp = VersionUtils.get_version_timestamp() + version._version_commit_hash = VersionUtils.get_version_commit_hash() + + return cls._instance + + + @classmethod + @fail_if_running_seedsigner_os + def reset_instance(cls): + """ Resets the singleton instance. Only used by the test suite between tests. """ + cls._instance = None + + + @classmethod + @fail_if_running_seedsigner_os + def override_data(cls, version_name, version_fork, version_timestamp, version_commit_hash): + """ + Only used by the test suite to manually change version data between tests. + """ + instance = cls.get_instance() + instance._version_name = version_name + instance._version_fork = version_fork + instance._version_timestamp = version_timestamp + instance._version_commit_hash = version_commit_hash + + + @classmethod + def get_version_name(cls) -> str | None: + return cls.get_instance()._version_name + + + @classmethod + def get_version_fork(cls) -> str | None: + return cls.get_instance()._version_fork + + + @classmethod + def get_version_timestamp(cls) -> datetime | None: + return cls.get_instance()._version_timestamp + + + @classmethod + def get_version_commit_hash(cls) -> str | None: + return cls.get_instance()._version_commit_hash + + + +class VersionUtils: + """ ********************************************************************************* + Not meant to be used elsewhere in the SeedSigner codebase (aside from + tools/write_versionfile.py). + + Methods are separated out here to a rather extreme degree in order to enable all the + mocking that is required for testing. + ********************************************************************************* """ + + ENV_VAR__SEEDSIGNER_VERSION_NAME = "SEEDSIGNER_VERSION_NAME" + VERSION_FILENAME = "version.json" + ATTR__VERSION_NAME = "name" + ATTR__VERSION_FORK = "fork" + ATTR__VERSION_TIMESTAMP = "timestamp" + ATTR__VERSION_COMMIT_HASH = "commit_hash" + + + @classmethod + def get_version_name(cls) -> str: + """ + Will prefix the version name "v" if it looks like a semantic version. + """ + if Settings.HOSTNAME == Settings.SEEDSIGNER_OS: + # The SeedSigner OS build process generates the version.json file for the tag, + # branch, or commit hash the image is targeting. + version_name = VersionUtils._get_version_name_from_version_file() + if version_name is None: + # Shouldn't be possible. Raise an exception to alert testers before this + # image goes out. + raise Exception("Could not read the version from the version.json file.") + return VersionUtils._prefix_version_name(version_name) + + elif VersionUtils.is_github_actions_ci(): + # In Github Actions CI, try to get the version name from env vars + version_name = VersionUtils.get_version_name_from_github_actions_env_vars() + if version_name is not None: + return VersionUtils._prefix_version_name(version_name) + else: + raise Exception("Could not determine version from Github Actions env vars.") + + else: + # In local dev, we try the following methods in order: + for get_version_name_method in [ + VersionUtils._get_version_name_from_env_var, + VersionUtils._get_version_name_from_git_shell, + VersionUtils._get_version_name_from_git_HEAD, + ]: + version_name = get_version_name_method() + if version_name is not None: + return VersionUtils._prefix_version_name(version_name) + + # If we reach here, none of the methods worked + # TODO: What do we want to do in this case? + # Intentionally not marking this for translation; end users should never see it. + return "version not detected" + + + @classmethod + def get_version_fork(cls) -> str | None: + """ + Returns the fork owner or None if it cannot be determined. + + e.g. https://github.com/SeedSigner/seedsigner -> "SeedSigner" + """ + if Settings.HOSTNAME == Settings.SEEDSIGNER_OS: + # The SeedSigner OS build process generates the version.json file which will + # already contain the fork name. + return VersionUtils._get_version_fork_from_version_file() + else: + # In local dev we try to access the current git state via: + for get_version_fork_method in [ + VersionUtils._get_version_fork_from_git_shell, + VersionUtils._get_version_fork_from_git_config, + ]: + version_fork = get_version_fork_method() + if version_fork is not None: + return version_fork + return None + + + @classmethod + def get_version_timestamp(cls) -> datetime: + """ + Returns a datetime object representing the last edit time of the source code via + the most recent git commit time (SeedSigner OS, as written in version.json) or + the most recently modified python source file (local dev). + """ + if Settings.HOSTNAME == Settings.SEEDSIGNER_OS: + # The SeedSigner OS build process generates the version.json file which will + # already contain the last edit time. + version_timestamp = VersionUtils._get_version_timestamp_from_version_file() + if version_timestamp is None: + # Shouldn't be possible. Raise an exception to alert testers before this + # image goes out. + raise Exception("Could not read the version timestamp from the version.json file.") + return datetime.fromisoformat(version_timestamp) + + else: + # In local dev we use the last modified time of the source python files + return VersionUtils._get_version_timestamp_from_src_files() + + + @classmethod + def get_version_commit_hash(cls) -> str | None: + """ + Returns the short commit hash string. + + Will be None if the local dev system has no git state available or if it only has + the .git/HEAD but is currently on a branch (not a tag or specific commit). + """ + if Settings.HOSTNAME == Settings.SEEDSIGNER_OS: + return VersionUtils._get_version_commit_hash_from_version_file() + else: + # In local dev we try to access the current git state via: + for get_commit_hash_method in [ + VersionUtils._get_version_commit_hash_from_git_shell, + VersionUtils._get_commit_hash_from_git_HEAD, + ]: + commit_hash = get_commit_hash_method() + if commit_hash is not None: + return commit_hash[:7] + + # If we're still here, we can try looking up based on the branch name + branch_name = VersionUtils._get_version_name_from_git_shell_branch() + if not branch_name: + branch_name, commit_hash = VersionUtils._read_git_HEAD_file() + if branch_name: + commit_hash = VersionUtils._get_commit_hash_from_git_refs_heads(branch_name) + if commit_hash is not None: + return commit_hash[:7] + + + @classmethod + def _prefix_version_name(cls, version_name: str) -> str: + """ + Ensure version strings are prefixed with 'v' if they look like semantic + versions. Only checks that the first part is numeric in order to be compatible + with non-numeric minor versions (e.g. "0.8.5-rc1"). + """ + if not version_name.startswith("v") and version_name.count(".") >= 1 and version_name.split(".")[0].isnumeric(): + return f"v{version_name}" + return version_name + + + @classmethod + def _get_version_file_path(cls) -> str: + # Have to back out of this file's location in the "helpers" dir to the main + # "seedsigner" dir. + return os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", cls.VERSION_FILENAME)) + + + @classmethod + def _read_version_file(cls) -> dict | None: + """ + Attempts to read version.json and return its contents as a dict. + """ + try: + with open(cls._get_version_file_path(), "r") as f: + return json.load(f) + except Exception: + # In local dev we don't expect/want this file to exist + return None + + + @classmethod + def _get_version_name_from_version_file(cls) -> str | None: + """ + Attempts to read the version.json and return the version name. + """ + version_data = cls._read_version_file() + if version_data: + return version_data.get(cls.ATTR__VERSION_NAME) + + + @classmethod + def _get_version_fork_from_version_file(cls) -> str | None: + """ + Attempts to read the version.json and return the version fork name. + """ + version_data = cls._read_version_file() + if version_data: + return version_data.get(cls.ATTR__VERSION_FORK) + + + @classmethod + def _get_version_timestamp_from_version_file(cls) -> str | None: + """ + Attempts to read the version.json and return the version timestamp. + """ + version_data = cls._read_version_file() + if version_data: + return version_data.get(cls.ATTR__VERSION_TIMESTAMP) + + + @classmethod + def _get_version_commit_hash_from_version_file(cls) -> str | None: + """ + Attempts to read the version.json and return the version commit hash. + """ + version_data = cls._read_version_file() + if version_data: + return version_data.get(cls.ATTR__VERSION_COMMIT_HASH) + + + @classmethod + def _get_version_name_from_env_var(cls) -> str | None: + """ + Primarily used during the SeedSigner OS build process to set the version name via env var. + + This env var can also be set manually in local dev when needed. + e.g. SEEDSIGNER_VERSION_NAME=some_name python main.py + """ + return os.getenv(cls.ENV_VAR__SEEDSIGNER_VERSION_NAME) + + + @classmethod + def is_github_actions_ci(cls) -> bool: + return os.getenv("CI") == "true" + + + @classmethod + def get_version_name_from_github_actions_env_vars(cls) -> str | None: + # REF_NAME will be the branch or tag name; SHA is the full commit hash + return os.getenv("GITHUB_REF_NAME") or os.getenv("GITHUB_SHA")[:7] + + + + """ ************************************************************************************* + These functions attempt to read directly from local .git/ or src/ files. These operations + aren't dangerous but aren't necessary when we're running in SeedSigner OS, so we restrict + them here. + ************************************************************************************* """ + @classmethod + @fail_if_running_seedsigner_os def _get_dot_git_dir(cls) -> str: # If it exists, the .git dir will be in the project root path = os.path.dirname(os.path.abspath(__file__)) - # Have to back out of "helpers" and "seedsigner" and "src" dirs + # Have to back out of this file's location in "helpers" and "seedsigner" and "src" dirs project_root = os.path.join(path, "..", "..", "..") - return os.path.join(project_root, ".git") + return os.path.normpath(os.path.join(project_root, ".git")) @classmethod - def _read_HEAD_file(cls) -> tuple[str,str]: + @fail_if_running_seedsigner_os + def _read_git_HEAD_file(cls) -> tuple[str | None, str | None]: + """ + Reads the .git/HEAD file and returns a tuple of (branch_name, commit_hash) where + only one will have a value. + + If there is no .git/HEAD file detected, both values will be None. + """ git_HEAD_path = os.path.join(cls._get_dot_git_dir(), "HEAD") branch_name = None @@ -53,8 +408,11 @@ class Version: @classmethod - def _get_matching_tag(cls, commit_hash: str) -> str: - # Check the .git/refs/tags dir for a tag matching this commit hash + @fail_if_running_seedsigner_os + def _get_matching_tag(cls, commit_hash: str) -> str | None: + """ + Checks the .git/refs/tags dir for a tag that matches the provided commit hash. + """ git_refs_tags_dir = os.path.join(cls._get_dot_git_dir(), "refs", "tags") if os.path.exists(git_refs_tags_dir): for tag_filename in os.listdir(git_refs_tags_dir): @@ -69,95 +427,111 @@ class Version: @classmethod - def _get_version_file_path(cls) -> str: - # Have to back out of "helpers" dir to the main "seedsigner" dir - return os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", cls.VERSION_FILENAME)) + @fail_if_running_seedsigner_os + def _get_version_name_from_git_HEAD(cls) -> str | None: + """ + Reads the .git/HEAD file and, depending on the local dev git state, returns + either the branch name, tag name, or short commit hash. + """ + branch_name, commit_hash = VersionUtils._read_git_HEAD_file() + if branch_name: + return branch_name + elif commit_hash: + # See if this commit_hash matches a tag + matching_tag = VersionUtils._get_matching_tag(commit_hash) + if matching_tag: + return matching_tag + else: + return commit_hash[:7] # short commit hash @classmethod - def _get_version_file(cls) -> dict | None: + @fail_if_running_seedsigner_os + def _get_commit_hash_from_git_HEAD(cls) -> str | None: """ - Attempts to read the VERSION_FILENAME and return its contents as a dict. + Reads the .git/HEAD file and, depending on the local dev git state, returns + the short commit hash. Will only return a value if HEAD is detached (on a tag or + on a specific commit). """ - version_file_path = cls._get_version_file_path() - try: - with open(version_file_path, "r") as f: - return json.load(f) - except Exception as e: - # In local dev we don't expect/want this file to exist - pass + branch_name, commit_hash = VersionUtils._read_git_HEAD_file() + return commit_hash + + + @classmethod + @fail_if_running_seedsigner_os + def _get_commit_hash_from_git_refs_heads(cls, branch_name: str) -> str | None: + """ + Reads the .git/refs/heads/ file to get the current commit hash + for the given branch. + """ + git_ref_path = os.path.join(cls._get_dot_git_dir(), "refs", "heads", branch_name) + if os.path.exists(git_ref_path): + with open(git_ref_path, "r") as f: + commit_hash = f.read().strip() + return commit_hash + + + @classmethod + def _parse_git_remote_url(cls, remote_url: str) -> str | None: + """ + Parses a git remote URL to extract the fork owner name. + + Formats: + * https://github.com/repo_owner/repo_name.git + * git@github.com:repo_owner/repo_name.git + """ + if not remote_url: + return None + if remote_url.startswith("https"): + parts = remote_url.rsplit("/", 2) + return parts[-2] + elif remote_url.startswith("git@"): + parts = remote_url.split(":") + owner_repo = parts[1] # repo_owner/repo_name.git + return owner_repo.split("/")[0] + + + @classmethod + @fail_if_running_seedsigner_os + def _get_version_fork_from_git_config(cls) -> str | None: + """ + Attempts to read the .git/config file to determine the remote "origin" URL + and extract the fork owner name from it. + + Format: + [some_section] + some_key = some_value + [remote "origin"] + url = git@github.com:SeedSigner/seedsigner.git + fetch = +refs/heads/*:refs/remotes/origin/* + [next_section] + ... + """ + git_config_path = os.path.join(cls._get_dot_git_dir(), "config") + if os.path.exists(git_config_path): + with open(git_config_path, "r") as f: + lines = f.readlines() + in_origin_section = False + for line in lines: + if line.startswith("[remote \"origin\"]"): + # Next lines fetched will be the ones we care about + in_origin_section = True + elif in_origin_section and line.strip().startswith("url ="): + # Found our "origin" url + remote_url = line.split("=", 1)[1].strip() + return cls._parse_git_remote_url(remote_url) + elif in_origin_section and line.startswith("["): + # We reached the next section without finding the url + raise Exception("Didn't find 'url' entry in 'origin' section of .git/config") return None @classmethod - def get_version(cls) -> str: - """ - Will attempt to read the current git branch name or commit hash from - .git/HEAD. But if there's no git info available, it will fall back to the - hard-coded VERSION constant. - """ - name = None - - def _prefix_version_name(version: str) -> str: - """ - Ensure version strings are prefixed with 'v' if they look like semantic - versions. Only checks that the first part is numeric in order to be compatible - with minor versions like an "rc1". - """ - if not version.startswith("v") and version.count(".") >= 1 and version.split(".")[0].isnumeric(): - return f"v{version}" - return version - - if Settings.HOSTNAME == Settings.SEEDSIGNER_OS: - # The SeedSigner OS build process generates the version.json file for the tag, - # branch, or commit hash the image is targeting. - try: - name = cls._get_version_file()["version"] - except Exception: - # Shouldn't be possible. Raise an exception to alert testers before this - # image goes out. - raise Exception("Could not read the version from the version.json file.") - return _prefix_version_name(name) - - # In local dev we dynamically read from the .git/HEAD file - branch_name, commit_hash = cls._read_HEAD_file() - if branch_name: - name = branch_name - elif commit_hash: - # See if this commit_hash matches a tag - matching_tag = cls._get_matching_tag(commit_hash) - if matching_tag: - name = matching_tag - else: - name = commit_hash[:7] # short commit hash - - if name is None: - # If we're running in the Github Actions CI, we'll have env vars we can use - if os.getenv("CI") == "true": - name = os.getenv("GITHUB_REF_NAME") or os.getenv("GITHUB_SHA")[:7] - - if name is None: - raise Exception("Could not determine version from git info nor CI env vars.") - - return _prefix_version_name(name) - - - @classmethod - def get_last_edit_timestamp(cls) -> datetime: + @fail_if_running_seedsigner_os + def _get_version_timestamp_from_src_files(cls) -> datetime | None: """ Recursively scan the src/ directory for the most recent python file edit time. """ - if Settings.HOSTNAME == Settings.SEEDSIGNER_OS: - # The SeedSigner OS build process generates the version.json file which will - # already contain the last edit time. - try: - last_src_edit_str = cls._get_version_file()["last_src_edit"] - return datetime.fromisoformat(last_src_edit_str) - except Exception as e: - # Shouldn't be possible. Raise an exception to alert testers before this - # image goes out. - raise Exception("Could not read the last_src_edit from the version.json file.") - try: path = os.path.dirname(os.path.abspath(__file__)) @@ -177,7 +551,6 @@ class Version: # getmtime returns the file's last modified time file_mtime = os.path.getmtime(filepath) last_modified = max(file_mtime, last_modified) - if num_files == 0: # Shouldn't be possible raise Exception("No python source files found in src/ directory") @@ -192,50 +565,107 @@ class Version: return None -if __name__ == "__main__": - """ - CLI to extract the current version and last edit time and write to `src/seedsigner/version.json`. - Used by the SeedSigner OS build process to generate the version.json file. + """ ************************************************************************************* + These functions use shell `git` commands which shouldn't be dangerous, but we don't want + them being run in SeedSigner OS regardless. + ************************************************************************************* """ + @classmethod + @fail_if_running_seedsigner_os + def _get_version_name_from_git_shell_branch(cls) -> str | None: + branch_name = os.popen("git branch --show-current 2> /dev/null").read() + return branch_name.strip() if branch_name else None - Uses the last git commit time (via `git log`) as the last edit time. - """ - version_info = dict() - # Run `git log` in the shell to get the last commit time - try: - last_commit = os.popen("git log -1 --format=%cI").read().strip() + @classmethod + @fail_if_running_seedsigner_os + def _get_version_name_from_git_shell_tag(cls) -> str | None: + tag_name = os.popen("git describe --tags --abbrev=0 2> /dev/null").read() + return tag_name.strip() if tag_name else None + + + @classmethod + @fail_if_running_seedsigner_os + def _get_version_name_from_git_shell_commit_hash(cls) -> str | None: + commit_hash = os.popen("git rev-parse --short HEAD 2> /dev/null").read() + return commit_hash.strip() if commit_hash else None + + + @classmethod + @fail_if_running_seedsigner_os + def _get_version_name_from_git_shell(cls) -> str | None: + """ + Attempts to get the version name via shell `git` commands. + """ + return ( + cls._get_version_name_from_git_shell_branch() or + cls._get_version_name_from_git_shell_tag() or + cls._get_version_name_from_git_shell_commit_hash() + ) + + + @classmethod + @fail_if_running_seedsigner_os + def _get_version_fork_from_git_shell(cls) -> str | None: + """ + Attempts to get the fork owner name via shell `git` commands. + """ + # We expect to at least have a remote named "origin" + remote_url = os.popen("git remote get-url origin 2> /dev/null").read().strip() + return cls._parse_git_remote_url(remote_url) + + + @classmethod + @fail_if_running_seedsigner_os + def _get_version_timestamp_from_git_shell(cls) -> datetime | None: + version_timestamp = os.popen("git log -1 --format=%cI").read().strip() # Parse the timestamp, ensure that it's in UTC, and omit tz info - version_info["last_src_edit"] = datetime.fromisoformat(last_commit).astimezone(timezone.utc).replace(tzinfo=None).isoformat() - except Exception as e: - raise Exception("Could not get last commit time from git log.") from e + return datetime.fromisoformat(version_timestamp).astimezone(timezone.utc).replace(tzinfo=None) - try: - # If we're currently building SeedSigner OS, check the env var - version_name = os.getenv("SEEDSIGNER_VERSION_NAME") - if not version_name: - version_name = os.popen("git branch --show-current").read().strip() + @classmethod + @fail_if_running_seedsigner_os + def _get_version_commit_hash_from_git_shell(cls) -> str | None: + """ + Attempts to get the current git commit hash via shell `git` commands. + """ + commit_hash = os.popen("git rev-parse --short HEAD").read() + return commit_hash.strip() if commit_hash else None - if not version_name: - # If we're on a tag, there won't be a current branch. Instead, try to get the - # current tag. - version_name = os.popen("git describe --tags --abbrev=0").read().strip() - - if not version_name: - # Fallback to commit hash - version_name = os.popen("git rev-parse --short HEAD").read().strip() - if not version_name: - raise Exception("No git info found for version name.") + @classmethod + @fail_if_running_seedsigner_os + def _fetch_latest_release_version(cls) -> tuple[str, datetime] | tuple[None, None]: + """ + Fetches the latest release version from the SeedSigner GitHub repo via the + GitHub API. Then attempts to resolve the tag name locally to get its associated + commit timestamp. If local git data is not available, falls back to using the + release published_at time from the API. - version_info["version"] = version_name - except Exception as e: - raise Exception("Could not get version name from SeedSigner OS env var nor git.") from e + This is only used by the screenshot renderer. + """ + import urllib.request + from http.client import HTTPResponse - version_file_path = Version._get_version_file_path() - with open(version_file_path, "w") as f: - json.dump(version_info, f, indent=4) + try: + req = urllib.request.Request( + "https://api.github.com/repos/SeedSigner/seedsigner/releases/latest" + ) + response: HTTPResponse = urllib.request.urlopen(req, timeout=5) + if response.status == 200: + release_data = json.loads(response.read().decode('utf-8')) + version_name = release_data.get("tag_name") - print(f"Wrote version info to: {version_file_path}") - print(json.dumps(version_info, indent=4)) \ No newline at end of file + # Try to find the commit timestamp for this tag from local git data + version_timestamp = os.popen(f"git show {version_name} --format=%cI").read().strip() + if not version_timestamp: + # Fallback: use the release published_at time from the API + version_timestamp = release_data.get("published_at") + version_timestamp = datetime.fromisoformat(version_timestamp).astimezone(timezone.utc).replace(tzinfo=None) + return (VersionUtils._prefix_version_name(version_name), version_timestamp) + else: + logger.warning(f"GitHub API returned status code {response.status}") + return (None, None) + except Exception as e: + logger.error(f"Error fetching latest release version: {e}") + return (None, None) \ No newline at end of file diff --git a/src/seedsigner/views/screensaver.py b/src/seedsigner/views/screensaver.py index 3160626a..0b0847b7 100644 --- a/src/seedsigner/views/screensaver.py +++ b/src/seedsigner/views/screensaver.py @@ -98,7 +98,7 @@ class OpeningSplashScreen(LogoScreen): # Display version num below SeedSigner logo font = Fonts.get_font(GUIConstants.get_body_font_name(), GUIConstants.get_top_nav_title_font_size()) - version = Version.get_version() + version = Version.get_version_name() # The logo png is 240x240, but the actual logo is 70px tall, vertically centered logo_height = 70 diff --git a/src/seedsigner/views/settings_views.py b/src/seedsigner/views/settings_views.py index 03b5f178..ee14ce13 100644 --- a/src/seedsigner/views/settings_views.py +++ b/src/seedsigner/views/settings_views.py @@ -330,10 +330,22 @@ class VersionView(View): def run(self): from seedsigner.helpers.version import Version + version_fork = Version.get_version_fork() + version_commit_hash = Version.get_version_commit_hash() + + print(f"{version_fork=}, {version_commit_hash=}") + + if version_fork and version_fork.lower() == "seedsigner": + # Don't display fork name or commit hash for the main repo + version_fork = None + version_commit_hash = None + self.run_screen( settings_screens.VersionScreen, - version=Version.get_version(), - last_edit=Version.get_last_edit_timestamp(), + version_name=Version.get_version_name(), + version_fork=version_fork, + version_timestamp=Version.get_version_timestamp(), + version_commit_hash=version_commit_hash, ) return Destination(SettingsMenuView) \ No newline at end of file diff --git a/tests/screenshot_generator/generator.py b/tests/screenshot_generator/generator.py index 8ac7f53b..e041b95b 100644 --- a/tests/screenshot_generator/generator.py +++ b/tests/screenshot_generator/generator.py @@ -13,6 +13,8 @@ from embit import compact from embit.psbt import PSBT, OutputScope from embit.script import Script +from seedsigner.helpers.version import Version, VersionUtils + # Prevent importing modules w/Raspi hardware dependencies. # These must precede any SeedSigner imports. sys.modules['seedsigner.hardware.displays.st7789_mpy'] = MagicMock() @@ -195,6 +197,17 @@ def generate_screenshots(locale): value=SettingsConstants.OPTION__ENABLED ) + # Initialize the Version data to the most recent release + (version_name, version_timestamp) = VersionUtils._fetch_latest_release_version() + if not version_name or not version_timestamp: + raise Exception("Could not fetch latest release version from GitHub") + Version.override_data( + version_name=version_name, + version_fork="SeedSigner", # main repo; screenshot should hide fork and commit hash + version_timestamp=version_timestamp, + version_commit_hash="abcd1234" # dummy value should be ignored + ) + # Automatically populate all Settings options Views settings_views_list = [] def add_settings_entries(visibility = SettingsConstants.VISIBILITY__GENERAL): @@ -297,6 +310,10 @@ def generate_screenshots(locale): decoder.add_data(BASE64_PSBT_WITH_OP_RETURN_RAW_BYTES) controller.psbt = decoder.get_psbt() controller.psbt_parser = PSBTParser(p=controller.psbt, seed=seed_12b) + + + def reset_version_to_local_git_state_cb(): + Version.reset_instance() screenshot_sections = { @@ -429,6 +446,7 @@ def generate_screenshots(locale): ScreenshotConfig(settings_views.IOTestView), ScreenshotConfig(settings_views.DonateView), ScreenshotConfig(settings_views.VersionView), + ScreenshotConfig(settings_views.VersionView, run_before=reset_version_to_local_git_state_cb, screenshot_name="VersionView_current_git_state"), ScreenshotConfig(settings_views.SettingsIngestSettingsQRView, dict(data=settingsqr_data_persistent), screenshot_name="SettingsIngestSettingsQRView_persistent"), ScreenshotConfig(settings_views.SettingsIngestSettingsQRView, dict(data=settingsqr_data_not_persistent), screenshot_name="SettingsIngestSettingsQRView_not_persistent"), ], diff --git a/tests/test_version.py b/tests/test_version.py index f398d2de..5eb44387 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -50,7 +50,7 @@ class TestVersion(BaseTest): branch_name = "my_feature_branch" git_HEAD_content = f"ref: refs/heads/{branch_name}" with mock.patch("builtins.open", mock.mock_open(read_data=git_HEAD_content)): - version = Version.get_version() + version = Version.get_version_name() assert version == branch_name # Mock the HEAD file read in _read_HEAD_file to return a fake commit hash @@ -58,13 +58,13 @@ class TestVersion(BaseTest): with mock.patch("builtins.open", mock.mock_open(read_data=commit_hash)): # Mock that there are no matching tags for this commit hash with mock.patch.object(Version, '_get_matching_tag', return_value=None): - version = Version.get_version() + version = Version.get_version_name() assert version == commit_hash[:7] # short commit hash # Now mock that there is a matching tag for this commit hash tag_name = "v1.2.3" with mock.patch.object(Version, '_get_matching_tag', return_value=tag_name): - version = Version.get_version() + version = Version.get_version_name() assert version == tag_name @@ -73,7 +73,7 @@ class TestVersion(BaseTest): Test that get_last_src_edit returns a sane datetime object. Assumes the system running this test has a reasonably correct system time. """ - last_edit = Version.get_last_edit_timestamp() + last_edit = Version.get_version_timestamp() assert isinstance(last_edit, datetime) # Has to be more recent than the first SeedSigner v0.0.1 release diff --git a/tools/write_versionfile.py b/tools/write_versionfile.py new file mode 100644 index 00000000..8e556e5d --- /dev/null +++ b/tools/write_versionfile.py @@ -0,0 +1,63 @@ +import json +import os +from datetime import datetime, timezone + +from seedsigner.helpers.version import VersionUtils +from seedsigner.models.settings import Settings + + + +""" +CLI utility to extract the current version and last edit time and write to +`src/seedsigner/version.json`. Primarily used by the SeedSigner OS build process. + +SeedSigner OS lifecycle: + * Build process runs this script to generate version.json. + * version.json is included in the SeedSigner OS image. + * SeedSigner OS reads version.json at runtime. + +This script can also be run in local dev but `git` shell commands are required. + +version_name: + * Check for the SEEDSIGNER_VERSION_NAME env var (provided in SeedSigner OS build env). + * Will be the branch, tag, or commit hash being built. + * In local dev will fall back to (via `git` shell commands): + * Current git branch name + * Current git tag name + * Current git commit hash + +version_timestamp: + * Pulls last git commit time from `git log`. + +version_commit_hash: + * Pulls current git commit hash from `git` shell command. +""" + +if __name__ == "__main__": + version_info = dict() + + for get_version_name_method in [ + VersionUtils._get_version_name_from_env_var, + VersionUtils._get_version_name_from_git_shell, + ]: + version_name = get_version_name_method() + if version_name: + break + + version_timestamp = VersionUtils._get_version_timestamp_from_git_shell() + version_commit_hash = VersionUtils._get_version_commit_hash_from_git_shell() + + if not version_name or not version_timestamp or not version_commit_hash: + raise Exception("Could not determine version information from git.") + + version_info[VersionUtils.ATTR__VERSION_NAME] = version_name + version_info[VersionUtils.ATTR__VERSION_FORK] = VersionUtils._get_version_fork_from_git_shell() + version_info[VersionUtils.ATTR__VERSION_TIMESTAMP] = version_timestamp.isoformat() + version_info[VersionUtils.ATTR__VERSION_COMMIT_HASH] = version_commit_hash[:7] # short hash + + version_file_path = VersionUtils._get_version_file_path() + with open(version_file_path, "w") as f: + json.dump(version_info, f, indent=4) + + print(f"Wrote version info to: {version_file_path}") + print(json.dumps(version_info, indent=4)) From c5efda306c60877191013a6093d92cd0bfcccec8 Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Tue, 23 Dec 2025 16:26:53 -0600 Subject: [PATCH 27/50] Adding tests, bugfixes; interim commit --- src/seedsigner/helpers/version.py | 183 ++++++--- tests/screenshot_generator/generator.py | 2 +- tests/test_version.py | 523 ++++++++++++++++++++++-- tools/write_versionfile.py | 37 +- 4 files changed, 648 insertions(+), 97 deletions(-) diff --git a/src/seedsigner/helpers/version.py b/src/seedsigner/helpers/version.py index 4365f159..cb6644d0 100644 --- a/src/seedsigner/helpers/version.py +++ b/src/seedsigner/helpers/version.py @@ -1,6 +1,8 @@ import json import logging import os +import traceback + from datetime import datetime, timezone from seedsigner.models.settings import Settings @@ -17,7 +19,7 @@ class NotAllowedInSeedSignerOS(Exception): pass -def fail_if_running_seedsigner_os(func: callable): +def not_allowed_in_seedsigner_os(func: callable): """ Simple decorator to enforce SeedSigner OS restrictions. """ def wrapper_func(*args, **kwargs): if Settings.HOSTNAME == Settings.SEEDSIGNER_OS: @@ -34,14 +36,21 @@ class Version(Singleton): """ Utility class to report the current version and the last edit time of the source code. + Implemented as a Singleton for convenient access (no need to keep passing an instance + around in the main code) and so that the version data is only determined once per + runtime. + On SeedSigner OS: * Version data is written to `src/seedsigner/version.json` during the build process via tools/write_versionfile.py. * version_name: copied from the command used to build the SeedSigner OS image: --app-branch (the target git branch OR tag) or --app-commit-id (target commit hash) + * version_fork: the git repo owner/fork name (e.g. "SeedSigner") targeted by the + build process. * version_timestamp: the last git commit time for the target branch, tag, or commit hash. + * commit_hash: the short commit hash for the specified build target. In local dev: * version_name: read dynamically from a few possible sources. In order: @@ -50,16 +59,28 @@ class Version(Singleton): * Directly parsing the .git/HEAD file and possibly .git/refs/tags. * Note: we avoid reading from the version.json file as it may be out of date and could lead to confusion. + * version_fork: read dynamically from: + * Shell `git` call to check the remote "origin" URL. + * Parse the .git/config for the remote "origin" URL. * version_timestamp: determined by scanning the src/ directory for the most recently modified python file. + * commit_hash: read dynamically from: + * Shell `git` call. + * Parse the .git/HEAD file and possibly .git/refs/heads/. In Github Actions CI: * version_name: read from GITHUB_REF_NAME or GITHUB_SHA env vars. + * version_fork: TODO * version_timestamp: TODO + * commit_hash: TODO - This `Version` class defines the limited methods that are meant to be used elsewhere - in the SeedSigner codebase. The misc utility functions in `VersionUtils` were - explicitly isolated because they should not be used elsewhere in the codebase. + This class defines the limited methods that are meant to be publicly accessible + across the SeedSigner codebase. + + The misc utility functions in `VersionUtils` were explicitly isolated because they + should NOT be used elsewhere in the codebase. + TODO: Should `VersionUtils` be an internal class within `Version` to further signal + that it is not to be used externally? """ _version_name: str = None _version_fork: str = None @@ -97,14 +118,14 @@ class Version(Singleton): @classmethod - @fail_if_running_seedsigner_os + @not_allowed_in_seedsigner_os def reset_instance(cls): """ Resets the singleton instance. Only used by the test suite between tests. """ cls._instance = None @classmethod - @fail_if_running_seedsigner_os + @not_allowed_in_seedsigner_os def override_data(cls, version_name, version_fork, version_timestamp, version_commit_hash): """ Only used by the test suite to manually change version data between tests. @@ -144,16 +165,31 @@ class VersionUtils: Methods are separated out here to a rather extreme degree in order to enable all the mocking that is required for testing. + + Summary of functions: + * Top-level "get" calls that manage all the possible ways of getting the version + data in an environment-aware manner (SeedSigner OS, local dev, Github Actions + CI). + * Reading data from the version.json file (SeedSigner OS). + * Getting data from SeedSigner OS build env vars. + * Detecting Github Actions CI environment and getting data from its env vars. + * Parsing local filesystem .git/ files. + * Making shell `git` calls. + * One external http GET to github to get the most recent release tag. ********************************************************************************* """ ENV_VAR__SEEDSIGNER_VERSION_NAME = "SEEDSIGNER_VERSION_NAME" VERSION_FILENAME = "version.json" + DOT_GIT_DIR_NAME = ".git" # defined to facilitate mocking in tests ATTR__VERSION_NAME = "name" ATTR__VERSION_FORK = "fork" ATTR__VERSION_TIMESTAMP = "timestamp" ATTR__VERSION_COMMIT_HASH = "commit_hash" + """ ************************************************************************************* + Top-level "get" calls. These are the only functions meant to be called externally. + ************************************************************************************* """ @classmethod def get_version_name(cls) -> str: """ @@ -281,6 +317,9 @@ class VersionUtils: return version_name + """ ************************************************************************************* + Reading data from the version.json file. + ************************************************************************************* """ @classmethod def _get_version_file_path(cls) -> str: # Have to back out of this file's location in the "helpers" dir to the main @@ -296,9 +335,11 @@ class VersionUtils: try: with open(cls._get_version_file_path(), "r") as f: return json.load(f) - except Exception: - # In local dev we don't expect/want this file to exist - return None + except FileNotFoundError as e: + logger.debug(f"Ignoring {e}") + except Exception as e: + # Something unexpected happened. Flag it but keep going. + logger.error(traceback.format_exc()) @classmethod @@ -341,6 +382,10 @@ class VersionUtils: return version_data.get(cls.ATTR__VERSION_COMMIT_HASH) + + """ ************************************************************************************* + Getting data from SeedSigner OS build env vars or Github Actions CI env vars. + ************************************************************************************* """ @classmethod def _get_version_name_from_env_var(cls) -> str | None: """ @@ -365,12 +410,13 @@ class VersionUtils: """ ************************************************************************************* - These functions attempt to read directly from local .git/ or src/ files. These operations - aren't dangerous but aren't necessary when we're running in SeedSigner OS, so we restrict - them here. + Reading directly from local .git/ or src/ files. + + These operations aren't dangerous but aren't necessary when we're running in + SeedSigner OS, so we apply the decorator restriction here. ************************************************************************************* """ @classmethod - @fail_if_running_seedsigner_os + @not_allowed_in_seedsigner_os def _get_dot_git_dir(cls) -> str: # If it exists, the .git dir will be in the project root path = os.path.dirname(os.path.abspath(__file__)) @@ -378,11 +424,11 @@ class VersionUtils: # Have to back out of this file's location in "helpers" and "seedsigner" and "src" dirs project_root = os.path.join(path, "..", "..", "..") - return os.path.normpath(os.path.join(project_root, ".git")) + return os.path.normpath(os.path.join(project_root, cls.DOT_GIT_DIR_NAME)) @classmethod - @fail_if_running_seedsigner_os + @not_allowed_in_seedsigner_os def _read_git_HEAD_file(cls) -> tuple[str | None, str | None]: """ Reads the .git/HEAD file and returns a tuple of (branch_name, commit_hash) where @@ -394,7 +440,7 @@ class VersionUtils: branch_name = None commit_hash = None - if os.path.exists(git_HEAD_path): + try: with open(git_HEAD_path, "r") as f: git_ref = f.read().strip() if git_ref.startswith("ref:"): @@ -404,17 +450,22 @@ class VersionUtils: # If we're on a detached HEAD, the contents will just be the current # commit hash. commit_hash = git_ref + except FileNotFoundError as e: + logger.debug(f"Ignoring {e}") + except Exception as e: + # Something unexpected happened. Flag it but keep going. + logger.error(traceback.format_exc()) return (branch_name, commit_hash) @classmethod - @fail_if_running_seedsigner_os - def _get_matching_tag(cls, commit_hash: str) -> str | None: + @not_allowed_in_seedsigner_os + def _get_matching_tag_from_git_refs_tags(cls, commit_hash: str) -> str | None: """ Checks the .git/refs/tags dir for a tag that matches the provided commit hash. """ - git_refs_tags_dir = os.path.join(cls._get_dot_git_dir(), "refs", "tags") - if os.path.exists(git_refs_tags_dir): + try: + git_refs_tags_dir = os.path.join(cls._get_dot_git_dir(), "refs", "tags") for tag_filename in os.listdir(git_refs_tags_dir): tag_path = os.path.join(git_refs_tags_dir, tag_filename) with open(tag_path, "r") as tag_file: @@ -423,11 +474,15 @@ class VersionUtils: if tag_commit_hash == commit_hash: # Filename is the tag name return tag_filename - return None + except FileNotFoundError as e: + logger.debug(f"Ignoring {e}") + except Exception as e: + # Something unexpected happened. Flag it but keep going. + logger.error(traceback.format_exc()) @classmethod - @fail_if_running_seedsigner_os + @not_allowed_in_seedsigner_os def _get_version_name_from_git_HEAD(cls) -> str | None: """ Reads the .git/HEAD file and, depending on the local dev git state, returns @@ -438,7 +493,7 @@ class VersionUtils: return branch_name elif commit_hash: # See if this commit_hash matches a tag - matching_tag = VersionUtils._get_matching_tag(commit_hash) + matching_tag = VersionUtils._get_matching_tag_from_git_refs_tags(commit_hash) if matching_tag: return matching_tag else: @@ -446,7 +501,7 @@ class VersionUtils: @classmethod - @fail_if_running_seedsigner_os + @not_allowed_in_seedsigner_os def _get_commit_hash_from_git_HEAD(cls) -> str | None: """ Reads the .git/HEAD file and, depending on the local dev git state, returns @@ -458,17 +513,22 @@ class VersionUtils: @classmethod - @fail_if_running_seedsigner_os + @not_allowed_in_seedsigner_os def _get_commit_hash_from_git_refs_heads(cls, branch_name: str) -> str | None: """ Reads the .git/refs/heads/ file to get the current commit hash for the given branch. """ - git_ref_path = os.path.join(cls._get_dot_git_dir(), "refs", "heads", branch_name) - if os.path.exists(git_ref_path): + try: + git_ref_path = os.path.join(cls._get_dot_git_dir(), "refs", "heads", branch_name) with open(git_ref_path, "r") as f: commit_hash = f.read().strip() return commit_hash + except FileNotFoundError as e: + logger.debug(f"Ignoring {e}") + except Exception as e: + # Something unexpected happened. Flag it but keep going. + logger.error(traceback.format_exc()) @classmethod @@ -492,7 +552,7 @@ class VersionUtils: @classmethod - @fail_if_running_seedsigner_os + @not_allowed_in_seedsigner_os def _get_version_fork_from_git_config(cls) -> str | None: """ Attempts to read the .git/config file to determine the remote "origin" URL @@ -507,8 +567,8 @@ class VersionUtils: [next_section] ... """ - git_config_path = os.path.join(cls._get_dot_git_dir(), "config") - if os.path.exists(git_config_path): + try: + git_config_path = os.path.join(cls._get_dot_git_dir(), "config") with open(git_config_path, "r") as f: lines = f.readlines() in_origin_section = False @@ -523,11 +583,15 @@ class VersionUtils: elif in_origin_section and line.startswith("["): # We reached the next section without finding the url raise Exception("Didn't find 'url' entry in 'origin' section of .git/config") - return None + except FileNotFoundError as e: + logger.debug(f"Ignoring {e}") + except Exception as e: + # Something unexpected happened. Flag it but keep going. + logger.error(traceback.format_exc()) @classmethod - @fail_if_running_seedsigner_os + @not_allowed_in_seedsigner_os def _get_version_timestamp_from_src_files(cls) -> datetime | None: """ Recursively scan the src/ directory for the most recent python file edit time. @@ -560,39 +624,41 @@ class VersionUtils: except Exception as e: # Catch and log any unexpected errors but this isn't a mission-critical # function so return gracefully. - import traceback logger.error(traceback.format_exc()) - return None """ ************************************************************************************* - These functions use shell `git` commands which shouldn't be dangerous, but we don't want - them being run in SeedSigner OS regardless. + Get data via shell `git` commands. + + These calls shouldn't be dangerous, but we definitely don't want them being run in + SeedSigner OS regardless so we apply the decorator restriction. ************************************************************************************* """ @classmethod - @fail_if_running_seedsigner_os + @not_allowed_in_seedsigner_os def _get_version_name_from_git_shell_branch(cls) -> str | None: branch_name = os.popen("git branch --show-current 2> /dev/null").read() return branch_name.strip() if branch_name else None @classmethod - @fail_if_running_seedsigner_os + @not_allowed_in_seedsigner_os def _get_version_name_from_git_shell_tag(cls) -> str | None: - tag_name = os.popen("git describe --tags --abbrev=0 2> /dev/null").read() + # Only return a value if the current commit exactly corresponds with a tag. + # (`--points-at` defaults to the current HEAD) + tag_name = os.popen(f"git tag --points-at 2> /dev/null").read() return tag_name.strip() if tag_name else None @classmethod - @fail_if_running_seedsigner_os + @not_allowed_in_seedsigner_os def _get_version_name_from_git_shell_commit_hash(cls) -> str | None: commit_hash = os.popen("git rev-parse --short HEAD 2> /dev/null").read() return commit_hash.strip() if commit_hash else None @classmethod - @fail_if_running_seedsigner_os + @not_allowed_in_seedsigner_os def _get_version_name_from_git_shell(cls) -> str | None: """ Attempts to get the version name via shell `git` commands. @@ -605,7 +671,7 @@ class VersionUtils: @classmethod - @fail_if_running_seedsigner_os + @not_allowed_in_seedsigner_os def _get_version_fork_from_git_shell(cls) -> str | None: """ Attempts to get the fork owner name via shell `git` commands. @@ -616,7 +682,7 @@ class VersionUtils: @classmethod - @fail_if_running_seedsigner_os + @not_allowed_in_seedsigner_os def _get_version_timestamp_from_git_shell(cls) -> datetime | None: version_timestamp = os.popen("git log -1 --format=%cI").read().strip() # Parse the timestamp, ensure that it's in UTC, and omit tz info @@ -624,7 +690,7 @@ class VersionUtils: @classmethod - @fail_if_running_seedsigner_os + @not_allowed_in_seedsigner_os def _get_version_commit_hash_from_git_shell(cls) -> str | None: """ Attempts to get the current git commit hash via shell `git` commands. @@ -633,12 +699,16 @@ class VersionUtils: return commit_hash.strip() if commit_hash else None + + """ ************************************************************************************* + External http GET call to github. + ************************************************************************************* """ @classmethod - @fail_if_running_seedsigner_os - def _fetch_latest_release_version(cls) -> tuple[str, datetime] | tuple[None, None]: + @not_allowed_in_seedsigner_os + def _fetch_latest_seedsigner_release_tag(cls) -> tuple[str, datetime] | tuple[None, None]: """ - Fetches the latest release version from the SeedSigner GitHub repo via the - GitHub API. Then attempts to resolve the tag name locally to get its associated + Fetches the latest release version from the SeedSigner github repo via the + github API. Then attempts to resolve the tag name locally to get its associated commit timestamp. If local git data is not available, falls back to using the release published_at time from the API. @@ -648,6 +718,17 @@ class VersionUtils: from http.client import HTTPResponse try: + """ + Excerpted example response from the github API: + { + "url": "https://api.github.com/repos/SeedSigner/seedsigner/releases/228915183", + "tag_name": "0.8.6", + "prerelease": false, + "created_at": "2025-06-22T01:38:41Z", + "updated_at": "2025-06-30T20:49:22Z", + "published_at": "2025-06-30T20:45:05Z" + } + """ req = urllib.request.Request( "https://api.github.com/repos/SeedSigner/seedsigner/releases/latest" ) @@ -660,12 +741,12 @@ class VersionUtils: version_timestamp = os.popen(f"git show {version_name} --format=%cI").read().strip() if not version_timestamp: # Fallback: use the release published_at time from the API - version_timestamp = release_data.get("published_at") + version_timestamp = release_data.get("published_at").replace("Z", "+00:00") version_timestamp = datetime.fromisoformat(version_timestamp).astimezone(timezone.utc).replace(tzinfo=None) return (VersionUtils._prefix_version_name(version_name), version_timestamp) else: - logger.warning(f"GitHub API returned status code {response.status}") + logger.error(f"GitHub API returned status code {response.status}: {response}") return (None, None) except Exception as e: - logger.error(f"Error fetching latest release version: {e}") + logger.error(f"Error fetching latest release version:\n{traceback.format_exc()}") return (None, None) \ No newline at end of file diff --git a/tests/screenshot_generator/generator.py b/tests/screenshot_generator/generator.py index e041b95b..3bce441a 100644 --- a/tests/screenshot_generator/generator.py +++ b/tests/screenshot_generator/generator.py @@ -198,7 +198,7 @@ def generate_screenshots(locale): ) # Initialize the Version data to the most recent release - (version_name, version_timestamp) = VersionUtils._fetch_latest_release_version() + (version_name, version_timestamp) = VersionUtils._fetch_latest_seedsigner_release_tag() if not version_name or not version_timestamp: raise Exception("Could not fetch latest release version from GitHub") Version.override_data( diff --git a/tests/test_version.py b/tests/test_version.py index 5eb44387..bdfc6211 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -1,16 +1,153 @@ +import json import os import pytest -from datetime import datetime +from datetime import datetime, timezone from unittest import mock +from unittest.mock import Mock, patch # Must import this before any SeedSigner imports from base import BaseTest -from seedsigner.helpers.version import Version +from seedsigner.helpers.version import Version, VersionUtils, NotAllowedInSeedSignerOS, not_allowed_in_seedsigner_os +from seedsigner.models.settings import Settings -class TestVersion(BaseTest): +# overrides +TEST__VERSION_FILE_NAME = "version-test.json" +TEST__DOT_GIT_DIR_NAME = f"dot-git-test" + +# Reusable test data +TEST__VERSION_NAME = "1.2.3" # Will require VersionUtils._prefix_version_name when verifying results +TEST__VERSION_FORK = "some_repo_owner" +TEST__VERSION_TIMESTAMP = datetime.now() +TEST__VERSION_COMMIT_HASH = "abcd123" +TEST__VERSION_DICT = { + VersionUtils.ATTR__VERSION_NAME: TEST__VERSION_NAME, + VersionUtils.ATTR__VERSION_FORK: TEST__VERSION_FORK, + VersionUtils.ATTR__VERSION_TIMESTAMP: TEST__VERSION_TIMESTAMP.isoformat(), + VersionUtils.ATTR__VERSION_COMMIT_HASH: TEST__VERSION_COMMIT_HASH, +} + +# Mimic result of reading from version.json +TEST__VERSION_FILE_CONTENTS = str(TEST__VERSION_DICT).replace("'", '"') # JSON uses double quotes + + + +class VersionBaseTest(BaseTest): + """ Sets up test-specific overrides and reusable methods and fixtures. """ + + @pytest.fixture(autouse=True, scope="class") + def mock_version_file_name(self): + """ + Every test in this class (and subclasses) will automatically run with this patch + applied (autouse=True), but the patch will not persist beyond the test class. + """ + with patch.object(VersionUtils, 'VERSION_FILENAME', TEST__VERSION_FILE_NAME): + yield + + + @pytest.fixture(autouse=True) + def mock_DOT_GIT_DIR_NAME(self): + """ + Patch out the DOT_GIT_DIR_NAME to facilitate testing git-related methods + without touching the real filesystem. + """ + with patch.object(VersionUtils, 'DOT_GIT_DIR_NAME', TEST__DOT_GIT_DIR_NAME): + yield + + + @pytest.fixture(autouse=True) + def mock_popen(self): + """ + Prevent any os.popen calls from actually executing during tests. + """ + with patch("os.popen", autospec=True) as mock_popen: + # Default to returning an empty string for `read()` + mock_popen.return_value.read.return_value = "" + yield mock_popen + + + @classmethod + def write_test_version_file(cls): + """ + Write the test version file to disk. + """ + assert VersionUtils.VERSION_FILENAME == TEST__VERSION_FILE_NAME + with open(VersionUtils._get_version_file_path(), "w") as f: + f.write(TEST__VERSION_FILE_CONTENTS) + + + @classmethod + def delete_test_version_file(cls): + """ + Delete the test version file from disk. + """ + assert VersionUtils.VERSION_FILENAME == TEST__VERSION_FILE_NAME + try: + os.remove(VersionUtils._get_version_file_path()) + except FileNotFoundError: + pass + + + def setup_method(self): + super().setup_method() + + + def teardown_method(self): + super().teardown_method() + # Clean up any test version file created + self.delete_test_version_file() + + + +class TestVersionBaseTest(VersionBaseTest): + def test_setup_and_teardown(self): + """ + Ensure that the setup and teardown methods work as expected. + """ + assert VersionUtils.VERSION_FILENAME == TEST__VERSION_FILE_NAME + + # During setup, the test version file should not exist + assert not os.path.exists(VersionUtils._get_version_file_path()) + + # Write the test version file + self.write_test_version_file() + assert os.path.exists(VersionUtils._get_version_file_path()) + + # Delete should remove it + self.delete_test_version_file() + assert not os.path.exists(VersionUtils._get_version_file_path()) + + # Teardown should also delete the test version file + self.write_test_version_file() + self.teardown_method() + assert not os.path.exists(VersionUtils._get_version_file_path()) + + + def test_mock_popen(self): + """ All os.popen calls should be automatically/invisibly mocked out. """ + result = os.popen("echo 'Hello, World!'") + assert isinstance(result, mock.MagicMock) + + + +class TestVersion(VersionBaseTest): + def test_seedsigner_os_reads_from_version_file(self): + """ + When running on SeedSigner OS, the version data should be read from the + version.json file. + """ + self.write_test_version_file() + + # Simulate running on SeedSigner OS + with patch("seedsigner.models.settings.Settings.HOSTNAME", Settings.SEEDSIGNER_OS): + assert Version.get_version_name() == VersionUtils._prefix_version_name(TEST__VERSION_NAME) + assert Version.get_version_fork() == TEST__VERSION_FORK + assert Version.get_version_timestamp() == TEST__VERSION_TIMESTAMP + assert Version.get_version_commit_hash() == TEST__VERSION_COMMIT_HASH + + # def test_version_with_no_git_head(self): # """ # If there is no .git/HEAD file, the hard-coded VERSION constant should be returned. @@ -40,32 +177,32 @@ class TestVersion(BaseTest): # pytest.skip(f"No .git dir found at {git_dot_dir}, skipping test.") - def test_version_with_mocked_git_head(self): - """ - If there is a .git/HEAD file, the version should report the current git branch - name, commit hash, or a matching tag. - """ - with mock.patch("os.path.exists", return_value=True): - # Mock the HEAD file read in _read_HEAD_file to return our fake branch name - branch_name = "my_feature_branch" - git_HEAD_content = f"ref: refs/heads/{branch_name}" - with mock.patch("builtins.open", mock.mock_open(read_data=git_HEAD_content)): - version = Version.get_version_name() - assert version == branch_name + # def test_version_with_mocked_git_head(self): + # """ + # If there is a .git/HEAD file, the version should report the current git branch + # name, commit hash, or a matching tag. + # """ + # with mock.patch("os.path.exists", return_value=True): + # # Mock the HEAD file read in _read_HEAD_file to return our fake branch name + # branch_name = "my_feature_branch" + # git_HEAD_content = f"ref: refs/heads/{branch_name}" + # with mock.patch("builtins.open", mock.mock_open(read_data=git_HEAD_content)): + # version = VersionUtils._get_version_name_from_git_HEAD() + # assert version == branch_name - # Mock the HEAD file read in _read_HEAD_file to return a fake commit hash - commit_hash = "abcdef1234567890" - with mock.patch("builtins.open", mock.mock_open(read_data=commit_hash)): - # Mock that there are no matching tags for this commit hash - with mock.patch.object(Version, '_get_matching_tag', return_value=None): - version = Version.get_version_name() - assert version == commit_hash[:7] # short commit hash + # # Mock the HEAD file read in _read_HEAD_file to return a fake commit hash + # commit_hash = "abcdef1234567890" + # with mock.patch("builtins.open", mock.mock_open(read_data=commit_hash)): + # # Mock that there are no matching tags for this commit hash + # with mock.patch.object(Version, '_get_matching_tag', return_value=None): + # version = Version.get_version_name() + # assert version == commit_hash[:7] # short commit hash - # Now mock that there is a matching tag for this commit hash - tag_name = "v1.2.3" - with mock.patch.object(Version, '_get_matching_tag', return_value=tag_name): - version = Version.get_version_name() - assert version == tag_name + # # Now mock that there is a matching tag for this commit hash + # tag_name = "v1.2.3" + # with mock.patch.object(Version, '_get_matching_tag', return_value=tag_name): + # version = Version.get_version_name() + # assert version == tag_name def test_get_last_edit(self): @@ -82,4 +219,334 @@ class TestVersion(BaseTest): # Could not have happened tomorrow known_future = datetime.now().replace(year=datetime.now().year + 1) - assert last_edit < known_future \ No newline at end of file + assert last_edit < known_future + + +class TestVersionUtils(VersionBaseTest): + def test__prefix_version_name(self): + """ + Semantic versions should be prefixed with 'v' but others should be left as-is. + """ + # Expected to end up starting with "v": + for version_name in [ + "1.2.3", + "v1.2.3", + "1.2.3-rc1", + "v1.2.3-rc1", + "21.known.slight.flaw", + ]: + result = VersionUtils._prefix_version_name(version_name) + assert result.startswith("v"), f"Expected '{result}' to start with 'v'" + if version_name.startswith("v"): + assert result == version_name, f"Expected '{result}' to equal input '{version_name}'" + + # Expect no change: + for version_name in [ + "release-branch", + "feature/foo", + "hotfix-123", + "foo.bar.not.semantic", + "1234abcd", + "1234", + ]: + result = VersionUtils._prefix_version_name(version_name) + assert result == version_name, f"Expected '{result}' to equal input '{version_name}'" + + + def test__read_version_file(self): + """ + Low-level test for reading the version.json file. + """ + self.write_test_version_file() + + version_data = VersionUtils._read_version_file() + assert version_data is not None + assert version_data[VersionUtils.ATTR__VERSION_NAME] == TEST__VERSION_NAME + assert version_data[VersionUtils.ATTR__VERSION_FORK] == TEST__VERSION_FORK + assert version_data[VersionUtils.ATTR__VERSION_TIMESTAMP] == TEST__VERSION_TIMESTAMP.isoformat() + assert version_data[VersionUtils.ATTR__VERSION_COMMIT_HASH] == TEST__VERSION_COMMIT_HASH + + + def test__read_version_file__missing(self): + """ _read_version_file should return None if the version file is missing. """ + assert os.path.exists(VersionUtils._get_version_file_path()) is False + assert VersionUtils._read_version_file() is None + + + def test__get_dot_git_dir(self): + """ + _get_dot_git_dir should return the expected .git directory path. + """ + # The mocked DOT_GIT_DIR_NAME should be in the result + result = VersionUtils._get_dot_git_dir() + assert TEST__DOT_GIT_DIR_NAME in result + + + def test__read_git_HEAD_file(self): + """ + _read_git_HEAD_file should parse the git HEAD file to extract the current + branch name or commit hash. Or gracefully return None if the HEAD file is missing. + """ + # Initially our test setup has no .git dir + assert VersionUtils._read_git_HEAD_file() == (None, None) + + # If we're on a branch... + expected_branch = "my_test_branch" + git_HEAD_content = f"ref: refs/heads/{expected_branch}" + with patch("builtins.open", mock.mock_open(read_data=git_HEAD_content)): + branch_name, commit_hash = VersionUtils._read_git_HEAD_file() + assert branch_name == expected_branch + assert commit_hash is None + + # If we're in a detached HEAD state at a specific commit hash... + expected_commit_hash = "47212c98f1bf948e9918b672c4bb88b1c965aff4" + with patch("builtins.open", mock.mock_open(read_data=expected_commit_hash)): + branch_name, commit_hash = VersionUtils._read_git_HEAD_file() + assert branch_name is None + assert commit_hash == expected_commit_hash + + # Gracefully handle a read error + with patch("builtins.open", side_effect=FileNotFoundError): + assert VersionUtils._read_git_HEAD_file() == (None, None) + + # Or any other kind of exception + with patch("builtins.open", side_effect=Exception("Unexpected error")): + assert VersionUtils._read_git_HEAD_file() == (None, None) + + + def test__get_matching_tag_from_git_refs_tags(self): + """ + _get_matching_tag_from_git_refs_tags should return the expected tag name + if a matching tag is found for the given commit hash. + """ + # No .git dir initially + assert VersionUtils._get_matching_tag_from_git_refs_tags("anyhash") is None + + # Mock out the .git/refs/tags file read to return some fake tags + fake_tags_content = """v1.0.0:abcd1234567890 +v1.2.3:deadbeefcafebabe +v2.0.0-rc1:47212c98f1bf948e9918b672c4bb88b1c965aff4 +""" + with patch("builtins.open", mock.mock_open(read_data=fake_tags_content)): + # Existing tag + tag_name = VersionUtils._get_matching_tag_from_git_refs_tags("deadbeefcafebabe") + assert tag_name == "v1.2.3" + + # Another existing tag + tag_name = VersionUtils._get_matching_tag_from_git_refs_tags("47212c98f1bf948e9918b672c4bb88b1c965aff4") + assert tag_name == "v2.0.0-rc1" + + # Non-existing tag + tag_name = VersionUtils._get_matching_tag_from_git_refs_tags("nonexistenthash") + assert tag_name is None + + + def test__get_version_timestamp_from_src_files(self): + """ + _get_version_timestamp_from_src_files should return the most recent file + modification timestamp from the SeedSigner python files. + """ + # Do the real filesystem scan + timestamp = VersionUtils._get_version_timestamp_from_src_files() + assert timestamp < datetime.now() + assert timestamp > datetime(2020, 12, 13) # after first SeedSigner release + + # Now mock out os.path.getmtime to force all files to have a known timestamp + expected_timestamp = datetime(2025, 12, 23, 0, 0, 0) + with mock.patch("os.path.getmtime", return_value=expected_timestamp.timestamp()): + assert VersionUtils._get_version_timestamp_from_src_files() == expected_timestamp + + # Mock out os.walk() to simulate no .py files found + with mock.patch("os.walk", return_value=[]): + assert VersionUtils._get_version_timestamp_from_src_files() is None + + + + + def test__get_version_name_from_git_shell(self): + """ + Test that _get_version_name_from_git_shell returns the expected name depending on + the current git state + """ + branch_name = "my_test_branch" + tag_name = "my_test_tag" + commit_hash = "abcd123" + + # Default mock_popen return empty string; simulates no `git` shell command available + # or no local git data. + result = VersionUtils._get_version_name_from_git_shell() + assert result is None + + # If we're on a branch, should return the branch name + with mock.patch.multiple( + "seedsigner.helpers.version.VersionUtils", + _get_version_name_from_git_shell_branch=Mock(return_value=branch_name), + _get_version_name_from_git_shell_tag=Mock(return_value=tag_name), + _get_version_name_from_git_shell_commit_hash=Mock(return_value=commit_hash), + ): + result = VersionUtils._get_version_name_from_git_shell() + assert result == branch_name + + # If we're on a tag, the detached HEAD state wipes out the branch name + with mock.patch.multiple( + "seedsigner.helpers.version.VersionUtils", + _get_version_name_from_git_shell_branch=Mock(return_value=None), + _get_version_name_from_git_shell_tag=Mock(return_value=tag_name), + _get_version_name_from_git_shell_commit_hash=Mock(return_value=commit_hash), + ): + result = VersionUtils._get_version_name_from_git_shell() + assert result == tag_name + + # Similarly, if we're detached at a specific commit hash + with mock.patch.multiple( + "seedsigner.helpers.version.VersionUtils", + _get_version_name_from_git_shell_branch=Mock(return_value=None), + _get_version_name_from_git_shell_tag=Mock(return_value=None), + _get_version_name_from_git_shell_commit_hash=Mock(return_value=commit_hash), + ): + result = VersionUtils._get_version_name_from_git_shell() + assert result == commit_hash[:7] # short hash + + + def test__get_version_fork_from_git_shell(self, mock_popen: Mock): + """ + Test that _get_version_fork_from_git_shell returns the expected repo owner from + the remote url. + """ + remote_url = "https://github.com/SeedSigner/seedsigner.git" + expected_fork = "SeedSigner" + + mock_popen.return_value.read.return_value = remote_url + result = VersionUtils._get_version_fork_from_git_shell() + assert result == expected_fork + + + def test__get_version_timestamp_from_git_shell(self, mock_popen: Mock): + """ + Test that _get_version_timestamp_from_git_shell returns the expected datetime. + """ + # Initial timestamp has timezone info + hour = 14 + tz_offset = 1 + test_local_isoformat = f"2025-12-20T{hour:02}:00:00-{tz_offset:02}:00" + mock_popen.return_value.read.return_value = test_local_isoformat + + # But the final result will be UTC + expected_datetime = datetime.fromisoformat(f"2025-12-20T{hour + tz_offset:02}:00:00") + assert VersionUtils._get_version_timestamp_from_git_shell() == expected_datetime + + # And UTC-to-UTC should be unchanged + mock_popen.return_value.read.return_value = expected_datetime.isoformat() + "+00:00" + assert VersionUtils._get_version_timestamp_from_git_shell() == expected_datetime + + + def test__get_version_commit_hash_from_git_shell(self, mock_popen: Mock): + """ + Test that _get_version_commit_hash_from_git_shell returns the expected short commit hash. + """ + commit_hash = "abcd123" + mock_popen.return_value.read.return_value = commit_hash + + result = VersionUtils._get_version_commit_hash_from_git_shell() + assert result == commit_hash + + + def test__fetch_latest_seedsigner_release_tag(self, mock_popen: Mock): + """ + Test that _fetch_latest_seedsigner_release_tag returns the expected version string. + """ + latest_releases_response_dict = { + "url": "https://api.github.com/repos/SeedSigner/seedsigner/releases/228915183", + "tag_name": "0.8.6", + "prerelease": False, + "created_at": "2025-06-22T01:38:41Z", + "updated_at": "2025-06-30T20:49:22Z", + "published_at": "2025-06-30T20:45:05Z" + } + + fake_response = Mock() + fake_response.status = 200 + fake_response.read.return_value = json.dumps(latest_releases_response_dict).encode('utf-8') + + with mock.patch("urllib.request.urlopen", return_value=fake_response): + release_tag, release_timestamp = VersionUtils._fetch_latest_seedsigner_release_tag() + + # mock_popen returns empty string, which mimics not having local git data to + # retrieve the commit timestamp. + assert mock_popen.called is True + + # So then we expect to fall back to using the published_at time from the API + expected_timestamp = datetime.fromisoformat(latest_releases_response_dict["published_at"].replace("Z", "+00:00")).replace(tzinfo=None) + + assert release_tag == VersionUtils._prefix_version_name(latest_releases_response_dict["tag_name"]) + assert release_timestamp == expected_timestamp + + # Update mock_popen so that we CAN get the (simulated) commit timestamp from + # local git data. + git_timestamp = "2025-06-21T21:38:41-04:00" + mock_popen.return_value.read.return_value = f"{git_timestamp}\n" + expected_timestamp = datetime.fromisoformat(git_timestamp).astimezone(timezone.utc).replace(tzinfo=None) + + release_tag, release_timestamp = VersionUtils._fetch_latest_seedsigner_release_tag() + assert release_tag == VersionUtils._prefix_version_name(latest_releases_response_dict["tag_name"]) + assert release_timestamp == expected_timestamp + + # Should gracefully handle HTTP errors + fake_error_response = Mock() + fake_error_response.status = 404 + with mock.patch("urllib.request.urlopen", return_value=fake_error_response): + release_tag, release_timestamp = VersionUtils._fetch_latest_seedsigner_release_tag() + assert release_tag is None + assert release_timestamp is None + + # And other exceptions + with mock.patch("urllib.request.urlopen", side_effect=Exception("Network error")): + release_tag, release_timestamp = VersionUtils._fetch_latest_seedsigner_release_tag() + assert release_tag is None + assert release_timestamp is None + + + +class TestNotAllowedInSeedSignerOSDecorator(BaseTest): + SUCCESS = "success" + + @not_allowed_in_seedsigner_os + def dummy_function(self): + return self.SUCCESS + + + def test_not_allowed_in_seedsigner_os(self): + """ + The not_allowed_in_seedsigner_os decorator should raise its associated exception + if we run a decorated function while in SeedSigner OS. + """ + # Patch over the Settings.HOSTNAME value to simulate running in SeedSigner OS + with mock.patch("seedsigner.models.settings.Settings.HOSTNAME", Settings.SEEDSIGNER_OS): + with pytest.raises(NotAllowedInSeedSignerOS): + self.dummy_function() + + + def test_allowed_outside_seedsigner_os(self): + # Now try with any other HOSTNAME + with mock.patch("seedsigner.models.settings.Settings.HOSTNAME", "my_dev_machine"): + assert self.dummy_function() == self.SUCCESS + + + +class TestNotVersionBaseTest(BaseTest): + def test_version_file_name__not_patched(self): + """ + Ensure that outside of VersionBaseTest, the VERSION_FILENAME patch does not + persist. + """ + assert VersionUtils.VERSION_FILENAME != TEST__VERSION_FILE_NAME + + + def test_mock_popen__not_patched(self): + """ + Ensure that outside of VersionBaseTest, os.popen is not patched. + """ + # Call os.popen + result = os.popen("echo 'Hello, World!'") + # The result should not be a MagicMock instance + assert not isinstance(result, mock.MagicMock) diff --git a/tools/write_versionfile.py b/tools/write_versionfile.py index 8e556e5d..89f736eb 100644 --- a/tools/write_versionfile.py +++ b/tools/write_versionfile.py @@ -1,14 +1,10 @@ import json -import os -from datetime import datetime, timezone from seedsigner.helpers.version import VersionUtils -from seedsigner.models.settings import Settings - """ -CLI utility to extract the current version and last edit time and write to +CLI utility to extract the current version data and write to `src/seedsigner/version.json`. Primarily used by the SeedSigner OS build process. SeedSigner OS lifecycle: @@ -16,21 +12,28 @@ SeedSigner OS lifecycle: * version.json is included in the SeedSigner OS image. * SeedSigner OS reads version.json at runtime. -This script can also be run in local dev but `git` shell commands are required. +Notes: + * The SeedSigner OS build environment already relies on `git` being installed. + * This script can also be run in local dev but `git` shell commands are required. -version_name: - * Check for the SEEDSIGNER_VERSION_NAME env var (provided in SeedSigner OS build env). - * Will be the branch, tag, or commit hash being built. - * In local dev will fall back to (via `git` shell commands): - * Current git branch name - * Current git tag name - * Current git commit hash +Version data: + * version_name: + * Check for the SEEDSIGNER_VERSION_NAME env var (provided in SeedSigner OS build + env). + * Will be the branch, tag, or commit hash being built. + * In local dev will fall back to using `git` shell commands: + * Current git branch name + * Current git tag name + * Current git commit hash -version_timestamp: - * Pulls last git commit time from `git log`. + * version_fork: + * Pulls the current repo owner from the `git remote` shell command. -version_commit_hash: - * Pulls current git commit hash from `git` shell command. + * version_timestamp: + * Pulls last git commit time from `git log`. + + * version_commit_hash: + * Pulls current git commit hash from `git` shell command. """ if __name__ == "__main__": From 74ebe10b040d9219a9d314f8da57dbe3edea7a41 Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Wed, 24 Dec 2025 11:48:07 -0600 Subject: [PATCH 28/50] Interim commit; coverage at 100% --- src/seedsigner/helpers/version.py | 200 +++++----- tests/test_version.py | 602 ++++++++++++++++++++++-------- tools/write_versionfile.py | 10 +- 3 files changed, 554 insertions(+), 258 deletions(-) diff --git a/src/seedsigner/helpers/version.py b/src/seedsigner/helpers/version.py index cb6644d0..bf4b246f 100644 --- a/src/seedsigner/helpers/version.py +++ b/src/seedsigner/helpers/version.py @@ -90,53 +90,20 @@ class Version(Singleton): @classmethod def get_instance(cls): - # This is the only way to access the one and only instance - if cls._instance: - return cls._instance - else: + """ This is the only way to access the one and only instance. """ + if not cls._instance: # Instantiate the one and only Version instance - return cls.configure_instance() - - - @classmethod - def configure_instance(cls): - # Must be called before the first get_instance() call - if cls._instance: - raise Exception("Instance already configured") - - # Create the one and only Version instance - version = cls.__new__(cls) - cls._instance = version - - # Populate version data - version._version_name = VersionUtils.get_version_name() - version._version_fork = VersionUtils.get_version_fork() - version._version_timestamp = VersionUtils.get_version_timestamp() - version._version_commit_hash = VersionUtils.get_version_commit_hash() + version = cls.__new__(cls) + cls._instance = version + # Populate version data + version._version_name = VersionUtils.get_version_name() + version._version_fork = VersionUtils.get_version_fork() + version._version_timestamp = VersionUtils.get_version_timestamp() + version._version_commit_hash = VersionUtils.get_short_commit_hash() return cls._instance - @classmethod - @not_allowed_in_seedsigner_os - def reset_instance(cls): - """ Resets the singleton instance. Only used by the test suite between tests. """ - cls._instance = None - - - @classmethod - @not_allowed_in_seedsigner_os - def override_data(cls, version_name, version_fork, version_timestamp, version_commit_hash): - """ - Only used by the test suite to manually change version data between tests. - """ - instance = cls.get_instance() - instance._version_name = version_name - instance._version_fork = version_fork - instance._version_timestamp = version_timestamp - instance._version_commit_hash = version_commit_hash - - @classmethod def get_version_name(cls) -> str | None: return cls.get_instance()._version_name @@ -178,13 +145,17 @@ class VersionUtils: * One external http GET to github to get the most recent release tag. ********************************************************************************* """ - ENV_VAR__SEEDSIGNER_VERSION_NAME = "SEEDSIGNER_VERSION_NAME" - VERSION_FILENAME = "version.json" + ENV_VAR__SEEDSIGNER_OS_BUILDER__VERSION_NAME = "SEEDSIGNER_VERSION_NAME" + ENV_VAR__GITHUB_ACTIONS__IS_CI = "CI" + ENV_VAR__GITHUB_ACTIONS__REF_NAME = "GITHUB_REF_NAME" + ENV_VAR__GITHUB_ACTIONS__SHA = "GITHUB_SHA" + ENV_VAR__GITHUB_ACTIONS__REPOSITORY_OWNER = "GITHUB_REPOSITORY_OWNER" DOT_GIT_DIR_NAME = ".git" # defined to facilitate mocking in tests - ATTR__VERSION_NAME = "name" - ATTR__VERSION_FORK = "fork" - ATTR__VERSION_TIMESTAMP = "timestamp" - ATTR__VERSION_COMMIT_HASH = "commit_hash" + VERSIONFILE__FILENAME = "version.json" + VERSIONFILE_ATTR__NAME = "name" + VERSIONFILE_ATTR__FORK = "fork" + VERSIONFILE_ATTR__TIMESTAMP = "timestamp" + VERSIONFILE_ATTR__COMMIT_HASH = "commit_hash" """ ************************************************************************************* @@ -207,7 +178,7 @@ class VersionUtils: elif VersionUtils.is_github_actions_ci(): # In Github Actions CI, try to get the version name from env vars - version_name = VersionUtils.get_version_name_from_github_actions_env_vars() + version_name = VersionUtils._get_version_name_from_github_actions_env_vars() if version_name is not None: return VersionUtils._prefix_version_name(version_name) else: @@ -241,6 +212,11 @@ class VersionUtils: # The SeedSigner OS build process generates the version.json file which will # already contain the fork name. return VersionUtils._get_version_fork_from_version_file() + + elif VersionUtils.is_github_actions_ci(): + # In Github Actions CI, try to get the version name from env vars + return VersionUtils._get_version_fork_from_github_actions_env_vars() + else: # In local dev we try to access the current git state via: for get_version_fork_method in [ @@ -270,13 +246,17 @@ class VersionUtils: raise Exception("Could not read the version timestamp from the version.json file.") return datetime.fromisoformat(version_timestamp) + elif VersionUtils.is_github_actions_ci(): + # In Github Actions CI `git` shell call should be available + return VersionUtils._get_version_timestamp_from_git_shell() + else: # In local dev we use the last modified time of the source python files return VersionUtils._get_version_timestamp_from_src_files() @classmethod - def get_version_commit_hash(cls) -> str | None: + def get_short_commit_hash(cls) -> str | None: """ Returns the short commit hash string. @@ -284,25 +264,33 @@ class VersionUtils: the .git/HEAD but is currently on a branch (not a tag or specific commit). """ if Settings.HOSTNAME == Settings.SEEDSIGNER_OS: - return VersionUtils._get_version_commit_hash_from_version_file() + return VersionUtils._get_short_commit_hash_from_version_file() + + full_commit_hash = None + if VersionUtils.is_github_actions_ci(): + # In Github Actions CI the "SHA" env var should always be available + full_commit_hash = VersionUtils._get_full_commit_hash_from_github_actions_env_vars() + else: # In local dev we try to access the current git state via: - for get_commit_hash_method in [ - VersionUtils._get_version_commit_hash_from_git_shell, - VersionUtils._get_commit_hash_from_git_HEAD, + for get_full_commit_hash_method in [ + VersionUtils._get_full_commit_hash_from_git_shell, + VersionUtils._get_full_commit_hash_from_git_HEAD, ]: - commit_hash = get_commit_hash_method() - if commit_hash is not None: - return commit_hash[:7] + full_commit_hash = get_full_commit_hash_method() + if full_commit_hash is not None: + break - # If we're still here, we can try looking up based on the branch name - branch_name = VersionUtils._get_version_name_from_git_shell_branch() - if not branch_name: - branch_name, commit_hash = VersionUtils._read_git_HEAD_file() - if branch_name: - commit_hash = VersionUtils._get_commit_hash_from_git_refs_heads(branch_name) - if commit_hash is not None: - return commit_hash[:7] + if full_commit_hash is None: + # `git` shell calls didn't work and HEAD might be on a branch, so dig + # deeper into the .git files to look up the commit hash via the branch + # name. + branch_name, expect_full_commit_hash_to_be_none = VersionUtils._read_git_HEAD_file() + if branch_name: + full_commit_hash = VersionUtils._get_full_commit_hash_from_git_refs_heads(branch_name) + + if full_commit_hash is not None: + return full_commit_hash[:7] @classmethod @@ -324,7 +312,7 @@ class VersionUtils: def _get_version_file_path(cls) -> str: # Have to back out of this file's location in the "helpers" dir to the main # "seedsigner" dir. - return os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", cls.VERSION_FILENAME)) + return os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", cls.VERSIONFILE__FILENAME)) @classmethod @@ -349,7 +337,7 @@ class VersionUtils: """ version_data = cls._read_version_file() if version_data: - return version_data.get(cls.ATTR__VERSION_NAME) + return version_data.get(cls.VERSIONFILE_ATTR__NAME) @classmethod @@ -359,7 +347,7 @@ class VersionUtils: """ version_data = cls._read_version_file() if version_data: - return version_data.get(cls.ATTR__VERSION_FORK) + return version_data.get(cls.VERSIONFILE_ATTR__FORK) @classmethod @@ -369,17 +357,17 @@ class VersionUtils: """ version_data = cls._read_version_file() if version_data: - return version_data.get(cls.ATTR__VERSION_TIMESTAMP) + return version_data.get(cls.VERSIONFILE_ATTR__TIMESTAMP) @classmethod - def _get_version_commit_hash_from_version_file(cls) -> str | None: + def _get_short_commit_hash_from_version_file(cls) -> str | None: """ Attempts to read the version.json and return the version commit hash. """ version_data = cls._read_version_file() if version_data: - return version_data.get(cls.ATTR__VERSION_COMMIT_HASH) + return version_data.get(cls.VERSIONFILE_ATTR__COMMIT_HASH) @@ -394,18 +382,31 @@ class VersionUtils: This env var can also be set manually in local dev when needed. e.g. SEEDSIGNER_VERSION_NAME=some_name python main.py """ - return os.getenv(cls.ENV_VAR__SEEDSIGNER_VERSION_NAME) + return os.getenv(cls.ENV_VAR__SEEDSIGNER_OS_BUILDER__VERSION_NAME) @classmethod def is_github_actions_ci(cls) -> bool: - return os.getenv("CI") == "true" + return os.getenv(cls.ENV_VAR__GITHUB_ACTIONS__IS_CI) == "true" @classmethod - def get_version_name_from_github_actions_env_vars(cls) -> str | None: + def _get_version_name_from_github_actions_env_vars(cls) -> str | None: # REF_NAME will be the branch or tag name; SHA is the full commit hash - return os.getenv("GITHUB_REF_NAME") or os.getenv("GITHUB_SHA")[:7] + # TODO: Will REF_NAME ever be missing? + ref_name = os.getenv(cls.ENV_VAR__GITHUB_ACTIONS__REF_NAME) + sha = os.getenv(cls.ENV_VAR__GITHUB_ACTIONS__SHA) + return ref_name or (sha[:7] if sha else None) + + + @classmethod + def _get_version_fork_from_github_actions_env_vars(cls) -> str | None: + return os.getenv("GITHUB_REPOSITORY_OWNER") + + + @classmethod + def _get_full_commit_hash_from_github_actions_env_vars(cls) -> str | None: + return os.getenv(cls.ENV_VAR__GITHUB_ACTIONS__SHA) @@ -431,7 +432,7 @@ class VersionUtils: @not_allowed_in_seedsigner_os def _read_git_HEAD_file(cls) -> tuple[str | None, str | None]: """ - Reads the .git/HEAD file and returns a tuple of (branch_name, commit_hash) where + Reads the .git/HEAD file and returns a tuple of (branch_name, full_commit_hash) where only one will have a value. If there is no .git/HEAD file detected, both values will be None. @@ -439,7 +440,7 @@ class VersionUtils: git_HEAD_path = os.path.join(cls._get_dot_git_dir(), "HEAD") branch_name = None - commit_hash = None + full_commit_hash = None try: with open(git_HEAD_path, "r") as f: git_ref = f.read().strip() @@ -448,14 +449,14 @@ class VersionUtils: branch_name = git_ref.split("/")[-1] else: # If we're on a detached HEAD, the contents will just be the current - # commit hash. - commit_hash = git_ref + # full commit hash. + full_commit_hash = git_ref except FileNotFoundError as e: logger.debug(f"Ignoring {e}") except Exception as e: # Something unexpected happened. Flag it but keep going. logger.error(traceback.format_exc()) - return (branch_name, commit_hash) + return (branch_name, full_commit_hash) @classmethod @@ -502,10 +503,10 @@ class VersionUtils: @classmethod @not_allowed_in_seedsigner_os - def _get_commit_hash_from_git_HEAD(cls) -> str | None: + def _get_full_commit_hash_from_git_HEAD(cls) -> str | None: """ Reads the .git/HEAD file and, depending on the local dev git state, returns - the short commit hash. Will only return a value if HEAD is detached (on a tag or + the full commit hash. Will only return a value if HEAD is detached (on a tag or on a specific commit). """ branch_name, commit_hash = VersionUtils._read_git_HEAD_file() @@ -514,10 +515,10 @@ class VersionUtils: @classmethod @not_allowed_in_seedsigner_os - def _get_commit_hash_from_git_refs_heads(cls, branch_name: str) -> str | None: + def _get_full_commit_hash_from_git_refs_heads(cls, branch_name: str) -> str | None: """ - Reads the .git/refs/heads/ file to get the current commit hash - for the given branch. + When HEAD is on a branch, it doesn't tell us the commit hash directly. So we have + to look it up here in .git/refs/heads/. """ try: git_ref_path = os.path.join(cls._get_dot_git_dir(), "refs", "heads", branch_name) @@ -573,10 +574,11 @@ class VersionUtils: lines = f.readlines() in_origin_section = False for line in lines: + line = line.strip() if line.startswith("[remote \"origin\"]"): # Next lines fetched will be the ones we care about in_origin_section = True - elif in_origin_section and line.strip().startswith("url ="): + elif in_origin_section and line.startswith("url ="): # Found our "origin" url remote_url = line.split("=", 1)[1].strip() return cls._parse_git_remote_url(remote_url) @@ -652,8 +654,11 @@ class VersionUtils: @classmethod @not_allowed_in_seedsigner_os - def _get_version_name_from_git_shell_commit_hash(cls) -> str | None: - commit_hash = os.popen("git rev-parse --short HEAD 2> /dev/null").read() + def _get_full_commit_hash_from_git_shell(cls) -> str | None: + """ + Attempts to get the current git commit hash via shell `git` commands. + """ + commit_hash = os.popen("git rev-parse HEAD").read() return commit_hash.strip() if commit_hash else None @@ -666,7 +671,7 @@ class VersionUtils: return ( cls._get_version_name_from_git_shell_branch() or cls._get_version_name_from_git_shell_tag() or - cls._get_version_name_from_git_shell_commit_hash() + cls._get_full_commit_hash_from_git_shell() ) @@ -678,25 +683,16 @@ class VersionUtils: """ # We expect to at least have a remote named "origin" remote_url = os.popen("git remote get-url origin 2> /dev/null").read().strip() - return cls._parse_git_remote_url(remote_url) + return cls._parse_git_remote_url(remote_url) if remote_url else None @classmethod @not_allowed_in_seedsigner_os def _get_version_timestamp_from_git_shell(cls) -> datetime | None: version_timestamp = os.popen("git log -1 --format=%cI").read().strip() - # Parse the timestamp, ensure that it's in UTC, and omit tz info - return datetime.fromisoformat(version_timestamp).astimezone(timezone.utc).replace(tzinfo=None) - - - @classmethod - @not_allowed_in_seedsigner_os - def _get_version_commit_hash_from_git_shell(cls) -> str | None: - """ - Attempts to get the current git commit hash via shell `git` commands. - """ - commit_hash = os.popen("git rev-parse --short HEAD").read() - return commit_hash.strip() if commit_hash else None + if version_timestamp: + # Parse the timestamp, ensure that it's in UTC, and omit tz info + return datetime.fromisoformat(version_timestamp).astimezone(timezone.utc).replace(tzinfo=None) diff --git a/tests/test_version.py b/tests/test_version.py index bdfc6211..aec75833 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -14,19 +14,23 @@ from seedsigner.models.settings import Settings # overrides -TEST__VERSION_FILE_NAME = "version-test.json" +TEST__VERSIONFILE__FILENAME = "version-test.json" TEST__DOT_GIT_DIR_NAME = f"dot-git-test" # Reusable test data TEST__VERSION_NAME = "1.2.3" # Will require VersionUtils._prefix_version_name when verifying results TEST__VERSION_FORK = "some_repo_owner" -TEST__VERSION_TIMESTAMP = datetime.now() -TEST__VERSION_COMMIT_HASH = "abcd123" +TEST__VERSION_TIMESTAMP = datetime.now().astimezone(timezone.utc).replace(tzinfo=None) +TEST__VERSION_BRANCH = "some_test_branch" +TEST__VERSION_TAG = "some_test_tag" +TEST__SEMANTIC_TAG = "1.2.3-rc1" +TEST__SHORT_COMMIT_HASH = "c5efda3" +TEST__FULL_COMMIT_HASH = "c5efda306c60877191013a6093d92cd0bfcccec8" TEST__VERSION_DICT = { - VersionUtils.ATTR__VERSION_NAME: TEST__VERSION_NAME, - VersionUtils.ATTR__VERSION_FORK: TEST__VERSION_FORK, - VersionUtils.ATTR__VERSION_TIMESTAMP: TEST__VERSION_TIMESTAMP.isoformat(), - VersionUtils.ATTR__VERSION_COMMIT_HASH: TEST__VERSION_COMMIT_HASH, + VersionUtils.VERSIONFILE_ATTR__NAME: TEST__VERSION_NAME, + VersionUtils.VERSIONFILE_ATTR__FORK: TEST__VERSION_FORK, + VersionUtils.VERSIONFILE_ATTR__TIMESTAMP: TEST__VERSION_TIMESTAMP.isoformat(), + VersionUtils.VERSIONFILE_ATTR__COMMIT_HASH: TEST__SHORT_COMMIT_HASH, } # Mimic result of reading from version.json @@ -38,12 +42,12 @@ class VersionBaseTest(BaseTest): """ Sets up test-specific overrides and reusable methods and fixtures. """ @pytest.fixture(autouse=True, scope="class") - def mock_version_file_name(self): + def mock_versionfile_filename(self): """ Every test in this class (and subclasses) will automatically run with this patch applied (autouse=True), but the patch will not persist beyond the test class. """ - with patch.object(VersionUtils, 'VERSION_FILENAME', TEST__VERSION_FILE_NAME): + with patch.object(VersionUtils, 'VERSIONFILE__FILENAME', TEST__VERSIONFILE__FILENAME): yield @@ -73,7 +77,7 @@ class VersionBaseTest(BaseTest): """ Write the test version file to disk. """ - assert VersionUtils.VERSION_FILENAME == TEST__VERSION_FILE_NAME + assert VersionUtils.VERSIONFILE__FILENAME == TEST__VERSIONFILE__FILENAME with open(VersionUtils._get_version_file_path(), "w") as f: f.write(TEST__VERSION_FILE_CONTENTS) @@ -83,22 +87,29 @@ class VersionBaseTest(BaseTest): """ Delete the test version file from disk. """ - assert VersionUtils.VERSION_FILENAME == TEST__VERSION_FILE_NAME + assert VersionUtils.VERSIONFILE__FILENAME == TEST__VERSIONFILE__FILENAME try: os.remove(VersionUtils._get_version_file_path()) except FileNotFoundError: pass + def reset_version_singleton(self): + Version._instance = None + + def setup_method(self): super().setup_method() def teardown_method(self): super().teardown_method() + # Clean up any test version file created self.delete_test_version_file() + self.reset_version_singleton() + class TestVersionBaseTest(VersionBaseTest): @@ -106,7 +117,7 @@ class TestVersionBaseTest(VersionBaseTest): """ Ensure that the setup and teardown methods work as expected. """ - assert VersionUtils.VERSION_FILENAME == TEST__VERSION_FILE_NAME + assert VersionUtils.VERSIONFILE__FILENAME == TEST__VERSIONFILE__FILENAME # During setup, the test version file should not exist assert not os.path.exists(VersionUtils._get_version_file_path()) @@ -132,97 +143,7 @@ class TestVersionBaseTest(VersionBaseTest): -class TestVersion(VersionBaseTest): - def test_seedsigner_os_reads_from_version_file(self): - """ - When running on SeedSigner OS, the version data should be read from the - version.json file. - """ - self.write_test_version_file() - - # Simulate running on SeedSigner OS - with patch("seedsigner.models.settings.Settings.HOSTNAME", Settings.SEEDSIGNER_OS): - assert Version.get_version_name() == VersionUtils._prefix_version_name(TEST__VERSION_NAME) - assert Version.get_version_fork() == TEST__VERSION_FORK - assert Version.get_version_timestamp() == TEST__VERSION_TIMESTAMP - assert Version.get_version_commit_hash() == TEST__VERSION_COMMIT_HASH - - - # def test_version_with_no_git_head(self): - # """ - # If there is no .git/HEAD file, the hard-coded VERSION constant should be returned. - # """ - # # mock out the os.path.exists call to always return False - # with mock.patch("os.path.exists", return_value=False): - # version = Version.get_version() - # assert version == f"v{Version.VERSION}" - - - # def test_version_with_actual_git_head(self): - # """ - # If the .git dir exists on our actual filesystem right now, we should get a version - # based on its contents. - - # NOTE: This test is potentially fragile as it depends on the test runner system's actual - # git state. The get_version() call should be able to handle all possible git states, but - # this does create the possibility of external variability. - # """ - # fake_hardcoded_version = "fake.version.123" - # with mock.patch.object(Version, 'VERSION', fake_hardcoded_version): - # git_dot_dir = Version._get_dot_git_dir() - # if os.path.exists(git_dot_dir): - # assert Version.get_version() != f"v{fake_hardcoded_version}" - # else: - # # If there's no .git dir, mark this test as skipped - # pytest.skip(f"No .git dir found at {git_dot_dir}, skipping test.") - - - # def test_version_with_mocked_git_head(self): - # """ - # If there is a .git/HEAD file, the version should report the current git branch - # name, commit hash, or a matching tag. - # """ - # with mock.patch("os.path.exists", return_value=True): - # # Mock the HEAD file read in _read_HEAD_file to return our fake branch name - # branch_name = "my_feature_branch" - # git_HEAD_content = f"ref: refs/heads/{branch_name}" - # with mock.patch("builtins.open", mock.mock_open(read_data=git_HEAD_content)): - # version = VersionUtils._get_version_name_from_git_HEAD() - # assert version == branch_name - - # # Mock the HEAD file read in _read_HEAD_file to return a fake commit hash - # commit_hash = "abcdef1234567890" - # with mock.patch("builtins.open", mock.mock_open(read_data=commit_hash)): - # # Mock that there are no matching tags for this commit hash - # with mock.patch.object(Version, '_get_matching_tag', return_value=None): - # version = Version.get_version_name() - # assert version == commit_hash[:7] # short commit hash - - # # Now mock that there is a matching tag for this commit hash - # tag_name = "v1.2.3" - # with mock.patch.object(Version, '_get_matching_tag', return_value=tag_name): - # version = Version.get_version_name() - # assert version == tag_name - - - def test_get_last_edit(self): - """ - Test that get_last_src_edit returns a sane datetime object. Assumes the system - running this test has a reasonably correct system time. - """ - last_edit = Version.get_version_timestamp() - assert isinstance(last_edit, datetime) - - # Has to be more recent than the first SeedSigner v0.0.1 release - known_past = datetime(2020, 12, 13) - assert last_edit > known_past - - # Could not have happened tomorrow - known_future = datetime.now().replace(year=datetime.now().year + 1) - assert last_edit < known_future - - -class TestVersionUtils(VersionBaseTest): +class TestVersionUtils_BasicCalls(VersionBaseTest): def test__prefix_version_name(self): """ Semantic versions should be prefixed with 'v' but others should be left as-is. @@ -253,6 +174,37 @@ class TestVersionUtils(VersionBaseTest): assert result == version_name, f"Expected '{result}' to equal input '{version_name}'" + +class TestVersionUtils_VersionFile(VersionBaseTest): + def test_seedsigner_os_reads_from_version_file(self): + """ + Test the high-level basic public calls. Does just the minimal necessary mocking + since all the downstream helper methods are tested in detail elsewhere. + + When running on SeedSigner OS, the version data should be read from the + version.json file. + """ + self.write_test_version_file() + + # Simulate running on SeedSigner OS + with patch("seedsigner.models.settings.Settings.HOSTNAME", Settings.SEEDSIGNER_OS): + assert VersionUtils.get_version_name() == VersionUtils._prefix_version_name(TEST__VERSION_NAME) + assert VersionUtils.get_version_fork() == TEST__VERSION_FORK + assert VersionUtils.get_version_timestamp() == TEST__VERSION_TIMESTAMP + assert VersionUtils.get_short_commit_hash() == TEST__SHORT_COMMIT_HASH + + # Expect errors if keys are missing from version.json + with mock.patch("builtins.open", mock.mock_open(read_data="{'some_key':'some_value'}")): + with pytest.raises(Exception): + VersionUtils.get_version_name() + + # This time it's the timestamp that's missing + with mock.patch("builtins.open", mock.mock_open(read_data=str({VersionUtils.VERSIONFILE_ATTR__NAME: TEST__VERSION_NAME}).replace("'", '"'))): + with pytest.raises(Exception): + VersionUtils.get_version_timestamp() + + + def test__read_version_file(self): """ Low-level test for reading the version.json file. @@ -261,10 +213,10 @@ class TestVersionUtils(VersionBaseTest): version_data = VersionUtils._read_version_file() assert version_data is not None - assert version_data[VersionUtils.ATTR__VERSION_NAME] == TEST__VERSION_NAME - assert version_data[VersionUtils.ATTR__VERSION_FORK] == TEST__VERSION_FORK - assert version_data[VersionUtils.ATTR__VERSION_TIMESTAMP] == TEST__VERSION_TIMESTAMP.isoformat() - assert version_data[VersionUtils.ATTR__VERSION_COMMIT_HASH] == TEST__VERSION_COMMIT_HASH + assert version_data[VersionUtils.VERSIONFILE_ATTR__NAME] == TEST__VERSION_NAME + assert version_data[VersionUtils.VERSIONFILE_ATTR__FORK] == TEST__VERSION_FORK + assert version_data[VersionUtils.VERSIONFILE_ATTR__TIMESTAMP] == TEST__VERSION_TIMESTAMP.isoformat() + assert version_data[VersionUtils.VERSIONFILE_ATTR__COMMIT_HASH] == TEST__SHORT_COMMIT_HASH def test__read_version_file__missing(self): @@ -272,6 +224,179 @@ class TestVersionUtils(VersionBaseTest): assert os.path.exists(VersionUtils._get_version_file_path()) is False assert VersionUtils._read_version_file() is None + # The upstream dependent calls should also return None + assert VersionUtils._get_version_name_from_version_file() is None + assert VersionUtils._get_version_fork_from_version_file() is None + assert VersionUtils._get_version_timestamp_from_version_file() is None + assert VersionUtils._get_short_commit_hash_from_version_file() is None + + # Gracefully handle other unexpected exceptions + with patch("builtins.open", side_effect=Exception("Unexpected error")): + assert VersionUtils._read_version_file() is None + + + def test__get_version_name_from_env_var(self): + assert os.environ.get(VersionUtils.ENV_VAR__SEEDSIGNER_OS_BUILDER__VERSION_NAME) is None + assert VersionUtils._get_version_name_from_env_var() is None + + with mock.patch.dict(os.environ, {VersionUtils.ENV_VAR__SEEDSIGNER_OS_BUILDER__VERSION_NAME: TEST__VERSION_NAME}): + result = VersionUtils._get_version_name_from_env_var() + assert result == TEST__VERSION_NAME + + + +class TestVersionUtils_GithubActions(VersionBaseTest): + def test_github_actions_env_vars(self): + """ + Test the high-level basic public calls. Does just the minimal necessary mocking + since all the downstream helper methods are tested in detail elsewhere. + + When running in a GitHub Actions CI environment, the version data should be + pulled from the appropriate env vars. + """ + # CI uses some limited `git` shell calls; mock out the associated calls. + with mock.patch("seedsigner.helpers.version.VersionUtils._get_version_timestamp_from_git_shell", return_value=TEST__VERSION_TIMESTAMP): + # When running CI on a branch + with mock.patch.dict(os.environ, { + VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "true", + VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: TEST__VERSION_BRANCH, + VersionUtils.ENV_VAR__GITHUB_ACTIONS__SHA: TEST__FULL_COMMIT_HASH, + VersionUtils.ENV_VAR__GITHUB_ACTIONS__REPOSITORY_OWNER: TEST__VERSION_FORK, + }): + assert Version.get_version_name() == TEST__VERSION_BRANCH + assert Version.get_version_fork() == TEST__VERSION_FORK + assert Version.get_version_commit_hash() == TEST__SHORT_COMMIT_HASH + assert Version.get_version_timestamp() == TEST__VERSION_TIMESTAMP + + # When running CI on a semantic tag + self.reset_version_singleton() + with mock.patch.dict(os.environ, { + VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "true", + VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: TEST__SEMANTIC_TAG, + }): + # Should be prefixed with "v" + assert Version.get_version_name() == f"v{TEST__SEMANTIC_TAG}" + + # When running CI on a generic tag + self.reset_version_singleton() + with mock.patch.dict(os.environ, { + VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "true", + VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: TEST__VERSION_TAG, + }): + # Should NOT be prefixed with "v" + assert Version.get_version_name() == TEST__VERSION_TAG + + # When running CI on a commit (detached HEAD) with no REF_NAME, the + # version_name should be the short commit hash. + # TODO: I don't think this scenario ever happens. + self.reset_version_singleton() + with mock.patch.dict(os.environ, { + VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "true", + VersionUtils.ENV_VAR__GITHUB_ACTIONS__SHA: TEST__FULL_COMMIT_HASH, + }): + assert Version.get_version_name() == TEST__SHORT_COMMIT_HASH + + # When running CI on a commit (detached HEAD) with no REF_NAME and no SHA, + # raise error. + # Note: This scenario definitely would never happen. Just trying to get to + # 100% test coverage. + self.reset_version_singleton() + with mock.patch.dict(os.environ, { + VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "true", + }): + with pytest.raises(Exception): + Version.get_version_name() + + + def test_is_github_actions_ci(self): + """ + is_github_actions_ci should return True only when the + ENV_VAR__GITHUB_ACTIONS__IS_CI env var is set to "true". + """ + # Default should be False + assert VersionUtils.is_github_actions_ci() is False + + with mock.patch.dict(os.environ, {VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "true"}): + assert VersionUtils.is_github_actions_ci() is True + + with mock.patch.dict(os.environ, {VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "false"}): + assert VersionUtils.is_github_actions_ci() is False + + with mock.patch.dict(os.environ, {VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "1"}): + assert VersionUtils.is_github_actions_ci() is False + + + def test_get_version_name_from_github_actions_env_vars(self): + """ + get_version_name_from_github_actions_env_vars should return the REF_NAME or SHA + env vars when set. + """ + # Need to signal that we're in a GitHub Actions CI environment + with mock.patch.dict(os.environ, {VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "true"}): + assert os.environ.get(VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME) is None + assert os.environ.get(VersionUtils.ENV_VAR__GITHUB_ACTIONS__SHA) is None + assert VersionUtils._get_version_name_from_github_actions_env_vars() is None + + with mock.patch.dict(os.environ, {VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: TEST__VERSION_NAME}): + result = VersionUtils._get_version_name_from_github_actions_env_vars() + assert result == TEST__VERSION_NAME + + with mock.patch.dict(os.environ, {VersionUtils.ENV_VAR__GITHUB_ACTIONS__SHA: TEST__FULL_COMMIT_HASH}): + result = VersionUtils._get_version_name_from_github_actions_env_vars() + assert result == TEST__SHORT_COMMIT_HASH[:7] + + + +class TestVersionUtils_DotGitFiles(VersionBaseTest): + def test_local_dev_with_dot_git_dir_parsing(self, mock_popen: Mock): + """ + Test the high-level basic public calls. Does just the minimal necessary mocking + since all the downstream helper methods are tested in detail elsewhere. + + When running in a local dev environment without access to `git` shell commands, + the version data should be pulled from parsing the .git directory. + + We pull in the `mock_popen` fixture here to simulate `git` shell commands failing. + + Note that in local dev we use the last modified timestamp from the source files + rather than from git. + """ + # If we're on a branch, getting the commit hash requires the + # .git/refs/heads/ file. + with mock.patch.multiple( + "seedsigner.helpers.version.VersionUtils", + _read_git_HEAD_file=Mock(return_value=(TEST__VERSION_BRANCH, None)), + _get_full_commit_hash_from_git_refs_heads=Mock(return_value=TEST__FULL_COMMIT_HASH), + _get_version_fork_from_git_config=Mock(return_value=TEST__VERSION_FORK), + _get_version_timestamp_from_src_files=Mock(return_value=TEST__VERSION_TIMESTAMP), + ): + assert Version.get_version_name() == TEST__VERSION_BRANCH + assert Version.get_version_fork() == TEST__VERSION_FORK + assert Version.get_version_timestamp() == TEST__VERSION_TIMESTAMP + assert Version.get_version_commit_hash() == TEST__SHORT_COMMIT_HASH + + # If we're on a tag (detached HEAD), getting the commit hash requires + # checking the refs/tags for a matching tag. + with mock.patch.multiple( + "seedsigner.helpers.version.VersionUtils", + _read_git_HEAD_file=Mock(return_value=(None, TEST__FULL_COMMIT_HASH)), + _get_matching_tag_from_git_refs_tags=Mock(return_value=TEST__VERSION_TAG), + ): + self.reset_version_singleton() + assert Version.get_version_name() == TEST__VERSION_TAG + assert Version.get_version_commit_hash() == TEST__SHORT_COMMIT_HASH + + # If we're on a commit hash (detached HEAD) with no matching tag, the + # version name should be the short commit hash. + with mock.patch.multiple( + "seedsigner.helpers.version.VersionUtils", + _read_git_HEAD_file=Mock(return_value=(None, TEST__FULL_COMMIT_HASH)), + _get_matching_tag_from_git_refs_tags=Mock(return_value=None), + ): + self.reset_version_singleton() + assert Version.get_version_name() == TEST__SHORT_COMMIT_HASH + assert Version.get_version_commit_hash() == TEST__SHORT_COMMIT_HASH + def test__get_dot_git_dir(self): """ @@ -291,19 +416,17 @@ class TestVersionUtils(VersionBaseTest): assert VersionUtils._read_git_HEAD_file() == (None, None) # If we're on a branch... - expected_branch = "my_test_branch" - git_HEAD_content = f"ref: refs/heads/{expected_branch}" + git_HEAD_content = f"ref: refs/heads/{TEST__VERSION_BRANCH}" with patch("builtins.open", mock.mock_open(read_data=git_HEAD_content)): branch_name, commit_hash = VersionUtils._read_git_HEAD_file() - assert branch_name == expected_branch + assert branch_name == TEST__VERSION_BRANCH assert commit_hash is None # If we're in a detached HEAD state at a specific commit hash... - expected_commit_hash = "47212c98f1bf948e9918b672c4bb88b1c965aff4" - with patch("builtins.open", mock.mock_open(read_data=expected_commit_hash)): + with patch("builtins.open", mock.mock_open(read_data=TEST__FULL_COMMIT_HASH)): branch_name, commit_hash = VersionUtils._read_git_HEAD_file() assert branch_name is None - assert commit_hash == expected_commit_hash + assert commit_hash == TEST__FULL_COMMIT_HASH # Gracefully handle a read error with patch("builtins.open", side_effect=FileNotFoundError): @@ -320,26 +443,146 @@ class TestVersionUtils(VersionBaseTest): if a matching tag is found for the given commit hash. """ # No .git dir initially - assert VersionUtils._get_matching_tag_from_git_refs_tags("anyhash") is None + assert os.path.exists(VersionUtils._get_dot_git_dir()) is False - # Mock out the .git/refs/tags file read to return some fake tags - fake_tags_content = """v1.0.0:abcd1234567890 -v1.2.3:deadbeefcafebabe -v2.0.0-rc1:47212c98f1bf948e9918b672c4bb88b1c965aff4 -""" - with patch("builtins.open", mock.mock_open(read_data=fake_tags_content)): - # Existing tag - tag_name = VersionUtils._get_matching_tag_from_git_refs_tags("deadbeefcafebabe") - assert tag_name == "v1.2.3" + # Mock the os.listdir of the .git/refs/tags directory to the target tag name + with patch("os.listdir", return_value=[TEST__VERSION_NAME]): + # Mock the open of the tag ref file to return our test commit hash + with patch("builtins.open", mock.mock_open(read_data=TEST__FULL_COMMIT_HASH)): + tag_name = VersionUtils._get_matching_tag_from_git_refs_tags(TEST__FULL_COMMIT_HASH) + assert tag_name == TEST__VERSION_NAME - # Another existing tag - tag_name = VersionUtils._get_matching_tag_from_git_refs_tags("47212c98f1bf948e9918b672c4bb88b1c965aff4") - assert tag_name == "v2.0.0-rc1" + # If no matching tag is found, should return None + with patch("os.listdir", return_value=["some_other_tag"]): + with patch("builtins.open", mock.mock_open(read_data="different_commit_hash")): + tag_name = VersionUtils._get_matching_tag_from_git_refs_tags(TEST__FULL_COMMIT_HASH) + assert tag_name is None - # Non-existing tag - tag_name = VersionUtils._get_matching_tag_from_git_refs_tags("nonexistenthash") + # If the refs/tags directory is missing, should return None + with patch("seedsigner.helpers.version.VersionUtils._get_dot_git_dir", return_value="/nonexistent/path"): + tag_name = VersionUtils._get_matching_tag_from_git_refs_tags(TEST__FULL_COMMIT_HASH) assert tag_name is None + # If any other exception occurs, should return None + with patch("os.listdir", side_effect=Exception("Unexpected error")): + tag_name = VersionUtils._get_matching_tag_from_git_refs_tags(TEST__FULL_COMMIT_HASH) + assert tag_name is None + + + def test__get_version_name_from_git_HEAD(self): + # Should gracefully return None if there's no .git dir or .git/HEAD file. + assert VersionUtils._get_version_name_from_git_HEAD() == None + + # We already tested _read_git_HEAD_file() above, so just mock scenarios here. + # On a branch: + with mock.patch.object(VersionUtils, '_read_git_HEAD_file', return_value=(TEST__VERSION_BRANCH, None)): + assert VersionUtils._get_version_name_from_git_HEAD() == TEST__VERSION_BRANCH + + # Detached HEAD at commit hash, with matching tag + with mock.patch.object(VersionUtils, '_read_git_HEAD_file', return_value=(None, TEST__FULL_COMMIT_HASH)): + with mock.patch.object(VersionUtils, '_get_matching_tag_from_git_refs_tags', return_value=TEST__VERSION_NAME): + assert VersionUtils._get_version_name_from_git_HEAD() == TEST__VERSION_NAME + + # Detached HEAD at commit hash, no matching tag; returns the short commit hash + with mock.patch.object(VersionUtils, '_read_git_HEAD_file', return_value=(None, TEST__FULL_COMMIT_HASH)): + with mock.patch.object(VersionUtils, '_get_matching_tag_from_git_refs_tags', return_value=None): + assert VersionUtils._get_version_name_from_git_HEAD() == TEST__SHORT_COMMIT_HASH + + + def test__get_commit_hash_from_git_HEAD(self): + # _get_commit_hash_from_git_HEAD is a trivial convenience function that relies on _read_git_HEAD_file which we've already tested. + # Just verify the expected outputs here. + with mock.patch.object(VersionUtils, '_read_git_HEAD_file', return_value=(None, TEST__SHORT_COMMIT_HASH)): + assert VersionUtils._get_full_commit_hash_from_git_HEAD() == TEST__SHORT_COMMIT_HASH + + + def test__get_full_commit_hash_from_git_refs_heads(self): + """ + _get_full_commit_hash_from_git_refs_heads should return the expected commit hash + for the given branch name. + """ + # No .git dir initially + assert os.path.exists(VersionUtils._get_dot_git_dir()) is False + assert VersionUtils._get_full_commit_hash_from_git_refs_heads(TEST__VERSION_BRANCH) is None + + # Mock the open of the branch ref file to return our test commit hash + with patch("builtins.open", mock.mock_open(read_data=TEST__FULL_COMMIT_HASH)): + with patch("os.path.exists", return_value=True): + commit_hash = VersionUtils._get_full_commit_hash_from_git_refs_heads(TEST__VERSION_BRANCH) + assert commit_hash == TEST__FULL_COMMIT_HASH + + # If the branch ref file is missing, should return None + with patch("os.path.exists", return_value=False): + commit_hash = VersionUtils._get_full_commit_hash_from_git_refs_heads(TEST__VERSION_BRANCH) + assert commit_hash is None + + # If any other exception occurs, should return None + with patch("builtins.open", side_effect=Exception("Unexpected error")): + commit_hash = VersionUtils._get_full_commit_hash_from_git_refs_heads(TEST__VERSION_BRANCH) + assert commit_hash is None + + + def test__parse_git_remote_url(self): + """ + _parse_git_remote_url should return the expected repo owner from various + git remote url formats. + """ + # Might be called with no remote url or unrecognized format + for url in [None, "", "what/is/this"]: + assert VersionUtils._parse_git_remote_url(url) is None + + for url, expected_owner in [ + ("https://github.com/SeedSigner/seedsigner.git", "SeedSigner"), + ("git@github.com:SeedSigner/seedsigner.git", "SeedSigner"), + ("https://gitlab.com/some_user/some-repo.git", "some_user"), + ("git@gitlab.com:other-user/some-repo.git", "other-user"), + ]: + assert VersionUtils._parse_git_remote_url(url) == expected_owner + + + def test__get_version_fork_from_git_config(self): + """ + Test that _get_version_fork_from_git_config returns the expected repo owner from + the remote url. + """ + expected_fork = "SeedSigner" + remote_url = f"git@github.com:{expected_fork}/seedsigner.git" + git_config = f""" + [some_section] + some_key = some_value + [remote "origin"] + url = {remote_url} + fetch = +refs/heads/*:refs/remotes/origin/* + [next_section] + next_key = next_value + """ + with mock.patch("builtins.open", mock.mock_open(read_data=git_config)): + assert VersionUtils._get_version_fork_from_git_config() == expected_fork + + # Missing origin section should return None + git_config_no_origin = """ + [some_section] + some_key = some_value + [next_section] + next_key = next_value + """ + with mock.patch("builtins.open", mock.mock_open(read_data=git_config_no_origin)): + assert VersionUtils._get_version_fork_from_git_config() is None + + # Handle malformed origin section (can't find url) should return None + git_config_malformed_origin = """ + [remote "origin"] + fetch = +refs/heads/*:refs/remotes/origin/* + [next_section] + next_key = next_value + """ + with mock.patch("builtins.open", mock.mock_open(read_data=git_config_malformed_origin)): + assert VersionUtils._get_version_fork_from_git_config() is None + + # If the git config file is missing, should return None + with mock.patch("builtins.open", side_effect=FileNotFoundError): + assert VersionUtils._get_version_fork_from_git_config() is None + def test__get_version_timestamp_from_src_files(self): """ @@ -362,16 +605,36 @@ v2.0.0-rc1:47212c98f1bf948e9918b672c4bb88b1c965aff4 +class TestVersionUtils_GitShell(VersionBaseTest): + def test_local_dev_with_git_shell_calls(self): + """ + Test the high-level basic public calls. Does just the minimal necessary mocking + since all the downstream helper methods are tested in detail elsewhere. + + When running in a local dev environment with access to `git` shell commands, + the version data should be pulled from those commands. + + Note that in local dev we use the last modified timestamp from the source files + rather than from git. + """ + with mock.patch.multiple( + "seedsigner.helpers.version.VersionUtils", + _get_version_name_from_git_shell=Mock(return_value=TEST__VERSION_BRANCH), + _get_version_fork_from_git_shell=Mock(return_value=TEST__VERSION_FORK), + _get_version_timestamp_from_src_files=Mock(return_value=TEST__VERSION_TIMESTAMP), + _get_full_commit_hash_from_git_shell=Mock(return_value=TEST__SHORT_COMMIT_HASH), + ): + assert Version.get_version_name() == TEST__VERSION_BRANCH + assert Version.get_version_fork() == TEST__VERSION_FORK + assert Version.get_version_timestamp() == TEST__VERSION_TIMESTAMP + assert Version.get_version_commit_hash() == TEST__SHORT_COMMIT_HASH + def test__get_version_name_from_git_shell(self): """ Test that _get_version_name_from_git_shell returns the expected name depending on the current git state """ - branch_name = "my_test_branch" - tag_name = "my_test_tag" - commit_hash = "abcd123" - # Default mock_popen return empty string; simulates no `git` shell command available # or no local git data. result = VersionUtils._get_version_name_from_git_shell() @@ -380,32 +643,32 @@ v2.0.0-rc1:47212c98f1bf948e9918b672c4bb88b1c965aff4 # If we're on a branch, should return the branch name with mock.patch.multiple( "seedsigner.helpers.version.VersionUtils", - _get_version_name_from_git_shell_branch=Mock(return_value=branch_name), - _get_version_name_from_git_shell_tag=Mock(return_value=tag_name), - _get_version_name_from_git_shell_commit_hash=Mock(return_value=commit_hash), + _get_version_name_from_git_shell_branch=Mock(return_value=TEST__VERSION_BRANCH), + _get_version_name_from_git_shell_tag=Mock(return_value=TEST__VERSION_TAG), + _get_full_commit_hash_from_git_shell=Mock(return_value=TEST__FULL_COMMIT_HASH), ): result = VersionUtils._get_version_name_from_git_shell() - assert result == branch_name - + assert result == TEST__VERSION_BRANCH + # If we're on a tag, the detached HEAD state wipes out the branch name with mock.patch.multiple( "seedsigner.helpers.version.VersionUtils", _get_version_name_from_git_shell_branch=Mock(return_value=None), - _get_version_name_from_git_shell_tag=Mock(return_value=tag_name), - _get_version_name_from_git_shell_commit_hash=Mock(return_value=commit_hash), + _get_version_name_from_git_shell_tag=Mock(return_value=TEST__VERSION_TAG), + _get_full_commit_hash_from_git_shell=Mock(return_value=TEST__FULL_COMMIT_HASH), ): result = VersionUtils._get_version_name_from_git_shell() - assert result == tag_name - + assert result == TEST__VERSION_TAG + # Similarly, if we're detached at a specific commit hash with mock.patch.multiple( "seedsigner.helpers.version.VersionUtils", _get_version_name_from_git_shell_branch=Mock(return_value=None), _get_version_name_from_git_shell_tag=Mock(return_value=None), - _get_version_name_from_git_shell_commit_hash=Mock(return_value=commit_hash), + _get_full_commit_hash_from_git_shell=Mock(return_value=TEST__FULL_COMMIT_HASH), ): result = VersionUtils._get_version_name_from_git_shell() - assert result == commit_hash[:7] # short hash + assert result == TEST__FULL_COMMIT_HASH def test__get_version_fork_from_git_shell(self, mock_popen: Mock): @@ -425,6 +688,10 @@ v2.0.0-rc1:47212c98f1bf948e9918b672c4bb88b1c965aff4 """ Test that _get_version_timestamp_from_git_shell returns the expected datetime. """ + # Should gracefully handle no `git` shell command available or no local git data. + mock_popen.return_value.read.return_value = "" + assert VersionUtils._get_version_timestamp_from_git_shell() is None + # Initial timestamp has timezone info hour = 14 tz_offset = 1 @@ -440,15 +707,14 @@ v2.0.0-rc1:47212c98f1bf948e9918b672c4bb88b1c965aff4 assert VersionUtils._get_version_timestamp_from_git_shell() == expected_datetime - def test__get_version_commit_hash_from_git_shell(self, mock_popen: Mock): + def test__get_short_commit_hash_from_git_shell(self, mock_popen: Mock): """ - Test that _get_version_commit_hash_from_git_shell returns the expected short commit hash. + Test that _get_full_commit_hash_from_git_shell returns the expected short commit hash. """ - commit_hash = "abcd123" - mock_popen.return_value.read.return_value = commit_hash + mock_popen.return_value.read.return_value = TEST__SHORT_COMMIT_HASH - result = VersionUtils._get_version_commit_hash_from_git_shell() - assert result == commit_hash + result = VersionUtils._get_full_commit_hash_from_git_shell() + assert result == TEST__SHORT_COMMIT_HASH def test__fetch_latest_seedsigner_release_tag(self, mock_popen: Mock): @@ -507,6 +773,40 @@ v2.0.0-rc1:47212c98f1bf948e9918b672c4bb88b1c965aff4 +class TestVersion(VersionBaseTest): + """ + `Version` is really just a way to store the version data; all the real work is done in + `VersionUtils` and all of those calls have already been fully covered by the above + tests. So there's nothing to really test here. Just providing minimal tests in order + to get full test coverage. + """ + def test_basic_calls(self): + self.write_test_version_file() + with patch("seedsigner.models.settings.Settings.HOSTNAME", Settings.SEEDSIGNER_OS): + Version.get_version_name() == TEST__VERSION_DICT[VersionUtils.VERSIONFILE_ATTR__NAME] + Version.get_version_fork() == TEST__VERSION_DICT[VersionUtils.VERSIONFILE_ATTR__FORK] + Version.get_version_commit_hash() == TEST__VERSION_DICT[VersionUtils.VERSIONFILE_ATTR__COMMIT_HASH] + Version.get_version_timestamp() == TEST__VERSION_TIMESTAMP + + + def test_get_last_edit(self): + """ + Test that get_last_src_edit returns a sane datetime object. Assumes the system + running this test has a reasonably correct system time. + """ + last_edit = Version.get_version_timestamp() + assert isinstance(last_edit, datetime) + + # Has to be more recent than the first SeedSigner v0.0.1 release + known_past = datetime(2020, 12, 13) + assert last_edit > known_past + + # Could not have happened tomorrow + known_future = datetime.now().replace(year=datetime.now().year + 1) + assert last_edit < known_future + + + class TestNotAllowedInSeedSignerOSDecorator(BaseTest): SUCCESS = "success" @@ -536,10 +836,10 @@ class TestNotAllowedInSeedSignerOSDecorator(BaseTest): class TestNotVersionBaseTest(BaseTest): def test_version_file_name__not_patched(self): """ - Ensure that outside of VersionBaseTest, the VERSION_FILENAME patch does not + Ensure that outside of VersionBaseTest, the VERSIONFILE__FILENAME patch does not persist. """ - assert VersionUtils.VERSION_FILENAME != TEST__VERSION_FILE_NAME + assert VersionUtils.VERSIONFILE__FILENAME != TEST__VERSIONFILE__FILENAME def test_mock_popen__not_patched(self): diff --git a/tools/write_versionfile.py b/tools/write_versionfile.py index 89f736eb..37b08a4d 100644 --- a/tools/write_versionfile.py +++ b/tools/write_versionfile.py @@ -48,15 +48,15 @@ if __name__ == "__main__": break version_timestamp = VersionUtils._get_version_timestamp_from_git_shell() - version_commit_hash = VersionUtils._get_version_commit_hash_from_git_shell() + version_commit_hash = VersionUtils._get_full_commit_hash_from_git_shell() if not version_name or not version_timestamp or not version_commit_hash: raise Exception("Could not determine version information from git.") - version_info[VersionUtils.ATTR__VERSION_NAME] = version_name - version_info[VersionUtils.ATTR__VERSION_FORK] = VersionUtils._get_version_fork_from_git_shell() - version_info[VersionUtils.ATTR__VERSION_TIMESTAMP] = version_timestamp.isoformat() - version_info[VersionUtils.ATTR__VERSION_COMMIT_HASH] = version_commit_hash[:7] # short hash + version_info[VersionUtils.VERSIONFILE_ATTR__NAME] = version_name + version_info[VersionUtils.VERSIONFILE_ATTR__FORK] = VersionUtils._get_version_fork_from_git_shell() + version_info[VersionUtils.VERSIONFILE_ATTR__TIMESTAMP] = version_timestamp.isoformat() + version_info[VersionUtils.VERSIONFILE_ATTR__COMMIT_HASH] = version_commit_hash[:7] # short hash version_file_path = VersionUtils._get_version_file_path() with open(version_file_path, "w") as f: From 70f86d093cec76e1d0d0a4f2de212988b8518ba1 Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Wed, 24 Dec 2025 12:02:58 -0600 Subject: [PATCH 29/50] test bugfixes --- tests/test_version.py | 63 +++++++++++++++++++++++-------------------- 1 file changed, 34 insertions(+), 29 deletions(-) diff --git a/tests/test_version.py b/tests/test_version.py index aec75833..aae1494d 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -61,6 +61,17 @@ class VersionBaseTest(BaseTest): yield + @pytest.fixture(autouse=True) + def mock_GITHUB_ACTIONS__IS_CI(self): + """ + Patch out the GITHUB_ACTIONS__IS_CI env var so that tests operate under the same + assumptions when running locally and when actually running in CI. Any test that + needs to simulate being in CI can override this env var as needed. + """ + with mock.patch.dict(os.environ, {VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "false"}): + yield + + @pytest.fixture(autouse=True) def mock_popen(self): """ @@ -263,59 +274,55 @@ class TestVersionUtils_GithubActions(VersionBaseTest): VersionUtils.ENV_VAR__GITHUB_ACTIONS__SHA: TEST__FULL_COMMIT_HASH, VersionUtils.ENV_VAR__GITHUB_ACTIONS__REPOSITORY_OWNER: TEST__VERSION_FORK, }): - assert Version.get_version_name() == TEST__VERSION_BRANCH - assert Version.get_version_fork() == TEST__VERSION_FORK - assert Version.get_version_commit_hash() == TEST__SHORT_COMMIT_HASH - assert Version.get_version_timestamp() == TEST__VERSION_TIMESTAMP + assert VersionUtils.get_version_name() == TEST__VERSION_BRANCH + assert VersionUtils.get_version_fork() == TEST__VERSION_FORK + assert VersionUtils.get_short_commit_hash() == TEST__SHORT_COMMIT_HASH + assert VersionUtils.get_version_timestamp() == TEST__VERSION_TIMESTAMP # When running CI on a semantic tag - self.reset_version_singleton() with mock.patch.dict(os.environ, { VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "true", VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: TEST__SEMANTIC_TAG, }): # Should be prefixed with "v" - assert Version.get_version_name() == f"v{TEST__SEMANTIC_TAG}" + assert VersionUtils.get_version_name() == f"v{TEST__SEMANTIC_TAG}" # When running CI on a generic tag - self.reset_version_singleton() with mock.patch.dict(os.environ, { VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "true", VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: TEST__VERSION_TAG, }): # Should NOT be prefixed with "v" - assert Version.get_version_name() == TEST__VERSION_TAG + assert VersionUtils.get_version_name() == TEST__VERSION_TAG # When running CI on a commit (detached HEAD) with no REF_NAME, the # version_name should be the short commit hash. # TODO: I don't think this scenario ever happens. - self.reset_version_singleton() with mock.patch.dict(os.environ, { VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "true", VersionUtils.ENV_VAR__GITHUB_ACTIONS__SHA: TEST__FULL_COMMIT_HASH, }): - assert Version.get_version_name() == TEST__SHORT_COMMIT_HASH + assert VersionUtils.get_version_name() == TEST__SHORT_COMMIT_HASH # When running CI on a commit (detached HEAD) with no REF_NAME and no SHA, # raise error. # Note: This scenario definitely would never happen. Just trying to get to # 100% test coverage. - self.reset_version_singleton() with mock.patch.dict(os.environ, { VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "true", }): with pytest.raises(Exception): - Version.get_version_name() + VersionUtils.get_version_name() def test_is_github_actions_ci(self): """ is_github_actions_ci should return True only when the ENV_VAR__GITHUB_ACTIONS__IS_CI env var is set to "true". - """ - # Default should be False - assert VersionUtils.is_github_actions_ci() is False + Note: Have to mock a value for ALL scenario variations here because this test will + actually run in Github Actions so the env var will already be present! + """ with mock.patch.dict(os.environ, {VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "true"}): assert VersionUtils.is_github_actions_ci() is True @@ -370,10 +377,10 @@ class TestVersionUtils_DotGitFiles(VersionBaseTest): _get_version_fork_from_git_config=Mock(return_value=TEST__VERSION_FORK), _get_version_timestamp_from_src_files=Mock(return_value=TEST__VERSION_TIMESTAMP), ): - assert Version.get_version_name() == TEST__VERSION_BRANCH - assert Version.get_version_fork() == TEST__VERSION_FORK - assert Version.get_version_timestamp() == TEST__VERSION_TIMESTAMP - assert Version.get_version_commit_hash() == TEST__SHORT_COMMIT_HASH + assert VersionUtils.get_version_name() == TEST__VERSION_BRANCH + assert VersionUtils.get_version_fork() == TEST__VERSION_FORK + assert VersionUtils.get_version_timestamp() == TEST__VERSION_TIMESTAMP + assert VersionUtils.get_short_commit_hash() == TEST__SHORT_COMMIT_HASH # If we're on a tag (detached HEAD), getting the commit hash requires # checking the refs/tags for a matching tag. @@ -382,9 +389,8 @@ class TestVersionUtils_DotGitFiles(VersionBaseTest): _read_git_HEAD_file=Mock(return_value=(None, TEST__FULL_COMMIT_HASH)), _get_matching_tag_from_git_refs_tags=Mock(return_value=TEST__VERSION_TAG), ): - self.reset_version_singleton() - assert Version.get_version_name() == TEST__VERSION_TAG - assert Version.get_version_commit_hash() == TEST__SHORT_COMMIT_HASH + assert VersionUtils.get_version_name() == TEST__VERSION_TAG + assert VersionUtils.get_short_commit_hash() == TEST__SHORT_COMMIT_HASH # If we're on a commit hash (detached HEAD) with no matching tag, the # version name should be the short commit hash. @@ -393,9 +399,8 @@ class TestVersionUtils_DotGitFiles(VersionBaseTest): _read_git_HEAD_file=Mock(return_value=(None, TEST__FULL_COMMIT_HASH)), _get_matching_tag_from_git_refs_tags=Mock(return_value=None), ): - self.reset_version_singleton() - assert Version.get_version_name() == TEST__SHORT_COMMIT_HASH - assert Version.get_version_commit_hash() == TEST__SHORT_COMMIT_HASH + assert VersionUtils.get_version_name() == TEST__SHORT_COMMIT_HASH + assert VersionUtils.get_short_commit_hash() == TEST__SHORT_COMMIT_HASH def test__get_dot_git_dir(self): @@ -624,10 +629,10 @@ class TestVersionUtils_GitShell(VersionBaseTest): _get_version_timestamp_from_src_files=Mock(return_value=TEST__VERSION_TIMESTAMP), _get_full_commit_hash_from_git_shell=Mock(return_value=TEST__SHORT_COMMIT_HASH), ): - assert Version.get_version_name() == TEST__VERSION_BRANCH - assert Version.get_version_fork() == TEST__VERSION_FORK - assert Version.get_version_timestamp() == TEST__VERSION_TIMESTAMP - assert Version.get_version_commit_hash() == TEST__SHORT_COMMIT_HASH + assert VersionUtils.get_version_name() == TEST__VERSION_BRANCH + assert VersionUtils.get_version_fork() == TEST__VERSION_FORK + assert VersionUtils.get_version_timestamp() == TEST__VERSION_TIMESTAMP + assert VersionUtils.get_short_commit_hash() == TEST__SHORT_COMMIT_HASH def test__get_version_name_from_git_shell(self): From b01de936cfa8c226af2ddc07062f3e38b2621bf3 Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Wed, 24 Dec 2025 12:10:48 -0600 Subject: [PATCH 30/50] CI bugfix --- tests/test_version.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/tests/test_version.py b/tests/test_version.py index aae1494d..e2bc1494 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -300,6 +300,7 @@ class TestVersionUtils_GithubActions(VersionBaseTest): # TODO: I don't think this scenario ever happens. with mock.patch.dict(os.environ, { VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "true", + VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: "", VersionUtils.ENV_VAR__GITHUB_ACTIONS__SHA: TEST__FULL_COMMIT_HASH, }): assert VersionUtils.get_version_name() == TEST__SHORT_COMMIT_HASH @@ -310,6 +311,8 @@ class TestVersionUtils_GithubActions(VersionBaseTest): # 100% test coverage. with mock.patch.dict(os.environ, { VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "true", + VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: "", + VersionUtils.ENV_VAR__GITHUB_ACTIONS__SHA: "", }): with pytest.raises(Exception): VersionUtils.get_version_name() @@ -339,16 +342,23 @@ class TestVersionUtils_GithubActions(VersionBaseTest): env vars when set. """ # Need to signal that we're in a GitHub Actions CI environment - with mock.patch.dict(os.environ, {VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "true"}): - assert os.environ.get(VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME) is None - assert os.environ.get(VersionUtils.ENV_VAR__GITHUB_ACTIONS__SHA) is None + with mock.patch.dict(os.environ, { + VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "true", + VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: "", + VersionUtils.ENV_VAR__GITHUB_ACTIONS__SHA: "", + }): assert VersionUtils._get_version_name_from_github_actions_env_vars() is None + # REF_NAME should be passed straight through with mock.patch.dict(os.environ, {VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: TEST__VERSION_NAME}): result = VersionUtils._get_version_name_from_github_actions_env_vars() assert result == TEST__VERSION_NAME - with mock.patch.dict(os.environ, {VersionUtils.ENV_VAR__GITHUB_ACTIONS__SHA: TEST__FULL_COMMIT_HASH}): + # Unlikely scenario: no REF_NAME but SHA is set; should return short commit hash + with mock.patch.dict(os.environ, { + VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: "", + VersionUtils.ENV_VAR__GITHUB_ACTIONS__SHA: TEST__FULL_COMMIT_HASH, + }): result = VersionUtils._get_version_name_from_github_actions_env_vars() assert result == TEST__SHORT_COMMIT_HASH[:7] From 10767de9d52b5898baece3580e69e547db455c2e Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Wed, 24 Dec 2025 12:15:05 -0600 Subject: [PATCH 31/50] restore Version.override_data for screenshot generator --- src/seedsigner/helpers/version.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/seedsigner/helpers/version.py b/src/seedsigner/helpers/version.py index bf4b246f..4795ae5b 100644 --- a/src/seedsigner/helpers/version.py +++ b/src/seedsigner/helpers/version.py @@ -124,6 +124,19 @@ class Version(Singleton): return cls.get_instance()._version_commit_hash + @classmethod + @not_allowed_in_seedsigner_os + def override_data(cls, version_name, version_fork, version_timestamp, version_commit_hash): + """ + Only used by the screenshot generator. + """ + instance = cls.get_instance() + instance._version_name = version_name + instance._version_fork = version_fork + instance._version_timestamp = version_timestamp + instance._version_commit_hash = version_commit_hash + + class VersionUtils: """ ********************************************************************************* From 8cbf69d49345922145005a0f4072e1e144d434c4 Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Wed, 24 Dec 2025 12:20:42 -0600 Subject: [PATCH 32/50] bugfix --- tests/screenshot_generator/generator.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/screenshot_generator/generator.py b/tests/screenshot_generator/generator.py index 3bce441a..2566a3ac 100644 --- a/tests/screenshot_generator/generator.py +++ b/tests/screenshot_generator/generator.py @@ -313,7 +313,9 @@ def generate_screenshots(locale): def reset_version_to_local_git_state_cb(): - Version.reset_instance() + # Normally, directly manipulating the singleton's internal instance is not + # allowed, but the screnshot generator is an atypical use case. + Version._instance = None screenshot_sections = { From 38c558472e4c6d5a99273efb83d091dc73d66e88 Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Thu, 25 Dec 2025 07:17:57 -0600 Subject: [PATCH 33/50] Get coverage back to 100% --- tests/test_version.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/test_version.py b/tests/test_version.py index e2bc1494..e37014cd 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -804,6 +804,44 @@ class TestVersion(VersionBaseTest): Version.get_version_timestamp() == TEST__VERSION_TIMESTAMP + + def test_override_data(self, mock_popen: Mock): + """ + Test that we can override the version data via the Version.override_version_data() + method. + """ + override_name = "v9.9.9-test" + override_fork = "TestFork" + override_commit_hash = "abcd123" + override_timestamp = datetime(2030, 1, 1, 0, 0, 0) + + # Initially the version data is pulled from the usual sources + self.write_test_version_file() + with patch("seedsigner.models.settings.Settings.HOSTNAME", Settings.SEEDSIGNER_OS): + assert Version.get_version_name() == f"v{TEST__VERSION_DICT[VersionUtils.VERSIONFILE_ATTR__NAME]}" + assert Version.get_version_fork() == TEST__VERSION_DICT[VersionUtils.VERSIONFILE_ATTR__FORK] + assert Version.get_version_commit_hash() == TEST__VERSION_DICT[VersionUtils.VERSIONFILE_ATTR__COMMIT_HASH] + assert Version.get_version_timestamp() == TEST__VERSION_TIMESTAMP + + # While we're in the mocked SeedSigner OS environment, verify that the + # override is not allowed. + with pytest.raises(NotAllowedInSeedSignerOS): + Version.override_data() + + # No longer in the mocked SeedSigner OS environment; should be allowed now. + Version.override_data( + version_name=override_name, + version_fork=override_fork, + version_commit_hash=override_commit_hash, + version_timestamp=override_timestamp, + ) + + assert Version.get_version_name() == override_name + assert Version.get_version_fork() == override_fork + assert Version.get_version_commit_hash() == override_commit_hash + assert Version.get_version_timestamp() == override_timestamp + + def test_get_last_edit(self): """ Test that get_last_src_edit returns a sane datetime object. Assumes the system From 65009775cae8f28ffe11e507f14dd8c90d26fac1 Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Thu, 25 Dec 2025 08:07:43 -0600 Subject: [PATCH 34/50] Slight test case reorg; additional tests to maintain 100% coverage --- src/seedsigner/helpers/version.py | 14 +++-- tests/test_version.py | 88 ++++++++++++++++++++----------- 2 files changed, 67 insertions(+), 35 deletions(-) diff --git a/src/seedsigner/helpers/version.py b/src/seedsigner/helpers/version.py index 4795ae5b..967f284f 100644 --- a/src/seedsigner/helpers/version.py +++ b/src/seedsigner/helpers/version.py @@ -200,7 +200,7 @@ class VersionUtils: else: # In local dev, we try the following methods in order: for get_version_name_method in [ - VersionUtils._get_version_name_from_env_var, + VersionUtils._get_version_name_from_seedsigner_os_env_var, VersionUtils._get_version_name_from_git_shell, VersionUtils._get_version_name_from_git_HEAD, ]: @@ -265,7 +265,7 @@ class VersionUtils: else: # In local dev we use the last modified time of the source python files - return VersionUtils._get_version_timestamp_from_src_files() + return VersionUtils._get_last_modified_timestamp_from_src_files() @classmethod @@ -385,10 +385,10 @@ class VersionUtils: """ ************************************************************************************* - Getting data from SeedSigner OS build env vars or Github Actions CI env vars. + Utilities used in the SeedSigner OS build environment and writing the version.json file. ************************************************************************************* """ @classmethod - def _get_version_name_from_env_var(cls) -> str | None: + def _get_version_name_from_seedsigner_os_env_var(cls) -> str | None: """ Primarily used during the SeedSigner OS build process to set the version name via env var. @@ -398,6 +398,10 @@ class VersionUtils: return os.getenv(cls.ENV_VAR__SEEDSIGNER_OS_BUILDER__VERSION_NAME) + + """ ************************************************************************************* + Getting data from Github Actions CI env vars. + ************************************************************************************* """ @classmethod def is_github_actions_ci(cls) -> bool: return os.getenv(cls.ENV_VAR__GITHUB_ACTIONS__IS_CI) == "true" @@ -607,7 +611,7 @@ class VersionUtils: @classmethod @not_allowed_in_seedsigner_os - def _get_version_timestamp_from_src_files(cls) -> datetime | None: + def _get_last_modified_timestamp_from_src_files(cls) -> datetime | None: """ Recursively scan the src/ directory for the most recent python file edit time. """ diff --git a/tests/test_version.py b/tests/test_version.py index e37014cd..8a09d500 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -1,7 +1,7 @@ import json import os import pytest -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from unittest import mock from unittest.mock import Mock, patch @@ -185,6 +185,43 @@ class TestVersionUtils_BasicCalls(VersionBaseTest): assert result == version_name, f"Expected '{result}' to equal input '{version_name}'" + def test_get_version_name__not_detected(self, mock_popen: Mock): + """ + The possible success scenarios are tested elsewhere. In order to get to 100% + coverage we just need this basic test to hit the total failure condition. + """ + # If we don't provide the SeedSigner OS or Github Actions CI env vars, mock out + # the `git` shell commands via `mock_popen`, and mock out all .git/ file parsing, + # then the method should return the fallback warning note as the version name. + with mock.patch("builtins.open", side_effect=FileNotFoundError): + result = VersionUtils.get_version_name() + assert "not detected" in result + + + def test_get_version_fork__not_detected(self, mock_popen: Mock): + """ + The possible success scenarios are tested elsewhere. In order to get to 100% + coverage we just need this basic test to hit the total failure condition. + """ + # If we don't provide the SeedSigner OS or Github Actions CI env vars, mock out + # the `git` shell commands via `mock_popen`, and mock out all .git/ file parsing, + # then the method should return None. + with mock.patch("builtins.open", side_effect=FileNotFoundError): + VersionUtils.get_version_fork() is None + + + def test_get_short_commit_hash__not_detected(self, mock_popen: Mock): + """ + The possible success scenarios are tested elsewhere. In order to get to 100% + coverage we just need this basic test to hit the total failure condition. + """ + # If we don't provide the SeedSigner OS or Github Actions CI env vars, mock out + # the `git` shell commands via `mock_popen`, and mock out all .git/ file parsing, + # then the method should return None. + with mock.patch("builtins.open", side_effect=FileNotFoundError): + VersionUtils.get_short_commit_hash() is None + + class TestVersionUtils_VersionFile(VersionBaseTest): def test_seedsigner_os_reads_from_version_file(self): @@ -248,10 +285,10 @@ class TestVersionUtils_VersionFile(VersionBaseTest): def test__get_version_name_from_env_var(self): assert os.environ.get(VersionUtils.ENV_VAR__SEEDSIGNER_OS_BUILDER__VERSION_NAME) is None - assert VersionUtils._get_version_name_from_env_var() is None + assert VersionUtils._get_version_name_from_seedsigner_os_env_var() is None with mock.patch.dict(os.environ, {VersionUtils.ENV_VAR__SEEDSIGNER_OS_BUILDER__VERSION_NAME: TEST__VERSION_NAME}): - result = VersionUtils._get_version_name_from_env_var() + result = VersionUtils._get_version_name_from_seedsigner_os_env_var() assert result == TEST__VERSION_NAME @@ -385,7 +422,7 @@ class TestVersionUtils_DotGitFiles(VersionBaseTest): _read_git_HEAD_file=Mock(return_value=(TEST__VERSION_BRANCH, None)), _get_full_commit_hash_from_git_refs_heads=Mock(return_value=TEST__FULL_COMMIT_HASH), _get_version_fork_from_git_config=Mock(return_value=TEST__VERSION_FORK), - _get_version_timestamp_from_src_files=Mock(return_value=TEST__VERSION_TIMESTAMP), + _get_last_modified_timestamp_from_src_files=Mock(return_value=TEST__VERSION_TIMESTAMP), ): assert VersionUtils.get_version_name() == TEST__VERSION_BRANCH assert VersionUtils.get_version_fork() == TEST__VERSION_FORK @@ -599,24 +636,28 @@ class TestVersionUtils_DotGitFiles(VersionBaseTest): assert VersionUtils._get_version_fork_from_git_config() is None - def test__get_version_timestamp_from_src_files(self): + def test__get_last_modified_timestamp_from_src_files(self): """ - _get_version_timestamp_from_src_files should return the most recent file + _get_last_modified_timestamp_from_src_files should return the most recent file modification timestamp from the SeedSigner python files. """ # Do the real filesystem scan - timestamp = VersionUtils._get_version_timestamp_from_src_files() - assert timestamp < datetime.now() - assert timestamp > datetime(2020, 12, 13) # after first SeedSigner release + timestamp = VersionUtils._get_last_modified_timestamp_from_src_files() + + # Should be more recent than the initial SeedSigner v0.0.1 commit + assert timestamp > datetime(2020, 12, 13) + + # Can't be in the future + assert timestamp < datetime.now() + timedelta(days=30) # Now mock out os.path.getmtime to force all files to have a known timestamp expected_timestamp = datetime(2025, 12, 23, 0, 0, 0) with mock.patch("os.path.getmtime", return_value=expected_timestamp.timestamp()): - assert VersionUtils._get_version_timestamp_from_src_files() == expected_timestamp + assert VersionUtils._get_last_modified_timestamp_from_src_files() == expected_timestamp # Mock out os.walk() to simulate no .py files found with mock.patch("os.walk", return_value=[]): - assert VersionUtils._get_version_timestamp_from_src_files() is None + assert VersionUtils._get_last_modified_timestamp_from_src_files() is None @@ -636,7 +677,7 @@ class TestVersionUtils_GitShell(VersionBaseTest): "seedsigner.helpers.version.VersionUtils", _get_version_name_from_git_shell=Mock(return_value=TEST__VERSION_BRANCH), _get_version_fork_from_git_shell=Mock(return_value=TEST__VERSION_FORK), - _get_version_timestamp_from_src_files=Mock(return_value=TEST__VERSION_TIMESTAMP), + _get_last_modified_timestamp_from_src_files=Mock(return_value=TEST__VERSION_TIMESTAMP), _get_full_commit_hash_from_git_shell=Mock(return_value=TEST__SHORT_COMMIT_HASH), ): assert VersionUtils.get_version_name() == TEST__VERSION_BRANCH @@ -732,6 +773,11 @@ class TestVersionUtils_GitShell(VersionBaseTest): assert result == TEST__SHORT_COMMIT_HASH + +class TestVersionUtils_Misc(VersionBaseTest): + """ + Tests for any other remaining methods in VersionUtils. + """ def test__fetch_latest_seedsigner_release_tag(self, mock_popen: Mock): """ Test that _fetch_latest_seedsigner_release_tag returns the expected version string. @@ -804,7 +850,6 @@ class TestVersion(VersionBaseTest): Version.get_version_timestamp() == TEST__VERSION_TIMESTAMP - def test_override_data(self, mock_popen: Mock): """ Test that we can override the version data via the Version.override_version_data() @@ -842,23 +887,6 @@ class TestVersion(VersionBaseTest): assert Version.get_version_timestamp() == override_timestamp - def test_get_last_edit(self): - """ - Test that get_last_src_edit returns a sane datetime object. Assumes the system - running this test has a reasonably correct system time. - """ - last_edit = Version.get_version_timestamp() - assert isinstance(last_edit, datetime) - - # Has to be more recent than the first SeedSigner v0.0.1 release - known_past = datetime(2020, 12, 13) - assert last_edit > known_past - - # Could not have happened tomorrow - known_future = datetime.now().replace(year=datetime.now().year + 1) - assert last_edit < known_future - - class TestNotAllowedInSeedSignerOSDecorator(BaseTest): SUCCESS = "success" From c475b8f79b90e3940e9494ed8df480554e9ba47e Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Thu, 25 Dec 2025 08:32:26 -0600 Subject: [PATCH 35/50] bugfix after rename --- tools/write_versionfile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/write_versionfile.py b/tools/write_versionfile.py index 37b08a4d..79c4c8b3 100644 --- a/tools/write_versionfile.py +++ b/tools/write_versionfile.py @@ -40,7 +40,7 @@ if __name__ == "__main__": version_info = dict() for get_version_name_method in [ - VersionUtils._get_version_name_from_env_var, + VersionUtils._get_version_name_from_seedsigner_os_env_var, VersionUtils._get_version_name_from_git_shell, ]: version_name = get_version_name_method() From 6cd932ab9a098b03bffa82b19b7ebb7922fb8313 Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Thu, 25 Dec 2025 08:33:03 -0600 Subject: [PATCH 36/50] re-org section placement; minor test name fixups after renames --- src/seedsigner/helpers/version.py | 132 ++++++++-------- tests/test_version.py | 242 +++++++++++++++--------------- 2 files changed, 188 insertions(+), 186 deletions(-) diff --git a/src/seedsigner/helpers/version.py b/src/seedsigner/helpers/version.py index 967f284f..20335a56 100644 --- a/src/seedsigner/helpers/version.py +++ b/src/seedsigner/helpers/version.py @@ -427,6 +427,72 @@ class VersionUtils: + """ ************************************************************************************* + Get data via shell `git` commands. + + These calls shouldn't be dangerous, but we definitely don't want them being run in + SeedSigner OS regardless so we apply the decorator restriction. + ************************************************************************************* """ + @classmethod + @not_allowed_in_seedsigner_os + def _get_version_name_from_git_shell_branch(cls) -> str | None: + branch_name = os.popen("git branch --show-current 2> /dev/null").read() + return branch_name.strip() if branch_name else None + + + @classmethod + @not_allowed_in_seedsigner_os + def _get_version_name_from_git_shell_tag(cls) -> str | None: + # Only return a value if the current commit exactly corresponds with a tag. + # (`--points-at` defaults to the current HEAD) + tag_name = os.popen(f"git tag --points-at 2> /dev/null").read() + return tag_name.strip() if tag_name else None + + + @classmethod + @not_allowed_in_seedsigner_os + def _get_full_commit_hash_from_git_shell(cls) -> str | None: + """ + Attempts to get the current git commit hash via shell `git` commands. + """ + commit_hash = os.popen("git rev-parse HEAD").read() + return commit_hash.strip() if commit_hash else None + + + @classmethod + @not_allowed_in_seedsigner_os + def _get_version_name_from_git_shell(cls) -> str | None: + """ + Attempts to get the version name via shell `git` commands. + """ + return ( + cls._get_version_name_from_git_shell_branch() or + cls._get_version_name_from_git_shell_tag() or + cls._get_full_commit_hash_from_git_shell() + ) + + + @classmethod + @not_allowed_in_seedsigner_os + def _get_version_fork_from_git_shell(cls) -> str | None: + """ + Attempts to get the fork owner name via shell `git` commands. + """ + # We expect to at least have a remote named "origin" + remote_url = os.popen("git remote get-url origin 2> /dev/null").read().strip() + return cls._parse_git_remote_url(remote_url) if remote_url else None + + + @classmethod + @not_allowed_in_seedsigner_os + def _get_version_timestamp_from_git_shell(cls) -> datetime | None: + version_timestamp = os.popen("git log -1 --format=%cI").read().strip() + if version_timestamp: + # Parse the timestamp, ensure that it's in UTC, and omit tz info + return datetime.fromisoformat(version_timestamp).astimezone(timezone.utc).replace(tzinfo=None) + + + """ ************************************************************************************* Reading directly from local .git/ or src/ files. @@ -647,72 +713,6 @@ class VersionUtils: - """ ************************************************************************************* - Get data via shell `git` commands. - - These calls shouldn't be dangerous, but we definitely don't want them being run in - SeedSigner OS regardless so we apply the decorator restriction. - ************************************************************************************* """ - @classmethod - @not_allowed_in_seedsigner_os - def _get_version_name_from_git_shell_branch(cls) -> str | None: - branch_name = os.popen("git branch --show-current 2> /dev/null").read() - return branch_name.strip() if branch_name else None - - - @classmethod - @not_allowed_in_seedsigner_os - def _get_version_name_from_git_shell_tag(cls) -> str | None: - # Only return a value if the current commit exactly corresponds with a tag. - # (`--points-at` defaults to the current HEAD) - tag_name = os.popen(f"git tag --points-at 2> /dev/null").read() - return tag_name.strip() if tag_name else None - - - @classmethod - @not_allowed_in_seedsigner_os - def _get_full_commit_hash_from_git_shell(cls) -> str | None: - """ - Attempts to get the current git commit hash via shell `git` commands. - """ - commit_hash = os.popen("git rev-parse HEAD").read() - return commit_hash.strip() if commit_hash else None - - - @classmethod - @not_allowed_in_seedsigner_os - def _get_version_name_from_git_shell(cls) -> str | None: - """ - Attempts to get the version name via shell `git` commands. - """ - return ( - cls._get_version_name_from_git_shell_branch() or - cls._get_version_name_from_git_shell_tag() or - cls._get_full_commit_hash_from_git_shell() - ) - - - @classmethod - @not_allowed_in_seedsigner_os - def _get_version_fork_from_git_shell(cls) -> str | None: - """ - Attempts to get the fork owner name via shell `git` commands. - """ - # We expect to at least have a remote named "origin" - remote_url = os.popen("git remote get-url origin 2> /dev/null").read().strip() - return cls._parse_git_remote_url(remote_url) if remote_url else None - - - @classmethod - @not_allowed_in_seedsigner_os - def _get_version_timestamp_from_git_shell(cls) -> datetime | None: - version_timestamp = os.popen("git log -1 --format=%cI").read().strip() - if version_timestamp: - # Parse the timestamp, ensure that it's in UTC, and omit tz info - return datetime.fromisoformat(version_timestamp).astimezone(timezone.utc).replace(tzinfo=None) - - - """ ************************************************************************************* External http GET call to github. ************************************************************************************* """ diff --git a/tests/test_version.py b/tests/test_version.py index 8a09d500..726a5170 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -283,7 +283,7 @@ class TestVersionUtils_VersionFile(VersionBaseTest): assert VersionUtils._read_version_file() is None - def test__get_version_name_from_env_var(self): + def test__get_version_name_from_seedsigner_os_env_var(self): assert os.environ.get(VersionUtils.ENV_VAR__SEEDSIGNER_OS_BUILDER__VERSION_NAME) is None assert VersionUtils._get_version_name_from_seedsigner_os_env_var() is None @@ -401,6 +401,119 @@ class TestVersionUtils_GithubActions(VersionBaseTest): +class TestVersionUtils_GitShell(VersionBaseTest): + def test_local_dev_with_git_shell_calls(self): + """ + Test the high-level basic public calls. Does just the minimal necessary mocking + since all the downstream helper methods are tested in detail elsewhere. + + When running in a local dev environment with access to `git` shell commands, + the version data should be pulled from those commands. + + Note that in local dev we use the last modified timestamp from the source files + rather than from git. + """ + with mock.patch.multiple( + "seedsigner.helpers.version.VersionUtils", + _get_version_name_from_git_shell=Mock(return_value=TEST__VERSION_BRANCH), + _get_version_fork_from_git_shell=Mock(return_value=TEST__VERSION_FORK), + _get_last_modified_timestamp_from_src_files=Mock(return_value=TEST__VERSION_TIMESTAMP), + _get_full_commit_hash_from_git_shell=Mock(return_value=TEST__SHORT_COMMIT_HASH), + ): + assert VersionUtils.get_version_name() == TEST__VERSION_BRANCH + assert VersionUtils.get_version_fork() == TEST__VERSION_FORK + assert VersionUtils.get_version_timestamp() == TEST__VERSION_TIMESTAMP + assert VersionUtils.get_short_commit_hash() == TEST__SHORT_COMMIT_HASH + + + def test__get_version_name_from_git_shell(self): + """ + Test that _get_version_name_from_git_shell returns the expected name depending on + the current git state + """ + # Default mock_popen return empty string; simulates no `git` shell command available + # or no local git data. + result = VersionUtils._get_version_name_from_git_shell() + assert result is None + + # If we're on a branch, should return the branch name + with mock.patch.multiple( + "seedsigner.helpers.version.VersionUtils", + _get_version_name_from_git_shell_branch=Mock(return_value=TEST__VERSION_BRANCH), + _get_version_name_from_git_shell_tag=Mock(return_value=TEST__VERSION_TAG), + _get_full_commit_hash_from_git_shell=Mock(return_value=TEST__FULL_COMMIT_HASH), + ): + result = VersionUtils._get_version_name_from_git_shell() + assert result == TEST__VERSION_BRANCH + + # If we're on a tag, the detached HEAD state wipes out the branch name + with mock.patch.multiple( + "seedsigner.helpers.version.VersionUtils", + _get_version_name_from_git_shell_branch=Mock(return_value=None), + _get_version_name_from_git_shell_tag=Mock(return_value=TEST__VERSION_TAG), + _get_full_commit_hash_from_git_shell=Mock(return_value=TEST__FULL_COMMIT_HASH), + ): + result = VersionUtils._get_version_name_from_git_shell() + assert result == TEST__VERSION_TAG + + # Similarly, if we're detached at a specific commit hash + with mock.patch.multiple( + "seedsigner.helpers.version.VersionUtils", + _get_version_name_from_git_shell_branch=Mock(return_value=None), + _get_version_name_from_git_shell_tag=Mock(return_value=None), + _get_full_commit_hash_from_git_shell=Mock(return_value=TEST__FULL_COMMIT_HASH), + ): + result = VersionUtils._get_version_name_from_git_shell() + assert result == TEST__FULL_COMMIT_HASH + + + def test__get_version_fork_from_git_shell(self, mock_popen: Mock): + """ + Test that _get_version_fork_from_git_shell returns the expected repo owner from + the remote url. + """ + remote_url = "https://github.com/SeedSigner/seedsigner.git" + expected_fork = "SeedSigner" + + mock_popen.return_value.read.return_value = remote_url + result = VersionUtils._get_version_fork_from_git_shell() + assert result == expected_fork + + + def test__get_version_timestamp_from_git_shell(self, mock_popen: Mock): + """ + Test that _get_version_timestamp_from_git_shell returns the expected datetime. + """ + # Should gracefully handle no `git` shell command available or no local git data. + mock_popen.return_value.read.return_value = "" + assert VersionUtils._get_version_timestamp_from_git_shell() is None + + # Initial timestamp has timezone info + hour = 14 + tz_offset = 1 + test_local_isoformat = f"2025-12-20T{hour:02}:00:00-{tz_offset:02}:00" + mock_popen.return_value.read.return_value = test_local_isoformat + + # But the final result will be UTC + expected_datetime = datetime.fromisoformat(f"2025-12-20T{hour + tz_offset:02}:00:00") + assert VersionUtils._get_version_timestamp_from_git_shell() == expected_datetime + + # And UTC-to-UTC should be unchanged + mock_popen.return_value.read.return_value = expected_datetime.isoformat() + "+00:00" + assert VersionUtils._get_version_timestamp_from_git_shell() == expected_datetime + + + def test__get_full_commit_hash_from_git_shell(self, mock_popen: Mock): + """ + Test that _get_full_commit_hash_from_git_shell returns the expected short commit hash. + """ + mock_popen.return_value.read.return_value = TEST__SHORT_COMMIT_HASH + + result = VersionUtils._get_full_commit_hash_from_git_shell() + assert result == TEST__SHORT_COMMIT_HASH + + + class TestVersionUtils_DotGitFiles(VersionBaseTest): def test_local_dev_with_dot_git_dir_parsing(self, mock_popen: Mock): """ @@ -542,8 +655,9 @@ class TestVersionUtils_DotGitFiles(VersionBaseTest): def test__get_commit_hash_from_git_HEAD(self): - # _get_commit_hash_from_git_HEAD is a trivial convenience function that relies on _read_git_HEAD_file which we've already tested. - # Just verify the expected outputs here. + # _get_commit_hash_from_git_HEAD is a trivial convenience function that relies on + # _read_git_HEAD_file which we've already tested. Just verify the expected outputs + # here. with mock.patch.object(VersionUtils, '_read_git_HEAD_file', return_value=(None, TEST__SHORT_COMMIT_HASH)): assert VersionUtils._get_full_commit_hash_from_git_HEAD() == TEST__SHORT_COMMIT_HASH @@ -579,7 +693,7 @@ class TestVersionUtils_DotGitFiles(VersionBaseTest): _parse_git_remote_url should return the expected repo owner from various git remote url formats. """ - # Might be called with no remote url or unrecognized format + # Might be called with no remote url. Should also handle unrecognized formats. for url in [None, "", "what/is/this"]: assert VersionUtils._parse_git_remote_url(url) is None @@ -661,119 +775,6 @@ class TestVersionUtils_DotGitFiles(VersionBaseTest): -class TestVersionUtils_GitShell(VersionBaseTest): - def test_local_dev_with_git_shell_calls(self): - """ - Test the high-level basic public calls. Does just the minimal necessary mocking - since all the downstream helper methods are tested in detail elsewhere. - - When running in a local dev environment with access to `git` shell commands, - the version data should be pulled from those commands. - - Note that in local dev we use the last modified timestamp from the source files - rather than from git. - """ - with mock.patch.multiple( - "seedsigner.helpers.version.VersionUtils", - _get_version_name_from_git_shell=Mock(return_value=TEST__VERSION_BRANCH), - _get_version_fork_from_git_shell=Mock(return_value=TEST__VERSION_FORK), - _get_last_modified_timestamp_from_src_files=Mock(return_value=TEST__VERSION_TIMESTAMP), - _get_full_commit_hash_from_git_shell=Mock(return_value=TEST__SHORT_COMMIT_HASH), - ): - assert VersionUtils.get_version_name() == TEST__VERSION_BRANCH - assert VersionUtils.get_version_fork() == TEST__VERSION_FORK - assert VersionUtils.get_version_timestamp() == TEST__VERSION_TIMESTAMP - assert VersionUtils.get_short_commit_hash() == TEST__SHORT_COMMIT_HASH - - - def test__get_version_name_from_git_shell(self): - """ - Test that _get_version_name_from_git_shell returns the expected name depending on - the current git state - """ - # Default mock_popen return empty string; simulates no `git` shell command available - # or no local git data. - result = VersionUtils._get_version_name_from_git_shell() - assert result is None - - # If we're on a branch, should return the branch name - with mock.patch.multiple( - "seedsigner.helpers.version.VersionUtils", - _get_version_name_from_git_shell_branch=Mock(return_value=TEST__VERSION_BRANCH), - _get_version_name_from_git_shell_tag=Mock(return_value=TEST__VERSION_TAG), - _get_full_commit_hash_from_git_shell=Mock(return_value=TEST__FULL_COMMIT_HASH), - ): - result = VersionUtils._get_version_name_from_git_shell() - assert result == TEST__VERSION_BRANCH - - # If we're on a tag, the detached HEAD state wipes out the branch name - with mock.patch.multiple( - "seedsigner.helpers.version.VersionUtils", - _get_version_name_from_git_shell_branch=Mock(return_value=None), - _get_version_name_from_git_shell_tag=Mock(return_value=TEST__VERSION_TAG), - _get_full_commit_hash_from_git_shell=Mock(return_value=TEST__FULL_COMMIT_HASH), - ): - result = VersionUtils._get_version_name_from_git_shell() - assert result == TEST__VERSION_TAG - - # Similarly, if we're detached at a specific commit hash - with mock.patch.multiple( - "seedsigner.helpers.version.VersionUtils", - _get_version_name_from_git_shell_branch=Mock(return_value=None), - _get_version_name_from_git_shell_tag=Mock(return_value=None), - _get_full_commit_hash_from_git_shell=Mock(return_value=TEST__FULL_COMMIT_HASH), - ): - result = VersionUtils._get_version_name_from_git_shell() - assert result == TEST__FULL_COMMIT_HASH - - - def test__get_version_fork_from_git_shell(self, mock_popen: Mock): - """ - Test that _get_version_fork_from_git_shell returns the expected repo owner from - the remote url. - """ - remote_url = "https://github.com/SeedSigner/seedsigner.git" - expected_fork = "SeedSigner" - - mock_popen.return_value.read.return_value = remote_url - result = VersionUtils._get_version_fork_from_git_shell() - assert result == expected_fork - - - def test__get_version_timestamp_from_git_shell(self, mock_popen: Mock): - """ - Test that _get_version_timestamp_from_git_shell returns the expected datetime. - """ - # Should gracefully handle no `git` shell command available or no local git data. - mock_popen.return_value.read.return_value = "" - assert VersionUtils._get_version_timestamp_from_git_shell() is None - - # Initial timestamp has timezone info - hour = 14 - tz_offset = 1 - test_local_isoformat = f"2025-12-20T{hour:02}:00:00-{tz_offset:02}:00" - mock_popen.return_value.read.return_value = test_local_isoformat - - # But the final result will be UTC - expected_datetime = datetime.fromisoformat(f"2025-12-20T{hour + tz_offset:02}:00:00") - assert VersionUtils._get_version_timestamp_from_git_shell() == expected_datetime - - # And UTC-to-UTC should be unchanged - mock_popen.return_value.read.return_value = expected_datetime.isoformat() + "+00:00" - assert VersionUtils._get_version_timestamp_from_git_shell() == expected_datetime - - - def test__get_short_commit_hash_from_git_shell(self, mock_popen: Mock): - """ - Test that _get_full_commit_hash_from_git_shell returns the expected short commit hash. - """ - mock_popen.return_value.read.return_value = TEST__SHORT_COMMIT_HASH - - result = VersionUtils._get_full_commit_hash_from_git_shell() - assert result == TEST__SHORT_COMMIT_HASH - - - class TestVersionUtils_Misc(VersionBaseTest): """ Tests for any other remaining methods in VersionUtils. @@ -915,6 +916,9 @@ class TestNotAllowedInSeedSignerOSDecorator(BaseTest): class TestNotVersionBaseTest(BaseTest): + """ + Sanity check to verify that VersionBaseTest mocks do not affect other tests. + """ def test_version_file_name__not_patched(self): """ Ensure that outside of VersionBaseTest, the VERSIONFILE__FILENAME patch does not @@ -925,9 +929,7 @@ class TestNotVersionBaseTest(BaseTest): def test_mock_popen__not_patched(self): """ - Ensure that outside of VersionBaseTest, os.popen is not patched. + Ensure that os.popen is not mocked outside of VersionBaseTest. """ - # Call os.popen result = os.popen("echo 'Hello, World!'") - # The result should not be a MagicMock instance assert not isinstance(result, mock.MagicMock) From 0a02616cef70950dfcc37d59cc564e17803f8321 Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Thu, 25 Dec 2025 09:41:17 -0600 Subject: [PATCH 37/50] Naming clean up; version.json gets "v" prefix; explicit SeedSigner OS builder paths --- .../gui/screens/settings_screens.py | 10 +-- src/seedsigner/helpers/version.py | 63 +++++++++++++++--- src/seedsigner/views/settings_views.py | 8 +-- tests/screenshot_generator/generator.py | 2 +- tests/test_version.py | 66 ++++++++++++++++--- 5 files changed, 119 insertions(+), 30 deletions(-) diff --git a/src/seedsigner/gui/screens/settings_screens.py b/src/seedsigner/gui/screens/settings_screens.py index 67835faf..375ded44 100644 --- a/src/seedsigner/gui/screens/settings_screens.py +++ b/src/seedsigner/gui/screens/settings_screens.py @@ -316,7 +316,7 @@ class VersionScreen(BaseTopNavScreen): version_name: str = None version_fork: str = None version_timestamp: datetime = None - version_commit_hash: str = None + short_commit_hash: str = None def __post_init__(self): self.title = _("Version") @@ -344,7 +344,7 @@ class VersionScreen(BaseTopNavScreen): (left, timestamp_char_height, timestamp_char_width, bottom) = timestamp_font.getbbox("UTC", anchor="ls") screen_y = self.top_nav.height + GUIConstants.COMPONENT_PADDING * 2 - if not self.version_fork and not self.version_commit_hash: + if not self.version_fork and not self.short_commit_hash: # Center the version name if there's no fork/commit info screen_y = int(self.canvas_height / 2) - (-1 * version_name_char_height) # char_height is negative @@ -366,7 +366,7 @@ class VersionScreen(BaseTopNavScreen): if self.version_fork: screen_x = 0 - if self.version_commit_hash: + if self.short_commit_hash: # right-align the labels screen_x = commit_label_width - fork_label_width self.components.append(TextArea( @@ -387,7 +387,7 @@ class VersionScreen(BaseTopNavScreen): )) screen_y = self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING - if self.version_commit_hash: + if self.short_commit_hash: self.components.append(TextArea( text=f"commit: ", is_text_centered=False, @@ -395,7 +395,7 @@ class VersionScreen(BaseTopNavScreen): screen_y=screen_y, )) self.components.append(TextArea( - text=self.version_commit_hash, + text=self.short_commit_hash, is_text_centered=False, font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME, font_size=GUIConstants.get_top_nav_title_font_size(), diff --git a/src/seedsigner/helpers/version.py b/src/seedsigner/helpers/version.py index 20335a56..7590f9de 100644 --- a/src/seedsigner/helpers/version.py +++ b/src/seedsigner/helpers/version.py @@ -85,7 +85,7 @@ class Version(Singleton): _version_name: str = None _version_fork: str = None _version_timestamp: datetime = None - _version_commit_hash: str = None + _short_commit_hash: str = None @classmethod @@ -100,7 +100,7 @@ class Version(Singleton): version._version_name = VersionUtils.get_version_name() version._version_fork = VersionUtils.get_version_fork() version._version_timestamp = VersionUtils.get_version_timestamp() - version._version_commit_hash = VersionUtils.get_short_commit_hash() + version._short_commit_hash = VersionUtils.get_short_commit_hash() return cls._instance @@ -120,13 +120,13 @@ class Version(Singleton): @classmethod - def get_version_commit_hash(cls) -> str | None: - return cls.get_instance()._version_commit_hash + def get_short_commit_hash(cls) -> str | None: + return cls.get_instance()._short_commit_hash @classmethod @not_allowed_in_seedsigner_os - def override_data(cls, version_name, version_fork, version_timestamp, version_commit_hash): + def override_data(cls, version_name, version_fork, version_timestamp, short_commit_hash): """ Only used by the screenshot generator. """ @@ -134,9 +134,19 @@ class Version(Singleton): instance._version_name = version_name instance._version_fork = version_fork instance._version_timestamp = version_timestamp - instance._version_commit_hash = version_commit_hash + instance._short_commit_hash = short_commit_hash + @classmethod + def to_dict(cls) -> dict: + instance = cls.get_instance() + return { + VersionUtils.VERSIONFILE_ATTR__NAME: instance._version_name, + VersionUtils.VERSIONFILE_ATTR__FORK: instance._version_fork, + VersionUtils.VERSIONFILE_ATTR__TIMESTAMP: instance._version_timestamp.isoformat() if instance._version_timestamp else None, + VersionUtils.VERSIONFILE_ATTR__COMMIT_HASH: instance._short_commit_hash, + } + class VersionUtils: """ ********************************************************************************* @@ -187,7 +197,11 @@ class VersionUtils: # Shouldn't be possible. Raise an exception to alert testers before this # image goes out. raise Exception("Could not read the version from the version.json file.") - return VersionUtils._prefix_version_name(version_name) + + # Note: the version.json file already contains any necessary pre-processing so + # if we're on a semantic version tag, version_name will already be prefixed + # with "v". + return version_name elif VersionUtils.is_github_actions_ci(): # In Github Actions CI, try to get the version name from env vars @@ -197,10 +211,18 @@ class VersionUtils: else: raise Exception("Could not determine version from Github Actions env vars.") + elif VersionUtils._is_seedsigner_os_builder_env(): + # In the SeedSigner OS build environment, get the version name from env var. + # Note: This get_version_name call will never fail because the env var that + # provides the version name is what defines whether we're in the SeedSigner OS + # builder in the first place. + version_name = VersionUtils._get_version_name_from_seedsigner_os_builder_env_var() + return VersionUtils._prefix_version_name(version_name) + else: # In local dev, we try the following methods in order: for get_version_name_method in [ - VersionUtils._get_version_name_from_seedsigner_os_env_var, + VersionUtils._get_version_name_from_seedsigner_os_builder_env_var, VersionUtils._get_version_name_from_git_shell, VersionUtils._get_version_name_from_git_HEAD, ]: @@ -230,6 +252,10 @@ class VersionUtils: # In Github Actions CI, try to get the version name from env vars return VersionUtils._get_version_fork_from_github_actions_env_vars() + elif VersionUtils._is_seedsigner_os_builder_env(): + # In the SeedSigner OS build environment `git` shell call should be available + return VersionUtils._get_version_fork_from_git_shell() + else: # In local dev we try to access the current git state via: for get_version_fork_method in [ @@ -263,8 +289,13 @@ class VersionUtils: # In Github Actions CI `git` shell call should be available return VersionUtils._get_version_timestamp_from_git_shell() + elif VersionUtils._is_seedsigner_os_builder_env(): + # In the SeedSigner OS build environment `git` shell call should be available + return VersionUtils._get_version_timestamp_from_git_shell() + else: - # In local dev we use the last modified time of the source python files + # In local dev we change our approach and instead use the last modified time + # of the source python files. return VersionUtils._get_last_modified_timestamp_from_src_files() @@ -284,6 +315,10 @@ class VersionUtils: # In Github Actions CI the "SHA" env var should always be available full_commit_hash = VersionUtils._get_full_commit_hash_from_github_actions_env_vars() + elif VersionUtils._is_seedsigner_os_builder_env(): + # In the SeedSigner OS build environment `git` shell call should be available + full_commit_hash = VersionUtils._get_full_commit_hash_from_git_shell() + else: # In local dev we try to access the current git state via: for get_full_commit_hash_method in [ @@ -388,7 +423,15 @@ class VersionUtils: Utilities used in the SeedSigner OS build environment and writing the version.json file. ************************************************************************************* """ @classmethod - def _get_version_name_from_seedsigner_os_env_var(cls) -> str | None: + def _is_seedsigner_os_builder_env(cls) -> bool: + """ + Simple check to see if we're running in the SeedSigner OS build environment. + """ + return os.getenv(cls.ENV_VAR__SEEDSIGNER_OS_BUILDER__VERSION_NAME) is not None + + + @classmethod + def _get_version_name_from_seedsigner_os_builder_env_var(cls) -> str | None: """ Primarily used during the SeedSigner OS build process to set the version name via env var. diff --git a/src/seedsigner/views/settings_views.py b/src/seedsigner/views/settings_views.py index ee14ce13..1c7fcc13 100644 --- a/src/seedsigner/views/settings_views.py +++ b/src/seedsigner/views/settings_views.py @@ -331,21 +331,21 @@ class VersionView(View): from seedsigner.helpers.version import Version version_fork = Version.get_version_fork() - version_commit_hash = Version.get_version_commit_hash() + short_commit_hash = Version.get_short_commit_hash() - print(f"{version_fork=}, {version_commit_hash=}") + print(f"{version_fork=}, {short_commit_hash=}") if version_fork and version_fork.lower() == "seedsigner": # Don't display fork name or commit hash for the main repo version_fork = None - version_commit_hash = None + short_commit_hash = None self.run_screen( settings_screens.VersionScreen, version_name=Version.get_version_name(), version_fork=version_fork, version_timestamp=Version.get_version_timestamp(), - version_commit_hash=version_commit_hash, + short_commit_hash=short_commit_hash, ) return Destination(SettingsMenuView) \ No newline at end of file diff --git a/tests/screenshot_generator/generator.py b/tests/screenshot_generator/generator.py index 2566a3ac..bd088eb0 100644 --- a/tests/screenshot_generator/generator.py +++ b/tests/screenshot_generator/generator.py @@ -205,7 +205,7 @@ def generate_screenshots(locale): version_name=version_name, version_fork="SeedSigner", # main repo; screenshot should hide fork and commit hash version_timestamp=version_timestamp, - version_commit_hash="abcd1234" # dummy value should be ignored + short_commit_hash="abcd1234" # dummy value should be ignored ) # Automatically populate all Settings options Views diff --git a/tests/test_version.py b/tests/test_version.py index 726a5170..354a5716 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -27,7 +27,7 @@ TEST__SEMANTIC_TAG = "1.2.3-rc1" TEST__SHORT_COMMIT_HASH = "c5efda3" TEST__FULL_COMMIT_HASH = "c5efda306c60877191013a6093d92cd0bfcccec8" TEST__VERSION_DICT = { - VersionUtils.VERSIONFILE_ATTR__NAME: TEST__VERSION_NAME, + VersionUtils.VERSIONFILE_ATTR__NAME: VersionUtils._prefix_version_name(TEST__VERSION_NAME), VersionUtils.VERSIONFILE_ATTR__FORK: TEST__VERSION_FORK, VersionUtils.VERSIONFILE_ATTR__TIMESTAMP: TEST__VERSION_TIMESTAMP.isoformat(), VersionUtils.VERSIONFILE_ATTR__COMMIT_HASH: TEST__SHORT_COMMIT_HASH, @@ -256,12 +256,14 @@ class TestVersionUtils_VersionFile(VersionBaseTest): def test__read_version_file(self): """ Low-level test for reading the version.json file. + + Note that the version.json file will already "v" prefix version_name as needed. """ self.write_test_version_file() version_data = VersionUtils._read_version_file() assert version_data is not None - assert version_data[VersionUtils.VERSIONFILE_ATTR__NAME] == TEST__VERSION_NAME + assert version_data[VersionUtils.VERSIONFILE_ATTR__NAME] == VersionUtils._prefix_version_name(TEST__VERSION_NAME) assert version_data[VersionUtils.VERSIONFILE_ATTR__FORK] == TEST__VERSION_FORK assert version_data[VersionUtils.VERSIONFILE_ATTR__TIMESTAMP] == TEST__VERSION_TIMESTAMP.isoformat() assert version_data[VersionUtils.VERSIONFILE_ATTR__COMMIT_HASH] == TEST__SHORT_COMMIT_HASH @@ -283,12 +285,12 @@ class TestVersionUtils_VersionFile(VersionBaseTest): assert VersionUtils._read_version_file() is None - def test__get_version_name_from_seedsigner_os_env_var(self): + def test__get_version_name_from_seedsigner_os_builder_env_var(self): assert os.environ.get(VersionUtils.ENV_VAR__SEEDSIGNER_OS_BUILDER__VERSION_NAME) is None - assert VersionUtils._get_version_name_from_seedsigner_os_env_var() is None + assert VersionUtils._get_version_name_from_seedsigner_os_builder_env_var() is None with mock.patch.dict(os.environ, {VersionUtils.ENV_VAR__SEEDSIGNER_OS_BUILDER__VERSION_NAME: TEST__VERSION_NAME}): - result = VersionUtils._get_version_name_from_seedsigner_os_env_var() + result = VersionUtils._get_version_name_from_seedsigner_os_builder_env_var() assert result == TEST__VERSION_NAME @@ -401,6 +403,41 @@ class TestVersionUtils_GithubActions(VersionBaseTest): +class TestVersionUtils_SeedSignerOSBuilder(VersionBaseTest): + def test_seed_signer_os_builder_env_var(self): + """ + Test the high-level basic public calls. Does just the minimal necessary mocking + since all the downstream helper methods are tested in detail elsewhere. + """ + # Simulate running in the SeedSigner OS build environment + with mock.patch.dict(os.environ, {VersionUtils.ENV_VAR__SEEDSIGNER_OS_BUILDER__VERSION_NAME: TEST__VERSION_NAME}): + # SeedSigner OS builder uses some limited `git` shell calls; mock out the + # associated calls. + with mock.patch.multiple( + "seedsigner.helpers.version.VersionUtils", + _get_version_timestamp_from_git_shell=Mock(return_value=TEST__VERSION_TIMESTAMP), + _get_version_fork_from_git_shell=Mock(return_value=TEST__VERSION_FORK), + _get_full_commit_hash_from_git_shell=Mock(return_value=TEST__FULL_COMMIT_HASH), + ): + assert VersionUtils.get_version_name() == VersionUtils._prefix_version_name(TEST__VERSION_NAME) + assert VersionUtils.get_version_fork() == TEST__VERSION_FORK + assert VersionUtils.get_short_commit_hash() == TEST__SHORT_COMMIT_HASH + assert VersionUtils.get_version_timestamp() == TEST__VERSION_TIMESTAMP + + + def test_is_seedsigner_os_builder_env(self): + """ + is_seedsigner_os_builder_env should return True only when the + ENV_VAR__SEEDSIGNER_OS_BUILDER__VERSION_NAME env var is set. + """ + with mock.patch.dict(os.environ, {}, clear=True): + assert VersionUtils._is_seedsigner_os_builder_env() is False + + with mock.patch.dict(os.environ, {VersionUtils.ENV_VAR__SEEDSIGNER_OS_BUILDER__VERSION_NAME: TEST__VERSION_NAME}): + assert VersionUtils._is_seedsigner_os_builder_env() is True + + + class TestVersionUtils_GitShell(VersionBaseTest): def test_local_dev_with_git_shell_calls(self): """ @@ -847,7 +884,7 @@ class TestVersion(VersionBaseTest): with patch("seedsigner.models.settings.Settings.HOSTNAME", Settings.SEEDSIGNER_OS): Version.get_version_name() == TEST__VERSION_DICT[VersionUtils.VERSIONFILE_ATTR__NAME] Version.get_version_fork() == TEST__VERSION_DICT[VersionUtils.VERSIONFILE_ATTR__FORK] - Version.get_version_commit_hash() == TEST__VERSION_DICT[VersionUtils.VERSIONFILE_ATTR__COMMIT_HASH] + Version.get_short_commit_hash() == TEST__VERSION_DICT[VersionUtils.VERSIONFILE_ATTR__COMMIT_HASH] Version.get_version_timestamp() == TEST__VERSION_TIMESTAMP @@ -864,9 +901,9 @@ class TestVersion(VersionBaseTest): # Initially the version data is pulled from the usual sources self.write_test_version_file() with patch("seedsigner.models.settings.Settings.HOSTNAME", Settings.SEEDSIGNER_OS): - assert Version.get_version_name() == f"v{TEST__VERSION_DICT[VersionUtils.VERSIONFILE_ATTR__NAME]}" + assert Version.get_version_name() == TEST__VERSION_DICT[VersionUtils.VERSIONFILE_ATTR__NAME] assert Version.get_version_fork() == TEST__VERSION_DICT[VersionUtils.VERSIONFILE_ATTR__FORK] - assert Version.get_version_commit_hash() == TEST__VERSION_DICT[VersionUtils.VERSIONFILE_ATTR__COMMIT_HASH] + assert Version.get_short_commit_hash() == TEST__VERSION_DICT[VersionUtils.VERSIONFILE_ATTR__COMMIT_HASH] assert Version.get_version_timestamp() == TEST__VERSION_TIMESTAMP # While we're in the mocked SeedSigner OS environment, verify that the @@ -878,16 +915,25 @@ class TestVersion(VersionBaseTest): Version.override_data( version_name=override_name, version_fork=override_fork, - version_commit_hash=override_commit_hash, + short_commit_hash=override_commit_hash, version_timestamp=override_timestamp, ) assert Version.get_version_name() == override_name assert Version.get_version_fork() == override_fork - assert Version.get_version_commit_hash() == override_commit_hash + assert Version.get_short_commit_hash() == override_commit_hash assert Version.get_version_timestamp() == override_timestamp + def test_to_dict(self): + """ + Test that Version.to_dict() returns the expected dictionary. + """ + self.write_test_version_file() + with patch("seedsigner.models.settings.Settings.HOSTNAME", Settings.SEEDSIGNER_OS): + assert Version.to_dict() == TEST__VERSION_DICT + + class TestNotAllowedInSeedSignerOSDecorator(BaseTest): SUCCESS = "success" From 7b5d21f67bc65f0a9a2ccd4907d810018559e141 Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Thu, 25 Dec 2025 09:42:47 -0600 Subject: [PATCH 38/50] Simplify version.json file write; standardize on matching SeedSigner OS builder output even in local dev --- tools/write_versionfile.py | 42 ++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/tools/write_versionfile.py b/tools/write_versionfile.py index 79c4c8b3..861e8c91 100644 --- a/tools/write_versionfile.py +++ b/tools/write_versionfile.py @@ -1,6 +1,7 @@ import json +import os -from seedsigner.helpers.version import VersionUtils +from seedsigner.helpers.version import Version, VersionUtils """ @@ -21,7 +22,8 @@ Version data: * Check for the SEEDSIGNER_VERSION_NAME env var (provided in SeedSigner OS build env). * Will be the branch, tag, or commit hash being built. - * In local dev will fall back to using `git` shell commands: + * If running in local dev instead, this script will try to populate that env var + using `git` shell commands: * Current git branch name * Current git tag name * Current git commit hash @@ -32,31 +34,23 @@ Version data: * version_timestamp: * Pulls last git commit time from `git log`. - * version_commit_hash: + * short_commit_hash: * Pulls current git commit hash from `git` shell command. """ - if __name__ == "__main__": - version_info = dict() + is_seedsigner_os_builder = VersionUtils._is_seedsigner_os_builder_env() - for get_version_name_method in [ - VersionUtils._get_version_name_from_seedsigner_os_env_var, - VersionUtils._get_version_name_from_git_shell, - ]: - version_name = get_version_name_method() - if version_name: - break + if not is_seedsigner_os_builder: + # Pull version_name from the current git state via `git` shell commands + version_name = VersionUtils._get_version_name_from_git_shell() + + # Temporarily set the env var + os.environ[VersionUtils.ENV_VAR__SEEDSIGNER_OS_BUILDER__VERSION_NAME] = version_name - version_timestamp = VersionUtils._get_version_timestamp_from_git_shell() - version_commit_hash = VersionUtils._get_full_commit_hash_from_git_shell() - - if not version_name or not version_timestamp or not version_commit_hash: - raise Exception("Could not determine version information from git.") - - version_info[VersionUtils.VERSIONFILE_ATTR__NAME] = version_name - version_info[VersionUtils.VERSIONFILE_ATTR__FORK] = VersionUtils._get_version_fork_from_git_shell() - version_info[VersionUtils.VERSIONFILE_ATTR__TIMESTAMP] = version_timestamp.isoformat() - version_info[VersionUtils.VERSIONFILE_ATTR__COMMIT_HASH] = version_commit_hash[:7] # short hash + # When the `Version` singleton instantiates itself, it will determine for itself how to + # get the timestamp based on the SeedSigner OS builder env var being set or not. + version_instance = Version.get_instance() + version_info = version_instance.to_dict() version_file_path = VersionUtils._get_version_file_path() with open(version_file_path, "w") as f: @@ -64,3 +58,7 @@ if __name__ == "__main__": print(f"Wrote version info to: {version_file_path}") print(json.dumps(version_info, indent=4)) + + # Clean up the temp env var if needed + if not is_seedsigner_os_builder: + del os.environ[VersionUtils.ENV_VAR__SEEDSIGNER_OS_BUILDER__VERSION_NAME] From eed16f73d824689f45b37d9322c3951ce7eafb12 Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Thu, 25 Dec 2025 09:44:09 -0600 Subject: [PATCH 39/50] Minor refactor --- tools/write_versionfile.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tools/write_versionfile.py b/tools/write_versionfile.py index 861e8c91..14323b5a 100644 --- a/tools/write_versionfile.py +++ b/tools/write_versionfile.py @@ -47,11 +47,7 @@ if __name__ == "__main__": # Temporarily set the env var os.environ[VersionUtils.ENV_VAR__SEEDSIGNER_OS_BUILDER__VERSION_NAME] = version_name - # When the `Version` singleton instantiates itself, it will determine for itself how to - # get the timestamp based on the SeedSigner OS builder env var being set or not. - version_instance = Version.get_instance() - version_info = version_instance.to_dict() - + version_info = Version.get_instance().to_dict() version_file_path = VersionUtils._get_version_file_path() with open(version_file_path, "w") as f: json.dump(version_info, f, indent=4) From d2914c33fba7f880f81e73a41639ab159f048218 Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Fri, 26 Dec 2025 13:41:32 -0600 Subject: [PATCH 40/50] Simplify SeedSigner OS builder path --- src/seedsigner/helpers/version.py | 279 ++++++++++---------- tests/screenshot_generator/generator.py | 32 ++- tests/test_version.py | 322 ++++++++++++++---------- tools/write_versionfile.py | 53 ++-- 4 files changed, 382 insertions(+), 304 deletions(-) diff --git a/src/seedsigner/helpers/version.py b/src/seedsigner/helpers/version.py index 7590f9de..b228dada 100644 --- a/src/seedsigner/helpers/version.py +++ b/src/seedsigner/helpers/version.py @@ -40,52 +40,70 @@ class Version(Singleton): around in the main code) and so that the version data is only determined once per runtime. - On SeedSigner OS: - * Version data is written to `src/seedsigner/version.json` during the build - process via tools/write_versionfile.py. - * version_name: copied from the command used to build the SeedSigner OS image: - --app-branch (the target git branch OR tag) or - --app-commit-id (target commit hash) - * version_fork: the git repo owner/fork name (e.g. "SeedSigner") targeted by the - build process. - * version_timestamp: the last git commit time for the target branch, tag, or - commit hash. - * commit_hash: the short commit hash for the specified build target. + SeedSigner OS lifecycle: + * The SeedSigner OS build process runs the tools/write_versionfile.py script to + generate version.json. + * The version.json file is included in the SeedSigner OS image. + + The version data is fetched differently depending on the environment: + + In SeedSigner OS: + * Version data is read exclusively from version.json at runtime. + + In the SeedSigner OS builder: + - Note: the build process relies on `git` being installed so we leverage it here. + * version_name: + * read dynamically from `git` shell calls to retrieve, in order: + * Current git branch name + * Current git tag name + * Current git short commit hash + * version_fork: + * the git repo owner parsed out of the remote url. + * https://github.com/SeedSigner/seedsigner.git -> "SeedSigner" + * Read dynamically from shell `git` call to check the remote "origin" URL. + * short_commit_hash: + * the commit hash for the current branch / tag / detached HEAD. + * Read dynamically from shell `git` call. + * version_timestamp: + * the last git commit time for the current branch / tag / detached HEAD. + * Read dynamically from shell `git` call. In local dev: - * version_name: read dynamically from a few possible sources. In order: - * SEEDSIGNER_VERSION_NAME env var, if available. - * Shell `git` calls (e.g. `git branch --show-current`). - * Directly parsing the .git/HEAD file and possibly .git/refs/tags. - * Note: we avoid reading from the version.json file as it may be out of date - and could lead to confusion. - * version_fork: read dynamically from: - * Shell `git` call to check the remote "origin" URL. - * Parse the .git/config for the remote "origin" URL. + - Similar process as for the SeedSigner OS builder, but with additional fallbacks and + a different method for determining version_timestamp. + - If a local version.json is present, it will be ignored; it may be out of date and + could lead to confusion. + * version_name: + * `git` shell calls + directly parsing the .git/HEAD file and .git/refs/tags + when necessary. + * version_fork: + * `git` shell call + parse the .git/config for the remote "origin" URL. + * commit_hash: + * Shell `git` call + parse the .git/HEAD file and + .git/refs/heads/ when necessary. * version_timestamp: determined by scanning the src/ directory for the most recently modified python file. - * commit_hash: read dynamically from: - * Shell `git` call. - * Parse the .git/HEAD file and possibly .git/refs/heads/. In Github Actions CI: - * version_name: read from GITHUB_REF_NAME or GITHUB_SHA env vars. - * version_fork: TODO - * version_timestamp: TODO - * commit_hash: TODO + * version_name: read from GITHUB_REF_NAME env var. + * version_fork: read from GITHUB_REPOSITORY_OWNER env var. + * version_timestamp: Shell `git` call to get the last commit time for the current + branch/tag/commit. + * commit_hash: read from GITHUB_SHA env var. This class defines the limited methods that are meant to be publicly accessible across the SeedSigner codebase. - The misc utility functions in `VersionUtils` were explicitly isolated because they - should NOT be used elsewhere in the codebase. + The utility functions in `VersionUtils` were explicitly isolated because they should + NOT be used elsewhere in the codebase. TODO: Should `VersionUtils` be an internal class within `Version` to further signal that it is not to be used externally? + TODO: Also pull git log history and display it in its own View? """ _version_name: str = None _version_fork: str = None - _version_timestamp: datetime = None _short_commit_hash: str = None + _version_timestamp: datetime = None @classmethod @@ -114,27 +132,31 @@ class Version(Singleton): return cls.get_instance()._version_fork - @classmethod - def get_version_timestamp(cls) -> datetime | None: - return cls.get_instance()._version_timestamp - - @classmethod def get_short_commit_hash(cls) -> str | None: return cls.get_instance()._short_commit_hash + @classmethod + def get_version_timestamp(cls) -> datetime | None: + return cls.get_instance()._version_timestamp + + @classmethod @not_allowed_in_seedsigner_os - def override_data(cls, version_name, version_fork, version_timestamp, short_commit_hash): + def override_data(cls, **kwargs): """ Only used by the screenshot generator. """ instance = cls.get_instance() - instance._version_name = version_name - instance._version_fork = version_fork - instance._version_timestamp = version_timestamp - instance._short_commit_hash = short_commit_hash + if VersionUtils.VERSIONFILE_ATTR__NAME in kwargs: + instance._version_name = kwargs[VersionUtils.VERSIONFILE_ATTR__NAME] + if VersionUtils.VERSIONFILE_ATTR__FORK in kwargs: + instance._version_fork = kwargs[VersionUtils.VERSIONFILE_ATTR__FORK] + if VersionUtils.VERSIONFILE_ATTR__SHORT_COMMIT_HASH in kwargs: + instance._short_commit_hash = kwargs[VersionUtils.VERSIONFILE_ATTR__SHORT_COMMIT_HASH] + if VersionUtils.VERSIONFILE_ATTR__TIMESTAMP in kwargs: + instance._version_timestamp = kwargs[VersionUtils.VERSIONFILE_ATTR__TIMESTAMP] @classmethod @@ -143,11 +165,12 @@ class Version(Singleton): return { VersionUtils.VERSIONFILE_ATTR__NAME: instance._version_name, VersionUtils.VERSIONFILE_ATTR__FORK: instance._version_fork, + VersionUtils.VERSIONFILE_ATTR__SHORT_COMMIT_HASH: instance._short_commit_hash, VersionUtils.VERSIONFILE_ATTR__TIMESTAMP: instance._version_timestamp.isoformat() if instance._version_timestamp else None, - VersionUtils.VERSIONFILE_ATTR__COMMIT_HASH: instance._short_commit_hash, } + class VersionUtils: """ ********************************************************************************* Not meant to be used elsewhere in the SeedSigner codebase (aside from @@ -168,7 +191,7 @@ class VersionUtils: * One external http GET to github to get the most recent release tag. ********************************************************************************* """ - ENV_VAR__SEEDSIGNER_OS_BUILDER__VERSION_NAME = "SEEDSIGNER_VERSION_NAME" + ENV_VAR__IS_SEEDSIGNER_OS_BUILDER = "SEEDSIGNER_OS_BUILDER" ENV_VAR__GITHUB_ACTIONS__IS_CI = "CI" ENV_VAR__GITHUB_ACTIONS__REF_NAME = "GITHUB_REF_NAME" ENV_VAR__GITHUB_ACTIONS__SHA = "GITHUB_SHA" @@ -177,8 +200,8 @@ class VersionUtils: VERSIONFILE__FILENAME = "version.json" VERSIONFILE_ATTR__NAME = "name" VERSIONFILE_ATTR__FORK = "fork" + VERSIONFILE_ATTR__SHORT_COMMIT_HASH = "short_commit_hash" VERSIONFILE_ATTR__TIMESTAMP = "timestamp" - VERSIONFILE_ATTR__COMMIT_HASH = "commit_hash" """ ************************************************************************************* @@ -211,18 +234,10 @@ class VersionUtils: else: raise Exception("Could not determine version from Github Actions env vars.") - elif VersionUtils._is_seedsigner_os_builder_env(): - # In the SeedSigner OS build environment, get the version name from env var. - # Note: This get_version_name call will never fail because the env var that - # provides the version name is what defines whether we're in the SeedSigner OS - # builder in the first place. - version_name = VersionUtils._get_version_name_from_seedsigner_os_builder_env_var() - return VersionUtils._prefix_version_name(version_name) - else: - # In local dev, we try the following methods in order: + # In the SeedSigner OS builder we know that we'll have the `git` shell + # commands available. We add extra fallbacks for local dev. for get_version_name_method in [ - VersionUtils._get_version_name_from_seedsigner_os_builder_env_var, VersionUtils._get_version_name_from_git_shell, VersionUtils._get_version_name_from_git_HEAD, ]: @@ -252,12 +267,9 @@ class VersionUtils: # In Github Actions CI, try to get the version name from env vars return VersionUtils._get_version_fork_from_github_actions_env_vars() - elif VersionUtils._is_seedsigner_os_builder_env(): - # In the SeedSigner OS build environment `git` shell call should be available - return VersionUtils._get_version_fork_from_git_shell() - else: - # In local dev we try to access the current git state via: + # In the SeedSigner OS builder we know that we'll have the `git` shell + # commands available. We add extra fallbacks for local dev. for get_version_fork_method in [ VersionUtils._get_version_fork_from_git_shell, VersionUtils._get_version_fork_from_git_config, @@ -268,6 +280,45 @@ class VersionUtils: return None + @classmethod + def get_short_commit_hash(cls) -> str | None: + """ + Returns the short commit hash string. + + Will be None if the local dev system has no git state available or if it only has + the .git/HEAD but is currently on a branch (not a tag or specific commit). + """ + if Settings.HOSTNAME == Settings.SEEDSIGNER_OS: + return VersionUtils._get_short_commit_hash_from_version_file() + + full_commit_hash = None + if VersionUtils.is_github_actions_ci(): + # In Github Actions CI the "SHA" env var should always be available + full_commit_hash = VersionUtils._get_full_commit_hash_from_github_actions_env_vars() + + else: + # In the SeedSigner OS builder we know that we'll have the `git` shell + # commands available. We add extra fallbacks for local dev. + for get_full_commit_hash_method in [ + VersionUtils._get_full_commit_hash_from_git_shell, + VersionUtils._get_full_commit_hash_from_git_HEAD, + ]: + full_commit_hash = get_full_commit_hash_method() + if full_commit_hash is not None: + break + + if full_commit_hash is None: + # `git` shell calls didn't work and HEAD might be on a branch, so dig + # deeper into the .git files to look up the commit hash via the branch + # name. + branch_name, expect_full_commit_hash_to_be_none = VersionUtils._read_git_HEAD_file() + if branch_name: + full_commit_hash = VersionUtils._get_full_commit_hash_from_git_refs_heads(branch_name) + + if full_commit_hash is not None: + return full_commit_hash[:7] + + @classmethod def get_version_timestamp(cls) -> datetime: """ @@ -290,7 +341,10 @@ class VersionUtils: return VersionUtils._get_version_timestamp_from_git_shell() elif VersionUtils._is_seedsigner_os_builder_env(): - # In the SeedSigner OS build environment `git` shell call should be available + # In the SeedSigner OS builder we know that we'll have the `git` shell + # commands available. + # Note: This is the only piece of version data that behaves differently + # between the SeedSigner OS builder and local dev. return VersionUtils._get_version_timestamp_from_git_shell() else: @@ -299,48 +353,10 @@ class VersionUtils: return VersionUtils._get_last_modified_timestamp_from_src_files() - @classmethod - def get_short_commit_hash(cls) -> str | None: - """ - Returns the short commit hash string. - - Will be None if the local dev system has no git state available or if it only has - the .git/HEAD but is currently on a branch (not a tag or specific commit). - """ - if Settings.HOSTNAME == Settings.SEEDSIGNER_OS: - return VersionUtils._get_short_commit_hash_from_version_file() - - full_commit_hash = None - if VersionUtils.is_github_actions_ci(): - # In Github Actions CI the "SHA" env var should always be available - full_commit_hash = VersionUtils._get_full_commit_hash_from_github_actions_env_vars() - - elif VersionUtils._is_seedsigner_os_builder_env(): - # In the SeedSigner OS build environment `git` shell call should be available - full_commit_hash = VersionUtils._get_full_commit_hash_from_git_shell() - - else: - # In local dev we try to access the current git state via: - for get_full_commit_hash_method in [ - VersionUtils._get_full_commit_hash_from_git_shell, - VersionUtils._get_full_commit_hash_from_git_HEAD, - ]: - full_commit_hash = get_full_commit_hash_method() - if full_commit_hash is not None: - break - - if full_commit_hash is None: - # `git` shell calls didn't work and HEAD might be on a branch, so dig - # deeper into the .git files to look up the commit hash via the branch - # name. - branch_name, expect_full_commit_hash_to_be_none = VersionUtils._read_git_HEAD_file() - if branch_name: - full_commit_hash = VersionUtils._get_full_commit_hash_from_git_refs_heads(branch_name) - - if full_commit_hash is not None: - return full_commit_hash[:7] - + """ ************************************************************************************* + Misc internal utility functions. + ************************************************************************************* """ @classmethod def _prefix_version_name(cls, version_name: str) -> str: """ @@ -353,6 +369,7 @@ class VersionUtils: return version_name + """ ************************************************************************************* Reading data from the version.json file. ************************************************************************************* """ @@ -398,6 +415,16 @@ class VersionUtils: return version_data.get(cls.VERSIONFILE_ATTR__FORK) + @classmethod + def _get_short_commit_hash_from_version_file(cls) -> str | None: + """ + Attempts to read the version.json and return the version commit hash. + """ + version_data = cls._read_version_file() + if version_data: + return version_data.get(cls.VERSIONFILE_ATTR__SHORT_COMMIT_HASH) + + @classmethod def _get_version_timestamp_from_version_file(cls) -> str | None: """ @@ -408,16 +435,6 @@ class VersionUtils: return version_data.get(cls.VERSIONFILE_ATTR__TIMESTAMP) - @classmethod - def _get_short_commit_hash_from_version_file(cls) -> str | None: - """ - Attempts to read the version.json and return the version commit hash. - """ - version_data = cls._read_version_file() - if version_data: - return version_data.get(cls.VERSIONFILE_ATTR__COMMIT_HASH) - - """ ************************************************************************************* Utilities used in the SeedSigner OS build environment and writing the version.json file. @@ -425,20 +442,10 @@ class VersionUtils: @classmethod def _is_seedsigner_os_builder_env(cls) -> bool: """ - Simple check to see if we're running in the SeedSigner OS build environment. + The `ENV_VAR__IS_SEEDSIGNER_OS_BUILDER` env var is set during the SeedSigner OS + build process. """ - return os.getenv(cls.ENV_VAR__SEEDSIGNER_OS_BUILDER__VERSION_NAME) is not None - - - @classmethod - def _get_version_name_from_seedsigner_os_builder_env_var(cls) -> str | None: - """ - Primarily used during the SeedSigner OS build process to set the version name via env var. - - This env var can also be set manually in local dev when needed. - e.g. SEEDSIGNER_VERSION_NAME=some_name python main.py - """ - return os.getenv(cls.ENV_VAR__SEEDSIGNER_OS_BUILDER__VERSION_NAME) + return os.getenv(cls.ENV_VAR__IS_SEEDSIGNER_OS_BUILDER) is not None @@ -478,14 +485,14 @@ class VersionUtils: ************************************************************************************* """ @classmethod @not_allowed_in_seedsigner_os - def _get_version_name_from_git_shell_branch(cls) -> str | None: + def _get_branch_name_from_git_shell(cls) -> str | None: branch_name = os.popen("git branch --show-current 2> /dev/null").read() return branch_name.strip() if branch_name else None @classmethod @not_allowed_in_seedsigner_os - def _get_version_name_from_git_shell_tag(cls) -> str | None: + def _get_tag_name_from_git_shell(cls) -> str | None: # Only return a value if the current commit exactly corresponds with a tag. # (`--points-at` defaults to the current HEAD) tag_name = os.popen(f"git tag --points-at 2> /dev/null").read() @@ -496,22 +503,34 @@ class VersionUtils: @not_allowed_in_seedsigner_os def _get_full_commit_hash_from_git_shell(cls) -> str | None: """ - Attempts to get the current git commit hash via shell `git` commands. + Attempts to get the current full commit hash via shell `git` commands. """ commit_hash = os.popen("git rev-parse HEAD").read() return commit_hash.strip() if commit_hash else None + @classmethod + @not_allowed_in_seedsigner_os + def _get_short_commit_hash_from_git_shell(cls) -> str | None: + """ + Attempts to get the current short commit hash via shell `git` commands. + """ + commit_hash = cls._get_full_commit_hash_from_git_shell() + return commit_hash[:7] if commit_hash else None + + @classmethod @not_allowed_in_seedsigner_os def _get_version_name_from_git_shell(cls) -> str | None: """ Attempts to get the version name via shell `git` commands. + + Note: If we have to fall back to the commit hash, we use the short version. """ return ( - cls._get_version_name_from_git_shell_branch() or - cls._get_version_name_from_git_shell_tag() or - cls._get_full_commit_hash_from_git_shell() + cls._get_branch_name_from_git_shell() or + cls._get_tag_name_from_git_shell() or + cls._get_short_commit_hash_from_git_shell() ) @@ -747,7 +766,7 @@ class VersionUtils: # Shouldn't be possible raise Exception("No python source files found in src/ directory") - return datetime.fromtimestamp(last_modified) + return datetime.fromtimestamp(last_modified).astimezone(timezone.utc).replace(tzinfo=None) except Exception as e: # Catch and log any unexpected errors but this isn't a mission-critical diff --git a/tests/screenshot_generator/generator.py b/tests/screenshot_generator/generator.py index bd088eb0..28dca7b0 100644 --- a/tests/screenshot_generator/generator.py +++ b/tests/screenshot_generator/generator.py @@ -197,16 +197,19 @@ def generate_screenshots(locale): value=SettingsConstants.OPTION__ENABLED ) - # Initialize the Version data to the most recent release - (version_name, version_timestamp) = VersionUtils._fetch_latest_seedsigner_release_tag() - if not version_name or not version_timestamp: - raise Exception("Could not fetch latest release version from GitHub") - Version.override_data( - version_name=version_name, - version_fork="SeedSigner", # main repo; screenshot should hide fork and commit hash - version_timestamp=version_timestamp, - short_commit_hash="abcd1234" # dummy value should be ignored - ) + def reset_version_most_recent_release(): + # Initialize the Version data to the most recent release + (version_name, version_timestamp) = VersionUtils._fetch_latest_seedsigner_release_tag() + if not version_name or not version_timestamp: + raise Exception("Could not fetch latest release version from GitHub") + new_values = { + VersionUtils.VERSIONFILE_ATTR__NAME: version_name, + VersionUtils.VERSIONFILE_ATTR__FORK: "SeedSigner", # main repo; screenshot should hide fork and commit hash + VersionUtils.VERSIONFILE_ATTR__TIMESTAMP: version_timestamp, + VersionUtils.VERSIONFILE_ATTR__SHORT_COMMIT_HASH: "abcd1234" # dummy value should be ignored + } + Version.override_data(**new_values) + reset_version_most_recent_release() # Automatically populate all Settings options Views settings_views_list = [] @@ -314,14 +317,19 @@ def generate_screenshots(locale): def reset_version_to_local_git_state_cb(): # Normally, directly manipulating the singleton's internal instance is not - # allowed, but the screnshot generator is an atypical use case. + # allowed, but the screnshot generator is a special exception. Version._instance = None + def reset_version_to_most_recent_release_cb(): + reset_version_most_recent_release() + + screenshot_sections = { "Main Menu Views": [ ScreenshotConfig(OpeningSplashView, dict(force_partner_logos=True)), ScreenshotConfig(OpeningSplashView, dict(force_partner_logos=False), screenshot_name="OpeningSplashView_no_partner_logos"), + ScreenshotConfig(OpeningSplashView, dict(force_partner_logos=True), run_before=reset_version_to_local_git_state_cb, screenshot_name="OpeningSplashView_current_git_state"), ScreenshotConfig(MainMenuView), ScreenshotConfig(MainMenuView, screenshot_name='MainMenuView_SDCardStateChangeToast_removed', toast_thread=SDCardStateChangeToastManagerThread(action=MicroSD.ACTION__REMOVED, activation_delay=0, duration=0)), ScreenshotConfig(MainMenuView, screenshot_name='MainMenuView_SDCardStateChangeToast_inserted', toast_thread=SDCardStateChangeToastManagerThread(action=MicroSD.ACTION__INSERTED, activation_delay=0, duration=0)), @@ -447,7 +455,7 @@ def generate_screenshots(locale): "Settings Views": settings_views_list + [ ScreenshotConfig(settings_views.IOTestView), ScreenshotConfig(settings_views.DonateView), - ScreenshotConfig(settings_views.VersionView), + ScreenshotConfig(settings_views.VersionView, run_before=reset_version_to_most_recent_release_cb), ScreenshotConfig(settings_views.VersionView, run_before=reset_version_to_local_git_state_cb, screenshot_name="VersionView_current_git_state"), ScreenshotConfig(settings_views.SettingsIngestSettingsQRView, dict(data=settingsqr_data_persistent), screenshot_name="SettingsIngestSettingsQRView_persistent"), ScreenshotConfig(settings_views.SettingsIngestSettingsQRView, dict(data=settingsqr_data_not_persistent), screenshot_name="SettingsIngestSettingsQRView_not_persistent"), diff --git a/tests/test_version.py b/tests/test_version.py index 354a5716..34119423 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -29,8 +29,8 @@ TEST__FULL_COMMIT_HASH = "c5efda306c60877191013a6093d92cd0bfcccec8" TEST__VERSION_DICT = { VersionUtils.VERSIONFILE_ATTR__NAME: VersionUtils._prefix_version_name(TEST__VERSION_NAME), VersionUtils.VERSIONFILE_ATTR__FORK: TEST__VERSION_FORK, + VersionUtils.VERSIONFILE_ATTR__SHORT_COMMIT_HASH: TEST__SHORT_COMMIT_HASH, VersionUtils.VERSIONFILE_ATTR__TIMESTAMP: TEST__VERSION_TIMESTAMP.isoformat(), - VersionUtils.VERSIONFILE_ATTR__COMMIT_HASH: TEST__SHORT_COMMIT_HASH, } # Mimic result of reading from version.json @@ -68,7 +68,7 @@ class VersionBaseTest(BaseTest): assumptions when running locally and when actually running in CI. Any test that needs to simulate being in CI can override this env var as needed. """ - with mock.patch.dict(os.environ, {VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "false"}): + with patch.dict(os.environ, {VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "false"}): yield @@ -193,7 +193,7 @@ class TestVersionUtils_BasicCalls(VersionBaseTest): # If we don't provide the SeedSigner OS or Github Actions CI env vars, mock out # the `git` shell commands via `mock_popen`, and mock out all .git/ file parsing, # then the method should return the fallback warning note as the version name. - with mock.patch("builtins.open", side_effect=FileNotFoundError): + with patch("builtins.open", side_effect=FileNotFoundError): result = VersionUtils.get_version_name() assert "not detected" in result @@ -206,7 +206,7 @@ class TestVersionUtils_BasicCalls(VersionBaseTest): # If we don't provide the SeedSigner OS or Github Actions CI env vars, mock out # the `git` shell commands via `mock_popen`, and mock out all .git/ file parsing, # then the method should return None. - with mock.patch("builtins.open", side_effect=FileNotFoundError): + with patch("builtins.open", side_effect=FileNotFoundError): VersionUtils.get_version_fork() is None @@ -218,7 +218,7 @@ class TestVersionUtils_BasicCalls(VersionBaseTest): # If we don't provide the SeedSigner OS or Github Actions CI env vars, mock out # the `git` shell commands via `mock_popen`, and mock out all .git/ file parsing, # then the method should return None. - with mock.patch("builtins.open", side_effect=FileNotFoundError): + with patch("builtins.open", side_effect=FileNotFoundError): VersionUtils.get_short_commit_hash() is None @@ -238,21 +238,20 @@ class TestVersionUtils_VersionFile(VersionBaseTest): with patch("seedsigner.models.settings.Settings.HOSTNAME", Settings.SEEDSIGNER_OS): assert VersionUtils.get_version_name() == VersionUtils._prefix_version_name(TEST__VERSION_NAME) assert VersionUtils.get_version_fork() == TEST__VERSION_FORK - assert VersionUtils.get_version_timestamp() == TEST__VERSION_TIMESTAMP assert VersionUtils.get_short_commit_hash() == TEST__SHORT_COMMIT_HASH + assert VersionUtils.get_version_timestamp() == TEST__VERSION_TIMESTAMP # Expect errors if keys are missing from version.json - with mock.patch("builtins.open", mock.mock_open(read_data="{'some_key':'some_value'}")): + with patch("builtins.open", mock.mock_open(read_data="{'some_key':'some_value'}")): with pytest.raises(Exception): VersionUtils.get_version_name() # This time it's the timestamp that's missing - with mock.patch("builtins.open", mock.mock_open(read_data=str({VersionUtils.VERSIONFILE_ATTR__NAME: TEST__VERSION_NAME}).replace("'", '"'))): + with patch("builtins.open", mock.mock_open(read_data=str({VersionUtils.VERSIONFILE_ATTR__NAME: TEST__VERSION_NAME}).replace("'", '"'))): with pytest.raises(Exception): VersionUtils.get_version_timestamp() - def test__read_version_file(self): """ Low-level test for reading the version.json file. @@ -265,8 +264,8 @@ class TestVersionUtils_VersionFile(VersionBaseTest): assert version_data is not None assert version_data[VersionUtils.VERSIONFILE_ATTR__NAME] == VersionUtils._prefix_version_name(TEST__VERSION_NAME) assert version_data[VersionUtils.VERSIONFILE_ATTR__FORK] == TEST__VERSION_FORK + assert version_data[VersionUtils.VERSIONFILE_ATTR__SHORT_COMMIT_HASH] == TEST__SHORT_COMMIT_HASH assert version_data[VersionUtils.VERSIONFILE_ATTR__TIMESTAMP] == TEST__VERSION_TIMESTAMP.isoformat() - assert version_data[VersionUtils.VERSIONFILE_ATTR__COMMIT_HASH] == TEST__SHORT_COMMIT_HASH def test__read_version_file__missing(self): @@ -277,23 +276,14 @@ class TestVersionUtils_VersionFile(VersionBaseTest): # The upstream dependent calls should also return None assert VersionUtils._get_version_name_from_version_file() is None assert VersionUtils._get_version_fork_from_version_file() is None - assert VersionUtils._get_version_timestamp_from_version_file() is None assert VersionUtils._get_short_commit_hash_from_version_file() is None + assert VersionUtils._get_version_timestamp_from_version_file() is None # Gracefully handle other unexpected exceptions with patch("builtins.open", side_effect=Exception("Unexpected error")): assert VersionUtils._read_version_file() is None - def test__get_version_name_from_seedsigner_os_builder_env_var(self): - assert os.environ.get(VersionUtils.ENV_VAR__SEEDSIGNER_OS_BUILDER__VERSION_NAME) is None - assert VersionUtils._get_version_name_from_seedsigner_os_builder_env_var() is None - - with mock.patch.dict(os.environ, {VersionUtils.ENV_VAR__SEEDSIGNER_OS_BUILDER__VERSION_NAME: TEST__VERSION_NAME}): - result = VersionUtils._get_version_name_from_seedsigner_os_builder_env_var() - assert result == TEST__VERSION_NAME - - class TestVersionUtils_GithubActions(VersionBaseTest): def test_github_actions_env_vars(self): @@ -305,9 +295,9 @@ class TestVersionUtils_GithubActions(VersionBaseTest): pulled from the appropriate env vars. """ # CI uses some limited `git` shell calls; mock out the associated calls. - with mock.patch("seedsigner.helpers.version.VersionUtils._get_version_timestamp_from_git_shell", return_value=TEST__VERSION_TIMESTAMP): + with patch("seedsigner.helpers.version.VersionUtils._get_version_timestamp_from_git_shell", return_value=TEST__VERSION_TIMESTAMP): # When running CI on a branch - with mock.patch.dict(os.environ, { + with patch.dict(os.environ, { VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "true", VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: TEST__VERSION_BRANCH, VersionUtils.ENV_VAR__GITHUB_ACTIONS__SHA: TEST__FULL_COMMIT_HASH, @@ -319,7 +309,7 @@ class TestVersionUtils_GithubActions(VersionBaseTest): assert VersionUtils.get_version_timestamp() == TEST__VERSION_TIMESTAMP # When running CI on a semantic tag - with mock.patch.dict(os.environ, { + with patch.dict(os.environ, { VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "true", VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: TEST__SEMANTIC_TAG, }): @@ -327,7 +317,7 @@ class TestVersionUtils_GithubActions(VersionBaseTest): assert VersionUtils.get_version_name() == f"v{TEST__SEMANTIC_TAG}" # When running CI on a generic tag - with mock.patch.dict(os.environ, { + with patch.dict(os.environ, { VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "true", VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: TEST__VERSION_TAG, }): @@ -337,7 +327,7 @@ class TestVersionUtils_GithubActions(VersionBaseTest): # When running CI on a commit (detached HEAD) with no REF_NAME, the # version_name should be the short commit hash. # TODO: I don't think this scenario ever happens. - with mock.patch.dict(os.environ, { + with patch.dict(os.environ, { VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "true", VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: "", VersionUtils.ENV_VAR__GITHUB_ACTIONS__SHA: TEST__FULL_COMMIT_HASH, @@ -348,7 +338,7 @@ class TestVersionUtils_GithubActions(VersionBaseTest): # raise error. # Note: This scenario definitely would never happen. Just trying to get to # 100% test coverage. - with mock.patch.dict(os.environ, { + with patch.dict(os.environ, { VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "true", VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: "", VersionUtils.ENV_VAR__GITHUB_ACTIONS__SHA: "", @@ -365,13 +355,13 @@ class TestVersionUtils_GithubActions(VersionBaseTest): Note: Have to mock a value for ALL scenario variations here because this test will actually run in Github Actions so the env var will already be present! """ - with mock.patch.dict(os.environ, {VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "true"}): + with patch.dict(os.environ, {VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "true"}): assert VersionUtils.is_github_actions_ci() is True - with mock.patch.dict(os.environ, {VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "false"}): + with patch.dict(os.environ, {VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "false"}): assert VersionUtils.is_github_actions_ci() is False - with mock.patch.dict(os.environ, {VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "1"}): + with patch.dict(os.environ, {VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "1"}): assert VersionUtils.is_github_actions_ci() is False @@ -381,7 +371,7 @@ class TestVersionUtils_GithubActions(VersionBaseTest): env vars when set. """ # Need to signal that we're in a GitHub Actions CI environment - with mock.patch.dict(os.environ, { + with patch.dict(os.environ, { VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "true", VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: "", VersionUtils.ENV_VAR__GITHUB_ACTIONS__SHA: "", @@ -389,12 +379,12 @@ class TestVersionUtils_GithubActions(VersionBaseTest): assert VersionUtils._get_version_name_from_github_actions_env_vars() is None # REF_NAME should be passed straight through - with mock.patch.dict(os.environ, {VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: TEST__VERSION_NAME}): + with patch.dict(os.environ, {VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: TEST__VERSION_NAME}): result = VersionUtils._get_version_name_from_github_actions_env_vars() assert result == TEST__VERSION_NAME # Unlikely scenario: no REF_NAME but SHA is set; should return short commit hash - with mock.patch.dict(os.environ, { + with patch.dict(os.environ, { VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: "", VersionUtils.ENV_VAR__GITHUB_ACTIONS__SHA: TEST__FULL_COMMIT_HASH, }): @@ -410,30 +400,40 @@ class TestVersionUtils_SeedSignerOSBuilder(VersionBaseTest): since all the downstream helper methods are tested in detail elsewhere. """ # Simulate running in the SeedSigner OS build environment - with mock.patch.dict(os.environ, {VersionUtils.ENV_VAR__SEEDSIGNER_OS_BUILDER__VERSION_NAME: TEST__VERSION_NAME}): + with patch.dict(os.environ, {VersionUtils.ENV_VAR__IS_SEEDSIGNER_OS_BUILDER: "1"}): # SeedSigner OS builder uses some limited `git` shell calls; mock out the # associated calls. - with mock.patch.multiple( + with patch.multiple( "seedsigner.helpers.version.VersionUtils", - _get_version_timestamp_from_git_shell=Mock(return_value=TEST__VERSION_TIMESTAMP), + _get_version_name_from_git_shell=Mock(return_value=TEST__VERSION_NAME), _get_version_fork_from_git_shell=Mock(return_value=TEST__VERSION_FORK), _get_full_commit_hash_from_git_shell=Mock(return_value=TEST__FULL_COMMIT_HASH), + _get_version_timestamp_from_git_shell=Mock(return_value=TEST__VERSION_TIMESTAMP), ): - assert VersionUtils.get_version_name() == VersionUtils._prefix_version_name(TEST__VERSION_NAME) - assert VersionUtils.get_version_fork() == TEST__VERSION_FORK - assert VersionUtils.get_short_commit_hash() == TEST__SHORT_COMMIT_HASH - assert VersionUtils.get_version_timestamp() == TEST__VERSION_TIMESTAMP + # Meanwhile, mock out the timestamp from source files and ensure it's NOT used + with patch.object( + VersionUtils, + "_get_last_modified_timestamp_from_src_files", + Mock(return_value=datetime.now()) + ): + assert VersionUtils.get_version_name() == VersionUtils._prefix_version_name(TEST__VERSION_NAME) + assert VersionUtils.get_version_fork() == TEST__VERSION_FORK + assert VersionUtils.get_short_commit_hash() == TEST__SHORT_COMMIT_HASH + assert VersionUtils.get_version_timestamp() == TEST__VERSION_TIMESTAMP + + # Verify that the source file timestamp method was NOT called + VersionUtils._get_last_modified_timestamp_from_src_files.assert_not_called() def test_is_seedsigner_os_builder_env(self): """ is_seedsigner_os_builder_env should return True only when the - ENV_VAR__SEEDSIGNER_OS_BUILDER__VERSION_NAME env var is set. + ENV_VAR__IS_SEEDSIGNER_OS_BUILDER env var is set. """ - with mock.patch.dict(os.environ, {}, clear=True): + with patch.dict(os.environ, {}, clear=True): assert VersionUtils._is_seedsigner_os_builder_env() is False - with mock.patch.dict(os.environ, {VersionUtils.ENV_VAR__SEEDSIGNER_OS_BUILDER__VERSION_NAME: TEST__VERSION_NAME}): + with patch.dict(os.environ, {VersionUtils.ENV_VAR__IS_SEEDSIGNER_OS_BUILDER: "1"}): assert VersionUtils._is_seedsigner_os_builder_env() is True @@ -450,17 +450,29 @@ class TestVersionUtils_GitShell(VersionBaseTest): Note that in local dev we use the last modified timestamp from the source files rather than from git. """ - with mock.patch.multiple( + with patch.multiple( "seedsigner.helpers.version.VersionUtils", _get_version_name_from_git_shell=Mock(return_value=TEST__VERSION_BRANCH), _get_version_fork_from_git_shell=Mock(return_value=TEST__VERSION_FORK), - _get_last_modified_timestamp_from_src_files=Mock(return_value=TEST__VERSION_TIMESTAMP), _get_full_commit_hash_from_git_shell=Mock(return_value=TEST__SHORT_COMMIT_HASH), + _get_last_modified_timestamp_from_src_files=Mock(return_value=TEST__VERSION_TIMESTAMP), ): - assert VersionUtils.get_version_name() == TEST__VERSION_BRANCH - assert VersionUtils.get_version_fork() == TEST__VERSION_FORK - assert VersionUtils.get_version_timestamp() == TEST__VERSION_TIMESTAMP - assert VersionUtils.get_short_commit_hash() == TEST__SHORT_COMMIT_HASH + # Mock out the git shell call that retrieves the timestamp. + with patch( + "seedsigner.helpers.version.VersionUtils._get_version_timestamp_from_git_shell", + return_value=None + ) as mock_timestamp_from_git_shell: + # Verify that we're using the local dev code path + assert VersionUtils.is_github_actions_ci() is False + assert VersionUtils._is_seedsigner_os_builder_env() is False + + assert VersionUtils.get_version_name() == TEST__VERSION_BRANCH + assert VersionUtils.get_version_fork() == TEST__VERSION_FORK + assert VersionUtils.get_short_commit_hash() == TEST__SHORT_COMMIT_HASH + assert VersionUtils.get_version_timestamp() == TEST__VERSION_TIMESTAMP + + # Verify that the git shell timestamp method was NOT called + mock_timestamp_from_git_shell.assert_not_called() def test__get_version_name_from_git_shell(self): @@ -474,34 +486,36 @@ class TestVersionUtils_GitShell(VersionBaseTest): assert result is None # If we're on a branch, should return the branch name - with mock.patch.multiple( + with patch.multiple( "seedsigner.helpers.version.VersionUtils", - _get_version_name_from_git_shell_branch=Mock(return_value=TEST__VERSION_BRANCH), - _get_version_name_from_git_shell_tag=Mock(return_value=TEST__VERSION_TAG), + _get_branch_name_from_git_shell=Mock(return_value=TEST__VERSION_BRANCH), + _get_tag_name_from_git_shell=Mock(return_value=TEST__VERSION_TAG), _get_full_commit_hash_from_git_shell=Mock(return_value=TEST__FULL_COMMIT_HASH), ): result = VersionUtils._get_version_name_from_git_shell() assert result == TEST__VERSION_BRANCH # If we're on a tag, the detached HEAD state wipes out the branch name - with mock.patch.multiple( + with patch.multiple( "seedsigner.helpers.version.VersionUtils", - _get_version_name_from_git_shell_branch=Mock(return_value=None), - _get_version_name_from_git_shell_tag=Mock(return_value=TEST__VERSION_TAG), + _get_branch_name_from_git_shell=Mock(return_value=None), + _get_tag_name_from_git_shell=Mock(return_value=TEST__VERSION_TAG), _get_full_commit_hash_from_git_shell=Mock(return_value=TEST__FULL_COMMIT_HASH), ): result = VersionUtils._get_version_name_from_git_shell() assert result == TEST__VERSION_TAG - # Similarly, if we're detached at a specific commit hash - with mock.patch.multiple( + # Similarly, if we're detached at a specific commit hash. + # Note: when falling back to the commit hash, we're expecting the SHORT commit + # hash. + with patch.multiple( "seedsigner.helpers.version.VersionUtils", - _get_version_name_from_git_shell_branch=Mock(return_value=None), - _get_version_name_from_git_shell_tag=Mock(return_value=None), + _get_branch_name_from_git_shell=Mock(return_value=None), + _get_tag_name_from_git_shell=Mock(return_value=None), _get_full_commit_hash_from_git_shell=Mock(return_value=TEST__FULL_COMMIT_HASH), ): result = VersionUtils._get_version_name_from_git_shell() - assert result == TEST__FULL_COMMIT_HASH + assert result == TEST__SHORT_COMMIT_HASH def test__get_version_fork_from_git_shell(self, mock_popen: Mock): @@ -509,12 +523,32 @@ class TestVersionUtils_GitShell(VersionBaseTest): Test that _get_version_fork_from_git_shell returns the expected repo owner from the remote url. """ - remote_url = "https://github.com/SeedSigner/seedsigner.git" - expected_fork = "SeedSigner" + for owner in ["SeedSigner", "seedsigner", "some-user"]: + remote_url = f"https://github.com/{owner}/seedsigner.git" - mock_popen.return_value.read.return_value = remote_url - result = VersionUtils._get_version_fork_from_git_shell() - assert result == expected_fork + mock_popen.return_value.read.return_value = remote_url + result = VersionUtils._get_version_fork_from_git_shell() + assert result == owner + + + def test__get_full_commit_hash_from_git_shell(self, mock_popen: Mock): + """ + Test that _get_full_commit_hash_from_git_shell returns the expected full commit hash. + """ + mock_popen.return_value.read.return_value = TEST__FULL_COMMIT_HASH + + result = VersionUtils._get_full_commit_hash_from_git_shell() + assert result == TEST__FULL_COMMIT_HASH + + + def test__get_short_commit_hash_from_git_shell(self, mock_popen: Mock): + """ + Test that _get_short_commit_hash_from_git_shell returns the expected short commit hash. + """ + mock_popen.return_value.read.return_value = TEST__FULL_COMMIT_HASH + + result = VersionUtils._get_short_commit_hash_from_git_shell() + assert result == TEST__SHORT_COMMIT_HASH def test__get_version_timestamp_from_git_shell(self, mock_popen: Mock): @@ -540,16 +574,6 @@ class TestVersionUtils_GitShell(VersionBaseTest): assert VersionUtils._get_version_timestamp_from_git_shell() == expected_datetime - def test__get_full_commit_hash_from_git_shell(self, mock_popen: Mock): - """ - Test that _get_full_commit_hash_from_git_shell returns the expected short commit hash. - """ - mock_popen.return_value.read.return_value = TEST__SHORT_COMMIT_HASH - - result = VersionUtils._get_full_commit_hash_from_git_shell() - assert result == TEST__SHORT_COMMIT_HASH - - class TestVersionUtils_DotGitFiles(VersionBaseTest): def test_local_dev_with_dot_git_dir_parsing(self, mock_popen: Mock): @@ -567,37 +591,50 @@ class TestVersionUtils_DotGitFiles(VersionBaseTest): """ # If we're on a branch, getting the commit hash requires the # .git/refs/heads/ file. - with mock.patch.multiple( + with patch.multiple( "seedsigner.helpers.version.VersionUtils", _read_git_HEAD_file=Mock(return_value=(TEST__VERSION_BRANCH, None)), _get_full_commit_hash_from_git_refs_heads=Mock(return_value=TEST__FULL_COMMIT_HASH), _get_version_fork_from_git_config=Mock(return_value=TEST__VERSION_FORK), _get_last_modified_timestamp_from_src_files=Mock(return_value=TEST__VERSION_TIMESTAMP), ): - assert VersionUtils.get_version_name() == TEST__VERSION_BRANCH - assert VersionUtils.get_version_fork() == TEST__VERSION_FORK - assert VersionUtils.get_version_timestamp() == TEST__VERSION_TIMESTAMP - assert VersionUtils.get_short_commit_hash() == TEST__SHORT_COMMIT_HASH + # Mock out the git shell call that retrieves the timestamp. + with patch( + "seedsigner.helpers.version.VersionUtils._get_version_timestamp_from_git_shell", + return_value=None + ) as mock_timestamp_from_git_shell: + # Verify that we're using the local dev code path + assert VersionUtils.is_github_actions_ci() is False + assert VersionUtils._is_seedsigner_os_builder_env() is False - # If we're on a tag (detached HEAD), getting the commit hash requires - # checking the refs/tags for a matching tag. - with mock.patch.multiple( - "seedsigner.helpers.version.VersionUtils", - _read_git_HEAD_file=Mock(return_value=(None, TEST__FULL_COMMIT_HASH)), - _get_matching_tag_from_git_refs_tags=Mock(return_value=TEST__VERSION_TAG), - ): - assert VersionUtils.get_version_name() == TEST__VERSION_TAG - assert VersionUtils.get_short_commit_hash() == TEST__SHORT_COMMIT_HASH + assert VersionUtils.get_version_name() == TEST__VERSION_BRANCH + assert VersionUtils.get_version_fork() == TEST__VERSION_FORK + assert VersionUtils.get_version_timestamp() == TEST__VERSION_TIMESTAMP + assert VersionUtils.get_short_commit_hash() == TEST__SHORT_COMMIT_HASH - # If we're on a commit hash (detached HEAD) with no matching tag, the - # version name should be the short commit hash. - with mock.patch.multiple( - "seedsigner.helpers.version.VersionUtils", - _read_git_HEAD_file=Mock(return_value=(None, TEST__FULL_COMMIT_HASH)), - _get_matching_tag_from_git_refs_tags=Mock(return_value=None), - ): - assert VersionUtils.get_version_name() == TEST__SHORT_COMMIT_HASH - assert VersionUtils.get_short_commit_hash() == TEST__SHORT_COMMIT_HASH + # If we're on a tag (detached HEAD), getting the commit hash requires + # checking the refs/tags for a matching tag. + with patch.multiple( + "seedsigner.helpers.version.VersionUtils", + _read_git_HEAD_file=Mock(return_value=(None, TEST__FULL_COMMIT_HASH)), + _get_matching_tag_from_git_refs_tags=Mock(return_value=TEST__VERSION_TAG), + ): + assert VersionUtils.get_version_name() == TEST__VERSION_TAG + assert VersionUtils.get_short_commit_hash() == TEST__SHORT_COMMIT_HASH + + # If we're on a commit hash (detached HEAD) with no matching tag, the + # version name should be the short commit hash. + with patch.multiple( + "seedsigner.helpers.version.VersionUtils", + _read_git_HEAD_file=Mock(return_value=(None, TEST__FULL_COMMIT_HASH)), + _get_matching_tag_from_git_refs_tags=Mock(return_value=None), + ): + assert VersionUtils.get_version_name() == TEST__SHORT_COMMIT_HASH + assert VersionUtils.get_short_commit_hash() == TEST__SHORT_COMMIT_HASH + + # Verify that through all of this that the git shell timestamp method was + # NOT called. + mock_timestamp_from_git_shell.assert_not_called() def test__get_dot_git_dir(self): @@ -677,17 +714,17 @@ class TestVersionUtils_DotGitFiles(VersionBaseTest): # We already tested _read_git_HEAD_file() above, so just mock scenarios here. # On a branch: - with mock.patch.object(VersionUtils, '_read_git_HEAD_file', return_value=(TEST__VERSION_BRANCH, None)): + with patch.object(VersionUtils, '_read_git_HEAD_file', return_value=(TEST__VERSION_BRANCH, None)): assert VersionUtils._get_version_name_from_git_HEAD() == TEST__VERSION_BRANCH # Detached HEAD at commit hash, with matching tag - with mock.patch.object(VersionUtils, '_read_git_HEAD_file', return_value=(None, TEST__FULL_COMMIT_HASH)): - with mock.patch.object(VersionUtils, '_get_matching_tag_from_git_refs_tags', return_value=TEST__VERSION_NAME): + with patch.object(VersionUtils, '_read_git_HEAD_file', return_value=(None, TEST__FULL_COMMIT_HASH)): + with patch.object(VersionUtils, '_get_matching_tag_from_git_refs_tags', return_value=TEST__VERSION_NAME): assert VersionUtils._get_version_name_from_git_HEAD() == TEST__VERSION_NAME # Detached HEAD at commit hash, no matching tag; returns the short commit hash - with mock.patch.object(VersionUtils, '_read_git_HEAD_file', return_value=(None, TEST__FULL_COMMIT_HASH)): - with mock.patch.object(VersionUtils, '_get_matching_tag_from_git_refs_tags', return_value=None): + with patch.object(VersionUtils, '_read_git_HEAD_file', return_value=(None, TEST__FULL_COMMIT_HASH)): + with patch.object(VersionUtils, '_get_matching_tag_from_git_refs_tags', return_value=None): assert VersionUtils._get_version_name_from_git_HEAD() == TEST__SHORT_COMMIT_HASH @@ -695,7 +732,7 @@ class TestVersionUtils_DotGitFiles(VersionBaseTest): # _get_commit_hash_from_git_HEAD is a trivial convenience function that relies on # _read_git_HEAD_file which we've already tested. Just verify the expected outputs # here. - with mock.patch.object(VersionUtils, '_read_git_HEAD_file', return_value=(None, TEST__SHORT_COMMIT_HASH)): + with patch.object(VersionUtils, '_read_git_HEAD_file', return_value=(None, TEST__SHORT_COMMIT_HASH)): assert VersionUtils._get_full_commit_hash_from_git_HEAD() == TEST__SHORT_COMMIT_HASH @@ -759,7 +796,7 @@ class TestVersionUtils_DotGitFiles(VersionBaseTest): [next_section] next_key = next_value """ - with mock.patch("builtins.open", mock.mock_open(read_data=git_config)): + with patch("builtins.open", mock.mock_open(read_data=git_config)): assert VersionUtils._get_version_fork_from_git_config() == expected_fork # Missing origin section should return None @@ -769,7 +806,7 @@ class TestVersionUtils_DotGitFiles(VersionBaseTest): [next_section] next_key = next_value """ - with mock.patch("builtins.open", mock.mock_open(read_data=git_config_no_origin)): + with patch("builtins.open", mock.mock_open(read_data=git_config_no_origin)): assert VersionUtils._get_version_fork_from_git_config() is None # Handle malformed origin section (can't find url) should return None @@ -779,11 +816,11 @@ class TestVersionUtils_DotGitFiles(VersionBaseTest): [next_section] next_key = next_value """ - with mock.patch("builtins.open", mock.mock_open(read_data=git_config_malformed_origin)): + with patch("builtins.open", mock.mock_open(read_data=git_config_malformed_origin)): assert VersionUtils._get_version_fork_from_git_config() is None # If the git config file is missing, should return None - with mock.patch("builtins.open", side_effect=FileNotFoundError): + with patch("builtins.open", side_effect=FileNotFoundError): assert VersionUtils._get_version_fork_from_git_config() is None @@ -802,12 +839,13 @@ class TestVersionUtils_DotGitFiles(VersionBaseTest): assert timestamp < datetime.now() + timedelta(days=30) # Now mock out os.path.getmtime to force all files to have a known timestamp - expected_timestamp = datetime(2025, 12, 23, 0, 0, 0) - with mock.patch("os.path.getmtime", return_value=expected_timestamp.timestamp()): - assert VersionUtils._get_last_modified_timestamp_from_src_files() == expected_timestamp + local_timestamp = datetime(2025, 12, 23, 0, 0, 0) + with patch("os.path.getmtime", return_value=local_timestamp.timestamp()): + # Note that the result will be converted to UTC with no tzinfo + assert VersionUtils._get_last_modified_timestamp_from_src_files() == local_timestamp.astimezone(timezone.utc).replace(tzinfo=None) # Mock out os.walk() to simulate no .py files found - with mock.patch("os.walk", return_value=[]): + with patch("os.walk", return_value=[]): assert VersionUtils._get_last_modified_timestamp_from_src_files() is None @@ -833,7 +871,7 @@ class TestVersionUtils_Misc(VersionBaseTest): fake_response.status = 200 fake_response.read.return_value = json.dumps(latest_releases_response_dict).encode('utf-8') - with mock.patch("urllib.request.urlopen", return_value=fake_response): + with patch("urllib.request.urlopen", return_value=fake_response): release_tag, release_timestamp = VersionUtils._fetch_latest_seedsigner_release_tag() # mock_popen returns empty string, which mimics not having local git data to @@ -859,13 +897,13 @@ class TestVersionUtils_Misc(VersionBaseTest): # Should gracefully handle HTTP errors fake_error_response = Mock() fake_error_response.status = 404 - with mock.patch("urllib.request.urlopen", return_value=fake_error_response): + with patch("urllib.request.urlopen", return_value=fake_error_response): release_tag, release_timestamp = VersionUtils._fetch_latest_seedsigner_release_tag() assert release_tag is None assert release_timestamp is None # And other exceptions - with mock.patch("urllib.request.urlopen", side_effect=Exception("Network error")): + with patch("urllib.request.urlopen", side_effect=Exception("Network error")): release_tag, release_timestamp = VersionUtils._fetch_latest_seedsigner_release_tag() assert release_tag is None assert release_timestamp is None @@ -884,7 +922,7 @@ class TestVersion(VersionBaseTest): with patch("seedsigner.models.settings.Settings.HOSTNAME", Settings.SEEDSIGNER_OS): Version.get_version_name() == TEST__VERSION_DICT[VersionUtils.VERSIONFILE_ATTR__NAME] Version.get_version_fork() == TEST__VERSION_DICT[VersionUtils.VERSIONFILE_ATTR__FORK] - Version.get_short_commit_hash() == TEST__VERSION_DICT[VersionUtils.VERSIONFILE_ATTR__COMMIT_HASH] + Version.get_short_commit_hash() == TEST__VERSION_DICT[VersionUtils.VERSIONFILE_ATTR__SHORT_COMMIT_HASH] Version.get_version_timestamp() == TEST__VERSION_TIMESTAMP @@ -893,17 +931,12 @@ class TestVersion(VersionBaseTest): Test that we can override the version data via the Version.override_version_data() method. """ - override_name = "v9.9.9-test" - override_fork = "TestFork" - override_commit_hash = "abcd123" - override_timestamp = datetime(2030, 1, 1, 0, 0, 0) - # Initially the version data is pulled from the usual sources self.write_test_version_file() with patch("seedsigner.models.settings.Settings.HOSTNAME", Settings.SEEDSIGNER_OS): assert Version.get_version_name() == TEST__VERSION_DICT[VersionUtils.VERSIONFILE_ATTR__NAME] assert Version.get_version_fork() == TEST__VERSION_DICT[VersionUtils.VERSIONFILE_ATTR__FORK] - assert Version.get_short_commit_hash() == TEST__VERSION_DICT[VersionUtils.VERSIONFILE_ATTR__COMMIT_HASH] + assert Version.get_short_commit_hash() == TEST__VERSION_DICT[VersionUtils.VERSIONFILE_ATTR__SHORT_COMMIT_HASH] assert Version.get_version_timestamp() == TEST__VERSION_TIMESTAMP # While we're in the mocked SeedSigner OS environment, verify that the @@ -911,19 +944,33 @@ class TestVersion(VersionBaseTest): with pytest.raises(NotAllowedInSeedSignerOS): Version.override_data() + override_name = "v9.9.9-test" + override_fork = "TestFork" + override_commit_hash = "abcd123" + override_timestamp = datetime(2030, 1, 1, 0, 0, 0) + override_dict = { + VersionUtils.VERSIONFILE_ATTR__NAME: override_name, + VersionUtils.VERSIONFILE_ATTR__FORK: override_fork, + VersionUtils.VERSIONFILE_ATTR__SHORT_COMMIT_HASH: override_commit_hash, + VersionUtils.VERSIONFILE_ATTR__TIMESTAMP: override_timestamp, + } + # No longer in the mocked SeedSigner OS environment; should be allowed now. - Version.override_data( - version_name=override_name, - version_fork=override_fork, - short_commit_hash=override_commit_hash, - version_timestamp=override_timestamp, - ) + Version.override_data(**override_dict) assert Version.get_version_name() == override_name assert Version.get_version_fork() == override_fork assert Version.get_short_commit_hash() == override_commit_hash assert Version.get_version_timestamp() == override_timestamp + # No overrides specified should result in no values changed. + Version.override_data() + assert Version.get_version_name() == override_name + assert Version.get_version_fork() == override_fork + assert Version.get_short_commit_hash() == override_commit_hash + assert Version.get_version_timestamp() == override_timestamp + + def test_to_dict(self): """ @@ -931,8 +978,25 @@ class TestVersion(VersionBaseTest): """ self.write_test_version_file() with patch("seedsigner.models.settings.Settings.HOSTNAME", Settings.SEEDSIGNER_OS): + # In SeedSigner OS, the version data comes from the version file. assert Version.to_dict() == TEST__VERSION_DICT + extra_attr = "extra" + different_dict = { + VersionUtils.VERSIONFILE_ATTR__NAME: "v1.2.3-different", + VersionUtils.VERSIONFILE_ATTR__FORK: "DifferentFork", + VersionUtils.VERSIONFILE_ATTR__SHORT_COMMIT_HASH: "differenthash", + VersionUtils.VERSIONFILE_ATTR__TIMESTAMP: datetime(2026, 1, 1, 0, 0, 0), # stored internally as a datetime + extra_attr: "should be ignored", + } + Version.override_data(**different_dict) + output_dict = Version.to_dict() + assert output_dict[VersionUtils.VERSIONFILE_ATTR__NAME] == different_dict[VersionUtils.VERSIONFILE_ATTR__NAME] + assert output_dict[VersionUtils.VERSIONFILE_ATTR__FORK] == different_dict[VersionUtils.VERSIONFILE_ATTR__FORK] + assert output_dict[VersionUtils.VERSIONFILE_ATTR__SHORT_COMMIT_HASH] == different_dict[VersionUtils.VERSIONFILE_ATTR__SHORT_COMMIT_HASH] + assert output_dict[VersionUtils.VERSIONFILE_ATTR__TIMESTAMP] == different_dict[VersionUtils.VERSIONFILE_ATTR__TIMESTAMP].isoformat() # returned as a string + assert extra_attr not in output_dict + class TestNotAllowedInSeedSignerOSDecorator(BaseTest): @@ -949,14 +1013,14 @@ class TestNotAllowedInSeedSignerOSDecorator(BaseTest): if we run a decorated function while in SeedSigner OS. """ # Patch over the Settings.HOSTNAME value to simulate running in SeedSigner OS - with mock.patch("seedsigner.models.settings.Settings.HOSTNAME", Settings.SEEDSIGNER_OS): + with patch("seedsigner.models.settings.Settings.HOSTNAME", Settings.SEEDSIGNER_OS): with pytest.raises(NotAllowedInSeedSignerOS): self.dummy_function() def test_allowed_outside_seedsigner_os(self): # Now try with any other HOSTNAME - with mock.patch("seedsigner.models.settings.Settings.HOSTNAME", "my_dev_machine"): + with patch("seedsigner.models.settings.Settings.HOSTNAME", "my_dev_machine"): assert self.dummy_function() == self.SUCCESS diff --git a/tools/write_versionfile.py b/tools/write_versionfile.py index 14323b5a..4bf4cf19 100644 --- a/tools/write_versionfile.py +++ b/tools/write_versionfile.py @@ -8,53 +8,40 @@ from seedsigner.helpers.version import Version, VersionUtils CLI utility to extract the current version data and write to `src/seedsigner/version.json`. Primarily used by the SeedSigner OS build process. -SeedSigner OS lifecycle: - * Build process runs this script to generate version.json. - * version.json is included in the SeedSigner OS image. - * SeedSigner OS reads version.json at runtime. - Notes: * The SeedSigner OS build environment already relies on `git` being installed. - * This script can also be run in local dev but `git` shell commands are required. + * This script can also be run in local dev but note slight difference in how + version_timestamp is gathered. + * Tries to pull current version status via `git` shell commands, but has fallbacks + to directly parse .git/ files. Version data: - * version_name: - * Check for the SEEDSIGNER_VERSION_NAME env var (provided in SeedSigner OS build - env). - * Will be the branch, tag, or commit hash being built. - * If running in local dev instead, this script will try to populate that env var - using `git` shell commands: - * Current git branch name - * Current git tag name - * Current git commit hash + * version_name: Retrieves the current git status by checking, in order: + * Current git branch name + * Current git tag name + * Current git commit hash * version_fork: - * Pulls the current repo owner from the `git remote` shell command. - - * version_timestamp: - * Pulls last git commit time from `git log`. + * The current repo owner. * short_commit_hash: - * Pulls current git commit hash from `git` shell command. + * Current git short commit hash. + + * version_timestamp: + * SeedSigner OS builder: Pulls last git commit time for the checked out + branch/tag/commit. + * Local dev: Scans the source python files for the most recent modified time. + """ if __name__ == "__main__": - is_seedsigner_os_builder = VersionUtils._is_seedsigner_os_builder_env() - - if not is_seedsigner_os_builder: - # Pull version_name from the current git state via `git` shell commands - version_name = VersionUtils._get_version_name_from_git_shell() - - # Temporarily set the env var - os.environ[VersionUtils.ENV_VAR__SEEDSIGNER_OS_BUILDER__VERSION_NAME] = version_name - + # As soon as `Version` is instantiated, it will gather all the version data + # according to the logic in `VersionUtils`. version_info = Version.get_instance().to_dict() + + # Write the version.json file. version_file_path = VersionUtils._get_version_file_path() with open(version_file_path, "w") as f: json.dump(version_info, f, indent=4) print(f"Wrote version info to: {version_file_path}") print(json.dumps(version_info, indent=4)) - - # Clean up the temp env var if needed - if not is_seedsigner_os_builder: - del os.environ[VersionUtils.ENV_VAR__SEEDSIGNER_OS_BUILDER__VERSION_NAME] From 3394d0cb7cde59f7345ce895284db26eda969741 Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Fri, 26 Dec 2025 13:56:32 -0600 Subject: [PATCH 41/50] Github Actions CI timestamp format bugfix --- src/seedsigner/helpers/version.py | 7 +++++++ tests/test_version.py | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/src/seedsigner/helpers/version.py b/src/seedsigner/helpers/version.py index b228dada..e9d71fc8 100644 --- a/src/seedsigner/helpers/version.py +++ b/src/seedsigner/helpers/version.py @@ -550,6 +550,13 @@ class VersionUtils: def _get_version_timestamp_from_git_shell(cls) -> datetime | None: version_timestamp = os.popen("git log -1 --format=%cI").read().strip() if version_timestamp: + if version_timestamp.endswith("Z"): + # Git outputs UTC time with a "Z" suffix; datetime.fromisoformat() + # doesn't like the "Z" so we replace it with "+00:00" + # TODO: Python 3.11+ has fromisoformat() support for "Z" suffixes so this + # can be removed once we drop python 3.10 support. + version_timestamp = version_timestamp.replace("Z", "+00:00") + # Parse the timestamp, ensure that it's in UTC, and omit tz info return datetime.fromisoformat(version_timestamp).astimezone(timezone.utc).replace(tzinfo=None) diff --git a/tests/test_version.py b/tests/test_version.py index 34119423..9a1e40ee 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -573,6 +573,13 @@ class TestVersionUtils_GitShell(VersionBaseTest): mock_popen.return_value.read.return_value = expected_datetime.isoformat() + "+00:00" assert VersionUtils._get_version_timestamp_from_git_shell() == expected_datetime + # Github Actions CI timestamps return "Z" format + expected_utc_isoformat = "2025-12-26T19:41:49" + github_format = expected_utc_isoformat + "Z" + mock_popen.return_value.read.return_value = github_format + expected_datetime = datetime.fromisoformat(expected_utc_isoformat) + assert VersionUtils._get_version_timestamp_from_git_shell() == expected_datetime + class TestVersionUtils_DotGitFiles(VersionBaseTest): From e08442ddd604403c6e6f0e7f745adcdeab2f2ccb Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Fri, 26 Dec 2025 14:33:20 -0600 Subject: [PATCH 42/50] Github Actions CI env var improvements --- .github/workflows/tests.yml | 1 + src/seedsigner/helpers/version.py | 38 ++++++++++++--- tests/test_version.py | 79 ++++++++++++++++++++++++++----- 3 files changed, 99 insertions(+), 19 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c1e14c97..b4165fd8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -39,6 +39,7 @@ jobs: pip install . - name: Test with pytest run: | + export PR_AUTHOR="${{ github.event.pull_request.user.login || github.actor }}" mkdir artifacts python -m pytest \ --color=yes \ diff --git a/src/seedsigner/helpers/version.py b/src/seedsigner/helpers/version.py index e9d71fc8..cc65e4fa 100644 --- a/src/seedsigner/helpers/version.py +++ b/src/seedsigner/helpers/version.py @@ -193,8 +193,10 @@ class VersionUtils: ENV_VAR__IS_SEEDSIGNER_OS_BUILDER = "SEEDSIGNER_OS_BUILDER" ENV_VAR__GITHUB_ACTIONS__IS_CI = "CI" + ENV_VAR__GITHUB_ACTIONS__HEAD_REF = "GITHUB_HEAD_REF" ENV_VAR__GITHUB_ACTIONS__REF_NAME = "GITHUB_REF_NAME" ENV_VAR__GITHUB_ACTIONS__SHA = "GITHUB_SHA" + ENV_VAR__GITHUB_ACTIONS__PR_AUTHOR = "PR_AUTHOR" ENV_VAR__GITHUB_ACTIONS__REPOSITORY_OWNER = "GITHUB_REPOSITORY_OWNER" DOT_GIT_DIR_NAME = ".git" # defined to facilitate mocking in tests VERSIONFILE__FILENAME = "version.json" @@ -264,7 +266,7 @@ class VersionUtils: return VersionUtils._get_version_fork_from_version_file() elif VersionUtils.is_github_actions_ci(): - # In Github Actions CI, try to get the version name from env vars + # In Github Actions CI, try to get the fork name from env vars. return VersionUtils._get_version_fork_from_github_actions_env_vars() else: @@ -459,16 +461,38 @@ class VersionUtils: @classmethod def _get_version_name_from_github_actions_env_vars(cls) -> str | None: - # REF_NAME will be the branch or tag name; SHA is the full commit hash - # TODO: Will REF_NAME ever be missing? - ref_name = os.getenv(cls.ENV_VAR__GITHUB_ACTIONS__REF_NAME) - sha = os.getenv(cls.ENV_VAR__GITHUB_ACTIONS__SHA) - return ref_name or (sha[:7] if sha else None) + """ + HEAD_REF: head ref or source branch. But only present for PRs. + REF_NAME: branch or tag name. But it is "/merge" for unmerged PRs (not + what we want) + SHA is the full commit hash; not expecting to ever need this fallback. + """ + for env_var in [ + cls.ENV_VAR__GITHUB_ACTIONS__HEAD_REF, + cls.ENV_VAR__GITHUB_ACTIONS__REF_NAME, + cls.ENV_VAR__GITHUB_ACTIONS__SHA + ]: + version_name = os.getenv(env_var) + if version_name: + if env_var == cls.ENV_VAR__GITHUB_ACTIONS__SHA: + # Return the short version + version_name = version_name[:7] + return version_name @classmethod def _get_version_fork_from_github_actions_env_vars(cls) -> str | None: - return os.getenv("GITHUB_REPOSITORY_OWNER") + """ + PR_AUTHOR: Set by the .github/workflows/tests.yml workflow. Should be the PR author. + REPOSITORY_OWNER: the repo owner, usually the main "SeedSigner" org. + """ + for env_var in [ + cls.ENV_VAR__GITHUB_ACTIONS__PR_AUTHOR, + cls.ENV_VAR__GITHUB_ACTIONS__REPOSITORY_OWNER + ]: + fork_name = os.getenv(env_var) + if fork_name: + return fork_name @classmethod diff --git a/tests/test_version.py b/tests/test_version.py index 9a1e40ee..8c15444b 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -365,31 +365,86 @@ class TestVersionUtils_GithubActions(VersionBaseTest): assert VersionUtils.is_github_actions_ci() is False - def test_get_version_name_from_github_actions_env_vars(self): + def test__get_version_name_from_github_actions_env_vars(self): """ - get_version_name_from_github_actions_env_vars should return the REF_NAME or SHA - env vars when set. + _get_version_name_from_github_actions_env_vars should prioritize HEAD_REF, then + REF_NAME, then SHA env vars when set. """ + test_head_ref = "head_ref" + test_ref_name = "ref_name" + test_sha = TEST__FULL_COMMIT_HASH + # Need to signal that we're in a GitHub Actions CI environment with patch.dict(os.environ, { VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "true", + VersionUtils.ENV_VAR__GITHUB_ACTIONS__HEAD_REF: "", VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: "", VersionUtils.ENV_VAR__GITHUB_ACTIONS__SHA: "", }): + # With none of the env vars set, should return None assert VersionUtils._get_version_name_from_github_actions_env_vars() is None - # REF_NAME should be passed straight through - with patch.dict(os.environ, {VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: TEST__VERSION_NAME}): - result = VersionUtils._get_version_name_from_github_actions_env_vars() - assert result == TEST__VERSION_NAME - - # Unlikely scenario: no REF_NAME but SHA is set; should return short commit hash + # HEAD_REF should be passed straight through, ignoring other vars with patch.dict(os.environ, { - VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: "", - VersionUtils.ENV_VAR__GITHUB_ACTIONS__SHA: TEST__FULL_COMMIT_HASH, + VersionUtils.ENV_VAR__GITHUB_ACTIONS__HEAD_REF: test_head_ref, + VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: test_ref_name, + VersionUtils.ENV_VAR__GITHUB_ACTIONS__SHA: test_sha, }): result = VersionUtils._get_version_name_from_github_actions_env_vars() - assert result == TEST__SHORT_COMMIT_HASH[:7] + assert result == test_head_ref + + # REF_NAME is our next fallback + with patch.dict(os.environ, { + VersionUtils.ENV_VAR__GITHUB_ACTIONS__HEAD_REF: "", + VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: test_ref_name, + VersionUtils.ENV_VAR__GITHUB_ACTIONS__SHA: test_sha, + }): + result = VersionUtils._get_version_name_from_github_actions_env_vars() + assert result == test_ref_name + + # Unlikely scenario: no HEAD_REF nor REF_NAME but SHA is set; should return + # short commit hash + with patch.dict(os.environ, { + VersionUtils.ENV_VAR__GITHUB_ACTIONS__HEAD_REF: "", + VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: "", + VersionUtils.ENV_VAR__GITHUB_ACTIONS__SHA: test_sha, + }): + result = VersionUtils._get_version_name_from_github_actions_env_vars() + assert result == test_sha[:7] + + + def test__get_version_fork_from_github_actions_env_vars(self): + """ + _get_version_fork_from_github_actions_env_vars should return the PR_AUTHOR, then + fall back to REPOSITORY_OWNER. + """ + test_pr_author = "some_pr_author" + test_repo_owner = "some_repo_owner" + + # Need to signal that we're in a GitHub Actions CI environment + with patch.dict(os.environ, { + VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "true", + VersionUtils.ENV_VAR__GITHUB_ACTIONS__PR_AUTHOR: "", + VersionUtils.ENV_VAR__GITHUB_ACTIONS__REPOSITORY_OWNER: "", + }): + # With none of the env vars set, should return None + assert VersionUtils._get_version_fork_from_github_actions_env_vars() is None + + # PR_AUTHOR should be passed straight through, ignoring REPOSITORY_OWNER + with patch.dict(os.environ, { + VersionUtils.ENV_VAR__GITHUB_ACTIONS__PR_AUTHOR: test_pr_author, + VersionUtils.ENV_VAR__GITHUB_ACTIONS__REPOSITORY_OWNER: test_repo_owner, + }): + result = VersionUtils._get_version_fork_from_github_actions_env_vars() + assert result == test_pr_author + + # REPOSITORY_OWNER is our next fallback + with patch.dict(os.environ, { + VersionUtils.ENV_VAR__GITHUB_ACTIONS__PR_AUTHOR: "", + VersionUtils.ENV_VAR__GITHUB_ACTIONS__REPOSITORY_OWNER: test_repo_owner, + }): + result = VersionUtils._get_version_fork_from_github_actions_env_vars() + assert result == test_repo_owner From e7da2e980335e43724c032230b36ecb98bbdb182 Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Fri, 26 Dec 2025 14:42:34 -0600 Subject: [PATCH 43/50] Github Actions CI bugfix --- tests/test_version.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/tests/test_version.py b/tests/test_version.py index 8c15444b..e4e1ecf3 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -299,9 +299,9 @@ class TestVersionUtils_GithubActions(VersionBaseTest): # When running CI on a branch with patch.dict(os.environ, { VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "true", - VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: TEST__VERSION_BRANCH, + VersionUtils.ENV_VAR__GITHUB_ACTIONS__HEAD_REF: TEST__VERSION_BRANCH, + VersionUtils.ENV_VAR__GITHUB_ACTIONS__PR_AUTHOR: TEST__VERSION_FORK, VersionUtils.ENV_VAR__GITHUB_ACTIONS__SHA: TEST__FULL_COMMIT_HASH, - VersionUtils.ENV_VAR__GITHUB_ACTIONS__REPOSITORY_OWNER: TEST__VERSION_FORK, }): assert VersionUtils.get_version_name() == TEST__VERSION_BRANCH assert VersionUtils.get_version_fork() == TEST__VERSION_FORK @@ -311,7 +311,7 @@ class TestVersionUtils_GithubActions(VersionBaseTest): # When running CI on a semantic tag with patch.dict(os.environ, { VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "true", - VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: TEST__SEMANTIC_TAG, + VersionUtils.ENV_VAR__GITHUB_ACTIONS__HEAD_REF: TEST__SEMANTIC_TAG, }): # Should be prefixed with "v" assert VersionUtils.get_version_name() == f"v{TEST__SEMANTIC_TAG}" @@ -319,27 +319,29 @@ class TestVersionUtils_GithubActions(VersionBaseTest): # When running CI on a generic tag with patch.dict(os.environ, { VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "true", - VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: TEST__VERSION_TAG, + VersionUtils.ENV_VAR__GITHUB_ACTIONS__HEAD_REF: TEST__VERSION_TAG, }): # Should NOT be prefixed with "v" assert VersionUtils.get_version_name() == TEST__VERSION_TAG - # When running CI on a commit (detached HEAD) with no REF_NAME, the - # version_name should be the short commit hash. + # When running CI on a commit (detached HEAD) with no HEAD_REF or REF_NAME, + # the version_name should be the short commit hash. # TODO: I don't think this scenario ever happens. with patch.dict(os.environ, { VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "true", + VersionUtils.ENV_VAR__GITHUB_ACTIONS__HEAD_REF: "", VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: "", VersionUtils.ENV_VAR__GITHUB_ACTIONS__SHA: TEST__FULL_COMMIT_HASH, }): assert VersionUtils.get_version_name() == TEST__SHORT_COMMIT_HASH - # When running CI on a commit (detached HEAD) with no REF_NAME and no SHA, - # raise error. + # When running CI on a commit (detached HEAD) with no HEAD_REF orREF_NAME and + # no SHA, raise error. # Note: This scenario definitely would never happen. Just trying to get to # 100% test coverage. with patch.dict(os.environ, { VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "true", + VersionUtils.ENV_VAR__GITHUB_ACTIONS__HEAD_REF: "", VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: "", VersionUtils.ENV_VAR__GITHUB_ACTIONS__SHA: "", }): From 8ce8971085b37b8d86ed416daa55ef95baf45cc9 Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Fri, 26 Dec 2025 15:27:19 -0600 Subject: [PATCH 44/50] CI env var fix --- .github/workflows/tests.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b4165fd8..01411b00 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,6 +13,10 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} cancel-in-progress: true +env: + # Used by the `Version` class so it can identify the current fork + PR_AUTHOR: ${{ github.event.pull_request.user.login || github.actor }} + jobs: test: runs-on: ubuntu-latest @@ -39,7 +43,6 @@ jobs: pip install . - name: Test with pytest run: | - export PR_AUTHOR="${{ github.event.pull_request.user.login || github.actor }}" mkdir artifacts python -m pytest \ --color=yes \ From 4a7552c801834419378d27831c006d9466167b08 Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Fri, 26 Dec 2025 15:48:48 -0600 Subject: [PATCH 45/50] Adds Github Actions CI `SOURCE_SHA` custom env var --- .github/workflows/tests.yml | 4 ++++ src/seedsigner/helpers/version.py | 18 +++++++++++---- tests/test_version.py | 38 +++++++++++++++++++++++-------- 3 files changed, 47 insertions(+), 13 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 01411b00..b272a5e3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -17,6 +17,10 @@ env: # Used by the `Version` class so it can identify the current fork PR_AUTHOR: ${{ github.event.pull_request.user.login || github.actor }} + # Used by `Version`. We want the PR author's latest commit hash if this is a PR + # but the default SHA env var reflects a new commit into the target repo. + SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + jobs: test: runs-on: ubuntu-latest diff --git a/src/seedsigner/helpers/version.py b/src/seedsigner/helpers/version.py index cc65e4fa..17e75cc7 100644 --- a/src/seedsigner/helpers/version.py +++ b/src/seedsigner/helpers/version.py @@ -86,10 +86,10 @@ class Version(Singleton): In Github Actions CI: * version_name: read from GITHUB_REF_NAME env var. - * version_fork: read from GITHUB_REPOSITORY_OWNER env var. + * version_fork: read from PR_AUTHOR custom CI env var or GITHUB_REPOSITORY_OWNER. * version_timestamp: Shell `git` call to get the last commit time for the current branch/tag/commit. - * commit_hash: read from GITHUB_SHA env var. + * commit_hash: read from SOURCE_SHA custom CI env var or GITHUB_SHA. This class defines the limited methods that are meant to be publicly accessible across the SeedSigner codebase. @@ -195,6 +195,7 @@ class VersionUtils: ENV_VAR__GITHUB_ACTIONS__IS_CI = "CI" ENV_VAR__GITHUB_ACTIONS__HEAD_REF = "GITHUB_HEAD_REF" ENV_VAR__GITHUB_ACTIONS__REF_NAME = "GITHUB_REF_NAME" + ENV_VAR__GITHUB_ACTIONS__SOURCE_SHA = "SOURCE_SHA" ENV_VAR__GITHUB_ACTIONS__SHA = "GITHUB_SHA" ENV_VAR__GITHUB_ACTIONS__PR_AUTHOR = "PR_AUTHOR" ENV_VAR__GITHUB_ACTIONS__REPOSITORY_OWNER = "GITHUB_REPOSITORY_OWNER" @@ -465,16 +466,19 @@ class VersionUtils: HEAD_REF: head ref or source branch. But only present for PRs. REF_NAME: branch or tag name. But it is "/merge" for unmerged PRs (not what we want) + SOURCE_SHA is the full commit hash of the commit that triggered the workflow (if + it's a PR). SHA is the full commit hash; not expecting to ever need this fallback. """ for env_var in [ cls.ENV_VAR__GITHUB_ACTIONS__HEAD_REF, cls.ENV_VAR__GITHUB_ACTIONS__REF_NAME, + cls.ENV_VAR__GITHUB_ACTIONS__SOURCE_SHA, cls.ENV_VAR__GITHUB_ACTIONS__SHA ]: version_name = os.getenv(env_var) if version_name: - if env_var == cls.ENV_VAR__GITHUB_ACTIONS__SHA: + if env_var in [cls.ENV_VAR__GITHUB_ACTIONS__SOURCE_SHA, cls.ENV_VAR__GITHUB_ACTIONS__SHA]: # Return the short version version_name = version_name[:7] return version_name @@ -497,7 +501,13 @@ class VersionUtils: @classmethod def _get_full_commit_hash_from_github_actions_env_vars(cls) -> str | None: - return os.getenv(cls.ENV_VAR__GITHUB_ACTIONS__SHA) + for env_var in [ + cls.ENV_VAR__GITHUB_ACTIONS__SOURCE_SHA, + cls.ENV_VAR__GITHUB_ACTIONS__SHA + ]: + commit_hash = os.getenv(env_var) + if commit_hash: + return commit_hash diff --git a/tests/test_version.py b/tests/test_version.py index e4e1ecf3..7530563e 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -296,12 +296,15 @@ class TestVersionUtils_GithubActions(VersionBaseTest): """ # CI uses some limited `git` shell calls; mock out the associated calls. with patch("seedsigner.helpers.version.VersionUtils._get_version_timestamp_from_git_shell", return_value=TEST__VERSION_TIMESTAMP): + random_sha = "abcd1234ef567890abcd1234ef567890abcd1234" + # When running CI on a branch with patch.dict(os.environ, { VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "true", VersionUtils.ENV_VAR__GITHUB_ACTIONS__HEAD_REF: TEST__VERSION_BRANCH, VersionUtils.ENV_VAR__GITHUB_ACTIONS__PR_AUTHOR: TEST__VERSION_FORK, - VersionUtils.ENV_VAR__GITHUB_ACTIONS__SHA: TEST__FULL_COMMIT_HASH, + VersionUtils.ENV_VAR__GITHUB_ACTIONS__SOURCE_SHA: TEST__FULL_COMMIT_HASH, + VersionUtils.ENV_VAR__GITHUB_ACTIONS__SHA: random_sha, }): assert VersionUtils.get_version_name() == TEST__VERSION_BRANCH assert VersionUtils.get_version_fork() == TEST__VERSION_FORK @@ -331,18 +334,20 @@ class TestVersionUtils_GithubActions(VersionBaseTest): VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "true", VersionUtils.ENV_VAR__GITHUB_ACTIONS__HEAD_REF: "", VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: "", - VersionUtils.ENV_VAR__GITHUB_ACTIONS__SHA: TEST__FULL_COMMIT_HASH, + VersionUtils.ENV_VAR__GITHUB_ACTIONS__SOURCE_SHA: TEST__FULL_COMMIT_HASH, + VersionUtils.ENV_VAR__GITHUB_ACTIONS__SHA: random_sha, }): assert VersionUtils.get_version_name() == TEST__SHORT_COMMIT_HASH - # When running CI on a commit (detached HEAD) with no HEAD_REF orREF_NAME and - # no SHA, raise error. + # When running CI on a commit (detached HEAD) with no HEAD_REF or REF_NAME and + # no SOURCE_SHA or SHA, raise error. # Note: This scenario definitely would never happen. Just trying to get to # 100% test coverage. with patch.dict(os.environ, { VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "true", VersionUtils.ENV_VAR__GITHUB_ACTIONS__HEAD_REF: "", VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: "", + VersionUtils.ENV_VAR__GITHUB_ACTIONS__SOURCE_SHA: "", VersionUtils.ENV_VAR__GITHUB_ACTIONS__SHA: "", }): with pytest.raises(Exception): @@ -375,12 +380,14 @@ class TestVersionUtils_GithubActions(VersionBaseTest): test_head_ref = "head_ref" test_ref_name = "ref_name" test_sha = TEST__FULL_COMMIT_HASH + random_sha = "abcd1234ef567890abcd1234ef567890abcd1234" # Need to signal that we're in a GitHub Actions CI environment with patch.dict(os.environ, { VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "true", VersionUtils.ENV_VAR__GITHUB_ACTIONS__HEAD_REF: "", VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: "", + VersionUtils.ENV_VAR__GITHUB_ACTIONS__SOURCE_SHA: "", VersionUtils.ENV_VAR__GITHUB_ACTIONS__SHA: "", }): # With none of the env vars set, should return None @@ -390,7 +397,8 @@ class TestVersionUtils_GithubActions(VersionBaseTest): with patch.dict(os.environ, { VersionUtils.ENV_VAR__GITHUB_ACTIONS__HEAD_REF: test_head_ref, VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: test_ref_name, - VersionUtils.ENV_VAR__GITHUB_ACTIONS__SHA: test_sha, + VersionUtils.ENV_VAR__GITHUB_ACTIONS__SOURCE_SHA: test_sha, + VersionUtils.ENV_VAR__GITHUB_ACTIONS__SHA: random_sha, }): result = VersionUtils._get_version_name_from_github_actions_env_vars() assert result == test_head_ref @@ -399,17 +407,29 @@ class TestVersionUtils_GithubActions(VersionBaseTest): with patch.dict(os.environ, { VersionUtils.ENV_VAR__GITHUB_ACTIONS__HEAD_REF: "", VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: test_ref_name, - VersionUtils.ENV_VAR__GITHUB_ACTIONS__SHA: test_sha, + VersionUtils.ENV_VAR__GITHUB_ACTIONS__SOURCE_SHA: test_sha, + VersionUtils.ENV_VAR__GITHUB_ACTIONS__SHA: random_sha, }): result = VersionUtils._get_version_name_from_github_actions_env_vars() assert result == test_ref_name - # Unlikely scenario: no HEAD_REF nor REF_NAME but SHA is set; should return - # short commit hash + # Unlikely scenario: no HEAD_REF nor REF_NAME but SOURCE_SHA is set; should return + # short commit hash. with patch.dict(os.environ, { VersionUtils.ENV_VAR__GITHUB_ACTIONS__HEAD_REF: "", VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: "", - VersionUtils.ENV_VAR__GITHUB_ACTIONS__SHA: test_sha, + VersionUtils.ENV_VAR__GITHUB_ACTIONS__SOURCE_SHA: test_sha, + }): + result = VersionUtils._get_version_name_from_github_actions_env_vars() + assert result == test_sha[:7] + + # Unlikely scenario 2: no HEAD_REF nor REF_NAME and no SOURCE_SHA, but SHA is + # set; should return short commit hash. + with patch.dict(os.environ, { + VersionUtils.ENV_VAR__GITHUB_ACTIONS__HEAD_REF: "", + VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: "", + VersionUtils.ENV_VAR__GITHUB_ACTIONS__SOURCE_SHA: "", + VersionUtils.ENV_VAR__GITHUB_ACTIONS__SOURCE_SHA: test_sha, }): result = VersionUtils._get_version_name_from_github_actions_env_vars() assert result == test_sha[:7] From 797d036a2481abf6261dbcc3003268ccfac610f7 Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Sat, 27 Dec 2025 11:02:29 -0600 Subject: [PATCH 46/50] Add `is_release_image` and related test --- src/seedsigner/helpers/version.py | 33 +++++++++++++ src/seedsigner/views/settings_views.py | 6 +-- tests/test_version.py | 64 ++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 4 deletions(-) diff --git a/src/seedsigner/helpers/version.py b/src/seedsigner/helpers/version.py index 17e75cc7..7bdaf358 100644 --- a/src/seedsigner/helpers/version.py +++ b/src/seedsigner/helpers/version.py @@ -142,6 +142,39 @@ class Version(Singleton): return cls.get_instance()._version_timestamp + @classmethod + def is_release_image(cls) -> bool: + """ + Returns True if all of the following are true: + * We're running in SeedSigner OS. + * `version_fork` is the main "seedsigner" repo. + * `version_name` corresponds to a "clean" semantic version tag. + * e.g. "v0.8.5" but not "v0.8.5-rc1". + """ + if Settings.HOSTNAME != Settings.SEEDSIGNER_OS: + return False + + fork = cls.get_version_fork() + if not fork or fork.lower() != "seedsigner": + return False + + version = cls.get_version_name() + # Even though our release tags in git do not include "v", at this point in the + # code the "v" will already be prepended to `version_name` if it is a semantic + # version tag. If "v" is prepended to future release tags, logic further up the + # chain will already gracefully handle it. + if not version or not version.startswith("v"): + return False + + # Is it a clean semantic version? + version = version[1:] # strip the "v" prefix + for part in version.split("."): + if not part.isnumeric(): + return False + + return True + + @classmethod @not_allowed_in_seedsigner_os def override_data(cls, **kwargs): diff --git a/src/seedsigner/views/settings_views.py b/src/seedsigner/views/settings_views.py index 1c7fcc13..a8adb46f 100644 --- a/src/seedsigner/views/settings_views.py +++ b/src/seedsigner/views/settings_views.py @@ -333,10 +333,8 @@ class VersionView(View): version_fork = Version.get_version_fork() short_commit_hash = Version.get_short_commit_hash() - print(f"{version_fork=}, {short_commit_hash=}") - - if version_fork and version_fork.lower() == "seedsigner": - # Don't display fork name or commit hash for the main repo + if Version.is_release_image(): + # Don't display fork name or commit hash for release images version_fork = None short_commit_hash = None diff --git a/tests/test_version.py b/tests/test_version.py index 7530563e..3f7cff37 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -353,6 +353,10 @@ class TestVersionUtils_GithubActions(VersionBaseTest): with pytest.raises(Exception): VersionUtils.get_version_name() + # Github Actions CI logic should gracefully handle missing SHA data, even + # if it's an impossible / unlikely scenario. + assert VersionUtils.get_short_commit_hash() is None + def test_is_github_actions_ci(self): """ @@ -1010,6 +1014,66 @@ class TestVersion(VersionBaseTest): Version.get_version_timestamp() == TEST__VERSION_TIMESTAMP + def test_is_release_image(self): + """ + Should return True if the heuristic checks indicate this is a release image. + """ + self.write_test_version_file() + + def change_version_data(changes_dict): + # Can only call override_data() when NOT in SeedSigner OS; temporarily patch + # it out. + with patch("seedsigner.models.settings.Settings.HOSTNAME", "not-seedsigner-os"): + Version.override_data(**changes_dict) + + # Must be running in SeedSigner OS + with patch("seedsigner.models.settings.Settings.HOSTNAME", "not-seedsigner-os"): + assert Version.is_release_image() is False + + with patch("seedsigner.models.settings.Settings.HOSTNAME", Settings.SEEDSIGNER_OS): + # Not from the main repo + for fork_name in [ + "some-fork", + "AnotherRepo", + "seedsigner-someone_else", + "seedsigner123" + ]: + change_version_data({ + VersionUtils.VERSIONFILE_ATTR__FORK: fork_name, + }) + assert Version.is_release_image() is False + + # Reset to the main repo + change_version_data({ + VersionUtils.VERSIONFILE_ATTR__FORK: "SeedSigner", + }) + + # Non-semantic version names + for version_name in [ + "some-branch-name", + "dev", + "name.with.dots", + "version_1_2_3", + "v1.hah.fooled.you", + "v1.2.3-rc1", # only fully "clean" semantic versions count as releases + ]: + change_version_data({ + VersionUtils.VERSIONFILE_ATTR__NAME: version_name, + }) + assert Version.is_release_image() is False + + # Semantic versions pass + for version_name in [ + "v0.8.5", + "v1.0", + "v10.20.30", + ]: + change_version_data({ + VersionUtils.VERSIONFILE_ATTR__NAME: version_name, + }) + assert Version.is_release_image() is True + + def test_override_data(self, mock_popen: Mock): """ Test that we can override the version data via the Version.override_version_data() From 037756dab2af069ab984c7324f38c5f2e7de7e9e Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Sat, 27 Dec 2025 13:46:00 -0600 Subject: [PATCH 47/50] Add `mock_context_manager`; factor out all `run_before` methods --- tests/screenshot_generator/generator.py | 177 +++++++++++++----------- tests/screenshot_generator/utils.py | 31 +++-- 2 files changed, 114 insertions(+), 94 deletions(-) diff --git a/tests/screenshot_generator/generator.py b/tests/screenshot_generator/generator.py index e45373fc..e982c704 100644 --- a/tests/screenshot_generator/generator.py +++ b/tests/screenshot_generator/generator.py @@ -1,13 +1,14 @@ -from dataclasses import dataclass import embit +import os import pathlib import pytest -import os import random import sys import time -from unittest.mock import Mock, patch, MagicMock +from contextlib import contextmanager +from dataclasses import dataclass from PIL import ImageFont +from unittest.mock import Mock, patch, MagicMock from embit import compact from embit.psbt import PSBT, OutputScope @@ -24,7 +25,6 @@ sys.modules['seedsigner.hardware.camera.Camera'] = MagicMock() sys.modules['seedsigner.hardware.microsd'] = MagicMock() from seedsigner.controller import Controller -from seedsigner.gui.components import GUIConstants from seedsigner.gui.renderer import Renderer from seedsigner.gui.screens.screen import BaseScreen from seedsigner.gui.screens.seed_screens import SeedAddPassphraseScreen @@ -240,32 +240,49 @@ def generate_screenshots(locale): settingsqr_data_persistent = f"settings::v1 name=English_noob_mode persistent=E coords=spa,spd denom=thr network=M qr_density=M xpub_export=E sigs=ss scripts=nat xpub_details=E passphrase=E camera=0 compact_seedqr=E bip85=D priv_warn=E dire_warn=E partners=E locale={locale}" settingsqr_data_not_persistent = f"settings::v1 name=Mode_Ephemeral persistent=D coords=spa,spd denom=thr network=M qr_density=M xpub_export=E sigs=ss scripts=nat xpub_details=E passphrase=E camera=0 compact_seedqr=E bip85=D priv_warn=E dire_warn=E partners=E locale={locale}" - # Set up screenshot-specific callbacks to inject data before the View is run and - # reset data after the View is run. - def load_single_sig_psbt_cb(): + # Set up screenshot-specific mocks to provide temporary data/state + @contextmanager + def mock_load_psbt(base64_psbt: str, seed: Seed = seed_12b): + """ + Reusable utility for other context managers to mock loading a PSBT into the + Controller. + """ decoder = DecodeQR() - decoder.add_data(BASE64_SINGLE_SIG_PSBT) - controller.psbt = decoder.get_psbt() - controller.psbt_seed = seed_12b - controller.psbt_parser = PSBTParser(p=controller.psbt, seed=seed_12b) - controller.multisig_wallet_descriptor = None + decoder.add_data(base64_psbt) + with patch.object(controller, 'psbt', decoder.get_psbt()): + with patch.object(controller, 'psbt_seed', seed): + with patch.object(controller, 'psbt_parser', PSBTParser(p=controller.psbt, seed=seed)): + yield - def load_multisig_psbt_cb(): - decoder = DecodeQR() - decoder.add_data(BASE64_MULTISIG_PSBT) - controller.psbt = decoder.get_psbt() - controller.psbt_seed = seed_12b - controller.psbt_parser = PSBTParser(p=controller.psbt, seed=seed_12b) - controller.multisig_wallet_descriptor = None + @contextmanager + def mock_single_sig_psbt_loaded(): + with mock_load_psbt(BASE64_SINGLE_SIG_PSBT): + yield - def load_multisig_wallet_descriptor_cb(): - controller.multisig_wallet_descriptor = embit.descriptor.Descriptor.from_string(MULTISIG_WALLET_DESCRIPTOR) + @contextmanager + def mock_multisig_psbt_loaded(): + with mock_load_psbt(BASE64_MULTISIG_PSBT): + yield - def load_address_verification_data_cb(): - controller.unverified_address = dict( + @contextmanager + def mock_multisig_wallet_descriptor_loaded(): + with patch.object(controller, 'multisig_wallet_descriptor', embit.descriptor.Descriptor.from_string(MULTISIG_WALLET_DESCRIPTOR)): + yield + + + @contextmanager + def mock_multisig_psbt_and_descriptor_loaded(): + with mock_multisig_psbt_loaded(): + with mock_multisig_wallet_descriptor_loaded(): + yield + + + @contextmanager + def mock_address_verification_data_loaded(): + fake_addr_verification_data = dict( # These are all totally fake data address="bc1q6p00wazu4nnqac29fvky6vhjnnhku5u2g9njss62rvy7e0yuperq86f5ek", network=SettingsConstants.MAINNET, @@ -275,25 +292,27 @@ def generate_screenshots(locale): verified_index=5, verified_index_is_change=False ) + with patch.object(controller, 'unverified_address', fake_addr_verification_data): + yield - def PSBTSelectSeedView_cb_before(): + @contextmanager + def mock_controller_psbt_seed_empty(): # Have to ensure this is cleared out in order to get the seed selection screen - controller.psbt_seed = None + with patch.object(controller, 'psbt_seed', None): + yield - def PSBTOverviewView_op_return_cb_before(): - controller.psbt_seed = seed_12b - decoder = DecodeQR() - decoder.add_data(BASE64_PSBT_WITH_OP_RETURN_TEXT) - controller.psbt = decoder.get_psbt() - controller.psbt_parser = PSBTParser(p=controller.psbt, seed=seed_12b) - + @contextmanager + def mock_psbt_with_op_return_loaded(): + with mock_load_psbt(BASE64_PSBT_WITH_OP_RETURN_TEXT): + yield - def PSBTOpReturnView_raw_hex_data_cb_before(): - decoder.add_data(BASE64_PSBT_WITH_OP_RETURN_RAW_BYTES) - controller.psbt = decoder.get_psbt() - controller.psbt_parser = PSBTParser(p=controller.psbt, seed=seed_12b) + + @contextmanager + def mock_psbt_with_op_return_raw_bytes_loaded(): + with mock_load_psbt(BASE64_PSBT_WITH_OP_RETURN_RAW_BYTES): + yield screenshot_sections = { @@ -330,31 +349,31 @@ def generate_screenshots(locale): ScreenshotConfig(seed_views.SeedReviewPassphraseView), ScreenshotConfig(seed_views.SeedOptionsView, dict(seed_num=0)), - ScreenshotConfig(seed_views.SeedBackupView, dict(seed_num=0)), - ScreenshotConfig(seed_views.SeedExportXpubSigTypeView, dict(seed_num=0)), + ScreenshotConfig(seed_views.SeedBackupView, dict(seed_num=0)), + ScreenshotConfig(seed_views.SeedExportXpubSigTypeView, dict(seed_num=0)), ScreenshotConfig(seed_views.SeedExportXpubScriptTypeView, dict(seed_num=0, sig_type="msig")), - ScreenshotConfig(seed_views.SeedExportXpubCustomDerivationView, dict(seed_num=0, sig_type="ss", script_type="")), - ScreenshotConfig(seed_views.SeedExportXpubCoordinatorView, dict(seed_num=0, sig_type="ss", script_type="nat")), - ScreenshotConfig(seed_views.SeedExportXpubWarningView, dict(seed_num=0, sig_type="msig", script_type="nes", coordinator="spd", custom_derivation="")), - ScreenshotConfig(seed_views.SeedExportXpubDetailsView, dict(seed_num=0, sig_type="ss", script_type="nat", coordinator="bw", custom_derivation="")), - ScreenshotConfig(SeedExportXpubQR_ScreenBrightnessView, dict(seed_num=0, coordinator="bw", derivation_path="m/84'/0'/0'")), + ScreenshotConfig(seed_views.SeedExportXpubCustomDerivationView, dict(seed_num=0, sig_type="ss", script_type="")), + ScreenshotConfig(seed_views.SeedExportXpubCoordinatorView, dict(seed_num=0, sig_type="ss", script_type="nat")), + ScreenshotConfig(seed_views.SeedExportXpubWarningView, dict(seed_num=0, sig_type="msig", script_type="nes", coordinator="spd", custom_derivation="")), + ScreenshotConfig(seed_views.SeedExportXpubDetailsView, dict(seed_num=0, sig_type="ss", script_type="nat", coordinator="bw", custom_derivation="")), + ScreenshotConfig(SeedExportXpubQR_ScreenBrightnessView, dict(seed_num=0, coordinator="bw", derivation_path="m/84'/0'/0'")), ScreenshotConfig(seed_views.SeedWordsWarningView, dict(seed_num=0)), ScreenshotConfig(seed_views.SeedWordsView, dict(seed_num=0)), ScreenshotConfig(seed_views.SeedWordsView, dict(seed_num=0, page_index=2), screenshot_name="SeedWordsView_2"), - ScreenshotConfig(seed_views.SeedBIP85SelectNumWordsView, dict(seed_num=0)), - ScreenshotConfig(seed_views.SeedBIP85SelectChildIndexView, dict(seed_num=0, num_words=24)), - ScreenshotConfig(seed_views.SeedBIP85InvalidChildIndexView, dict(seed_num=0, num_words=12)), - ScreenshotConfig(seed_views.SeedWordsBackupTestPromptView, dict(seed_num=0)), - ScreenshotConfig(seed_views.SeedWordsBackupTestView, dict(seed_num=0, rand_seed=6102)), - ScreenshotConfig(seed_views.SeedWordsBackupTestMistakeView, dict(seed_num=0, cur_index=7, wrong_word="satoshi")), - ScreenshotConfig(seed_views.SeedWordsBackupTestSuccessView, dict(seed_num=0)), - ScreenshotConfig(seed_views.SeedTranscribeSeedQRFormatView, dict(seed_num=0)), + ScreenshotConfig(seed_views.SeedBIP85SelectNumWordsView, dict(seed_num=0)), + ScreenshotConfig(seed_views.SeedBIP85SelectChildIndexView, dict(seed_num=0, num_words=24)), + ScreenshotConfig(seed_views.SeedBIP85InvalidChildIndexView, dict(seed_num=0, num_words=12)), + ScreenshotConfig(seed_views.SeedWordsBackupTestPromptView, dict(seed_num=0)), + ScreenshotConfig(seed_views.SeedWordsBackupTestView, dict(seed_num=0, rand_seed=6102)), + ScreenshotConfig(seed_views.SeedWordsBackupTestMistakeView, dict(seed_num=0, cur_index=7, wrong_word="satoshi")), + ScreenshotConfig(seed_views.SeedWordsBackupTestSuccessView, dict(seed_num=0)), + ScreenshotConfig(seed_views.SeedTranscribeSeedQRFormatView, dict(seed_num=0)), ScreenshotConfig(seed_views.SeedTranscribeSeedQRWarningView, dict(seed_num=0)), - ScreenshotConfig(seed_views.SeedTranscribeSeedQRWholeQRView, dict(seed_num=0, seedqr_format=QRType.SEED__COMPACTSEEDQR, num_modules=21), screenshot_name="SeedTranscribeSeedQRWholeQRView_12_Compact"), - ScreenshotConfig(seed_views.SeedTranscribeSeedQRWholeQRView, dict(seed_num=0, seedqr_format=QRType.SEED__SEEDQR, num_modules=25), screenshot_name="SeedTranscribeSeedQRWholeQRView_12_Standard"), - ScreenshotConfig(seed_views.SeedTranscribeSeedQRWholeQRView, dict(seed_num=2, seedqr_format=QRType.SEED__COMPACTSEEDQR, num_modules=25), screenshot_name="SeedTranscribeSeedQRWholeQRView_24_Compact"), - ScreenshotConfig(seed_views.SeedTranscribeSeedQRWholeQRView, dict(seed_num=2, seedqr_format=QRType.SEED__SEEDQR, num_modules=29), screenshot_name="SeedTranscribeSeedQRWholeQRView_24_Standard"), + ScreenshotConfig(seed_views.SeedTranscribeSeedQRWholeQRView, dict(seed_num=0, seedqr_format=QRType.SEED__COMPACTSEEDQR, num_modules=21), screenshot_name="SeedTranscribeSeedQRWholeQRView_12_Compact"), + ScreenshotConfig(seed_views.SeedTranscribeSeedQRWholeQRView, dict(seed_num=0, seedqr_format=QRType.SEED__SEEDQR, num_modules=25), screenshot_name="SeedTranscribeSeedQRWholeQRView_12_Standard"), + ScreenshotConfig(seed_views.SeedTranscribeSeedQRWholeQRView, dict(seed_num=2, seedqr_format=QRType.SEED__COMPACTSEEDQR, num_modules=25), screenshot_name="SeedTranscribeSeedQRWholeQRView_24_Compact"), + ScreenshotConfig(seed_views.SeedTranscribeSeedQRWholeQRView, dict(seed_num=2, seedqr_format=QRType.SEED__SEEDQR, num_modules=29), screenshot_name="SeedTranscribeSeedQRWholeQRView_24_Standard"), ScreenshotConfig(seed_views.SeedTranscribeSeedQRZoomedInView, dict(seed_num=0, seedqr_format=QRType.SEED__COMPACTSEEDQR, initial_zone_x=1, initial_zone_y=1), screenshot_name="SeedTranscribeSeedQRZoomedInView_12_Compact"), ScreenshotConfig(seed_views.SeedTranscribeSeedQRZoomedInView, dict(seed_num=0, seedqr_format=QRType.SEED__SEEDQR, initial_zone_x=2, initial_zone_y=2), screenshot_name="SeedTranscribeSeedQRZoomedInView_12_Standard"), @@ -368,11 +387,11 @@ def generate_screenshots(locale): ScreenshotConfig(seed_views.SeedSelectSeedView, dict(flow=Controller.FLOW__VERIFY_SINGLESIG_ADDR), screenshot_name="SeedSelectSeedView_address_verification"), ScreenshotConfig(seed_views.AddressVerificationSigTypeView), - ScreenshotConfig(seed_views.SeedAddressVerificationView, dict(seed_num=0), run_before=load_address_verification_data_cb), - ScreenshotConfig(seed_views.SeedAddressVerificationSuccessView, dict(seed_num=0)), # Relies on callback above + ScreenshotConfig(seed_views.SeedAddressVerificationView, dict(seed_num=0), mock_context_manager=mock_address_verification_data_loaded), + ScreenshotConfig(seed_views.SeedAddressVerificationSuccessView, dict(seed_num=0), mock_context_manager=mock_address_verification_data_loaded), ScreenshotConfig(seed_views.LoadMultisigWalletDescriptorView), - ScreenshotConfig(seed_views.MultisigWalletDescriptorView, run_before=load_multisig_wallet_descriptor_cb), + ScreenshotConfig(seed_views.MultisigWalletDescriptorView, mock_context_manager=mock_multisig_wallet_descriptor_loaded), ScreenshotConfig(seed_views.SeedDiscardView, dict(seed_num=0)), ScreenshotConfig(seed_views.SeedSelectSeedView, dict(flow=Controller.FLOW__SIGN_MESSAGE), screenshot_name="SeedSelectSeedView_sign_message"), @@ -382,27 +401,27 @@ def generate_screenshots(locale): ScreenshotConfig(seed_views.SeedElectrumMnemonicStartView), ], "PSBT Views": [ - ScreenshotConfig(psbt_views.PSBTSelectSeedView, run_before=PSBTSelectSeedView_cb_before), - ScreenshotConfig(psbt_views.PSBTOverviewView, run_before=load_multisig_psbt_cb), + ScreenshotConfig(psbt_views.PSBTSelectSeedView, mock_context_manager=mock_controller_psbt_seed_empty), + ScreenshotConfig(psbt_views.PSBTOverviewView, mock_context_manager=mock_multisig_psbt_loaded), ScreenshotConfig(psbt_views.PSBTUnsupportedScriptTypeWarningView), ScreenshotConfig(psbt_views.PSBTNoChangeWarningView), - ScreenshotConfig(psbt_views.PSBTMathView), - ScreenshotConfig(psbt_views.PSBTAddressDetailsView, dict(address_num=0)), + ScreenshotConfig(psbt_views.PSBTMathView, mock_context_manager=mock_multisig_psbt_loaded), + ScreenshotConfig(psbt_views.PSBTAddressDetailsView, dict(address_num=0), mock_context_manager=mock_multisig_psbt_loaded), - ScreenshotConfig(psbt_views.PSBTChangeDetailsView, dict(change_address_num=0), screenshot_name="PSBTChangeDetailsView_single_sig_change_verified", run_before=load_single_sig_psbt_cb), - ScreenshotConfig(psbt_views.PSBTChangeDetailsView, dict(change_address_num=1), screenshot_name="PSBTChangeDetailsView_single_sig_self_transfer_verified", run_before=load_single_sig_psbt_cb), - ScreenshotConfig(psbt_views.PSBTChangeDetailsView, dict(change_address_num=0), screenshot_name="PSBTChangeDetailsView_multisig_unverified", run_before=load_multisig_psbt_cb), - ScreenshotConfig(psbt_views.PSBTChangeDetailsView, dict(change_address_num=0), screenshot_name="PSBTChangeDetailsView_multisig_verified", run_before=load_multisig_wallet_descriptor_cb), - ScreenshotConfig(psbt_views.PSBTOverviewView, screenshot_name="PSBTOverviewView_op_return", run_before=PSBTOverviewView_op_return_cb_before), - ScreenshotConfig(psbt_views.PSBTOpReturnView, screenshot_name="PSBTOpReturnView_text"), # Relies on callback above - ScreenshotConfig(psbt_views.PSBTOpReturnView, screenshot_name="PSBTOpReturnView_raw_hex_data", run_before=PSBTOpReturnView_raw_hex_data_cb_before), + ScreenshotConfig(psbt_views.PSBTChangeDetailsView, dict(change_address_num=0), screenshot_name="PSBTChangeDetailsView_single_sig_change_verified", mock_context_manager=mock_single_sig_psbt_loaded), + ScreenshotConfig(psbt_views.PSBTChangeDetailsView, dict(change_address_num=1), screenshot_name="PSBTChangeDetailsView_single_sig_self_transfer_verified", mock_context_manager=mock_single_sig_psbt_loaded), + ScreenshotConfig(psbt_views.PSBTChangeDetailsView, dict(change_address_num=0), screenshot_name="PSBTChangeDetailsView_multisig_unverified", mock_context_manager=mock_multisig_psbt_loaded), + ScreenshotConfig(psbt_views.PSBTChangeDetailsView, dict(change_address_num=0), screenshot_name="PSBTChangeDetailsView_multisig_verified", mock_context_manager=mock_multisig_psbt_and_descriptor_loaded), + ScreenshotConfig(psbt_views.PSBTOverviewView, screenshot_name="PSBTOverviewView_op_return", mock_context_manager=mock_psbt_with_op_return_loaded), + ScreenshotConfig(psbt_views.PSBTOpReturnView, screenshot_name="PSBTOpReturnView_text", mock_context_manager=mock_psbt_with_op_return_loaded), + ScreenshotConfig(psbt_views.PSBTOpReturnView, screenshot_name="PSBTOpReturnView_raw_hex_data", mock_context_manager=mock_psbt_with_op_return_raw_bytes_loaded), ScreenshotConfig(psbt_views.PSBTAddressVerificationFailedView, dict(is_change=True, is_multisig=False), screenshot_name="PSBTAddressVerificationFailedView_singlesig_change"), ScreenshotConfig(psbt_views.PSBTAddressVerificationFailedView, dict(is_change=False, is_multisig=False), screenshot_name="PSBTAddressVerificationFailedView_singlesig_selftransfer"), ScreenshotConfig(psbt_views.PSBTAddressVerificationFailedView, dict(is_change=True, is_multisig=True), screenshot_name="PSBTAddressVerificationFailedView_multisig_change"), ScreenshotConfig(psbt_views.PSBTAddressVerificationFailedView, dict(is_change=False, is_multisig=True), screenshot_name="PSBTAddressVerificationFailedView_multisig_selftransfer"), - ScreenshotConfig(psbt_views.PSBTFinalizeView), + ScreenshotConfig(psbt_views.PSBTFinalizeView, mock_context_manager=mock_multisig_psbt_loaded), #ScreenshotConfig(PSBTSignedQRDisplayViewScreenshotConfig), - ScreenshotConfig(psbt_views.PSBTSigningErrorView), + ScreenshotConfig(psbt_views.PSBTSigningErrorView, mock_context_manager=mock_multisig_psbt_loaded), ], "Tools Views": [ ScreenshotConfig(tools_views.ToolsMenuView), @@ -418,14 +437,14 @@ def generate_screenshots(locale): ScreenshotConfig(tools_views.ToolsCalcFinalWordShowFinalWordView, dict(coin_flips="0010101"), screenshot_name="ToolsCalcFinalWordShowFinalWordView_coin_flips"), ScreenshotConfig(tools_views.ToolsCalcFinalWordDoneView), ScreenshotConfig(tools_views.ToolsAddressExplorerSelectSourceView), - ScreenshotConfig(tools_views.ToolsAddressExplorerAddressTypeView), + ScreenshotConfig(tools_views.ToolsAddressExplorerAddressTypeView, mock_context_manager=mock_multisig_wallet_descriptor_loaded), ScreenshotConfig(tools_views.ToolsAddressExplorerAddressListView), # ScreenshotConfig(tools_views.ToolsAddressExplorerAddressView), ], "Settings Views": settings_views_list + [ ScreenshotConfig(settings_views.IOTestView), ScreenshotConfig(settings_views.DonateView), - ScreenshotConfig(settings_views.SettingsIngestSettingsQRView, dict(data=settingsqr_data_persistent), screenshot_name="SettingsIngestSettingsQRView_persistent"), + ScreenshotConfig(settings_views.SettingsIngestSettingsQRView, dict(data=settingsqr_data_persistent), screenshot_name="SettingsIngestSettingsQRView_persistent"), ScreenshotConfig(settings_views.SettingsIngestSettingsQRView, dict(data=settingsqr_data_not_persistent), screenshot_name="SettingsIngestSettingsQRView_not_persistent"), ], "Misc Error Views": [ @@ -433,7 +452,7 @@ def generate_screenshots(locale): ScreenshotConfig(UnhandledExceptionView, dict(error=["IndexError", "line 1, in some_buggy_code.py", "list index out of range"])), ScreenshotConfig(CameraConnectionErrorView), ScreenshotConfig(NetworkMismatchErrorView, dict(derivation_path="m/84'/1'/0'")), - ScreenshotConfig(OptionDisabledView, dict(settings_attr=SettingsConstants.SETTING__MESSAGE_SIGNING)), + ScreenshotConfig(OptionDisabledView, dict(settings_attr=SettingsConstants.SETTING__MESSAGE_SIGNING)), ScreenshotConfig(scan_views.ScanInvalidQRTypeView) ] } @@ -456,9 +475,11 @@ def generate_screenshots(locale): try: cur_count = screenshot_renderer.render_count - # Set up and run the target View - screenshot_config.run_callback_before() - screenshot_config.View_cls(**screenshot_config.view_kwargs).run() + # Activate the (optional) context manager for this screenshot to activate + # any specialized mocks/patches. + with screenshot_config.mock_context_manager(): + # Set up and run the target View + screenshot_config.View_cls(**screenshot_config.view_kwargs).run() if screenshot_renderer.render_count == cur_count: # The View didn't actually render anything @@ -485,8 +506,6 @@ def generate_screenshots(locale): toast_thread.stop() toast_thread.join() - screenshot_config.run_callback_after() - # Parse the main `l10n/messages.pot` for overall stats messages_source_path = os.path.join(pathlib.Path(__file__).parent.resolve().parent.resolve().parent.resolve(), "l10n", "messages.pot") diff --git a/tests/screenshot_generator/utils.py b/tests/screenshot_generator/utils.py index 56bfb917..0998936a 100644 --- a/tests/screenshot_generator/utils.py +++ b/tests/screenshot_generator/utils.py @@ -1,5 +1,5 @@ import os - +from contextlib import contextmanager from dataclasses import dataclass from PIL import Image, ImageDraw @@ -40,11 +40,11 @@ class ScreenshotRenderer(Renderer): renderer.draw = ImageDraw.Draw(renderer.canvas) renderer.render_count = 0 - + def set_screenshot_filename(self, filename:str): self.screenshot_filename = filename - + def set_screenshot_path(self, path): if not os.path.exists(path): @@ -73,14 +73,25 @@ class ScreenshotRenderer(Renderer): +@contextmanager +def default_mock_context_manager(): + # Just a no-op context manager + yield + + + @dataclass class ScreenshotConfig: + """ + - mock_context_manager: Sets up temporary mock/patch context for the screenshot. + Ensures that there are no persistent state changes left over that might affect other + screenshots. + """ View_cls: View view_kwargs: dict = None screenshot_name: str = None toast_thread: BaseToastOverlayManagerThread = None - run_before: callable = None - run_after: callable = None + mock_context_manager: callable = default_mock_context_manager def __post_init__(self): @@ -88,13 +99,3 @@ class ScreenshotConfig: self.view_kwargs = {} if not self.screenshot_name: self.screenshot_name = self.View_cls.__name__ - - - def run_callback_before(self): - if self.run_before: - self.run_before() - - - def run_callback_after(self): - if self.run_after: - self.run_after() From fc6fa4f54b24943fccdef29a229178e044ff3e69 Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Sat, 27 Dec 2025 14:20:33 -0600 Subject: [PATCH 48/50] Minor comments updates --- tests/screenshot_generator/generator.py | 5 +++-- tests/screenshot_generator/utils.py | 7 ++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/screenshot_generator/generator.py b/tests/screenshot_generator/generator.py index e982c704..d489666c 100644 --- a/tests/screenshot_generator/generator.py +++ b/tests/screenshot_generator/generator.py @@ -240,7 +240,8 @@ def generate_screenshots(locale): settingsqr_data_persistent = f"settings::v1 name=English_noob_mode persistent=E coords=spa,spd denom=thr network=M qr_density=M xpub_export=E sigs=ss scripts=nat xpub_details=E passphrase=E camera=0 compact_seedqr=E bip85=D priv_warn=E dire_warn=E partners=E locale={locale}" settingsqr_data_not_persistent = f"settings::v1 name=Mode_Ephemeral persistent=D coords=spa,spd denom=thr network=M qr_density=M xpub_export=E sigs=ss scripts=nat xpub_details=E passphrase=E camera=0 compact_seedqr=E bip85=D priv_warn=E dire_warn=E partners=E locale={locale}" - # Set up screenshot-specific mocks to provide temporary data/state + # Set up mocks to provide whatever temporary data/state a particular screenshot + # might need. @contextmanager def mock_load_psbt(base64_psbt: str, seed: Seed = seed_12b): """ @@ -476,7 +477,7 @@ def generate_screenshots(locale): cur_count = screenshot_renderer.render_count # Activate the (optional) context manager for this screenshot to activate - # any specialized mocks/patches. + # any specialized mocks. with screenshot_config.mock_context_manager(): # Set up and run the target View screenshot_config.View_cls(**screenshot_config.view_kwargs).run() diff --git a/tests/screenshot_generator/utils.py b/tests/screenshot_generator/utils.py index 0998936a..83581f4b 100644 --- a/tests/screenshot_generator/utils.py +++ b/tests/screenshot_generator/utils.py @@ -83,9 +83,10 @@ def default_mock_context_manager(): @dataclass class ScreenshotConfig: """ - - mock_context_manager: Sets up temporary mock/patch context for the screenshot. - Ensures that there are no persistent state changes left over that might affect other - screenshots. + - mock_context_manager: Option to provide mocks to set up custom data or state that + the screenshot might need. The mocks will only be active during this one + screenshot's generation. Ensures that there are no persistent state changes left + over that might affect other screenshots. """ View_cls: View view_kwargs: dict = None From 1d433b10580196b5dcc2708c854f37ea9da9165b Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Sat, 27 Dec 2025 15:05:31 -0600 Subject: [PATCH 49/50] refactor screenshot generator after `mock_context_manager` merge --- tests/screenshot_generator/generator.py | 27 ++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/tests/screenshot_generator/generator.py b/tests/screenshot_generator/generator.py index 2a09a148..3bdcf6d2 100644 --- a/tests/screenshot_generator/generator.py +++ b/tests/screenshot_generator/generator.py @@ -116,6 +116,11 @@ seed_24_w_passphrase = Seed(mnemonic=mnemonic_24, passphrase="some-PASS*phrase9" MULTISIG_WALLET_DESCRIPTOR = """wsh(sortedmulti(1,[22bde1a9/48h/1h/0h/2h]tpubDFfsBrmpj226ZYiRszYi2qK6iGvh2vkkghfGB2YiRUVY4rqqedHCFEgw12FwDkm7rUoVtq9wLTKc6BN2sxswvQeQgp7m8st4FP8WtP8go76/{0,1}/*,[73c5da0a/48h/1h/0h/2h]tpubDFH9dgzveyD8zTbPUFuLrGmCydNvxehyNdUXKJAQN8x4aZ4j6UZqGfnqFrD4NqyaTVGKbvEW54tsvPTK2UoSbCC1PJY8iCNiwTL3RWZEheQ/{0,1}/*))#3jhtf6yx""" +# Grab the most recent release version info +(latest_release_version_name, latest_release_version_timestamp) = VersionUtils._fetch_latest_seedsigner_release_tag() +if not latest_release_version_name or not latest_release_version_timestamp: + print("Could not fetch latest release version from GitHub") + # Wrap QRDisplayScreen's `render_brightness_tip` in a simple View + Screen so we # can call it outside of its child thread and generate a screenshot. @@ -335,11 +340,25 @@ def generate_screenshots(locale): yield + @contextmanager + def mock_version_to_most_recent_release(): + # Patch the Version get_* calls to the most recent release + with patch.multiple(Version, + get_version_name=Mock(return_value=latest_release_version_name), + get_version_fork=Mock(return_value="SeedSigner"), + get_version_timestamp=Mock(return_value=latest_release_version_timestamp), + get_short_commit_hash=Mock(return_value="abcd1234") # dummy value should be ignored + ): + # Also have to mock us into SeedSigner OS + with patch("seedsigner.models.settings.Settings.HOSTNAME", Settings.SEEDSIGNER_OS): + yield + + screenshot_sections = { "Main Menu Views": [ - ScreenshotConfig(OpeningSplashView, dict(force_partner_logos=True)), - ScreenshotConfig(OpeningSplashView, dict(force_partner_logos=False), screenshot_name="OpeningSplashView_no_partner_logos"), - ScreenshotConfig(OpeningSplashView, dict(force_partner_logos=True), run_before=reset_version_to_local_git_state_cb, screenshot_name="OpeningSplashView_current_git_state"), + ScreenshotConfig(OpeningSplashView, dict(force_partner_logos=True), mock_context_manager=mock_version_to_most_recent_release), + ScreenshotConfig(OpeningSplashView, dict(force_partner_logos=False), screenshot_name="OpeningSplashView_no_partner_logos", mock_context_manager=mock_version_to_most_recent_release), + ScreenshotConfig(OpeningSplashView, dict(force_partner_logos=True), screenshot_name="OpeningSplashView_current_git_state"), ScreenshotConfig(MainMenuView), ScreenshotConfig(MainMenuView, screenshot_name='MainMenuView_SDCardStateChangeToast_removed', toast_thread=SDCardStateChangeToastManagerThread(action=MicroSD.ACTION__REMOVED, activation_delay=0, duration=0)), ScreenshotConfig(MainMenuView, screenshot_name='MainMenuView_SDCardStateChangeToast_inserted', toast_thread=SDCardStateChangeToastManagerThread(action=MicroSD.ACTION__INSERTED, activation_delay=0, duration=0)), @@ -465,6 +484,8 @@ def generate_screenshots(locale): "Settings Views": settings_views_list + [ ScreenshotConfig(settings_views.IOTestView), ScreenshotConfig(settings_views.DonateView), + ScreenshotConfig(settings_views.VersionView, mock_context_manager=mock_version_to_most_recent_release), + ScreenshotConfig(settings_views.VersionView, screenshot_name="VersionView_current_git_state"), ScreenshotConfig(settings_views.SettingsIngestSettingsQRView, dict(data=settingsqr_data_persistent), screenshot_name="SettingsIngestSettingsQRView_persistent"), ScreenshotConfig(settings_views.SettingsIngestSettingsQRView, dict(data=settingsqr_data_not_persistent), screenshot_name="SettingsIngestSettingsQRView_not_persistent"), ], From 45d75ea300c08725dfc1db5148652d0f7fa8eef6 Mon Sep 17 00:00:00 2001 From: kdmukai <934746+kdmukai@users.noreply.github.com> Date: Sat, 27 Dec 2025 15:15:53 -0600 Subject: [PATCH 50/50] Cleanup --- tests/screenshot_generator/generator.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/tests/screenshot_generator/generator.py b/tests/screenshot_generator/generator.py index 3bdcf6d2..a384a6d0 100644 --- a/tests/screenshot_generator/generator.py +++ b/tests/screenshot_generator/generator.py @@ -202,20 +202,6 @@ def generate_screenshots(locale): value=SettingsConstants.OPTION__ENABLED ) - def reset_version_most_recent_release(): - # Initialize the Version data to the most recent release - (version_name, version_timestamp) = VersionUtils._fetch_latest_seedsigner_release_tag() - if not version_name or not version_timestamp: - raise Exception("Could not fetch latest release version from GitHub") - new_values = { - VersionUtils.VERSIONFILE_ATTR__NAME: version_name, - VersionUtils.VERSIONFILE_ATTR__FORK: "SeedSigner", # main repo; screenshot should hide fork and commit hash - VersionUtils.VERSIONFILE_ATTR__TIMESTAMP: version_timestamp, - VersionUtils.VERSIONFILE_ATTR__SHORT_COMMIT_HASH: "abcd1234" # dummy value should be ignored - } - Version.override_data(**new_values) - reset_version_most_recent_release() - # Automatically populate all Settings options Views settings_views_list = [] def add_settings_entries(visibility = SettingsConstants.VISIBILITY__GENERAL):