diff --git a/.gitignore b/.gitignore index c75a32f..b121d11 100644 --- a/.gitignore +++ b/.gitignore @@ -139,3 +139,4 @@ dmypy.json cython_debug/ tags +unhacs.txt diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 74bdf4a..e6d32d3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,13 +1,11 @@ --- -default_language_version: - python: python3.8 repos: - repo: https://github.com/psf/black - rev: 20.8b1 + rev: 24.4.2 hooks: - id: black - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v3.4.0 + rev: v4.6.0 hooks: - id: check-added-large-files - id: check-merge-conflict @@ -16,11 +14,14 @@ repos: - id: trailing-whitespace - id: name-tests-test exclude: tests/(common.py|util.py|(helpers|integration/factories)/(.+).py) - - repo: https://github.com/asottile/reorder_python_imports - rev: v2.4.0 + - repo: https://github.com/pycqa/isort + rev: 5.13.2 hooks: - - id: reorder-python-imports + - id: isort - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.800 + rev: v1.10.0 hooks: - id: mypy + exclude: docs/ + additional_dependencies: + - types-requests diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a0a6609 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["setuptools", "wheel"] + +[project] +name = "unhacs" +version = "0.1.0" +description = "" +authors = [{name = "Ian Fijolek", email = "ian@iamthefij.com"}] +requires-python = ">=3.8" + +dependencies = [ + "requests" +] + +[project.scripts] +unhacs = 'unhacs.main:main' diff --git a/requirements-dev.txt b/requirements-dev.txt index 84e5ecd..6b6f737 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,3 +2,5 @@ pytest coverage pre-commit +mypy +types-requests diff --git a/setup.py b/setup.py deleted file mode 100644 index 4eb6625..0000000 --- a/setup.py +++ /dev/null @@ -1,45 +0,0 @@ -from codecs import open -from os import path - -from setuptools import find_packages -from setuptools import setup - -here = path.abspath(path.dirname(__file__)) - -# Get the long description from the README file -with open(path.join(here, "README.md"), encoding="utf-8") as f: - long_description = f.read() - -setup( - name="unhacs", - version="0.0.0", - description="A command line alternative to the "Home Assistant Community Store", aka HACS", - long_description=long_description, - long_description_content_type="text/markdown", - url="https://git.iamthefij.com/iamthefij/unhacs.git", - download_url=("https://git.iamthefij.com/iamthefij/unhacs.git/archive/master.tar.gz"), - author="iamthefij", - author_email="", - classifiers=[ - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.5", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - ], - keywords="", - packages=find_packages( - exclude=[ - "contrib", - "docs", - "examples", - "scripts", - "tests", - ] - ), - install_requires=[], - entry_points={ - "console_scripts": [ - "unhacs=unhacs:main", - ], - }, -) diff --git a/unhacs/__init__.py b/unhacs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/unhacs/__main__.py b/unhacs/__main__.py new file mode 100644 index 0000000..e22624a --- /dev/null +++ b/unhacs/__main__.py @@ -0,0 +1,4 @@ +from unhacs.main import main + +if __name__ == "__main__": + main() diff --git a/unhacs/main.py b/unhacs/main.py new file mode 100644 index 0000000..a78905f --- /dev/null +++ b/unhacs/main.py @@ -0,0 +1,206 @@ +import json +import shutil +import tempfile +from argparse import ArgumentParser +from io import BytesIO +from pathlib import Path +from zipfile import ZipFile + +import requests + +from unhacs.packages import DEFAULT_PACKAGE_FILE +from unhacs.packages import Package +from unhacs.packages import read_packages +from unhacs.packages import write_packages + + +DEFAULT_HASS_CONFIG_PATH = Path(".") + + +def extract_zip(zip_file: ZipFile, dest_dir: Path): + for info in zip_file.infolist(): + if info.is_dir(): + continue + file = Path(info.filename) + # Strip top directory from path + file = Path(*file.parts[1:]) + path = dest_dir / file + path.parent.mkdir(parents=True, exist_ok=True) + with zip_file.open(info) as source, open(path, "wb") as dest: + dest.write(source.read()) + + +def create_parser(): + parser = ArgumentParser() + parser.add_argument( + "--config", + "-c", + type=Path, + default=DEFAULT_HASS_CONFIG_PATH, + help="The path to the Home Assistant configuration directory.", + ) + parser.add_argument( + "--package-file", + "-p", + type=Path, + default=DEFAULT_PACKAGE_FILE, + help="The path to the package file.", + ) + + subparsers = parser.add_subparsers(dest="subcommand", required=True) + + list_parser = subparsers.add_parser("list") + list_parser.add_argument("--verbose", "-v", action="store_true") + + add_parser = subparsers.add_parser("add") + add_parser.add_argument("url", type=str, help="The URL of the package.") + add_parser.add_argument( + "name", type=str, nargs="?", help="The name of the package." + ) + add_parser.add_argument( + "--version", "-v", type=str, help="The version of the package." + ) + add_parser.add_argument( + "--update", + "-u", + action="store_true", + help="Update the package if it already exists.", + ) + + remove_parser = subparsers.add_parser("remove") + remove_parser.add_argument("packages", nargs="*") + + update_parser = subparsers.add_parser("update") + update_parser.add_argument("packages", nargs="*") + + return parser + + +class Unhacs: + def add_package( + self, + package_url: str, + package_name: str | None = None, + version: str | None = None, + update: bool = False, + ): + # Parse the package URL to get the owner and repo name + parts = package_url.split("/") + owner = parts[-2] + repo = parts[-1] + + # Fetch the releases from the GitHub API + response = requests.get(f"https://api.github.com/repos/{owner}/{repo}/releases") + response.raise_for_status() + releases = response.json() + + # If a version is provided, check if it exists in the releases + if version: + for release in releases: + if release["tag_name"] == version: + break + else: + raise ValueError(f"Version {version} does not exist for this package") + else: + # If no version is provided, use the latest release + version = releases[0]["tag_name"] + + if not version: + raise ValueError("No releases found for this package") + + package = Package(name=package_name or repo, url=package_url, version=version) + packages = read_packages() + + # Raise an error if the package is already in the list + if package in packages: + if update: + # Remove old version of the package + packages = [p for p in packages if p.url != package_url] + else: + raise ValueError("Package already exists in the list") + + packages.append(package) + write_packages(packages) + + self.download_package(package) + + def download_package(self, package: Package, replace: bool = True): + # Parse the package URL to get the owner and repo name + parts = package.url.split("/") + owner = parts[-2] + repo = parts[-1] + + # Fetch the releases from the GitHub API + response = requests.get(f"https://api.github.com/repos/{owner}/{repo}/releases") + response.raise_for_status() + releases = response.json() + + # Find the release with the specified version + for release in releases: + if release["tag_name"] == package.version: + break + else: + raise ValueError(f"Version {package.version} not found for this package") + + # Download the release zip with the specified name + response = requests.get(release["zipball_url"]) + response.raise_for_status() + + release_zip = ZipFile(BytesIO(response.content)) + + with tempfile.TemporaryDirectory(prefix="unhacs-") as tempdir: + tmpdir = Path(tempdir) + extract_zip(release_zip, tmpdir) + + for file in tmpdir.glob("*"): + print(file) + hacs = json.loads((tmpdir / "hacs.json").read_text()) + print(hacs) + + for custom_component in tmpdir.glob("custom_components/*"): + dest = ( + DEFAULT_HASS_CONFIG_PATH + / "custom_components" + / custom_component.name + ) + if replace: + shutil.rmtree(dest, ignore_errors=True) + + shutil.move(custom_component, dest) + + def update_packages(self, package_names: list[str]): + if not package_names: + package_urls = [p.url for p in read_packages()] + else: + package_urls = [p.url for p in read_packages() if p.name in package_names] + + for package in package_urls: + print("Updating", package) + self.add_package(package, update=True) + + def list_packages(self, verbose: bool = False): + for package in read_packages(): + print(package.verbose_str() if verbose else str(package)) + + +def main(): + # If the sub command is add package, it should pass the parsed arguments to the add_package function and return + parser = create_parser() + args = parser.parse_args() + + unhacs = Unhacs() + + if args.subcommand == "add": + unhacs.add_package(args.url, args.name, args.version, args.update) + elif args.subcommand == "list": + unhacs.list_packages(args.verbose) + elif args.subcommand == "remove": + print("Not implemented") + elif args.subcommand == "update": + unhacs.update_packages(args.packages) + else: + print("Not implemented") + + +if __name__ == "__main__": + main() diff --git a/unhacs/packages.py b/unhacs/packages.py new file mode 100644 index 0000000..56ee27a --- /dev/null +++ b/unhacs/packages.py @@ -0,0 +1,34 @@ +from dataclasses import dataclass +from pathlib import Path + + +DEFAULT_PACKAGE_FILE = "unhacs.txt" + + +@dataclass +class Package: + url: str + version: str + name: str + + def __str__(self): + return f"{self.name} {self.version}" + + def verbose_str(self): + return f"{self.name} {self.version} ({self.url})" + + +# Read a list of Packages from a text file in the plain text format "URL version name" +def read_packages(package_file: str = DEFAULT_PACKAGE_FILE) -> list[Package]: + path = Path(package_file) + if path.exists(): + with path.open() as f: + return [Package(*line.strip().split()) for line in f] + return [] + + +# Write a list of Packages to a text file in the format URL version name +def write_packages(packages: list[Package], package_file: str = DEFAULT_PACKAGE_FILE): + with open(package_file, "w") as f: + for package in packages: + f.write(f"{package.url} {package.version} {package.name}\n")