Skip to content

Commit

Permalink
Show documentation for the downloaded version if downloaded (#3305)
Browse files Browse the repository at this point in the history
* Add documentation key to hacs.json

* warn only

* Use default in validation as well

* Allow multiple languages

* test for both outcomes

* Add hacs/repository/documentation WS to get the documentation

* Update legacy command

* decode content

* replace svg in backend

* Allow subdir

* Use commit if version is not set

* Return early if no arguments

* Get the correct file

* Add helper for get_hacs_json

* Add tests for the new WS endpoint

* Test unknown version as well

* lint

* Register all on init

* Add components

* Only validate documentation if present

* Skip storing the documentation key

* rename test

* Do not skip keys

* lint

* Verify not storing default

* simplify

* more simplifiacation

* simplify
  • Loading branch information
ludeeus authored Nov 17, 2023
1 parent 8f0c7cf commit 09ec684
Show file tree
Hide file tree
Showing 5 changed files with 171 additions and 39 deletions.
72 changes: 52 additions & 20 deletions custom_components/hacs/repositories/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@
from ..utils.path import is_safe
from ..utils.queue_manager import QueueManager
from ..utils.store import async_remove_store
from ..utils.template import render_template
from ..utils.url import archive_download, asset_download
from ..utils.validate import Validate
from ..utils.version import (
Expand Down Expand Up @@ -793,25 +792,7 @@ def _info_file_variants() -> tuple[str, ...]:
if not info_files:
return ""

try:
response = await self.hacs.async_github_api_method(
method=self.hacs.githubapi.repos.contents.get,
raise_exception=False,
repository=self.data.full_name,
path=info_files[0],
)
if response:
return render_template(
self.hacs,
decode_content(response.data.content)
.replace("<svg", "<disabled")
.replace("</svg", "</disabled"),
self,
)
except BaseException as exc: # lgtm [py/catch-base-exception] pylint: disable=broad-except
self.logger.error("%s %s", self.string, exc)

return ""
return await self.get_documentation(filename=info_files[0]) or ""

def remove(self) -> None:
"""Run remove tasks."""
Expand Down Expand Up @@ -1384,3 +1365,54 @@ def version_to_download(self) -> str:
return self.data.selected_tag

return self.data.default_branch or "main"

async def get_documentation(
self,
*,
filename: str | None = None,
**kwargs,
) -> str | None:
"""Get the documentation of the repository."""
if filename is None:
return None

version = (
(self.data.installed_version or self.data.installed_commit)
if self.data.installed
else (self.data.last_version or self.data.last_commit)
)
self.logger.debug(
"%s Getting documentation for version=%s,filename=%s",
self.string,
version,
filename,
)
if version is None:
return None

result = await self.hacs.async_download_file(
f"https://raw.githubusercontent.com/{self.data.full_name}/{version}/{filename}",
nolog=True,
)

return (
result.decode(encoding="utf-8")
.replace("<svg", "<disabled")
.replace("</svg", "</disabled")
if result
else None
)

async def get_hacs_json(self, *, version: str, **kwargs) -> HacsManifest | None:
"""Get the hacs.json file of the repository."""
self.logger.debug("%s Getting hacs.json for version=%s", self.string, version)
try:
result = await self.hacs.async_download_file(
f"https://raw.githubusercontent.com/{self.data.full_name}/{version}/hacs.json",
nolog=True,
)
if result is None:
return None
return HacsManifest.from_dict(json_loads(result))
except Exception: # pylint: disable=broad-except
return None
80 changes: 77 additions & 3 deletions tests/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,15 @@
import functools as ft
import json
import os
from typing import Any

from aiohttp import ClientWebSocketResponse
from homeassistant import auth, config_entries, core as ha
from homeassistant.auth import auth_store
from homeassistant.auth import (
auth_store,
models as auth_models,
permissions as auth_permissions,
)
from homeassistant.components.http import (
CONFIG_SCHEMA as HTTP_CONFIG_SCHEMA,
async_setup as http_async_setup,
Expand All @@ -18,11 +24,14 @@
from homeassistant.helpers.device_registry import DeviceRegistry
from homeassistant.helpers.entity_registry import EntityRegistry
from homeassistant.helpers.issue_registry import IssueRegistry
from homeassistant.setup import async_setup_component
import homeassistant.util.dt as date_util
from homeassistant.util.unit_system import METRIC_SYSTEM

from custom_components.hacs.base import HacsBase
from custom_components.hacs.repositories.base import HacsManifest, HacsRepository
from custom_components.hacs.utils.logger import LOGGER
from custom_components.hacs.websocket import async_register_websocket_commands

from tests.async_mock import AsyncMock, Mock, patch

Expand Down Expand Up @@ -138,7 +147,9 @@ def async_create_task(coroutine, *args):
hass.config.units = METRIC_SYSTEM
hass.config.skip_pip = True
hass.data = {
"integrations": {},
"custom_components": {},
"components": {},
"device_registry": DeviceRegistry(hass),
"entity_registry": EntityRegistry(hass),
"issue_registry": IssueRegistry(hass),
Expand All @@ -165,9 +176,9 @@ async def mock_async_start():

hass.async_start = mock_async_start

@ha.callback
def clear_instance(event):
async def clear_instance(event):
"""Clear global instance."""
await hass.http.stop()
INSTANCES.remove(hass)

hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, clear_instance)
Expand Down Expand Up @@ -238,3 +249,66 @@ async def mock_remove(store):
autospec=True,
):
yield data


class MockOwner(auth_models.User):
"""Mock a user in Home Assistant."""

def __init__(self):
"""Initialize mock user."""
super().__init__(
**{
"is_owner": True,
"is_active": True,
"name": "Mocked Owner User",
"system_generated": False,
"groups": [],
"perm_lookup": None,
}
)

@staticmethod
def create(hass: ha.HomeAssistant):
"""Create a mock user."""
user = MockOwner()
ensure_auth_manager_loaded(hass.auth)
hass.auth._store._users[user.id] = user
return user


class WSClient:
"""WS Client to be used in testing."""

client: ClientWebSocketResponse | None = None

def __init__(self, hacs: HacsBase, token: str) -> None:
self.hacs = hacs
self.token = token
self.id = 0

async def _create_client(self) -> None:
if self.client is not None:
return

await async_setup_component(self.hacs.hass, "websocket_api", {})
await self.hacs.hass.http.start()
async_register_websocket_commands(self.hacs.hass)
self.client = await self.hacs.session.ws_connect("http://localhost:8123/api/websocket")
auth_response = await self.client.receive_json()
assert auth_response["type"] == "auth_required"
await self.client.send_json({"type": "auth", "access_token": self.token})

auth_response = await self.client.receive_json()
assert auth_response["type"] == "auth_ok"

async def send_json(self, type: str, payload: dict[str, Any]) -> dict[str, Any]:
self.id += 1
await self._create_client()
await self.client.send_json({"id": self.id, "type": type, **payload})

async def receive_json(self) -> dict[str, Any]:
return await self.client.receive_json()

async def send_and_receive_json(self, type: str, payload: dict[str, Any]) -> dict[str, Any]:
await self.send_json(type=type, payload=payload)
return await self.client.receive_json()
26 changes: 26 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
from aiogithubapi import GitHub, GitHubAPI
from aiogithubapi.const import ACCEPT_HEADERS
from awesomeversion import AwesomeVersion
from homeassistant.auth.models import Credentials
from homeassistant.auth.providers.homeassistant import HassAuthProvider
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import __version__ as HAVERSION
from homeassistant.core import HomeAssistant
Expand Down Expand Up @@ -43,6 +45,8 @@
from tests.async_mock import MagicMock
from tests.common import (
TOKEN,
MockOwner,
WSClient,
async_test_home_assistant,
dummy_repository_base,
mock_storage as mock_storage,
Expand Down Expand Up @@ -222,3 +226,25 @@ def config_entry() -> ConfigEntry:
options={},
unique_id="12345",
)


@pytest_asyncio.fixture
async def ws_client(hacs: HacsBase, hass: HomeAssistant) -> WSClient:
"""Owner authenticated Websocket client fixture."""
auth_provider = HassAuthProvider(hass, hass.auth._store, {"type": "homeassistant"})
hass.auth._providers[(auth_provider.type, auth_provider.id)] = auth_provider
owner = MockOwner.create(hass)

credentials = Credentials(
auth_provider_type=auth_provider.type,
auth_provider_id=auth_provider.id,
data={"username": "testadmin"},
)

await auth_provider.async_initialize()
await hass.auth.async_link_user(owner, credentials)
refresh_token = await hass.auth.async_create_refresh_token(
owner, "https://hacs.xyz/testing", credential=credentials
)

return WSClient(hacs, hass.auth.async_create_access_token(refresh_token))
8 changes: 8 additions & 0 deletions tests/utils/test_validate.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import re

from awesomeversion import AwesomeVersion
import pytest
from voluptuous.error import Invalid
Expand Down Expand Up @@ -62,6 +64,12 @@ def test_hacs_manufest_json_schema():
}
)

assert hacs_json_schema(
{
"name": "My awesome thing",
}
)

with pytest.raises(Invalid, match="extra keys not allowed"):
hacs_json_schema({"name": "My awesome thing", "not": "valid"})

Expand Down
24 changes: 8 additions & 16 deletions tests/validate/test_hacsjson_check.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
from aiogithubapi.objects.repository.content import AIOGitHubAPIRepositoryTreeContent
import pytest

from custom_components.hacs.repositories.base import HacsManifest
from custom_components.hacs.validate.hacsjson import Validator

test_tree = [
AIOGitHubAPIRepositoryTreeContent({"path": "hacs.json", "type": "file"}, "test/test", "main"),
AIOGitHubAPIRepositoryTreeContent({"path": "README.md", "type": "file"}, "test/test", "main"),
]


@pytest.mark.asyncio
async def test_hacs_manifest_no_manifest(repository):
Expand All @@ -14,11 +18,7 @@ async def test_hacs_manifest_no_manifest(repository):

@pytest.mark.asyncio
async def test_hacs_manifest_with_valid_manifest(repository):
repository.tree = [
AIOGitHubAPIRepositoryTreeContent(
{"path": "hacs.json", "type": "file"}, "test/test", "main"
)
]
repository.tree = test_tree

async def _async_get_hacs_json(_):
return {"name": "test"}
Expand All @@ -32,11 +32,7 @@ async def _async_get_hacs_json(_):

@pytest.mark.asyncio
async def test_hacs_manifest_with_invalid_manifest(repository):
repository.tree = [
AIOGitHubAPIRepositoryTreeContent(
{"path": "hacs.json", "type": "file"}, "test/test", "main"
)
]
repository.tree = test_tree

async def _async_get_hacs_json(_):
return {"not": "valid"}
Expand All @@ -50,11 +46,7 @@ async def _async_get_hacs_json(_):

@pytest.mark.asyncio
async def test_hacs_manifest_with_missing_filename(repository, caplog):
repository.tree = [
AIOGitHubAPIRepositoryTreeContent(
{"path": "hacs.json", "type": "file"}, "test/test", "main"
)
]
repository.tree = test_tree
repository.data.category = "integration"

async def _async_get_hacs_json(_):
Expand Down

0 comments on commit 09ec684

Please sign in to comment.