From b56d8e72cac92168576c9a94e6529811e9747eae Mon Sep 17 00:00:00 2001 From: stanislas Date: Mon, 3 Mar 2025 22:58:12 +0100 Subject: [PATCH] Download the latest asset from Github --- .gitignore | 6 +++ logging.conf | 27 ++++++++++++ majordome/__init__.py | 5 +++ majordome/errors.py | 14 ++++++ majordome/github_service.py | 85 +++++++++++++++++++++++++++++++++++++ majordome/settings.py | 8 ++++ majordome/software_repo.py | 51 ++++++++++++++++++++++ pyproject.toml | 20 ++++++++- tests/__init__.py | 0 tests/conftest.py | 47 ++++++++++++++++++++ tests/software_repo_test.py | 22 ++++++++++ 11 files changed, 283 insertions(+), 2 deletions(-) create mode 100644 logging.conf create mode 100644 majordome/__init__.py create mode 100644 majordome/errors.py create mode 100644 majordome/github_service.py create mode 100644 majordome/settings.py create mode 100644 majordome/software_repo.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/software_repo_test.py diff --git a/.gitignore b/.gitignore index 505a3b1..3da35a8 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,9 @@ wheels/ # Virtual environments .venv + +# .env file +.env + +# IDE files +.idea diff --git a/logging.conf b/logging.conf new file mode 100644 index 0000000..8af3281 --- /dev/null +++ b/logging.conf @@ -0,0 +1,27 @@ +[loggers] +keys=root,simpleExample + +[handlers] +keys=consoleHandler + +[formatters] +keys=simpleFormatter + +[logger_root] +level=DEBUG +handlers=consoleHandler + +[logger_simpleExample] +level=DEBUG +handlers=consoleHandler +qualname=simpleExample +propagate=0 + +[handler_consoleHandler] +class=StreamHandler +level=DEBUG +formatter=simpleFormatter +args=(sys.stdout,) + +[formatter_simpleFormatter] +format=%(asctime)s - %(name)s - %(levelname)s - %(message)s \ No newline at end of file diff --git a/majordome/__init__.py b/majordome/__init__.py new file mode 100644 index 0000000..7007b3e --- /dev/null +++ b/majordome/__init__.py @@ -0,0 +1,5 @@ +import logging.config +from pathlib import Path + +current_path = Path.cwd() +logging.config.fileConfig(str(current_path / "logging.conf")) diff --git a/majordome/errors.py b/majordome/errors.py new file mode 100644 index 0000000..ed96477 --- /dev/null +++ b/majordome/errors.py @@ -0,0 +1,14 @@ +class NoReleaseFound(Exception): + pass + + +class NoAssetMatchingMnemonic(Exception): + pass + + +class AssetNotFound(Exception): + pass + + +class DownloadFailure(Exception): + pass diff --git a/majordome/github_service.py b/majordome/github_service.py new file mode 100644 index 0000000..086a2bf --- /dev/null +++ b/majordome/github_service.py @@ -0,0 +1,85 @@ +from typing import Any +import logging +import requests + +from majordome.errors import NoReleaseFound, AssetNotFound, DownloadFailure + +logger = logging.getLogger(__name__) + + +class GithubConnector: + def __init__(self, token: str): + self.base_url = "https://api.github.com" + self.token = token + + @property + def headers(self) -> dict[str, str]: + return { + "Accept": "application/vnd.github.v3+json", + "Authorization": f"Bearer {self.token}", + "X-GitHub-Api-Version": "2022-11-28", + } + + def get_latest_release(self, owner: str, repo: str) -> dict[str, Any]: + logger.info("Getting latest release from Github") + logger.debug( + "GET %s with headers %s", self.latest_release_url(owner, repo), self.headers + ) + response = requests.get( + self.latest_release_url(owner, repo), headers=self.headers + ) + if response.ok: + logger.debug("Latest release found") + return response.json()[0] + logger.error( + "Failed to get latest release from Github. Response code was: %s", + response.status_code, + ) + raise NoReleaseFound( + f"No release found on Github for the repo {repo} from owner {owner}" + ) + + @staticmethod + def latest_release_url(owner: str, repo: str) -> str: + return f"https://api.github.com/repos/{owner}/{repo}/releases?per_page=1" + + def download_asset(self, owner: str, repo: str, asset_id: int) -> bytes: + logger.info( + "Downloading asset with id %s from Github owner %s and repo %s", + asset_id, + owner, + repo, + ) + asset_response = requests.get( + self.asset_url(owner, repo, asset_id), headers=self.headers + ) + if asset_response.ok: + download_url = asset_response.json().get("browser_download_url") + downloaded_response = requests.get(download_url, headers=self.headers) + if downloaded_response.ok: + return downloaded_response.content + + logger.error( + "Failed to download asset from Github repo %s of owner %s. Response code was: %s", + repo, + owner, + downloaded_response.status_code, + ) + raise DownloadFailure( + f"The asset {asset_id} was not found on Github for the repo {repo} from owner {owner}" + ) + + logger.error( + "Failed to find asset with id %s from Github repo %s of owner %s. Response code was: %s", + asset_id, + repo, + owner, + asset_response.status_code, + ) + raise AssetNotFound( + f"The asset {asset_id} was not found on Github for the repo {repo} from owner {owner}" + ) + + @staticmethod + def asset_url(owner: str, repo: str, asset_id: int) -> str: + return f"https://api.github.com/repos/{owner}/{repo}/releases/assets/{asset_id}" diff --git a/majordome/settings.py b/majordome/settings.py new file mode 100644 index 0000000..f07db61 --- /dev/null +++ b/majordome/settings.py @@ -0,0 +1,8 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic import Field + + +class GithubSettings(BaseSettings): + model_config = SettingsConfigDict(env_prefix="github_", env_file=".env") + + token: str = Field(default="") diff --git a/majordome/software_repo.py b/majordome/software_repo.py new file mode 100644 index 0000000..a544d24 --- /dev/null +++ b/majordome/software_repo.py @@ -0,0 +1,51 @@ +import logging + +from majordome.github_service import GithubConnector +from majordome.errors import NoAssetMatchingMnemonic + +logger = logging.getLogger(__name__) + + +class SoftwareRepo: + def __init__( + self, + url: str, + github_connector: GithubConnector, + tag_name_mnemonic: str = "prefix-{{VERSION}}-suffix", + asset_mnemonic: str = "prefix-{{VERSION}}-suffix", + ): + self.url = url + self.github_connector = github_connector + self.owner, self.repo = url.removeprefix("https://github.com/").split("/") + self.tag_prefix, self.tag_suffix = tag_name_mnemonic.split("{{VERSION}}") + self.asset_mnemonic = asset_mnemonic + + def get_latest_version(self) -> str: + logger.info("Getting latest version of %s", self.url) + release = self.github_connector.get_latest_release(self.owner, self.repo) + + tag_name = release.get("tag_name") + version = tag_name.removeprefix(self.tag_prefix).removesuffix(self.tag_suffix) + logger.info("Latest version for %s is %s", self.url, version) + return version + + def get_latest_asset_id(self) -> int: + logger.info("Getting asset version of %s", self.url) + latest_release = self.github_connector.get_latest_release(self.owner, self.repo) + assets = latest_release.get("assets", []) + + version = self.get_latest_version() + asset_name = self.asset_mnemonic.replace("{{VERSION}}", version) + + for asset in assets: + if asset.get("name") == asset_name: + return asset.get("id") + + raise NoAssetMatchingMnemonic( + f"No asset found matching {self.asset_mnemonic} on {self.url}" + ) + + def download_latest_asset(self) -> bytes: + logger.info("Downloading latest asset for %s", self.url) + id_ = self.get_latest_asset_id() + return self.github_connector.download_asset(self.owner, self.repo, id_) diff --git a/pyproject.toml b/pyproject.toml index a94bf87..d0dc1f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,23 @@ [project] name = "majordome" version = "0.1.0" -description = "Add your description here" +description = """MàJordome is used to update local softwares that are not automatically +installed by the system""" readme = "README.md" requires-python = ">=3.12" -dependencies = [] +dependencies = [ + "pydantic>=2.10.6", + "pydantic-settings>=2.8.1", + "requests>=2.32.3", +] + +[dependency-groups] +dev = [ + "pytest>=8.3.4", + "pytest-icdiff>=0.9", + "pytest-sugar>=1.0.0", +] +lint = [ + "black>=25.1.0", + "ruff>=0.9.9", +] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..2b47f96 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,47 @@ +import pytest + +from majordome.github_service import GithubConnector +from majordome.settings import GithubSettings +from majordome.software_repo import SoftwareRepo + +github_settings = GithubSettings() + + +@pytest.fixture +def github_token(): + return github_settings.token + + +@pytest.fixture +def project_url(): + return "https://github.com/FreeTubeApp/FreeTube" + + +@pytest.fixture +def github_connector(github_token): + return GithubConnector(github_token) + + +@pytest.fixture +def freetube_tag_name_mnemonic(): + return "v{{VERSION}}-beta" + + +@pytest.fixture +def freetube_amd64_deb_mnemonic(): + return "freetube_{{VERSION}}_amd64.deb" + + +@pytest.fixture +def freetube_repo( + project_url, + github_connector, + freetube_tag_name_mnemonic, + freetube_amd64_deb_mnemonic, +): + return SoftwareRepo( + project_url, + github_connector, + freetube_tag_name_mnemonic, + freetube_amd64_deb_mnemonic, + ) diff --git a/tests/software_repo_test.py b/tests/software_repo_test.py new file mode 100644 index 0000000..02342c3 --- /dev/null +++ b/tests/software_repo_test.py @@ -0,0 +1,22 @@ +from pathlib import Path + + +def test_get_latest_version(freetube_repo): + version = freetube_repo.get_latest_version() + + assert version == "0.23.2" + + +def test_latest_amd_64_deb_asset(freetube_repo): + asset_name = freetube_repo.get_latest_asset_id() + + assert asset_name == 231868075 + + +def test_download_latest_asset(freetube_repo): + asset_bytes = freetube_repo.download_latest_asset() + + assert ( + asset_bytes + == Path("/home/stan/Téléchargements/freetube_0.23.2_amd64.deb").read_bytes() + )