diff --git a/.gitignore b/.gitignore index ab3e8ce..0537cc2 100644 --- a/.gitignore +++ b/.gitignore @@ -162,3 +162,4 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ +**/test.env diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ff9a4ed --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,35 @@ +[project] +name = "Booker" +version = "0.1.0" + +dependencies = [ + "pydantic>=2.8.2,<3.0.0", + "pydantic-settings>=2.4.0,<3.0.0", + "pyyaml>=6.0,<7.0", + "pendulum>=3.0.0,<4.0.0", + "requests>=2.32.3,<3.0.0", + "typer>=0.12.5,<0.13.0" +] + +[project.optional-dependencies] +dev = [ + "black>=24.4.2,<25.0.0", + "isort>=5.13.2,<6.0.0", + "ruff>=0.5.4,<0.6.0", + "pytest>=8.3.1,<9.0.0", + "pytest-icdiff>=0.9,<1.0", + "pytest-sugar>=1.0.0,<2.0.0", +] + +[tool.ruff] +line-length = 88 + +[tool.isort] +profile = "black" + +[tool.pytest.ini_options] +pythonpath = [ + "src" +] +log_cli = 1 +log_cli_level = "DEBUG" \ No newline at end of file diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/losoup/__init__.py b/src/losoup/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/losoup/cvs/__init__.py b/src/losoup/cvs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/losoup/cvs/github.py b/src/losoup/cvs/github.py new file mode 100644 index 0000000..6cb9ce2 --- /dev/null +++ b/src/losoup/cvs/github.py @@ -0,0 +1,41 @@ +import requests + +from losoup.models import Asset, GithubSoftware +from losoup.cvs.github_settings import GithubSettings + + +class GithubConnector: + def __init__(self, settings: GithubSettings): + self.settings = settings + + def get_release_assets_list(self, release_url: str) -> list[Asset]: + headers = { + "Accept": "application/vnd.github+json", + "Authorization": f"token {self.settings.token}", + "X-Github-Api-Version": "2022-11-28", + } + response = requests.get(release_url, headers=headers) + assets = [] + for asset in response.json().get("assets"): + assets.append(Asset(name=asset.get("name"), url=asset.get("url"))) + return assets + + def get_release_asset(self, software: GithubSoftware) -> Asset: + for asset in self.get_release_assets_list(software.release_url): + if asset.name == software.filename: + return asset + else: + continue + + def download_release_asset(self, asset: Asset) -> bytes: + headers = { + "Accept": "application/octet-stream", + "Authorization": f"token {self.settings.token}", + "X-Github-Api-Version": "2022-11-28", + } + response = requests.get(asset.url, headers=headers) + return response.content + + def download_software(self, software: GithubSoftware) -> bytes: + release_asset = self.get_release_asset(software) + return self.download_release_asset(release_asset) diff --git a/src/losoup/cvs/github_settings.py b/src/losoup/cvs/github_settings.py new file mode 100644 index 0000000..d70d72f --- /dev/null +++ b/src/losoup/cvs/github_settings.py @@ -0,0 +1,8 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class GithubSettings(BaseSettings): + model_config = SettingsConfigDict(env_prefix="GITHUB_") + + token: str + url: str = "https://api.github.com/repos" diff --git a/src/losoup/cvs/gitlab.py b/src/losoup/cvs/gitlab.py new file mode 100644 index 0000000..e69de29 diff --git a/src/losoup/file_operations/__init__.py b/src/losoup/file_operations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/losoup/file_operations/local_files.py b/src/losoup/file_operations/local_files.py new file mode 100644 index 0000000..9b612c8 --- /dev/null +++ b/src/losoup/file_operations/local_files.py @@ -0,0 +1,24 @@ +from pathlib import Path + +from losoup.models import Software, Asset + + +class LocalFile: + def __init__(self, software: Software): + self.software = software + + def get_files_in_folder(self) -> list[str]: + return [file.name for file in Path(self.software.folder).glob("*")] + + def is_file_present(self): + files = self.get_files_in_folder() + return self.software.filename in files + + def is_file_absent(self): + return not self.is_file_present() + + def write_file(self, file: Asset) -> None: + self.software.absolute_path.write_bytes(file.content) + + def delete_file(self): + self.software.absolute_path.unlink() diff --git a/src/losoup/main.py b/src/losoup/main.py new file mode 100644 index 0000000..566c852 --- /dev/null +++ b/src/losoup/main.py @@ -0,0 +1,28 @@ +from pathlib import Path + +import yaml + +from losoup.file_operations import local_files +from losoup.models import Software, GitlabSoftware, GithubSoftware + + +def load_software_list(software_file: Path) -> list[Software]: + software_yaml = yaml.safe_load(software_file.open()) + software_list = [] + for software in software_yaml.get("software", []): + match software.get("cvs").lower(): + case "github": + software_list.append(GithubSoftware(**software)) + case "gitlab": + software_list.append(GitlabSoftware(**software)) + case _: + pass + return software_list + + +def is_software_to_update(software: Software) -> bool: + return local_files.LocalFile(software).is_file_absent() + + +if __name__ == "__main__": + pass diff --git a/src/losoup/models.py b/src/losoup/models.py new file mode 100644 index 0000000..c755e84 --- /dev/null +++ b/src/losoup/models.py @@ -0,0 +1,52 @@ +from pathlib import Path + +from pydantic import BaseModel, Field, field_validator + + +class Software(BaseModel): + name: str + owner: str + repo: str + version: str + folder: str + filename_format: str = Field(..., alias="filenameFormat") + + @field_validator("folder") + @classmethod + def replace_tilde(cls, value: str): + return value.replace("~", Path.home().as_posix()) + + @property + def filename(self) -> str: + return self.filename_format.replace("{{version}}", self.version) + + @property + def absolute_path(self) -> Path: + return Path(self.folder, self.filename) + + +class GithubSoftware(Software): + base_url: str = "https://api.github.com" + + @property + def release_url(self): + if self.version == "latest": + release_path = self.version + else: + release_path = f"tags/{self.version}" + + return f"{self.base_url}/repos/{self.owner}/{self.repo}/releases/{release_path}" + + +class GitlabSoftware(Software): + base_url: str = Field(..., alias="baseUrl") + + @property + def release_url(self): + return f"{self.base_url}/{self.owner}/{self.repo}/-/releases/{self.version}" + + +class Asset(BaseModel): + name: str + url: str + content: bytes | str | None = None diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/losoup/__init__.py b/test/losoup/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/losoup/conftest.py b/test/losoup/conftest.py new file mode 100644 index 0000000..55fcad3 --- /dev/null +++ b/test/losoup/conftest.py @@ -0,0 +1,121 @@ +from pathlib import Path + +import pytest + +from losoup.file_operations.local_files import LocalFile +from losoup.models import GithubSoftware, Asset +from losoup.cvs.github import GithubConnector +from losoup.cvs.github_settings import GithubSettings + +current_dir = Path(__file__).parent.absolute() +dotenv = current_dir / "test.env" + + +@pytest.fixture +def github_settings(): + return GithubSettings(_env_file=dotenv) + + +@pytest.fixture +def github_connector(github_settings): + return GithubConnector(github_settings) + + +@pytest.fixture +def keepassxc(): + return GithubSoftware( + name="KeepassXC", + owner="keepassxreboot", + repo="keepassxc", + version="2.7.9", + folder="~/Softwares", + filenameFormat="KeePassXC-{{version}}-x86_64.AppImage", + ) + + +@pytest.fixture +def latest_keepassxc(): + return GithubSoftware( + name="KeepassXC", + owner="keepassxreboot", + repo="keepassxc", + version="latest", + folder="~/Softwares", + filenameFormat="KeePassXC-{{version}}-x86_64.AppImage", + ) + + +@pytest.fixture +def keepassxc_not_present(): + return GithubSoftware( + name="KeepassXC", + owner="keepassxreboot", + repo="keepassxc", + version="2.79", + folder="~/Softwares", + filenameFormat="KeePassXC-{{version}}-x86_64.AppImage", + ) + + +@pytest.fixture +def local_file(keepassxc): + return LocalFile(keepassxc) + + +@pytest.fixture +def local_file_not_present(keepassxc_not_present): + return LocalFile(keepassxc_not_present) + + +@pytest.fixture +def software_to_update(): + return GithubSoftware( + name="KeepassXC", + owner="keepassxreboot", + repo="keepassxc", + version="2.8.1", + folder="~/Softwares", + filenameFormat="KeePassXC-{{version}}-x86_64.AppImage", + ) + + +@pytest.fixture +def software_up_to_date(keepassxc): + return keepassxc + + +@pytest.fixture +def asset(): + return Asset( + name="KeePassXC-2.7.9-x86_64.AppImage.DIGEST", + url="https://api.github.com/repos/keepassxreboot/keepassxc/releases/assets/1747" + "89149", + ) + + +@pytest.fixture +def keepassxc_digest(): + return GithubSoftware( + name="KeepassXC digest", + owner="keepassxreboot", + repo="keepassxc", + version="2.7.7", + folder="~/Softwares", + filenameFormat="KeePassXC-{{version}}-x86_64.AppImage.DIGEST", + ) + + +@pytest.fixture +def keepassxc_digest_file(keepassxc_digest): + return LocalFile(keepassxc_digest) + + +@pytest.fixture +def asset_to_write(): + return Asset( + name="KeePassXC-2.7.7-x86_64.AppImage.DIGEST", + url="https://api.github.com/repos/keepassxreboot/keepassxc/releases/assets/1747" + "89149", + content=b"2a868b681a8ec4e381ac14203aec3d80ff6fa7a535fa102265a3ec9329b4b846 Kee" + b"PassXC-2.7.9-x86_64.AppImage\n", + ) diff --git a/test/losoup/software.yaml b/test/losoup/software.yaml new file mode 100644 index 0000000..73cd9ff --- /dev/null +++ b/test/losoup/software.yaml @@ -0,0 +1,16 @@ +software: + - name: NextCloud + cvs: github + owner: nextcloud-releases + repo: desktop + version: latest + folder: /home/stan/Softwares + filenameFormat: KeePassXC-{{version}}-x86_64.AppImage + + - name: KeePassXC + cvs: giTHub + owner: keepassxreboot + repo: keepassxc + version: latest + folder: /home/stan/Softwares + filenameFormat: Nextcloud-{{version}}-x86_64.AppImage diff --git a/test/losoup/test_github_connector.py b/test/losoup/test_github_connector.py new file mode 100644 index 0000000..11d287a --- /dev/null +++ b/test/losoup/test_github_connector.py @@ -0,0 +1,40 @@ +def test_github_connector_is_initialized(github_connector): + assert github_connector is not None + assert github_connector.settings.token is not None + + +def test_get_latest_release(github_connector, latest_keepassxc): + release_assets = github_connector.get_release_assets_list( + latest_keepassxc.release_url + ) + assert len(release_assets) == 27 + + +def test_get_some_release(github_connector, keepassxc): + release_asset_list = github_connector.get_release_assets_list(keepassxc.release_url) + assert len(release_asset_list) == 27 + + +def test_get_release_asset(github_connector, keepassxc): + release_asset = github_connector.get_release_asset(keepassxc) + assert release_asset.name == keepassxc.filename + + +def test_download_release_asset(github_connector, asset): + release_asset_file = github_connector.download_release_asset(asset) + assert len(release_asset_file) == 98 + assert ( + release_asset_file + == b"2a868b681a8ec4e381ac14203aec3d80ff6fa7a535fa102265a3ec9329b4b846 KeePassX" + b"C-2.7.9-x86_64.AppImage\n" + ) + + +def test_download_software(github_connector, keepassxc_digest): + release_asset_file = github_connector.download_software(keepassxc_digest) + assert len(release_asset_file) == 98 + assert ( + release_asset_file + == b"796c4c0ad20b124476195dacd7f86c75be51cace1c734174ec293c46f41c3d05 KeePassX" + b"C-2.7.7-x86_64.AppImage\n" + ) diff --git a/test/losoup/test_local_files.py b/test/losoup/test_local_files.py new file mode 100644 index 0000000..1dc00d1 --- /dev/null +++ b/test/losoup/test_local_files.py @@ -0,0 +1,27 @@ +from losoup.file_operations.local_files import LocalFile +from losoup.models import Asset +import typer + + +def test_local_files_init(keepassxc): + local_file = LocalFile(keepassxc) + assert local_file.software == keepassxc + + +def test_get_local_files(local_file): + assert "KeePassXC-2.7.9-x86_64.AppImage" in local_file.get_files_in_folder() + + +def test_is_file_present(local_file): + assert local_file.is_file_present() + + +def test_is_file_absent(local_file_not_present): + assert local_file_not_present.is_file_absent() + + +def test_write_file(keepassxc_digest_file: LocalFile, asset_to_write: Asset): + assert keepassxc_digest_file.is_file_absent() + keepassxc_digest_file.write_file(asset_to_write) + assert keepassxc_digest_file.is_file_present() + keepassxc_digest_file.delete_file() diff --git a/test/losoup/test_main.py b/test/losoup/test_main.py new file mode 100644 index 0000000..e043ebd --- /dev/null +++ b/test/losoup/test_main.py @@ -0,0 +1,29 @@ +from pathlib import Path + +from losoup import main +from losoup.models import GithubSoftware + + +def test_read_files(): + cur_dir = Path(__file__).parent + software_file = cur_dir / "software.yaml" + + files = main.load_software_list(software_file) + + assert len(files) == 2 + + nextcloud = files[0] + assert nextcloud.name == "NextCloud" + assert isinstance(nextcloud, GithubSoftware) + + keepassxc = files[1] + assert keepassxc.name == "KeePassXC" + assert isinstance(nextcloud, GithubSoftware) + + +def test_software_needs_to_be_updated(software_to_update): + assert main.is_software_to_update(software_to_update) + + +def test_software_does_not_need_to_be_updated(software_up_to_date): + assert not main.is_software_to_update(software_up_to_date)