diff --git a/release_gitter.py b/release_gitter.py index 400a554..1876932 100755 --- a/release_gitter.py +++ b/release_gitter.py @@ -6,6 +6,7 @@ import platform from collections.abc import Sequence from dataclasses import dataclass from io import BytesIO +from itertools import product from mimetypes import guess_type from pathlib import Path from subprocess import check_call @@ -47,6 +48,31 @@ def removesuffix(s: str, suf: str) -> str: return s[: -len(suf)] if s and s.endswith(suf) else s +SYSTEM_SYNONYMS: list[list[str]] = [ + ["Darwin", "darwin", "MacOS", "macos", "macOS"], + ["Windows", "windows", "win", "win32", "win64"], + ["Linux", "linux"], +] + +ARCH_SYNONYMS: list[list[str]] = [ + ["arm"], + ["x86_64", "amd64", "AMD64"], + ["arm64", "aarch64", "armv8b", "armv8l"], + ["x86", "i386", "i686"], +] + + +def get_synonyms(value: str, thesaurus: list[list[str]]) -> list[str]: + """Gets synonym list for a given value.""" + results = [value] + + for l in thesaurus: + if value in l: + results += l + + return results + + @dataclass class GitRemoteInfo: """Extracts information about a repository""" @@ -245,21 +271,29 @@ def match_asset( system = platform.system() if system_mapping: - system = system_mapping.get(system, system) + systems = [system_mapping.get(system, system)] + else: + systems = get_synonyms(system, SYSTEM_SYNONYMS) arch = platform.machine() if arch_mapping: - arch = arch_mapping.get(arch, arch) + archs = [arch_mapping.get(arch, arch)] + else: + archs = get_synonyms(arch, ARCH_SYNONYMS) expected_names = { format.format( - version=normalized_version, - system=system, - arch=arch, + version=version_opt, + system=system_opt, + arch=arch_opt, ) - for normalized_version in ( - version.lstrip("v"), - "v" + version if not version.startswith("v") else version, + for version_opt, system_opt, arch_opt in product( + ( + version.lstrip("v"), + "v" + version if not version.startswith("v") else version, + ), + systems, + archs, ) } diff --git a/release_gitter_test.py b/release_gitter_test.py index 1bef606..6e27226 100644 --- a/release_gitter_test.py +++ b/release_gitter_test.py @@ -1,6 +1,8 @@ from __future__ import annotations import unittest +from itertools import chain +from itertools import product from tarfile import TarFile from typing import Any from typing import Callable @@ -21,9 +23,10 @@ class TestExpression(NamedTuple): kwargs: dict[str, Any] expected: Any exception: type[Exception] | None = None + msg: str | None = None def run(self, f: Callable): - with self.t.subTest(f=f, args=self.args, kwargs=self.kwargs): + with self.t.subTest(msg=self.msg, f=f, args=self.args, kwargs=self.kwargs): try: result = f(*self.args, **self.kwargs) self.t.assertIsNone( @@ -196,5 +199,277 @@ class TestContentTypeDetection(unittest.TestCase): ) +class TestMatchAsset(unittest.TestCase): + def test_match_asset_versions(self, *_): + # Input variations: + # Case 1: Version provided with prefix + # Case 2: Version provided without prefix + # Case 3: No version provided, tag exists in release + # These should be impossible + # Case 4: No version provided, tag doesn't exist in release but not in template + # Case 5: No version provided, tag doesn't exist in release and is in template + + # Release variations: + # Case 1: tag_name with version prefix + # Case 2: tag_name without version prefix + + # File variations: + # Case 1: file name with version prefix + # Case 2: file name without version prefix + + def new_expression(version: str | None, tag_name: str, file_name: str): + release = {"tag_name": tag_name, "assets": [{"name": file_name}]} + expected = {"name": file_name} + return TestExpression( + self, [release, "file-{version}.zip", version], {}, expected + ) + + happy_cases = [ + new_expression(version, tag_name, file_name) + for version, tag_name, file_name in product( + ("v1.0.0", "1.0.0", None), + ("v1.0.0", "1.0.0"), + ("file-v1.0.0.zip", "file-1.0.0.zip"), + ) + ] + for test_case in happy_cases: + test_case.run(release_gitter.match_asset) + + def test_match_asset_systems(self, *_): + # Input variations: + # Case 1: System mapping provided + # Case 2: No system mapping provided + + # Test: We want to show that default matching will work out of the box with some values for the current machine + # Test: We want to show that non-standard mappings will always work if provided manually + + def run_with_context(actual_system: str, *args, **kwargs): + with patch("platform.system", return_value=actual_system): + return release_gitter.match_asset(*args, **kwargs) + + def new_expression( + actual_system: str, + system_mapping: dict[str, str] | None, + file_name: str, + expected: dict[str, str], + exception: type[Exception] | None = None, + msg: str | None = None, + ): + release = { + "name": "v1.0.0", + "tag_name": "v1.0.0", + "assets": [{"name": file_name}], + } + return TestExpression( + self, + [actual_system, release, "file-{system}.zip"], + {"system_mapping": system_mapping}, + expected, + exception, + msg, + ) + + test_cases = chain( + [ + new_expression( + "Earth", + None, + "file-Earth.zip", + {"name": "file-Earth.zip"}, + msg="Current system always included as an exact match synonym", + ), + new_expression( + "Linux", + {"Linux": "jumanji"}, + "file-jumanji.zip", + {"name": "file-jumanji.zip"}, + msg="Non-standard system mapping works", + ), + new_expression( + "Linux", + {}, + "file-darwin.zip", + {}, + ValueError, + msg="No matching system", + ), + ], + # Test default mappings + ( + new_expression( + actual_system, + None, + file_name, + {"name": file_name}, + msg="Default Linux mappings", + ) + for actual_system, file_name in product( + ("Linux", "linux"), + ("file-Linux.zip", "file-linux.zip"), + ) + ), + ( + new_expression( + actual_system, + None, + file_name, + {"name": file_name}, + msg="Default macOS mappings", + ) + for actual_system, file_name in product( + ("Darwin", "darwin", "MacOS", "macos", "macOS"), + ( + "file-Darwin.zip", + "file-darwin.zip", + "file-MacOS.zip", + "file-macos.zip", + ), + ) + ), + ( + new_expression( + actual_system, + None, + file_name, + {"name": file_name}, + msg="Default Windows mappings", + ) + for actual_system, file_name in product( + ("Windows", "windows", "win", "win32", "win64"), + ( + "file-Windows.zip", + "file-windows.zip", + "file-win.zip", + "file-win32.zip", + "file-win64.zip", + ), + ) + ), + ) + for test_case in test_cases: + test_case.run(run_with_context) + + def test_match_asset_archs(self, *_): + # Input variations: + # Case 1: Arch mapping provided + # Case 2: No arch mapping provided + + # Test: We want to show that default matching will work out of the box with some values for the current machine + # Test: We want to show that non-standard mappings will always work if provided manually + + def run_with_context(actual_arch: str, *args, **kwargs): + with patch("platform.machine", return_value=actual_arch): + return release_gitter.match_asset(*args, **kwargs) + + def new_expression( + actual_arch: str, + arch_mapping: dict[str, str] | None, + file_name: str, + expected: dict[str, str], + exception: type[Exception] | None = None, + msg: str | None = None, + ): + release = { + "name": "v1.0.0", + "tag_name": "v1.0.0", + "assets": [{"name": file_name}], + } + return TestExpression( + self, + [actual_arch, release, "file-{arch}.zip"], + {"arch_mapping": arch_mapping}, + expected, + exception, + msg, + ) + + test_cases = chain( + [ + new_expression( + "Earth", + None, + "file-Earth.zip", + {"name": "file-Earth.zip"}, + msg="Current arch always included as an exact match synonym", + ), + new_expression( + "x86_64", + {"x86_64": "jumanji"}, + "file-jumanji.zip", + {"name": "file-jumanji.zip"}, + msg="Non-standard arch mapping works", + ), + new_expression( + "x86_64", + {}, + "file-arm.zip", + {}, + ValueError, + msg="No matching arch", + ), + ], + # Test default mappings + ( + new_expression( + actual_arch, + None, + file_name, + {"name": file_name}, + msg="Default arm mappings", + ) + for actual_arch, file_name in product( + ("arm",), + ("file-arm.zip",), + ) + ), + ( + new_expression( + actual_arch, + None, + file_name, + {"name": file_name}, + msg="Default amd64 mappings", + ) + for actual_arch, file_name in product( + ("amd64", "x86_64", "AMD64"), + ("file-amd64.zip", "file-x86_64.zip"), + ) + ), + ( + new_expression( + actual_arch, + None, + file_name, + {"name": file_name}, + msg="Default arm64 mappings", + ) + for actual_arch, file_name in product( + ("arm64", "aarch64", "armv8b", "armv8l"), + ( + "file-arm64.zip", + "file-aarch64.zip", + "file-armv8b.zip", + "file-armv8l.zip", + ), + ) + ), + ( + new_expression( + actual_arch, + None, + file_name, + {"name": file_name}, + msg="Default x86 mappings", + ) + for actual_arch, file_name in product( + ("x86", "i386", "i686"), + ("file-x86.zip", "file-i386.zip", "file-i686.zip"), + ) + ), + ) + for test_case in test_cases: + test_case.run(run_with_context) + + if __name__ == "__main__": unittest.main()