Skip to content

Commit

Permalink
[add] Wrapper around GitHub Ledger applications
Browse files Browse the repository at this point in the history
  • Loading branch information
lpascal-ledger committed Apr 12, 2024
1 parent 64e1a82 commit 3b85fbb
Show file tree
Hide file tree
Showing 7 changed files with 269 additions and 0 deletions.
6 changes: 6 additions & 0 deletions .github/workflows/build_and_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ requires-python = ">=3.7"
dependencies = [
"toml",
"pyelftools",
"pygithub",
]

[project.optional-dependencies]
Expand Down
129 changes: 129 additions & 0 deletions src/ledgered/github.py
Original file line number Diff line number Diff line change
@@ -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)
27 changes: 27 additions & 0 deletions tests/functional/conftest.py
Original file line number Diff line number Diff line change
@@ -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")
51 changes: 51 additions & 0 deletions tests/functional/test_github.py
Original file line number Diff line number Diff line change
@@ -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")
48 changes: 48 additions & 0 deletions tests/unit/test_github.py
Original file line number Diff line number Diff line change
@@ -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-")

0 comments on commit 3b85fbb

Please sign in to comment.