diff --git a/.github/workflows/build_and_tests.yml b/.github/workflows/build_and_tests.yml index 2914da3..690118a 100644 --- a/.github/workflows/build_and_tests.yml +++ b/.github/workflows/build_and_tests.yml @@ -43,6 +43,12 @@ jobs: - name: Run tests and generate coverage run: pytest -v --tb=short tests/ --cov ledgered --cov-report xml + - name: Run unit tests and generate coverage + run: pytest -v --tb=short tests/unit --cov ledgered --cov-report xml + + - name: Run functional tests and generate coverage + run: pytest -v --tb=short tests/functional --cov ledgered --cov-report xml --cov-append --token "${{ secrets.GITHUB_TOKEN }}" + - name: Upload coverage to Codecov uses: codecov/codecov-action@v4 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index f3984ae..a13a30b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.7.0] - 2024-04-12 + +### Added + +- Added wrapper around GitHub API to ease manipulating Ledger application repositories + + ## [0.6.3] - 2024-03-26 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index 188a421..b686fd6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ requires-python = ">=3.7" dependencies = [ "toml", "pyelftools", + "pygithub", ] [project.optional-dependencies] diff --git a/src/ledgered/github.py b/src/ledgered/github.py new file mode 100644 index 0000000..822535a --- /dev/null +++ b/src/ledgered/github.py @@ -0,0 +1,129 @@ +from enum import IntEnum, auto +from github import ContentFile as PyContentFile, Github as PyGithub, Repository as PyRepository +from pathlib import Path +from typing import List, Optional +from unittest.mock import patch + +from ledgered.manifest import MANIFEST_FILE_NAME, Manifest + +LEDGER_ORG_NAME = "ledgerhq" + + +class Condition(IntEnum): + WITH = auto() + WITHOUT = auto() + ONLY = auto() + + +class AppRepository(PyRepository.Repository): + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self._manifest: Optional[Manifest] = None + self._makefile: Optional[str] = None + self._branch: str = self.default_branch + + @property + def manifest(self) -> Manifest: + if self._manifest is None: + manifest = self.get_contents(MANIFEST_FILE_NAME, ref=self.current_branch) + # `get_contents` can return a list, but here there can only be one manifest + assert isinstance(manifest, PyContentFile.ContentFile) + manifest_content = manifest.decoded_content.decode() + self._manifest = Manifest.from_string(manifest_content) + return self._manifest + + @property + def makefile_path(self) -> Path: + location = self.manifest.app.build_directory + if self.manifest.app.is_rust: + location /= "Cargo.toml" + else: + location /= "Makefile" + return location + + @property + def makefile(self) -> str: + if self._makefile is None: + # paths on Windows contain "\" which are not compatible with GitHub remote paths + makefile = self.get_contents(str(self.makefile_path).replace("\\", "/"), + ref=self.current_branch) + # `get_contents` can return a list, but here there can only be one Makefile / Cargo.toml + assert isinstance(makefile, PyContentFile.ContentFile) + self._makefile = makefile.decoded_content.decode() + return self._makefile + + @property + def variants(self) -> List[str]: + variants = [] + for line in self.makefile.splitlines(): + if "VARIANTS" in line: + variants.extend(line.split(' ')[3:]) + elif "VARIANT_VALUES = " in line: + variants.extend(line.split(" = ")[1].split(' ')) + return variants + + @property + def current_branch(self) -> str: + return self._branch + + @current_branch.setter + def current_branch(self, new_branch: str) -> None: + self._branch = self.get_branch(new_branch).name + # invalidating previously fetched info, as they may differ on another branch + self._manifest = None + self._makefile = None + + +class GitHubApps(list): + + def __init__(self, apps: List[AppRepository]): + super().__init__([r for r in apps if r.name.startswith("app-")]) + + def filter(self, + name: Optional[str] = None, + archived: Condition = Condition.WITH, + private: Condition = Condition.WITH) -> "GitHubApps": + new_list = [i for i in self] + # archived filtering + if archived == Condition.WITHOUT: + new_list = [r for r in new_list if not r.archived] + elif archived == Condition.ONLY: + new_list = [r for r in new_list if r.archived] + # private filtering + if private == Condition.WITHOUT: + new_list = [r for r in new_list if not r.private] + elif private == Condition.ONLY: + new_list = [r for r in new_list if r.private] + # name filtering + if name is not None: + new_list = [r for r in new_list if name.lower() in r.name.lower()] + return GitHubApps(new_list) + + def first(self, *args, **kwargs) -> Optional[AppRepository]: + results = self.filter(*args, **kwargs) + return results[0] if results else None + + +class GitHubLedgerHQ(PyGithub): + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self._org = self.get_organization(LEDGER_ORG_NAME) + self._apps: Optional[GitHubApps] = None + + @property + def apps(self) -> GitHubApps: + if self._apps is None: + with patch("github.Repository.Repository", AppRepository): + self._apps = GitHubApps(self._org.get_repos()) + return self._apps + + def get_app(self, name) -> AppRepository: + """ + Fetch a specific application repository on GitHub. + The name must be exact. + """ + assert name.startswith("app-"), f"'{name}' is not prefixed with 'app-'!" + with patch("github.Repository.Repository", AppRepository): + return self._org.get_repo(name) diff --git a/tests/functional/conftest.py b/tests/functional/conftest.py new file mode 100644 index 0000000..5ec8fe0 --- /dev/null +++ b/tests/functional/conftest.py @@ -0,0 +1,27 @@ +import pytest +from github.Auth import Token +from typing import Optional + +from ledgered.github import GitHubLedgerHQ + + +def pytest_addoption(parser): + parser.addoption("--token", required=False, default=None, + help="Provide a GitHub token so that functional test won't trigger API " + "restrictions too fast") + + +@pytest.fixture(scope="session") +def token(pytestconfig) -> Optional[Token]: + token = pytestconfig.getoption("token") + return None if token is None else Token(token) + + +@pytest.fixture(scope="session") +def gh(token: Token): + return GitHubLedgerHQ() if token is None else GitHubLedgerHQ(auth=token) + + +@pytest.fixture(scope="session") +def exchange(gh): + return gh.get_app("app-exchange") diff --git a/tests/functional/test_github.py b/tests/functional/test_github.py new file mode 100644 index 0000000..ed075c1 --- /dev/null +++ b/tests/functional/test_github.py @@ -0,0 +1,51 @@ +import pytest +import requests +from pathlib import Path +from unittest import TestCase + +from github.GithubException import GithubException +from ledgered.github import AppRepository, Condition, GitHubApps, GitHubLedgerHQ + + +def test_apps(gh): + assert isinstance(gh.apps, list) + assert isinstance(gh.apps, GitHubApps) + + +def test_get_app(gh): + name = "app-exchange" + app = gh.get_app(name) + assert isinstance(app, AppRepository) + assert app.name == name + + +def test_exchange_manifest(exchange): + assert exchange.manifest.app.sdk == "c" + assert len(exchange.manifest.app.devices) == 4 + + +def test_exchange_makefile_path(exchange): + assert exchange.makefile_path == Path("./Makefile") + + +def test_exchange_makefile(exchange): + makefile = requests.get("https://raw.githubusercontent.com/LedgerHQ/app-exchange/develop/Makefile").content.decode() + assert exchange.makefile == makefile + + +def test_exchange_branches(exchange): + assert exchange.current_branch == "develop" + exchange.current_branch = "master" + assert exchange.current_branch == "master" + + with pytest.raises(GithubException): + exchange.current_branch = "does not exists" + + +def test_exchange_variant(exchange): + assert exchange.variants == ["exchange"] + + +def test_starknet_makefile_path(gh): + app = gh.get_app("app-starknet") + assert app.makefile_path == Path("./Cargo.toml") diff --git a/tests/unit/test_github.py b/tests/unit/test_github.py new file mode 100644 index 0000000..8cf03bc --- /dev/null +++ b/tests/unit/test_github.py @@ -0,0 +1,48 @@ +from dataclasses import dataclass +from unittest import TestCase +from unittest.mock import patch, MagicMock + +from ledgered.github import AppRepository, Condition, GitHubApps, GitHubLedgerHQ + + +@dataclass +class AppRepositoryMock: + name: str + archived: bool = False + private: bool = False + + +class TestGitHubApps(TestCase): + + def setUp(self): + self.app1 = AppRepositoryMock("app-1") + self.app2 = AppRepositoryMock("not-app") + self.app3 = AppRepositoryMock("app-3", private=True) + self.app4 = AppRepositoryMock("app-4", archived=True) + self.apps = GitHubApps([self.app1, self.app2, self.app3, self.app4]) + + def test___init__(self): + self.assertListEqual(self.apps, [self.app1, self.app3, self.app4]) + + def test_filter(self): + self.assertCountEqual(self.apps.filter(), self.apps) + self.assertCountEqual(self.apps.filter(name="3"), [self.app3]) + self.assertCountEqual(self.apps.filter(name="app"), self.apps) + self.assertCountEqual(self.apps.filter(archived=Condition.WITHOUT), [self.app1, self.app3]) + self.assertCountEqual(self.apps.filter(archived=Condition.ONLY), [self.app4]) + self.assertCountEqual(self.apps.filter(private=Condition.WITHOUT), [self.app1, self.app4]) + self.assertCountEqual(self.apps.filter(private=Condition.ONLY), [self.app3]) + + def test_first(self): + self.assertEqual(self.apps.first("3"), self.app3) + self.assertEqual(self.apps.first(), self.app1) + + +class TestGitHubLedgerHQ(TestCase): + + def setUp(self): + self.g = GitHubLedgerHQ() + + def test_get_app_wrong_name(self): + with self.assertRaises(AssertionError): + self.g.get_app("not-starting-with-app-")