diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c9cfae88..3ec94b71 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -18,6 +18,14 @@ 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 }} + + # 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/.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 diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py index be8ef6c9..49d1f25f 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -1,4 +1,6 @@ +from datetime import datetime import logging +import os import time import traceback @@ -100,8 +102,6 @@ class Controller(Singleton): rather than at the top in order avoid circular imports. """ - VERSION = "0.8.7" - # 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 @@ -468,7 +468,7 @@ class Controller(Singleton): if ", line " in traceback_line: line_info = traceback_line.split("/")[-1].replace("\"", "").replace("line ", "") break - + error = [ exception_type, line_info, diff --git a/src/seedsigner/gui/screens/settings_screens.py b/src/seedsigner/gui/screens/settings_screens.py index 70a8d5e1..221946e9 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 @@ -314,6 +315,110 @@ class DonateScreen(BaseTopNavScreen): +@dataclass +class VersionScreen(BaseTopNavScreen): + version_name: str = None + version_fork: str = None + version_timestamp: datetime = None + short_commit_hash: str = None + + def __post_init__(self): + self.title = _("Version") + super().__post_init__() + + 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") + + 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_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_name[i:]) + self.version_name = "\n".join(wrapped_version) + + 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.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 + + self.components.append(TextArea( + 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, + )) + + 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.short_commit_hash: + # right-align the labels + screen_x = commit_label_width - fork_label_width + self.components.append(TextArea( + 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.short_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.short_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, + )) + + + @dataclass class SettingsQRConfirmationScreen(ButtonListScreen): config_name: str = None diff --git a/src/seedsigner/helpers/version.py b/src/seedsigner/helpers/version.py new file mode 100644 index 00000000..7bdaf358 --- /dev/null +++ b/src/seedsigner/helpers/version.py @@ -0,0 +1,901 @@ +import json +import logging +import os +import traceback + +from datetime import datetime, timezone + +from seedsigner.models.settings import Settings +from seedsigner.models.singleton import Singleton + + +logger = logging.getLogger(__name__) + + + +# 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 + + +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: + 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): + """ + 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. + + 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: + - 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. + + In Github Actions CI: + * version_name: read from GITHUB_REF_NAME 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 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. + + 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 + _short_commit_hash: str = None + _version_timestamp: datetime = None + + + @classmethod + def get_instance(cls): + """ This is the only way to access the one and only instance. """ + if not cls._instance: + # Instantiate 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._short_commit_hash = VersionUtils.get_short_commit_hash() + return cls._instance + + + @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_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 + 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): + """ + Only used by the screenshot generator. + """ + instance = cls.get_instance() + 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 + 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__SHORT_COMMIT_HASH: instance._short_commit_hash, + VersionUtils.VERSIONFILE_ATTR__TIMESTAMP: instance._version_timestamp.isoformat() if instance._version_timestamp else None, + } + + + +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. + + 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__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__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" + DOT_GIT_DIR_NAME = ".git" # defined to facilitate mocking in tests + VERSIONFILE__FILENAME = "version.json" + VERSIONFILE_ATTR__NAME = "name" + VERSIONFILE_ATTR__FORK = "fork" + VERSIONFILE_ATTR__SHORT_COMMIT_HASH = "short_commit_hash" + VERSIONFILE_ATTR__TIMESTAMP = "timestamp" + + + """ ************************************************************************************* + Top-level "get" calls. These are the only functions meant to be called externally. + ************************************************************************************* """ + @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.") + + # 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 + 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 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_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() + + elif VersionUtils.is_github_actions_ci(): + # In Github Actions CI, try to get the fork name from env vars. + return VersionUtils._get_version_fork_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_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_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: + """ + 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) + + elif VersionUtils.is_github_actions_ci(): + # 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 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: + # 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() + + + + """ ************************************************************************************* + Misc internal utility functions. + ************************************************************************************* """ + @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 + + + + """ ************************************************************************************* + 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 + # "seedsigner" dir. + return os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", cls.VERSIONFILE__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 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 + 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.VERSIONFILE_ATTR__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.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: + """ + 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.VERSIONFILE_ATTR__TIMESTAMP) + + + + """ ************************************************************************************* + Utilities used in the SeedSigner OS build environment and writing the version.json file. + ************************************************************************************* """ + @classmethod + def _is_seedsigner_os_builder_env(cls) -> bool: + """ + The `ENV_VAR__IS_SEEDSIGNER_OS_BUILDER` env var is set during the SeedSigner OS + build process. + """ + return os.getenv(cls.ENV_VAR__IS_SEEDSIGNER_OS_BUILDER) is not None + + + + """ ************************************************************************************* + 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" + + + @classmethod + def _get_version_name_from_github_actions_env_vars(cls) -> str | 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) + 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 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 + + + @classmethod + def _get_version_fork_from_github_actions_env_vars(cls) -> str | None: + """ + 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 + def _get_full_commit_hash_from_github_actions_env_vars(cls) -> str | None: + 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 + + + + """ ************************************************************************************* + 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_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_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() + 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 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_branch_name_from_git_shell() or + cls._get_tag_name_from_git_shell() or + cls._get_short_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: + 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) + + + + """ ************************************************************************************* + 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 + @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__)) + + # 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, cls.DOT_GIT_DIR_NAME)) + + + @classmethod + @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, full_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 + full_commit_hash = None + try: + 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 + # 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, full_commit_hash) + + + @classmethod + @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. + """ + 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: + # 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 + 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 + @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 + 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_from_git_refs_tags(commit_hash) + if matching_tag: + return matching_tag + else: + return commit_hash[:7] # short commit hash + + + @classmethod + @not_allowed_in_seedsigner_os + 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 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() + return commit_hash + + + @classmethod + @not_allowed_in_seedsigner_os + def _get_full_commit_hash_from_git_refs_heads(cls, branch_name: str) -> str | None: + """ + 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) + 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 + 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 + @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 + 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] + ... + """ + 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 + 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.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") + 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 + @not_allowed_in_seedsigner_os + def _get_last_modified_timestamp_from_src_files(cls) -> datetime | None: + """ + Recursively scan the src/ directory for the most recent python file edit time. + """ + try: + path = os.path.dirname(os.path.abspath(__file__)) + + # Have to back out of "helpers" and "seedsigner" dirs + src_path = os.path.join(path, "..", "..") + + 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 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") + + 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 + # function so return gracefully. + logger.error(traceback.format_exc()) + + + + """ ************************************************************************************* + External http GET call to github. + ************************************************************************************* """ + @classmethod + @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 + commit timestamp. If local git data is not available, falls back to using the + release published_at time from the API. + + This is only used by the screenshot renderer. + """ + import urllib.request + 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" + ) + 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") + + # 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").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.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:\n{traceback.format_exc()}") + return (None, None) \ No newline at end of file diff --git a/src/seedsigner/views/screensaver.py b/src/seedsigner/views/screensaver.py index 8b2970da..0b0847b7 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,13 +98,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 = Version.get_version_name() # 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 56141c20..b0b51fd6 100644 --- a/src/seedsigner/views/settings_views.py +++ b/src/seedsigner/views/settings_views.py @@ -17,11 +17,13 @@ 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): + 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 @@ -33,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") @@ -49,6 +44,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") @@ -65,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, @@ -96,6 +101,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) @@ -353,3 +361,27 @@ class DonateView(View): self.run_screen(settings_screens.DonateScreen) return Destination(SettingsMenuView) + + + +class VersionView(View): + def run(self): + from seedsigner.helpers.version import Version + + version_fork = Version.get_version_fork() + short_commit_hash = Version.get_short_commit_hash() + + if Version.is_release_image(): + # Don't display fork name or commit hash for release images + version_fork = 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(), + 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 c8467e27..de2db1a8 100644 --- a/tests/screenshot_generator/generator.py +++ b/tests/screenshot_generator/generator.py @@ -14,6 +14,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() @@ -114,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. @@ -211,6 +218,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... @@ -316,10 +326,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), 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)), @@ -445,6 +470,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"), ScreenshotConfig(settings_views.SettingsSelectionRequiredWarningView, dict(attr_name=SettingsConstants.SETTING__SCRIPT_TYPES)), diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 00000000..3f7cff37 --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,1193 @@ +import json +import os +import pytest +from datetime import datetime, timedelta, 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, VersionUtils, NotAllowedInSeedSignerOS, not_allowed_in_seedsigner_os +from seedsigner.models.settings import Settings + + + +# overrides +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().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.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(), +} + +# 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_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, 'VERSIONFILE__FILENAME', TEST__VERSIONFILE__FILENAME): + 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_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 patch.dict(os.environ, {VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "false"}): + 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.VERSIONFILE__FILENAME == TEST__VERSIONFILE__FILENAME + 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.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): + def test_setup_and_teardown(self): + """ + Ensure that the setup and teardown methods work as expected. + """ + 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()) + + # 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 TestVersionUtils_BasicCalls(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_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 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 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 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): + """ + 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_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 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 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. + + 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] == 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() + + + 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 + + # 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_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 + + + +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 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__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 + 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 + with patch.dict(os.environ, { + VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "true", + 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}" + + # 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__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 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__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 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): + 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): + """ + is_github_actions_ci should return True only when the + ENV_VAR__GITHUB_ACTIONS__IS_CI env var is set to "true". + + 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 patch.dict(os.environ, {VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "true"}): + assert VersionUtils.is_github_actions_ci() is True + + with patch.dict(os.environ, {VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "false"}): + assert VersionUtils.is_github_actions_ci() is False + + with 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 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 + 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 + assert VersionUtils._get_version_name_from_github_actions_env_vars() is None + + # HEAD_REF should be passed straight through, ignoring other vars + 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__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 + + # 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__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 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__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] + + + 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 + + + +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 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 patch.multiple( + "seedsigner.helpers.version.VersionUtils", + _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), + ): + # 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__IS_SEEDSIGNER_OS_BUILDER env var is set. + """ + with patch.dict(os.environ, {}, clear=True): + assert VersionUtils._is_seedsigner_os_builder_env() is False + + with patch.dict(os.environ, {VersionUtils.ENV_VAR__IS_SEEDSIGNER_OS_BUILDER: "1"}): + assert VersionUtils._is_seedsigner_os_builder_env() is True + + + +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 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_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), + ): + # 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): + """ + 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 patch.multiple( + "seedsigner.helpers.version.VersionUtils", + _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 patch.multiple( + "seedsigner.helpers.version.VersionUtils", + _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. + # Note: when falling back to the commit hash, we're expecting the SHORT commit + # hash. + with patch.multiple( + "seedsigner.helpers.version.VersionUtils", + _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__SHORT_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. + """ + 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 == 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): + """ + 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 + + # 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): + 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 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), + ): + # 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_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. + 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): + """ + _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... + 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 == TEST__VERSION_BRANCH + assert commit_hash is None + + # If we're in a detached HEAD state at a specific 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 == TEST__FULL_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 os.path.exists(VersionUtils._get_dot_git_dir()) is False + + # 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 + + # 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 + + # 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 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 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 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 + + + 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 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. Should also handle unrecognized formats. + 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 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 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 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 patch("builtins.open", side_effect=FileNotFoundError): + assert VersionUtils._get_version_fork_from_git_config() is None + + + def test__get_last_modified_timestamp_from_src_files(self): + """ + _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_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 + 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 patch("os.walk", return_value=[]): + assert VersionUtils._get_last_modified_timestamp_from_src_files() is None + + + +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. + """ + 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 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 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 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 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_short_commit_hash() == TEST__VERSION_DICT[VersionUtils.VERSIONFILE_ATTR__SHORT_COMMIT_HASH] + 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() + method. + """ + # 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__SHORT_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() + + 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(**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): + """ + Test that Version.to_dict() returns the expected dictionary. + """ + 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): + 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 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 patch("seedsigner.models.settings.Settings.HOSTNAME", "my_dev_machine"): + assert self.dummy_function() == self.SUCCESS + + + +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 + persist. + """ + assert VersionUtils.VERSIONFILE__FILENAME != TEST__VERSIONFILE__FILENAME + + + def test_mock_popen__not_patched(self): + """ + Ensure that os.popen is not mocked outside of VersionBaseTest. + """ + result = os.popen("echo 'Hello, World!'") + assert not isinstance(result, mock.MagicMock) diff --git a/tools/write_versionfile.py b/tools/write_versionfile.py new file mode 100644 index 00000000..4bf4cf19 --- /dev/null +++ b/tools/write_versionfile.py @@ -0,0 +1,47 @@ +import json +import os + +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. + +Notes: + * The SeedSigner OS build environment already relies on `git` being installed. + * 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: Retrieves the current git status by checking, in order: + * Current git branch name + * Current git tag name + * Current git commit hash + + * version_fork: + * The current repo owner. + + * short_commit_hash: + * 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__": + # 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))