From 39efbcb815d3c8eb274ec06ef15c9f266e03dfba Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 26 Nov 2020 16:00:50 +0100 Subject: [PATCH] Allow importing gist (#43659) --- .../components/blueprint/importer.py | 64 ++++++++++++-- tests/components/blueprint/test_importer.py | 25 +++++- tests/fixtures/blueprint/github_gist.json | 84 +++++++++++++++++++ 3 files changed, 161 insertions(+), 12 deletions(-) create mode 100644 tests/fixtures/blueprint/github_gist.json diff --git a/homeassistant/components/blueprint/importer.py b/homeassistant/components/blueprint/importer.py index f538c0897f0..bc40f76e7c2 100644 --- a/homeassistant/components/blueprint/importer.py +++ b/homeassistant/components/blueprint/importer.py @@ -25,7 +25,6 @@ COMMUNITY_CODE_BLOCK = re.compile( GITHUB_FILE_PATTERN = re.compile( r"^https://github.com/(?P.+)/blob/(?P.+)$" ) -GITHUB_RAW_FILE_PATTERN = re.compile(r"^https://raw.githubusercontent.com/") COMMUNITY_TOPIC_SCHEMA = vol.Schema( { @@ -37,6 +36,10 @@ COMMUNITY_TOPIC_SCHEMA = vol.Schema( ) +class UnsupportedUrl(HomeAssistantError): + """When the function doesn't support the url.""" + + @dataclass(frozen=True) class ImportedBlueprint: """Imported blueprint.""" @@ -51,14 +54,13 @@ def _get_github_import_url(url: str) -> str: Async friendly. """ - match = GITHUB_RAW_FILE_PATTERN.match(url) - if match is not None: + if url.startswith("https://raw.githubusercontent.com/"): return url match = GITHUB_FILE_PATTERN.match(url) if match is None: - raise ValueError("Not a GitHub file url") + raise UnsupportedUrl("Not a GitHub file url") repo, path = match.groups() @@ -72,7 +74,7 @@ def _get_community_post_import_url(url: str) -> str: """ match = COMMUNITY_TOPIC_PATTERN.match(url) if match is None: - raise ValueError("Not a topic url") + raise UnsupportedUrl("Not a topic url") _topic, post = match.groups() @@ -122,7 +124,7 @@ def _extract_blueprint_from_community_topic( break if blueprint is None: - return None + raise HomeAssistantError("No valid blueprint found in the topic") return ImportedBlueprint( f'{post["username"]}/{topic["slug"]}', block_content, blueprint @@ -167,14 +169,60 @@ async def fetch_blueprint_from_github_url( return ImportedBlueprint(suggested_filename, raw_yaml, blueprint) +async def fetch_blueprint_from_github_gist_url( + hass: HomeAssistant, url: str +) -> ImportedBlueprint: + """Get a blueprint from a Github Gist.""" + if not url.startswith("https://gist.github.com/"): + raise UnsupportedUrl("Not a GitHub gist url") + + parsed_url = yarl.URL(url) + session = aiohttp_client.async_get_clientsession(hass) + + resp = await session.get( + f"https://api.github.com/gists/{parsed_url.parts[2]}", + headers={"Accept": "application/vnd.github.v3+json"}, + raise_for_status=True, + ) + gist = await resp.json() + + blueprint = None + filename = None + content = None + + for filename, info in gist["files"].items(): + if not filename.endswith(".yaml"): + continue + + content = info["content"] + data = yaml.parse_yaml(content) + + if not is_blueprint_config(data): + continue + + blueprint = Blueprint(data) + break + + if blueprint is None: + raise HomeAssistantError("No valid blueprint found in the gist") + + return ImportedBlueprint( + f"{gist['owner']['login']}/{filename[:-5]}", content, blueprint + ) + + async def fetch_blueprint_from_url(hass: HomeAssistant, url: str) -> ImportedBlueprint: """Get a blueprint from a url.""" - for func in (fetch_blueprint_from_community_post, fetch_blueprint_from_github_url): + for func in ( + fetch_blueprint_from_community_post, + fetch_blueprint_from_github_url, + fetch_blueprint_from_github_gist_url, + ): try: imported_bp = await func(hass, url) imported_bp.blueprint.update_metadata(source_url=url) return imported_bp - except ValueError: + except UnsupportedUrl: pass raise HomeAssistantError("Unsupported url") diff --git a/tests/components/blueprint/test_importer.py b/tests/components/blueprint/test_importer.py index 3fa3dbab2f1..9d6bb48bcaf 100644 --- a/tests/components/blueprint/test_importer.py +++ b/tests/components/blueprint/test_importer.py @@ -80,8 +80,8 @@ def test_extract_blueprint_from_community_topic_invalid_yaml(): def test_extract_blueprint_from_community_topic_wrong_lang(): """Test extracting blueprint with invalid YAML.""" - assert ( - importer._extract_blueprint_from_community_topic( + with pytest.raises(importer.HomeAssistantError): + assert importer._extract_blueprint_from_community_topic( "http://example.com", { "post_stream": { @@ -91,8 +91,6 @@ def test_extract_blueprint_from_community_topic_wrong_lang(): } }, ) - is None - ) async def test_fetch_blueprint_from_community_url(hass, aioclient_mock, community_post): @@ -141,3 +139,22 @@ async def test_fetch_blueprint_from_github_url(hass, aioclient_mock, url): } assert imported_blueprint.suggested_filename == "balloob/motion_light" assert imported_blueprint.blueprint.metadata["source_url"] == url + + +async def test_fetch_blueprint_from_github_gist_url(hass, aioclient_mock): + """Test fetching blueprint from url.""" + aioclient_mock.get( + "https://api.github.com/gists/e717ce85dd0d2f1bdcdfc884ea25a344", + text=load_fixture("blueprint/github_gist.json"), + ) + + url = "https://gist.github.com/balloob/e717ce85dd0d2f1bdcdfc884ea25a344" + imported_blueprint = await importer.fetch_blueprint_from_url(hass, url) + assert isinstance(imported_blueprint, importer.ImportedBlueprint) + assert imported_blueprint.blueprint.domain == "automation" + assert imported_blueprint.blueprint.placeholders == { + "motion_entity", + "light_entity", + } + assert imported_blueprint.suggested_filename == "balloob/motion_light" + assert imported_blueprint.blueprint.metadata["source_url"] == url diff --git a/tests/fixtures/blueprint/github_gist.json b/tests/fixtures/blueprint/github_gist.json new file mode 100644 index 00000000000..f25c7ca0238 --- /dev/null +++ b/tests/fixtures/blueprint/github_gist.json @@ -0,0 +1,84 @@ +{ + "url": "https://api.github.com/gists/e717ce85dd0d2f1bdcdfc884ea25a344", + "forks_url": "https://api.github.com/gists/e717ce85dd0d2f1bdcdfc884ea25a344/forks", + "commits_url": "https://api.github.com/gists/e717ce85dd0d2f1bdcdfc884ea25a344/commits", + "id": "e717ce85dd0d2f1bdcdfc884ea25a344", + "node_id": "MDQ6R2lzdGU3MTdjZTg1ZGQwZDJmMWJkY2RmYzg4NGVhMjVhMzQ0", + "git_pull_url": "https://gist.github.com/e717ce85dd0d2f1bdcdfc884ea25a344.git", + "git_push_url": "https://gist.github.com/e717ce85dd0d2f1bdcdfc884ea25a344.git", + "html_url": "https://gist.github.com/e717ce85dd0d2f1bdcdfc884ea25a344", + "files": { + "motion_light.yaml": { + "filename": "motion_light.yaml", + "type": "text/x-yaml", + "language": "YAML", + "raw_url": "https://gist.githubusercontent.com/balloob/e717ce85dd0d2f1bdcdfc884ea25a344/raw/d3cede19ffed75443934325177cd78d26b1700ad/motion_light.yaml", + "size": 803, + "truncated": false, + "content": "blueprint:\n name: Motion-activated Light\n domain: automation\n input:\n motion_entity:\n name: Motion Sensor\n selector:\n entity:\n domain: binary_sensor\n device_class: motion\n light_entity:\n name: Light\n selector:\n entity:\n domain: light\n\n# If motion is detected within the 120s delay,\n# we restart the script.\nmode: restart\nmax_exceeded: silent\n\ntrigger:\n platform: state\n entity_id: !placeholder motion_entity\n from: \"off\"\n to: \"on\"\n\naction:\n - service: homeassistant.turn_on\n entity_id: !placeholder light_entity\n - wait_for_trigger:\n platform: state\n entity_id: !placeholder motion_entity\n from: \"on\"\n to: \"off\"\n - delay: 120\n - service: homeassistant.turn_off\n entity_id: !placeholder light_entity\n" + } + }, + "public": false, + "created_at": "2020-11-25T22:49:50Z", + "updated_at": "2020-11-25T22:49:51Z", + "description": "Example gist", + "comments": 0, + "user": null, + "comments_url": "https://api.github.com/gists/e717ce85dd0d2f1bdcdfc884ea25a344/comments", + "owner": { + "login": "balloob", + "id": 1444314, + "node_id": "MDQ6VXNlcjE0NDQzMTQ=", + "avatar_url": "https://avatars3.githubusercontent.com/u/1444314?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/balloob", + "html_url": "https://github.com/balloob", + "followers_url": "https://api.github.com/users/balloob/followers", + "following_url": "https://api.github.com/users/balloob/following{/other_user}", + "gists_url": "https://api.github.com/users/balloob/gists{/gist_id}", + "starred_url": "https://api.github.com/users/balloob/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/balloob/subscriptions", + "organizations_url": "https://api.github.com/users/balloob/orgs", + "repos_url": "https://api.github.com/users/balloob/repos", + "events_url": "https://api.github.com/users/balloob/events{/privacy}", + "received_events_url": "https://api.github.com/users/balloob/received_events", + "type": "User", + "site_admin": false + }, + "forks": [ + + ], + "history": [ + { + "user": { + "login": "balloob", + "id": 1444314, + "node_id": "MDQ6VXNlcjE0NDQzMTQ=", + "avatar_url": "https://avatars3.githubusercontent.com/u/1444314?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/balloob", + "html_url": "https://github.com/balloob", + "followers_url": "https://api.github.com/users/balloob/followers", + "following_url": "https://api.github.com/users/balloob/following{/other_user}", + "gists_url": "https://api.github.com/users/balloob/gists{/gist_id}", + "starred_url": "https://api.github.com/users/balloob/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/balloob/subscriptions", + "organizations_url": "https://api.github.com/users/balloob/orgs", + "repos_url": "https://api.github.com/users/balloob/repos", + "events_url": "https://api.github.com/users/balloob/events{/privacy}", + "received_events_url": "https://api.github.com/users/balloob/received_events", + "type": "User", + "site_admin": false + }, + "version": "0b1028d04209ad0cc7942d3dcf02f9b036cea21f", + "committed_at": "2020-11-25T22:49:50Z", + "change_status": { + "total": 38, + "additions": 38, + "deletions": 0 + }, + "url": "https://api.github.com/gists/e717ce85dd0d2f1bdcdfc884ea25a344/0b1028d04209ad0cc7942d3dcf02f9b036cea21f" + } + ], + "truncated": false +}