diff --git a/src/seedsigner/helpers/version.py b/src/seedsigner/helpers/version.py index 7590f9de..b228dada 100644 --- a/src/seedsigner/helpers/version.py +++ b/src/seedsigner/helpers/version.py @@ -40,52 +40,70 @@ class Version(Singleton): around in the main code) and so that the version data is only determined once per runtime. - On SeedSigner OS: - * Version data is written to `src/seedsigner/version.json` during the build - process via tools/write_versionfile.py. - * version_name: copied from the command used to build the SeedSigner OS image: - --app-branch (the target git branch OR tag) or - --app-commit-id (target commit hash) - * version_fork: the git repo owner/fork name (e.g. "SeedSigner") targeted by the - build process. - * version_timestamp: the last git commit time for the target branch, tag, or - commit hash. - * commit_hash: the short commit hash for the specified build target. + SeedSigner OS lifecycle: + * The SeedSigner OS build process runs the tools/write_versionfile.py script to + generate version.json. + * The version.json file is included in the SeedSigner OS image. + + The version data is fetched differently depending on the environment: + + In SeedSigner OS: + * Version data is read exclusively from version.json at runtime. + + In the SeedSigner OS builder: + - Note: the build process relies on `git` being installed so we leverage it here. + * version_name: + * read dynamically from `git` shell calls to retrieve, in order: + * Current git branch name + * Current git tag name + * Current git short commit hash + * version_fork: + * the git repo owner parsed out of the remote url. + * https://github.com/SeedSigner/seedsigner.git -> "SeedSigner" + * Read dynamically from shell `git` call to check the remote "origin" URL. + * short_commit_hash: + * the commit hash for the current branch / tag / detached HEAD. + * Read dynamically from shell `git` call. + * version_timestamp: + * the last git commit time for the current branch / tag / detached HEAD. + * Read dynamically from shell `git` call. In local dev: - * version_name: read dynamically from a few possible sources. In order: - * SEEDSIGNER_VERSION_NAME env var, if available. - * Shell `git` calls (e.g. `git branch --show-current`). - * Directly parsing the .git/HEAD file and possibly .git/refs/tags. - * Note: we avoid reading from the version.json file as it may be out of date - and could lead to confusion. - * version_fork: read dynamically from: - * Shell `git` call to check the remote "origin" URL. - * Parse the .git/config for the remote "origin" URL. + - Similar process as for the SeedSigner OS builder, but with additional fallbacks and + a different method for determining version_timestamp. + - If a local version.json is present, it will be ignored; it may be out of date and + could lead to confusion. + * version_name: + * `git` shell calls + directly parsing the .git/HEAD file and .git/refs/tags + when necessary. + * version_fork: + * `git` shell call + parse the .git/config for the remote "origin" URL. + * commit_hash: + * Shell `git` call + parse the .git/HEAD file and + .git/refs/heads/ when necessary. * version_timestamp: determined by scanning the src/ directory for the most recently modified python file. - * commit_hash: read dynamically from: - * Shell `git` call. - * Parse the .git/HEAD file and possibly .git/refs/heads/. In Github Actions CI: - * version_name: read from GITHUB_REF_NAME or GITHUB_SHA env vars. - * version_fork: TODO - * version_timestamp: TODO - * commit_hash: TODO + * version_name: read from GITHUB_REF_NAME env var. + * version_fork: read from GITHUB_REPOSITORY_OWNER env var. + * version_timestamp: Shell `git` call to get the last commit time for the current + branch/tag/commit. + * commit_hash: read from GITHUB_SHA env var. This class defines the limited methods that are meant to be publicly accessible across the SeedSigner codebase. - The misc utility functions in `VersionUtils` were explicitly isolated because they - should NOT be used elsewhere in the codebase. + The utility functions in `VersionUtils` were explicitly isolated because they should + NOT be used elsewhere in the codebase. TODO: Should `VersionUtils` be an internal class within `Version` to further signal that it is not to be used externally? + TODO: Also pull git log history and display it in its own View? """ _version_name: str = None _version_fork: str = None - _version_timestamp: datetime = None _short_commit_hash: str = None + _version_timestamp: datetime = None @classmethod @@ -114,27 +132,31 @@ class Version(Singleton): return cls.get_instance()._version_fork - @classmethod - def get_version_timestamp(cls) -> datetime | None: - return cls.get_instance()._version_timestamp - - @classmethod def get_short_commit_hash(cls) -> str | None: return cls.get_instance()._short_commit_hash + @classmethod + def get_version_timestamp(cls) -> datetime | None: + return cls.get_instance()._version_timestamp + + @classmethod @not_allowed_in_seedsigner_os - def override_data(cls, version_name, version_fork, version_timestamp, short_commit_hash): + def override_data(cls, **kwargs): """ Only used by the screenshot generator. """ instance = cls.get_instance() - instance._version_name = version_name - instance._version_fork = version_fork - instance._version_timestamp = version_timestamp - instance._short_commit_hash = short_commit_hash + if VersionUtils.VERSIONFILE_ATTR__NAME in kwargs: + instance._version_name = kwargs[VersionUtils.VERSIONFILE_ATTR__NAME] + if VersionUtils.VERSIONFILE_ATTR__FORK in kwargs: + instance._version_fork = kwargs[VersionUtils.VERSIONFILE_ATTR__FORK] + if VersionUtils.VERSIONFILE_ATTR__SHORT_COMMIT_HASH in kwargs: + instance._short_commit_hash = kwargs[VersionUtils.VERSIONFILE_ATTR__SHORT_COMMIT_HASH] + if VersionUtils.VERSIONFILE_ATTR__TIMESTAMP in kwargs: + instance._version_timestamp = kwargs[VersionUtils.VERSIONFILE_ATTR__TIMESTAMP] @classmethod @@ -143,11 +165,12 @@ class Version(Singleton): return { VersionUtils.VERSIONFILE_ATTR__NAME: instance._version_name, VersionUtils.VERSIONFILE_ATTR__FORK: instance._version_fork, + VersionUtils.VERSIONFILE_ATTR__SHORT_COMMIT_HASH: instance._short_commit_hash, VersionUtils.VERSIONFILE_ATTR__TIMESTAMP: instance._version_timestamp.isoformat() if instance._version_timestamp else None, - VersionUtils.VERSIONFILE_ATTR__COMMIT_HASH: instance._short_commit_hash, } + class VersionUtils: """ ********************************************************************************* Not meant to be used elsewhere in the SeedSigner codebase (aside from @@ -168,7 +191,7 @@ class VersionUtils: * One external http GET to github to get the most recent release tag. ********************************************************************************* """ - ENV_VAR__SEEDSIGNER_OS_BUILDER__VERSION_NAME = "SEEDSIGNER_VERSION_NAME" + ENV_VAR__IS_SEEDSIGNER_OS_BUILDER = "SEEDSIGNER_OS_BUILDER" ENV_VAR__GITHUB_ACTIONS__IS_CI = "CI" ENV_VAR__GITHUB_ACTIONS__REF_NAME = "GITHUB_REF_NAME" ENV_VAR__GITHUB_ACTIONS__SHA = "GITHUB_SHA" @@ -177,8 +200,8 @@ class VersionUtils: VERSIONFILE__FILENAME = "version.json" VERSIONFILE_ATTR__NAME = "name" VERSIONFILE_ATTR__FORK = "fork" + VERSIONFILE_ATTR__SHORT_COMMIT_HASH = "short_commit_hash" VERSIONFILE_ATTR__TIMESTAMP = "timestamp" - VERSIONFILE_ATTR__COMMIT_HASH = "commit_hash" """ ************************************************************************************* @@ -211,18 +234,10 @@ class VersionUtils: else: raise Exception("Could not determine version from Github Actions env vars.") - elif VersionUtils._is_seedsigner_os_builder_env(): - # In the SeedSigner OS build environment, get the version name from env var. - # Note: This get_version_name call will never fail because the env var that - # provides the version name is what defines whether we're in the SeedSigner OS - # builder in the first place. - version_name = VersionUtils._get_version_name_from_seedsigner_os_builder_env_var() - return VersionUtils._prefix_version_name(version_name) - else: - # In local dev, we try the following methods in order: + # In the SeedSigner OS builder we know that we'll have the `git` shell + # commands available. We add extra fallbacks for local dev. for get_version_name_method in [ - VersionUtils._get_version_name_from_seedsigner_os_builder_env_var, VersionUtils._get_version_name_from_git_shell, VersionUtils._get_version_name_from_git_HEAD, ]: @@ -252,12 +267,9 @@ class VersionUtils: # In Github Actions CI, try to get the version name from env vars return VersionUtils._get_version_fork_from_github_actions_env_vars() - elif VersionUtils._is_seedsigner_os_builder_env(): - # In the SeedSigner OS build environment `git` shell call should be available - return VersionUtils._get_version_fork_from_git_shell() - else: - # In local dev we try to access the current git state via: + # In the SeedSigner OS builder we know that we'll have the `git` shell + # commands available. We add extra fallbacks for local dev. for get_version_fork_method in [ VersionUtils._get_version_fork_from_git_shell, VersionUtils._get_version_fork_from_git_config, @@ -268,6 +280,45 @@ class VersionUtils: return None + @classmethod + def get_short_commit_hash(cls) -> str | None: + """ + Returns the short commit hash string. + + Will be None if the local dev system has no git state available or if it only has + the .git/HEAD but is currently on a branch (not a tag or specific commit). + """ + if Settings.HOSTNAME == Settings.SEEDSIGNER_OS: + return VersionUtils._get_short_commit_hash_from_version_file() + + full_commit_hash = None + if VersionUtils.is_github_actions_ci(): + # In Github Actions CI the "SHA" env var should always be available + full_commit_hash = VersionUtils._get_full_commit_hash_from_github_actions_env_vars() + + else: + # In the SeedSigner OS builder we know that we'll have the `git` shell + # commands available. We add extra fallbacks for local dev. + for get_full_commit_hash_method in [ + VersionUtils._get_full_commit_hash_from_git_shell, + VersionUtils._get_full_commit_hash_from_git_HEAD, + ]: + full_commit_hash = get_full_commit_hash_method() + if full_commit_hash is not None: + break + + if full_commit_hash is None: + # `git` shell calls didn't work and HEAD might be on a branch, so dig + # deeper into the .git files to look up the commit hash via the branch + # name. + branch_name, expect_full_commit_hash_to_be_none = VersionUtils._read_git_HEAD_file() + if branch_name: + full_commit_hash = VersionUtils._get_full_commit_hash_from_git_refs_heads(branch_name) + + if full_commit_hash is not None: + return full_commit_hash[:7] + + @classmethod def get_version_timestamp(cls) -> datetime: """ @@ -290,7 +341,10 @@ class VersionUtils: return VersionUtils._get_version_timestamp_from_git_shell() elif VersionUtils._is_seedsigner_os_builder_env(): - # In the SeedSigner OS build environment `git` shell call should be available + # In the SeedSigner OS builder we know that we'll have the `git` shell + # commands available. + # Note: This is the only piece of version data that behaves differently + # between the SeedSigner OS builder and local dev. return VersionUtils._get_version_timestamp_from_git_shell() else: @@ -299,48 +353,10 @@ class VersionUtils: return VersionUtils._get_last_modified_timestamp_from_src_files() - @classmethod - def get_short_commit_hash(cls) -> str | None: - """ - Returns the short commit hash string. - - Will be None if the local dev system has no git state available or if it only has - the .git/HEAD but is currently on a branch (not a tag or specific commit). - """ - if Settings.HOSTNAME == Settings.SEEDSIGNER_OS: - return VersionUtils._get_short_commit_hash_from_version_file() - - full_commit_hash = None - if VersionUtils.is_github_actions_ci(): - # In Github Actions CI the "SHA" env var should always be available - full_commit_hash = VersionUtils._get_full_commit_hash_from_github_actions_env_vars() - - elif VersionUtils._is_seedsigner_os_builder_env(): - # In the SeedSigner OS build environment `git` shell call should be available - full_commit_hash = VersionUtils._get_full_commit_hash_from_git_shell() - - else: - # In local dev we try to access the current git state via: - for get_full_commit_hash_method in [ - VersionUtils._get_full_commit_hash_from_git_shell, - VersionUtils._get_full_commit_hash_from_git_HEAD, - ]: - full_commit_hash = get_full_commit_hash_method() - if full_commit_hash is not None: - break - - if full_commit_hash is None: - # `git` shell calls didn't work and HEAD might be on a branch, so dig - # deeper into the .git files to look up the commit hash via the branch - # name. - branch_name, expect_full_commit_hash_to_be_none = VersionUtils._read_git_HEAD_file() - if branch_name: - full_commit_hash = VersionUtils._get_full_commit_hash_from_git_refs_heads(branch_name) - - if full_commit_hash is not None: - return full_commit_hash[:7] - + """ ************************************************************************************* + Misc internal utility functions. + ************************************************************************************* """ @classmethod def _prefix_version_name(cls, version_name: str) -> str: """ @@ -353,6 +369,7 @@ class VersionUtils: return version_name + """ ************************************************************************************* Reading data from the version.json file. ************************************************************************************* """ @@ -398,6 +415,16 @@ class VersionUtils: return version_data.get(cls.VERSIONFILE_ATTR__FORK) + @classmethod + def _get_short_commit_hash_from_version_file(cls) -> str | None: + """ + Attempts to read the version.json and return the version commit hash. + """ + version_data = cls._read_version_file() + if version_data: + return version_data.get(cls.VERSIONFILE_ATTR__SHORT_COMMIT_HASH) + + @classmethod def _get_version_timestamp_from_version_file(cls) -> str | None: """ @@ -408,16 +435,6 @@ class VersionUtils: return version_data.get(cls.VERSIONFILE_ATTR__TIMESTAMP) - @classmethod - def _get_short_commit_hash_from_version_file(cls) -> str | None: - """ - Attempts to read the version.json and return the version commit hash. - """ - version_data = cls._read_version_file() - if version_data: - return version_data.get(cls.VERSIONFILE_ATTR__COMMIT_HASH) - - """ ************************************************************************************* Utilities used in the SeedSigner OS build environment and writing the version.json file. @@ -425,20 +442,10 @@ class VersionUtils: @classmethod def _is_seedsigner_os_builder_env(cls) -> bool: """ - Simple check to see if we're running in the SeedSigner OS build environment. + The `ENV_VAR__IS_SEEDSIGNER_OS_BUILDER` env var is set during the SeedSigner OS + build process. """ - return os.getenv(cls.ENV_VAR__SEEDSIGNER_OS_BUILDER__VERSION_NAME) is not None - - - @classmethod - def _get_version_name_from_seedsigner_os_builder_env_var(cls) -> str | None: - """ - Primarily used during the SeedSigner OS build process to set the version name via env var. - - This env var can also be set manually in local dev when needed. - e.g. SEEDSIGNER_VERSION_NAME=some_name python main.py - """ - return os.getenv(cls.ENV_VAR__SEEDSIGNER_OS_BUILDER__VERSION_NAME) + return os.getenv(cls.ENV_VAR__IS_SEEDSIGNER_OS_BUILDER) is not None @@ -478,14 +485,14 @@ class VersionUtils: ************************************************************************************* """ @classmethod @not_allowed_in_seedsigner_os - def _get_version_name_from_git_shell_branch(cls) -> str | None: + def _get_branch_name_from_git_shell(cls) -> str | None: branch_name = os.popen("git branch --show-current 2> /dev/null").read() return branch_name.strip() if branch_name else None @classmethod @not_allowed_in_seedsigner_os - def _get_version_name_from_git_shell_tag(cls) -> str | None: + def _get_tag_name_from_git_shell(cls) -> str | None: # Only return a value if the current commit exactly corresponds with a tag. # (`--points-at` defaults to the current HEAD) tag_name = os.popen(f"git tag --points-at 2> /dev/null").read() @@ -496,22 +503,34 @@ class VersionUtils: @not_allowed_in_seedsigner_os def _get_full_commit_hash_from_git_shell(cls) -> str | None: """ - Attempts to get the current git commit hash via shell `git` commands. + Attempts to get the current full commit hash via shell `git` commands. """ commit_hash = os.popen("git rev-parse HEAD").read() return commit_hash.strip() if commit_hash else None + @classmethod + @not_allowed_in_seedsigner_os + def _get_short_commit_hash_from_git_shell(cls) -> str | None: + """ + Attempts to get the current short commit hash via shell `git` commands. + """ + commit_hash = cls._get_full_commit_hash_from_git_shell() + return commit_hash[:7] if commit_hash else None + + @classmethod @not_allowed_in_seedsigner_os def _get_version_name_from_git_shell(cls) -> str | None: """ Attempts to get the version name via shell `git` commands. + + Note: If we have to fall back to the commit hash, we use the short version. """ return ( - cls._get_version_name_from_git_shell_branch() or - cls._get_version_name_from_git_shell_tag() or - cls._get_full_commit_hash_from_git_shell() + cls._get_branch_name_from_git_shell() or + cls._get_tag_name_from_git_shell() or + cls._get_short_commit_hash_from_git_shell() ) @@ -747,7 +766,7 @@ class VersionUtils: # Shouldn't be possible raise Exception("No python source files found in src/ directory") - return datetime.fromtimestamp(last_modified) + return datetime.fromtimestamp(last_modified).astimezone(timezone.utc).replace(tzinfo=None) except Exception as e: # Catch and log any unexpected errors but this isn't a mission-critical diff --git a/tests/screenshot_generator/generator.py b/tests/screenshot_generator/generator.py index bd088eb0..28dca7b0 100644 --- a/tests/screenshot_generator/generator.py +++ b/tests/screenshot_generator/generator.py @@ -197,16 +197,19 @@ def generate_screenshots(locale): value=SettingsConstants.OPTION__ENABLED ) - # Initialize the Version data to the most recent release - (version_name, version_timestamp) = VersionUtils._fetch_latest_seedsigner_release_tag() - if not version_name or not version_timestamp: - raise Exception("Could not fetch latest release version from GitHub") - Version.override_data( - version_name=version_name, - version_fork="SeedSigner", # main repo; screenshot should hide fork and commit hash - version_timestamp=version_timestamp, - short_commit_hash="abcd1234" # dummy value should be ignored - ) + def reset_version_most_recent_release(): + # Initialize the Version data to the most recent release + (version_name, version_timestamp) = VersionUtils._fetch_latest_seedsigner_release_tag() + if not version_name or not version_timestamp: + raise Exception("Could not fetch latest release version from GitHub") + new_values = { + VersionUtils.VERSIONFILE_ATTR__NAME: version_name, + VersionUtils.VERSIONFILE_ATTR__FORK: "SeedSigner", # main repo; screenshot should hide fork and commit hash + VersionUtils.VERSIONFILE_ATTR__TIMESTAMP: version_timestamp, + VersionUtils.VERSIONFILE_ATTR__SHORT_COMMIT_HASH: "abcd1234" # dummy value should be ignored + } + Version.override_data(**new_values) + reset_version_most_recent_release() # Automatically populate all Settings options Views settings_views_list = [] @@ -314,14 +317,19 @@ def generate_screenshots(locale): def reset_version_to_local_git_state_cb(): # Normally, directly manipulating the singleton's internal instance is not - # allowed, but the screnshot generator is an atypical use case. + # allowed, but the screnshot generator is a special exception. Version._instance = None + def reset_version_to_most_recent_release_cb(): + reset_version_most_recent_release() + + screenshot_sections = { "Main Menu Views": [ ScreenshotConfig(OpeningSplashView, dict(force_partner_logos=True)), ScreenshotConfig(OpeningSplashView, dict(force_partner_logos=False), screenshot_name="OpeningSplashView_no_partner_logos"), + ScreenshotConfig(OpeningSplashView, dict(force_partner_logos=True), run_before=reset_version_to_local_git_state_cb, screenshot_name="OpeningSplashView_current_git_state"), ScreenshotConfig(MainMenuView), ScreenshotConfig(MainMenuView, screenshot_name='MainMenuView_SDCardStateChangeToast_removed', toast_thread=SDCardStateChangeToastManagerThread(action=MicroSD.ACTION__REMOVED, activation_delay=0, duration=0)), ScreenshotConfig(MainMenuView, screenshot_name='MainMenuView_SDCardStateChangeToast_inserted', toast_thread=SDCardStateChangeToastManagerThread(action=MicroSD.ACTION__INSERTED, activation_delay=0, duration=0)), @@ -447,7 +455,7 @@ def generate_screenshots(locale): "Settings Views": settings_views_list + [ ScreenshotConfig(settings_views.IOTestView), ScreenshotConfig(settings_views.DonateView), - ScreenshotConfig(settings_views.VersionView), + ScreenshotConfig(settings_views.VersionView, run_before=reset_version_to_most_recent_release_cb), ScreenshotConfig(settings_views.VersionView, run_before=reset_version_to_local_git_state_cb, screenshot_name="VersionView_current_git_state"), ScreenshotConfig(settings_views.SettingsIngestSettingsQRView, dict(data=settingsqr_data_persistent), screenshot_name="SettingsIngestSettingsQRView_persistent"), ScreenshotConfig(settings_views.SettingsIngestSettingsQRView, dict(data=settingsqr_data_not_persistent), screenshot_name="SettingsIngestSettingsQRView_not_persistent"), diff --git a/tests/test_version.py b/tests/test_version.py index 354a5716..34119423 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -29,8 +29,8 @@ TEST__FULL_COMMIT_HASH = "c5efda306c60877191013a6093d92cd0bfcccec8" TEST__VERSION_DICT = { VersionUtils.VERSIONFILE_ATTR__NAME: VersionUtils._prefix_version_name(TEST__VERSION_NAME), VersionUtils.VERSIONFILE_ATTR__FORK: TEST__VERSION_FORK, + VersionUtils.VERSIONFILE_ATTR__SHORT_COMMIT_HASH: TEST__SHORT_COMMIT_HASH, VersionUtils.VERSIONFILE_ATTR__TIMESTAMP: TEST__VERSION_TIMESTAMP.isoformat(), - VersionUtils.VERSIONFILE_ATTR__COMMIT_HASH: TEST__SHORT_COMMIT_HASH, } # Mimic result of reading from version.json @@ -68,7 +68,7 @@ class VersionBaseTest(BaseTest): assumptions when running locally and when actually running in CI. Any test that needs to simulate being in CI can override this env var as needed. """ - with mock.patch.dict(os.environ, {VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "false"}): + with patch.dict(os.environ, {VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "false"}): yield @@ -193,7 +193,7 @@ class TestVersionUtils_BasicCalls(VersionBaseTest): # If we don't provide the SeedSigner OS or Github Actions CI env vars, mock out # the `git` shell commands via `mock_popen`, and mock out all .git/ file parsing, # then the method should return the fallback warning note as the version name. - with mock.patch("builtins.open", side_effect=FileNotFoundError): + with patch("builtins.open", side_effect=FileNotFoundError): result = VersionUtils.get_version_name() assert "not detected" in result @@ -206,7 +206,7 @@ class TestVersionUtils_BasicCalls(VersionBaseTest): # If we don't provide the SeedSigner OS or Github Actions CI env vars, mock out # the `git` shell commands via `mock_popen`, and mock out all .git/ file parsing, # then the method should return None. - with mock.patch("builtins.open", side_effect=FileNotFoundError): + with patch("builtins.open", side_effect=FileNotFoundError): VersionUtils.get_version_fork() is None @@ -218,7 +218,7 @@ class TestVersionUtils_BasicCalls(VersionBaseTest): # If we don't provide the SeedSigner OS or Github Actions CI env vars, mock out # the `git` shell commands via `mock_popen`, and mock out all .git/ file parsing, # then the method should return None. - with mock.patch("builtins.open", side_effect=FileNotFoundError): + with patch("builtins.open", side_effect=FileNotFoundError): VersionUtils.get_short_commit_hash() is None @@ -238,21 +238,20 @@ class TestVersionUtils_VersionFile(VersionBaseTest): with patch("seedsigner.models.settings.Settings.HOSTNAME", Settings.SEEDSIGNER_OS): assert VersionUtils.get_version_name() == VersionUtils._prefix_version_name(TEST__VERSION_NAME) assert VersionUtils.get_version_fork() == TEST__VERSION_FORK - assert VersionUtils.get_version_timestamp() == TEST__VERSION_TIMESTAMP assert VersionUtils.get_short_commit_hash() == TEST__SHORT_COMMIT_HASH + assert VersionUtils.get_version_timestamp() == TEST__VERSION_TIMESTAMP # Expect errors if keys are missing from version.json - with mock.patch("builtins.open", mock.mock_open(read_data="{'some_key':'some_value'}")): + with patch("builtins.open", mock.mock_open(read_data="{'some_key':'some_value'}")): with pytest.raises(Exception): VersionUtils.get_version_name() # This time it's the timestamp that's missing - with mock.patch("builtins.open", mock.mock_open(read_data=str({VersionUtils.VERSIONFILE_ATTR__NAME: TEST__VERSION_NAME}).replace("'", '"'))): + with patch("builtins.open", mock.mock_open(read_data=str({VersionUtils.VERSIONFILE_ATTR__NAME: TEST__VERSION_NAME}).replace("'", '"'))): with pytest.raises(Exception): VersionUtils.get_version_timestamp() - def test__read_version_file(self): """ Low-level test for reading the version.json file. @@ -265,8 +264,8 @@ class TestVersionUtils_VersionFile(VersionBaseTest): assert version_data is not None assert version_data[VersionUtils.VERSIONFILE_ATTR__NAME] == VersionUtils._prefix_version_name(TEST__VERSION_NAME) assert version_data[VersionUtils.VERSIONFILE_ATTR__FORK] == TEST__VERSION_FORK + assert version_data[VersionUtils.VERSIONFILE_ATTR__SHORT_COMMIT_HASH] == TEST__SHORT_COMMIT_HASH assert version_data[VersionUtils.VERSIONFILE_ATTR__TIMESTAMP] == TEST__VERSION_TIMESTAMP.isoformat() - assert version_data[VersionUtils.VERSIONFILE_ATTR__COMMIT_HASH] == TEST__SHORT_COMMIT_HASH def test__read_version_file__missing(self): @@ -277,23 +276,14 @@ class TestVersionUtils_VersionFile(VersionBaseTest): # The upstream dependent calls should also return None assert VersionUtils._get_version_name_from_version_file() is None assert VersionUtils._get_version_fork_from_version_file() is None - assert VersionUtils._get_version_timestamp_from_version_file() is None assert VersionUtils._get_short_commit_hash_from_version_file() is None + assert VersionUtils._get_version_timestamp_from_version_file() is None # Gracefully handle other unexpected exceptions with patch("builtins.open", side_effect=Exception("Unexpected error")): assert VersionUtils._read_version_file() is None - def test__get_version_name_from_seedsigner_os_builder_env_var(self): - assert os.environ.get(VersionUtils.ENV_VAR__SEEDSIGNER_OS_BUILDER__VERSION_NAME) is None - assert VersionUtils._get_version_name_from_seedsigner_os_builder_env_var() is None - - with mock.patch.dict(os.environ, {VersionUtils.ENV_VAR__SEEDSIGNER_OS_BUILDER__VERSION_NAME: TEST__VERSION_NAME}): - result = VersionUtils._get_version_name_from_seedsigner_os_builder_env_var() - assert result == TEST__VERSION_NAME - - class TestVersionUtils_GithubActions(VersionBaseTest): def test_github_actions_env_vars(self): @@ -305,9 +295,9 @@ class TestVersionUtils_GithubActions(VersionBaseTest): pulled from the appropriate env vars. """ # CI uses some limited `git` shell calls; mock out the associated calls. - with mock.patch("seedsigner.helpers.version.VersionUtils._get_version_timestamp_from_git_shell", return_value=TEST__VERSION_TIMESTAMP): + with patch("seedsigner.helpers.version.VersionUtils._get_version_timestamp_from_git_shell", return_value=TEST__VERSION_TIMESTAMP): # When running CI on a branch - with mock.patch.dict(os.environ, { + with patch.dict(os.environ, { VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "true", VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: TEST__VERSION_BRANCH, VersionUtils.ENV_VAR__GITHUB_ACTIONS__SHA: TEST__FULL_COMMIT_HASH, @@ -319,7 +309,7 @@ class TestVersionUtils_GithubActions(VersionBaseTest): assert VersionUtils.get_version_timestamp() == TEST__VERSION_TIMESTAMP # When running CI on a semantic tag - with mock.patch.dict(os.environ, { + with patch.dict(os.environ, { VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "true", VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: TEST__SEMANTIC_TAG, }): @@ -327,7 +317,7 @@ class TestVersionUtils_GithubActions(VersionBaseTest): assert VersionUtils.get_version_name() == f"v{TEST__SEMANTIC_TAG}" # When running CI on a generic tag - with mock.patch.dict(os.environ, { + with patch.dict(os.environ, { VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "true", VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: TEST__VERSION_TAG, }): @@ -337,7 +327,7 @@ class TestVersionUtils_GithubActions(VersionBaseTest): # When running CI on a commit (detached HEAD) with no REF_NAME, the # version_name should be the short commit hash. # TODO: I don't think this scenario ever happens. - with mock.patch.dict(os.environ, { + with patch.dict(os.environ, { VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "true", VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: "", VersionUtils.ENV_VAR__GITHUB_ACTIONS__SHA: TEST__FULL_COMMIT_HASH, @@ -348,7 +338,7 @@ class TestVersionUtils_GithubActions(VersionBaseTest): # raise error. # Note: This scenario definitely would never happen. Just trying to get to # 100% test coverage. - with mock.patch.dict(os.environ, { + with patch.dict(os.environ, { VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "true", VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: "", VersionUtils.ENV_VAR__GITHUB_ACTIONS__SHA: "", @@ -365,13 +355,13 @@ class TestVersionUtils_GithubActions(VersionBaseTest): Note: Have to mock a value for ALL scenario variations here because this test will actually run in Github Actions so the env var will already be present! """ - with mock.patch.dict(os.environ, {VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "true"}): + with patch.dict(os.environ, {VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "true"}): assert VersionUtils.is_github_actions_ci() is True - with mock.patch.dict(os.environ, {VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "false"}): + with patch.dict(os.environ, {VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "false"}): assert VersionUtils.is_github_actions_ci() is False - with mock.patch.dict(os.environ, {VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "1"}): + with patch.dict(os.environ, {VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "1"}): assert VersionUtils.is_github_actions_ci() is False @@ -381,7 +371,7 @@ class TestVersionUtils_GithubActions(VersionBaseTest): env vars when set. """ # Need to signal that we're in a GitHub Actions CI environment - with mock.patch.dict(os.environ, { + with patch.dict(os.environ, { VersionUtils.ENV_VAR__GITHUB_ACTIONS__IS_CI: "true", VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: "", VersionUtils.ENV_VAR__GITHUB_ACTIONS__SHA: "", @@ -389,12 +379,12 @@ class TestVersionUtils_GithubActions(VersionBaseTest): assert VersionUtils._get_version_name_from_github_actions_env_vars() is None # REF_NAME should be passed straight through - with mock.patch.dict(os.environ, {VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: TEST__VERSION_NAME}): + with patch.dict(os.environ, {VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: TEST__VERSION_NAME}): result = VersionUtils._get_version_name_from_github_actions_env_vars() assert result == TEST__VERSION_NAME # Unlikely scenario: no REF_NAME but SHA is set; should return short commit hash - with mock.patch.dict(os.environ, { + with patch.dict(os.environ, { VersionUtils.ENV_VAR__GITHUB_ACTIONS__REF_NAME: "", VersionUtils.ENV_VAR__GITHUB_ACTIONS__SHA: TEST__FULL_COMMIT_HASH, }): @@ -410,30 +400,40 @@ class TestVersionUtils_SeedSignerOSBuilder(VersionBaseTest): since all the downstream helper methods are tested in detail elsewhere. """ # Simulate running in the SeedSigner OS build environment - with mock.patch.dict(os.environ, {VersionUtils.ENV_VAR__SEEDSIGNER_OS_BUILDER__VERSION_NAME: TEST__VERSION_NAME}): + with patch.dict(os.environ, {VersionUtils.ENV_VAR__IS_SEEDSIGNER_OS_BUILDER: "1"}): # SeedSigner OS builder uses some limited `git` shell calls; mock out the # associated calls. - with mock.patch.multiple( + with patch.multiple( "seedsigner.helpers.version.VersionUtils", - _get_version_timestamp_from_git_shell=Mock(return_value=TEST__VERSION_TIMESTAMP), + _get_version_name_from_git_shell=Mock(return_value=TEST__VERSION_NAME), _get_version_fork_from_git_shell=Mock(return_value=TEST__VERSION_FORK), _get_full_commit_hash_from_git_shell=Mock(return_value=TEST__FULL_COMMIT_HASH), + _get_version_timestamp_from_git_shell=Mock(return_value=TEST__VERSION_TIMESTAMP), ): - assert VersionUtils.get_version_name() == VersionUtils._prefix_version_name(TEST__VERSION_NAME) - assert VersionUtils.get_version_fork() == TEST__VERSION_FORK - assert VersionUtils.get_short_commit_hash() == TEST__SHORT_COMMIT_HASH - assert VersionUtils.get_version_timestamp() == TEST__VERSION_TIMESTAMP + # Meanwhile, mock out the timestamp from source files and ensure it's NOT used + with patch.object( + VersionUtils, + "_get_last_modified_timestamp_from_src_files", + Mock(return_value=datetime.now()) + ): + assert VersionUtils.get_version_name() == VersionUtils._prefix_version_name(TEST__VERSION_NAME) + assert VersionUtils.get_version_fork() == TEST__VERSION_FORK + assert VersionUtils.get_short_commit_hash() == TEST__SHORT_COMMIT_HASH + assert VersionUtils.get_version_timestamp() == TEST__VERSION_TIMESTAMP + + # Verify that the source file timestamp method was NOT called + VersionUtils._get_last_modified_timestamp_from_src_files.assert_not_called() def test_is_seedsigner_os_builder_env(self): """ is_seedsigner_os_builder_env should return True only when the - ENV_VAR__SEEDSIGNER_OS_BUILDER__VERSION_NAME env var is set. + ENV_VAR__IS_SEEDSIGNER_OS_BUILDER env var is set. """ - with mock.patch.dict(os.environ, {}, clear=True): + with patch.dict(os.environ, {}, clear=True): assert VersionUtils._is_seedsigner_os_builder_env() is False - with mock.patch.dict(os.environ, {VersionUtils.ENV_VAR__SEEDSIGNER_OS_BUILDER__VERSION_NAME: TEST__VERSION_NAME}): + with patch.dict(os.environ, {VersionUtils.ENV_VAR__IS_SEEDSIGNER_OS_BUILDER: "1"}): assert VersionUtils._is_seedsigner_os_builder_env() is True @@ -450,17 +450,29 @@ class TestVersionUtils_GitShell(VersionBaseTest): Note that in local dev we use the last modified timestamp from the source files rather than from git. """ - with mock.patch.multiple( + with patch.multiple( "seedsigner.helpers.version.VersionUtils", _get_version_name_from_git_shell=Mock(return_value=TEST__VERSION_BRANCH), _get_version_fork_from_git_shell=Mock(return_value=TEST__VERSION_FORK), - _get_last_modified_timestamp_from_src_files=Mock(return_value=TEST__VERSION_TIMESTAMP), _get_full_commit_hash_from_git_shell=Mock(return_value=TEST__SHORT_COMMIT_HASH), + _get_last_modified_timestamp_from_src_files=Mock(return_value=TEST__VERSION_TIMESTAMP), ): - assert VersionUtils.get_version_name() == TEST__VERSION_BRANCH - assert VersionUtils.get_version_fork() == TEST__VERSION_FORK - assert VersionUtils.get_version_timestamp() == TEST__VERSION_TIMESTAMP - assert VersionUtils.get_short_commit_hash() == TEST__SHORT_COMMIT_HASH + # Mock out the git shell call that retrieves the timestamp. + with patch( + "seedsigner.helpers.version.VersionUtils._get_version_timestamp_from_git_shell", + return_value=None + ) as mock_timestamp_from_git_shell: + # Verify that we're using the local dev code path + assert VersionUtils.is_github_actions_ci() is False + assert VersionUtils._is_seedsigner_os_builder_env() is False + + assert VersionUtils.get_version_name() == TEST__VERSION_BRANCH + assert VersionUtils.get_version_fork() == TEST__VERSION_FORK + assert VersionUtils.get_short_commit_hash() == TEST__SHORT_COMMIT_HASH + assert VersionUtils.get_version_timestamp() == TEST__VERSION_TIMESTAMP + + # Verify that the git shell timestamp method was NOT called + mock_timestamp_from_git_shell.assert_not_called() def test__get_version_name_from_git_shell(self): @@ -474,34 +486,36 @@ class TestVersionUtils_GitShell(VersionBaseTest): assert result is None # If we're on a branch, should return the branch name - with mock.patch.multiple( + with patch.multiple( "seedsigner.helpers.version.VersionUtils", - _get_version_name_from_git_shell_branch=Mock(return_value=TEST__VERSION_BRANCH), - _get_version_name_from_git_shell_tag=Mock(return_value=TEST__VERSION_TAG), + _get_branch_name_from_git_shell=Mock(return_value=TEST__VERSION_BRANCH), + _get_tag_name_from_git_shell=Mock(return_value=TEST__VERSION_TAG), _get_full_commit_hash_from_git_shell=Mock(return_value=TEST__FULL_COMMIT_HASH), ): result = VersionUtils._get_version_name_from_git_shell() assert result == TEST__VERSION_BRANCH # If we're on a tag, the detached HEAD state wipes out the branch name - with mock.patch.multiple( + with patch.multiple( "seedsigner.helpers.version.VersionUtils", - _get_version_name_from_git_shell_branch=Mock(return_value=None), - _get_version_name_from_git_shell_tag=Mock(return_value=TEST__VERSION_TAG), + _get_branch_name_from_git_shell=Mock(return_value=None), + _get_tag_name_from_git_shell=Mock(return_value=TEST__VERSION_TAG), _get_full_commit_hash_from_git_shell=Mock(return_value=TEST__FULL_COMMIT_HASH), ): result = VersionUtils._get_version_name_from_git_shell() assert result == TEST__VERSION_TAG - # Similarly, if we're detached at a specific commit hash - with mock.patch.multiple( + # Similarly, if we're detached at a specific commit hash. + # Note: when falling back to the commit hash, we're expecting the SHORT commit + # hash. + with patch.multiple( "seedsigner.helpers.version.VersionUtils", - _get_version_name_from_git_shell_branch=Mock(return_value=None), - _get_version_name_from_git_shell_tag=Mock(return_value=None), + _get_branch_name_from_git_shell=Mock(return_value=None), + _get_tag_name_from_git_shell=Mock(return_value=None), _get_full_commit_hash_from_git_shell=Mock(return_value=TEST__FULL_COMMIT_HASH), ): result = VersionUtils._get_version_name_from_git_shell() - assert result == TEST__FULL_COMMIT_HASH + assert result == TEST__SHORT_COMMIT_HASH def test__get_version_fork_from_git_shell(self, mock_popen: Mock): @@ -509,12 +523,32 @@ class TestVersionUtils_GitShell(VersionBaseTest): Test that _get_version_fork_from_git_shell returns the expected repo owner from the remote url. """ - remote_url = "https://github.com/SeedSigner/seedsigner.git" - expected_fork = "SeedSigner" + for owner in ["SeedSigner", "seedsigner", "some-user"]: + remote_url = f"https://github.com/{owner}/seedsigner.git" - mock_popen.return_value.read.return_value = remote_url - result = VersionUtils._get_version_fork_from_git_shell() - assert result == expected_fork + mock_popen.return_value.read.return_value = remote_url + result = VersionUtils._get_version_fork_from_git_shell() + assert result == owner + + + def test__get_full_commit_hash_from_git_shell(self, mock_popen: Mock): + """ + Test that _get_full_commit_hash_from_git_shell returns the expected full commit hash. + """ + mock_popen.return_value.read.return_value = TEST__FULL_COMMIT_HASH + + result = VersionUtils._get_full_commit_hash_from_git_shell() + assert result == TEST__FULL_COMMIT_HASH + + + def test__get_short_commit_hash_from_git_shell(self, mock_popen: Mock): + """ + Test that _get_short_commit_hash_from_git_shell returns the expected short commit hash. + """ + mock_popen.return_value.read.return_value = TEST__FULL_COMMIT_HASH + + result = VersionUtils._get_short_commit_hash_from_git_shell() + assert result == TEST__SHORT_COMMIT_HASH def test__get_version_timestamp_from_git_shell(self, mock_popen: Mock): @@ -540,16 +574,6 @@ class TestVersionUtils_GitShell(VersionBaseTest): assert VersionUtils._get_version_timestamp_from_git_shell() == expected_datetime - def test__get_full_commit_hash_from_git_shell(self, mock_popen: Mock): - """ - Test that _get_full_commit_hash_from_git_shell returns the expected short commit hash. - """ - mock_popen.return_value.read.return_value = TEST__SHORT_COMMIT_HASH - - result = VersionUtils._get_full_commit_hash_from_git_shell() - assert result == TEST__SHORT_COMMIT_HASH - - class TestVersionUtils_DotGitFiles(VersionBaseTest): def test_local_dev_with_dot_git_dir_parsing(self, mock_popen: Mock): @@ -567,37 +591,50 @@ class TestVersionUtils_DotGitFiles(VersionBaseTest): """ # If we're on a branch, getting the commit hash requires the # .git/refs/heads/ file. - with mock.patch.multiple( + with patch.multiple( "seedsigner.helpers.version.VersionUtils", _read_git_HEAD_file=Mock(return_value=(TEST__VERSION_BRANCH, None)), _get_full_commit_hash_from_git_refs_heads=Mock(return_value=TEST__FULL_COMMIT_HASH), _get_version_fork_from_git_config=Mock(return_value=TEST__VERSION_FORK), _get_last_modified_timestamp_from_src_files=Mock(return_value=TEST__VERSION_TIMESTAMP), ): - assert VersionUtils.get_version_name() == TEST__VERSION_BRANCH - assert VersionUtils.get_version_fork() == TEST__VERSION_FORK - assert VersionUtils.get_version_timestamp() == TEST__VERSION_TIMESTAMP - assert VersionUtils.get_short_commit_hash() == TEST__SHORT_COMMIT_HASH + # Mock out the git shell call that retrieves the timestamp. + with patch( + "seedsigner.helpers.version.VersionUtils._get_version_timestamp_from_git_shell", + return_value=None + ) as mock_timestamp_from_git_shell: + # Verify that we're using the local dev code path + assert VersionUtils.is_github_actions_ci() is False + assert VersionUtils._is_seedsigner_os_builder_env() is False - # If we're on a tag (detached HEAD), getting the commit hash requires - # checking the refs/tags for a matching tag. - with mock.patch.multiple( - "seedsigner.helpers.version.VersionUtils", - _read_git_HEAD_file=Mock(return_value=(None, TEST__FULL_COMMIT_HASH)), - _get_matching_tag_from_git_refs_tags=Mock(return_value=TEST__VERSION_TAG), - ): - assert VersionUtils.get_version_name() == TEST__VERSION_TAG - assert VersionUtils.get_short_commit_hash() == TEST__SHORT_COMMIT_HASH + assert VersionUtils.get_version_name() == TEST__VERSION_BRANCH + assert VersionUtils.get_version_fork() == TEST__VERSION_FORK + assert VersionUtils.get_version_timestamp() == TEST__VERSION_TIMESTAMP + assert VersionUtils.get_short_commit_hash() == TEST__SHORT_COMMIT_HASH - # If we're on a commit hash (detached HEAD) with no matching tag, the - # version name should be the short commit hash. - with mock.patch.multiple( - "seedsigner.helpers.version.VersionUtils", - _read_git_HEAD_file=Mock(return_value=(None, TEST__FULL_COMMIT_HASH)), - _get_matching_tag_from_git_refs_tags=Mock(return_value=None), - ): - assert VersionUtils.get_version_name() == TEST__SHORT_COMMIT_HASH - assert VersionUtils.get_short_commit_hash() == TEST__SHORT_COMMIT_HASH + # If we're on a tag (detached HEAD), getting the commit hash requires + # checking the refs/tags for a matching tag. + with patch.multiple( + "seedsigner.helpers.version.VersionUtils", + _read_git_HEAD_file=Mock(return_value=(None, TEST__FULL_COMMIT_HASH)), + _get_matching_tag_from_git_refs_tags=Mock(return_value=TEST__VERSION_TAG), + ): + assert VersionUtils.get_version_name() == TEST__VERSION_TAG + assert VersionUtils.get_short_commit_hash() == TEST__SHORT_COMMIT_HASH + + # If we're on a commit hash (detached HEAD) with no matching tag, the + # version name should be the short commit hash. + with patch.multiple( + "seedsigner.helpers.version.VersionUtils", + _read_git_HEAD_file=Mock(return_value=(None, TEST__FULL_COMMIT_HASH)), + _get_matching_tag_from_git_refs_tags=Mock(return_value=None), + ): + assert VersionUtils.get_version_name() == TEST__SHORT_COMMIT_HASH + assert VersionUtils.get_short_commit_hash() == TEST__SHORT_COMMIT_HASH + + # Verify that through all of this that the git shell timestamp method was + # NOT called. + mock_timestamp_from_git_shell.assert_not_called() def test__get_dot_git_dir(self): @@ -677,17 +714,17 @@ class TestVersionUtils_DotGitFiles(VersionBaseTest): # We already tested _read_git_HEAD_file() above, so just mock scenarios here. # On a branch: - with mock.patch.object(VersionUtils, '_read_git_HEAD_file', return_value=(TEST__VERSION_BRANCH, None)): + with patch.object(VersionUtils, '_read_git_HEAD_file', return_value=(TEST__VERSION_BRANCH, None)): assert VersionUtils._get_version_name_from_git_HEAD() == TEST__VERSION_BRANCH # Detached HEAD at commit hash, with matching tag - with mock.patch.object(VersionUtils, '_read_git_HEAD_file', return_value=(None, TEST__FULL_COMMIT_HASH)): - with mock.patch.object(VersionUtils, '_get_matching_tag_from_git_refs_tags', return_value=TEST__VERSION_NAME): + with patch.object(VersionUtils, '_read_git_HEAD_file', return_value=(None, TEST__FULL_COMMIT_HASH)): + with patch.object(VersionUtils, '_get_matching_tag_from_git_refs_tags', return_value=TEST__VERSION_NAME): assert VersionUtils._get_version_name_from_git_HEAD() == TEST__VERSION_NAME # Detached HEAD at commit hash, no matching tag; returns the short commit hash - with mock.patch.object(VersionUtils, '_read_git_HEAD_file', return_value=(None, TEST__FULL_COMMIT_HASH)): - with mock.patch.object(VersionUtils, '_get_matching_tag_from_git_refs_tags', return_value=None): + with patch.object(VersionUtils, '_read_git_HEAD_file', return_value=(None, TEST__FULL_COMMIT_HASH)): + with patch.object(VersionUtils, '_get_matching_tag_from_git_refs_tags', return_value=None): assert VersionUtils._get_version_name_from_git_HEAD() == TEST__SHORT_COMMIT_HASH @@ -695,7 +732,7 @@ class TestVersionUtils_DotGitFiles(VersionBaseTest): # _get_commit_hash_from_git_HEAD is a trivial convenience function that relies on # _read_git_HEAD_file which we've already tested. Just verify the expected outputs # here. - with mock.patch.object(VersionUtils, '_read_git_HEAD_file', return_value=(None, TEST__SHORT_COMMIT_HASH)): + with patch.object(VersionUtils, '_read_git_HEAD_file', return_value=(None, TEST__SHORT_COMMIT_HASH)): assert VersionUtils._get_full_commit_hash_from_git_HEAD() == TEST__SHORT_COMMIT_HASH @@ -759,7 +796,7 @@ class TestVersionUtils_DotGitFiles(VersionBaseTest): [next_section] next_key = next_value """ - with mock.patch("builtins.open", mock.mock_open(read_data=git_config)): + with patch("builtins.open", mock.mock_open(read_data=git_config)): assert VersionUtils._get_version_fork_from_git_config() == expected_fork # Missing origin section should return None @@ -769,7 +806,7 @@ class TestVersionUtils_DotGitFiles(VersionBaseTest): [next_section] next_key = next_value """ - with mock.patch("builtins.open", mock.mock_open(read_data=git_config_no_origin)): + with patch("builtins.open", mock.mock_open(read_data=git_config_no_origin)): assert VersionUtils._get_version_fork_from_git_config() is None # Handle malformed origin section (can't find url) should return None @@ -779,11 +816,11 @@ class TestVersionUtils_DotGitFiles(VersionBaseTest): [next_section] next_key = next_value """ - with mock.patch("builtins.open", mock.mock_open(read_data=git_config_malformed_origin)): + with patch("builtins.open", mock.mock_open(read_data=git_config_malformed_origin)): assert VersionUtils._get_version_fork_from_git_config() is None # If the git config file is missing, should return None - with mock.patch("builtins.open", side_effect=FileNotFoundError): + with patch("builtins.open", side_effect=FileNotFoundError): assert VersionUtils._get_version_fork_from_git_config() is None @@ -802,12 +839,13 @@ class TestVersionUtils_DotGitFiles(VersionBaseTest): assert timestamp < datetime.now() + timedelta(days=30) # Now mock out os.path.getmtime to force all files to have a known timestamp - expected_timestamp = datetime(2025, 12, 23, 0, 0, 0) - with mock.patch("os.path.getmtime", return_value=expected_timestamp.timestamp()): - assert VersionUtils._get_last_modified_timestamp_from_src_files() == expected_timestamp + local_timestamp = datetime(2025, 12, 23, 0, 0, 0) + with patch("os.path.getmtime", return_value=local_timestamp.timestamp()): + # Note that the result will be converted to UTC with no tzinfo + assert VersionUtils._get_last_modified_timestamp_from_src_files() == local_timestamp.astimezone(timezone.utc).replace(tzinfo=None) # Mock out os.walk() to simulate no .py files found - with mock.patch("os.walk", return_value=[]): + with patch("os.walk", return_value=[]): assert VersionUtils._get_last_modified_timestamp_from_src_files() is None @@ -833,7 +871,7 @@ class TestVersionUtils_Misc(VersionBaseTest): fake_response.status = 200 fake_response.read.return_value = json.dumps(latest_releases_response_dict).encode('utf-8') - with mock.patch("urllib.request.urlopen", return_value=fake_response): + with patch("urllib.request.urlopen", return_value=fake_response): release_tag, release_timestamp = VersionUtils._fetch_latest_seedsigner_release_tag() # mock_popen returns empty string, which mimics not having local git data to @@ -859,13 +897,13 @@ class TestVersionUtils_Misc(VersionBaseTest): # Should gracefully handle HTTP errors fake_error_response = Mock() fake_error_response.status = 404 - with mock.patch("urllib.request.urlopen", return_value=fake_error_response): + with patch("urllib.request.urlopen", return_value=fake_error_response): release_tag, release_timestamp = VersionUtils._fetch_latest_seedsigner_release_tag() assert release_tag is None assert release_timestamp is None # And other exceptions - with mock.patch("urllib.request.urlopen", side_effect=Exception("Network error")): + with patch("urllib.request.urlopen", side_effect=Exception("Network error")): release_tag, release_timestamp = VersionUtils._fetch_latest_seedsigner_release_tag() assert release_tag is None assert release_timestamp is None @@ -884,7 +922,7 @@ class TestVersion(VersionBaseTest): with patch("seedsigner.models.settings.Settings.HOSTNAME", Settings.SEEDSIGNER_OS): Version.get_version_name() == TEST__VERSION_DICT[VersionUtils.VERSIONFILE_ATTR__NAME] Version.get_version_fork() == TEST__VERSION_DICT[VersionUtils.VERSIONFILE_ATTR__FORK] - Version.get_short_commit_hash() == TEST__VERSION_DICT[VersionUtils.VERSIONFILE_ATTR__COMMIT_HASH] + Version.get_short_commit_hash() == TEST__VERSION_DICT[VersionUtils.VERSIONFILE_ATTR__SHORT_COMMIT_HASH] Version.get_version_timestamp() == TEST__VERSION_TIMESTAMP @@ -893,17 +931,12 @@ class TestVersion(VersionBaseTest): Test that we can override the version data via the Version.override_version_data() method. """ - override_name = "v9.9.9-test" - override_fork = "TestFork" - override_commit_hash = "abcd123" - override_timestamp = datetime(2030, 1, 1, 0, 0, 0) - # Initially the version data is pulled from the usual sources self.write_test_version_file() with patch("seedsigner.models.settings.Settings.HOSTNAME", Settings.SEEDSIGNER_OS): assert Version.get_version_name() == TEST__VERSION_DICT[VersionUtils.VERSIONFILE_ATTR__NAME] assert Version.get_version_fork() == TEST__VERSION_DICT[VersionUtils.VERSIONFILE_ATTR__FORK] - assert Version.get_short_commit_hash() == TEST__VERSION_DICT[VersionUtils.VERSIONFILE_ATTR__COMMIT_HASH] + assert Version.get_short_commit_hash() == TEST__VERSION_DICT[VersionUtils.VERSIONFILE_ATTR__SHORT_COMMIT_HASH] assert Version.get_version_timestamp() == TEST__VERSION_TIMESTAMP # While we're in the mocked SeedSigner OS environment, verify that the @@ -911,19 +944,33 @@ class TestVersion(VersionBaseTest): with pytest.raises(NotAllowedInSeedSignerOS): Version.override_data() + override_name = "v9.9.9-test" + override_fork = "TestFork" + override_commit_hash = "abcd123" + override_timestamp = datetime(2030, 1, 1, 0, 0, 0) + override_dict = { + VersionUtils.VERSIONFILE_ATTR__NAME: override_name, + VersionUtils.VERSIONFILE_ATTR__FORK: override_fork, + VersionUtils.VERSIONFILE_ATTR__SHORT_COMMIT_HASH: override_commit_hash, + VersionUtils.VERSIONFILE_ATTR__TIMESTAMP: override_timestamp, + } + # No longer in the mocked SeedSigner OS environment; should be allowed now. - Version.override_data( - version_name=override_name, - version_fork=override_fork, - short_commit_hash=override_commit_hash, - version_timestamp=override_timestamp, - ) + Version.override_data(**override_dict) assert Version.get_version_name() == override_name assert Version.get_version_fork() == override_fork assert Version.get_short_commit_hash() == override_commit_hash assert Version.get_version_timestamp() == override_timestamp + # No overrides specified should result in no values changed. + Version.override_data() + assert Version.get_version_name() == override_name + assert Version.get_version_fork() == override_fork + assert Version.get_short_commit_hash() == override_commit_hash + assert Version.get_version_timestamp() == override_timestamp + + def test_to_dict(self): """ @@ -931,8 +978,25 @@ class TestVersion(VersionBaseTest): """ self.write_test_version_file() with patch("seedsigner.models.settings.Settings.HOSTNAME", Settings.SEEDSIGNER_OS): + # In SeedSigner OS, the version data comes from the version file. assert Version.to_dict() == TEST__VERSION_DICT + extra_attr = "extra" + different_dict = { + VersionUtils.VERSIONFILE_ATTR__NAME: "v1.2.3-different", + VersionUtils.VERSIONFILE_ATTR__FORK: "DifferentFork", + VersionUtils.VERSIONFILE_ATTR__SHORT_COMMIT_HASH: "differenthash", + VersionUtils.VERSIONFILE_ATTR__TIMESTAMP: datetime(2026, 1, 1, 0, 0, 0), # stored internally as a datetime + extra_attr: "should be ignored", + } + Version.override_data(**different_dict) + output_dict = Version.to_dict() + assert output_dict[VersionUtils.VERSIONFILE_ATTR__NAME] == different_dict[VersionUtils.VERSIONFILE_ATTR__NAME] + assert output_dict[VersionUtils.VERSIONFILE_ATTR__FORK] == different_dict[VersionUtils.VERSIONFILE_ATTR__FORK] + assert output_dict[VersionUtils.VERSIONFILE_ATTR__SHORT_COMMIT_HASH] == different_dict[VersionUtils.VERSIONFILE_ATTR__SHORT_COMMIT_HASH] + assert output_dict[VersionUtils.VERSIONFILE_ATTR__TIMESTAMP] == different_dict[VersionUtils.VERSIONFILE_ATTR__TIMESTAMP].isoformat() # returned as a string + assert extra_attr not in output_dict + class TestNotAllowedInSeedSignerOSDecorator(BaseTest): @@ -949,14 +1013,14 @@ class TestNotAllowedInSeedSignerOSDecorator(BaseTest): if we run a decorated function while in SeedSigner OS. """ # Patch over the Settings.HOSTNAME value to simulate running in SeedSigner OS - with mock.patch("seedsigner.models.settings.Settings.HOSTNAME", Settings.SEEDSIGNER_OS): + with patch("seedsigner.models.settings.Settings.HOSTNAME", Settings.SEEDSIGNER_OS): with pytest.raises(NotAllowedInSeedSignerOS): self.dummy_function() def test_allowed_outside_seedsigner_os(self): # Now try with any other HOSTNAME - with mock.patch("seedsigner.models.settings.Settings.HOSTNAME", "my_dev_machine"): + with patch("seedsigner.models.settings.Settings.HOSTNAME", "my_dev_machine"): assert self.dummy_function() == self.SUCCESS diff --git a/tools/write_versionfile.py b/tools/write_versionfile.py index 14323b5a..4bf4cf19 100644 --- a/tools/write_versionfile.py +++ b/tools/write_versionfile.py @@ -8,53 +8,40 @@ from seedsigner.helpers.version import Version, VersionUtils CLI utility to extract the current version data and write to `src/seedsigner/version.json`. Primarily used by the SeedSigner OS build process. -SeedSigner OS lifecycle: - * Build process runs this script to generate version.json. - * version.json is included in the SeedSigner OS image. - * SeedSigner OS reads version.json at runtime. - Notes: * The SeedSigner OS build environment already relies on `git` being installed. - * This script can also be run in local dev but `git` shell commands are required. + * This script can also be run in local dev but note slight difference in how + version_timestamp is gathered. + * Tries to pull current version status via `git` shell commands, but has fallbacks + to directly parse .git/ files. Version data: - * version_name: - * Check for the SEEDSIGNER_VERSION_NAME env var (provided in SeedSigner OS build - env). - * Will be the branch, tag, or commit hash being built. - * If running in local dev instead, this script will try to populate that env var - using `git` shell commands: - * Current git branch name - * Current git tag name - * Current git commit hash + * version_name: Retrieves the current git status by checking, in order: + * Current git branch name + * Current git tag name + * Current git commit hash * version_fork: - * Pulls the current repo owner from the `git remote` shell command. - - * version_timestamp: - * Pulls last git commit time from `git log`. + * The current repo owner. * short_commit_hash: - * Pulls current git commit hash from `git` shell command. + * Current git short commit hash. + + * version_timestamp: + * SeedSigner OS builder: Pulls last git commit time for the checked out + branch/tag/commit. + * Local dev: Scans the source python files for the most recent modified time. + """ if __name__ == "__main__": - is_seedsigner_os_builder = VersionUtils._is_seedsigner_os_builder_env() - - if not is_seedsigner_os_builder: - # Pull version_name from the current git state via `git` shell commands - version_name = VersionUtils._get_version_name_from_git_shell() - - # Temporarily set the env var - os.environ[VersionUtils.ENV_VAR__SEEDSIGNER_OS_BUILDER__VERSION_NAME] = version_name - + # As soon as `Version` is instantiated, it will gather all the version data + # according to the logic in `VersionUtils`. version_info = Version.get_instance().to_dict() + + # Write the version.json file. version_file_path = VersionUtils._get_version_file_path() with open(version_file_path, "w") as f: json.dump(version_info, f, indent=4) print(f"Wrote version info to: {version_file_path}") print(json.dumps(version_info, indent=4)) - - # Clean up the temp env var if needed - if not is_seedsigner_os_builder: - del os.environ[VersionUtils.ENV_VAR__SEEDSIGNER_OS_BUILDER__VERSION_NAME]