Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions script/scaffold/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Scaffold new integration."""
56 changes: 56 additions & 0 deletions script/scaffold/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""Validate manifests."""
from pathlib import Path
import subprocess
import sys

from . import gather_info, generate, error, model


def main():
"""Scaffold an integration."""
if not Path("requirements_all.txt").is_file():
print("Run from project root")
return 1

print("Creating a new integration for Home Assistant.")

if "--develop" in sys.argv:
print("Running in developer mode. Automatically filling in info.")
print()

info = model.Info(
domain="develop",
name="Develop Hub",
codeowner="@developer",
requirement="aiodevelop==1.2.3",
)
else:
try:
info = gather_info.gather_info()
except error.ExitApp as err:
print()
print(err.reason)
return err.exit_code

generate.generate(info)

print("Running hassfest to pick up new codeowner and config flow.")
subprocess.run("python -m script.hassfest", shell=True)
print()

print("Running tests")
print(f"$ pytest tests/components/{info.domain}")
if (
subprocess.run(f"pytest tests/components/{info.domain}", shell=True).returncode
!= 0
):
return 1
print()

print(f"Successfully created the {info.domain} integration!")

return 0


if __name__ == "__main__":
sys.exit(main())
5 changes: 5 additions & 0 deletions script/scaffold/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Constants for scaffolding."""
from pathlib import Path

COMPONENT_DIR = Path("homeassistant/components")
TESTS_DIR = Path("tests/components")
10 changes: 10 additions & 0 deletions script/scaffold/error.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""Errors for scaffolding."""


class ExitApp(Exception):
"""Exception to indicate app should exit."""

def __init__(self, reason, exit_code):
"""Initialize the exit app exception."""
self.reason = reason
self.exit_code = exit_code
79 changes: 79 additions & 0 deletions script/scaffold/gather_info.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""Gather info for scaffolding."""
from homeassistant.util import slugify

from .const import COMPONENT_DIR
from .model import Info
from .error import ExitApp


CHECK_EMPTY = ["Cannot be empty", lambda value: value]


FIELDS = {
"domain": {
"prompt": "What is the domain?",
"validators": [
CHECK_EMPTY,
[
"Domains cannot contain spaces or special characters.",
lambda value: value == slugify(value),
],
[
"There already is an integration with this domain.",
lambda value: not (COMPONENT_DIR / value).exists(),
],
],
},
"name": {
"prompt": "What is the name of your integration?",
"validators": [CHECK_EMPTY],
},
"codeowner": {
"prompt": "What is your GitHub handle?",
"validators": [
CHECK_EMPTY,
[
'GitHub handles need to start with an "@"',
lambda value: value.startswith("@"),
],
],
},
"requirement": {
"prompt": "What PyPI package and version do you depend on? Leave blank for none.",
"validators": [
["Versions should be pinned using '=='.", lambda value: "==" in value]
],
},
}


def gather_info() -> Info:
"""Gather info from user."""
answers = {}

for key, info in FIELDS.items():
hint = None
while key not in answers:
if hint is not None:
print()
print(f"Error: {hint}")

try:
print()
value = input(info["prompt"] + "\n> ")
except (KeyboardInterrupt, EOFError):
raise ExitApp("Interrupted!", 1)

value = value.strip()
hint = None

for validator_hint, validator in info["validators"]:
if not validator(value):
hint = validator_hint
break

if hint is None:
answers[key] = value

print()
return Info(**answers)
47 changes: 47 additions & 0 deletions script/scaffold/generate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Generate an integration."""
import json
from pathlib import Path

from .const import COMPONENT_DIR, TESTS_DIR
from .model import Info

TEMPLATE_DIR = Path(__file__).parent / "templates"
TEMPLATE_INTEGRATION = TEMPLATE_DIR / "integration"
TEMPLATE_TESTS = TEMPLATE_DIR / "tests"


def generate(info: Info) -> None:
"""Generate an integration."""
print(f"Generating the {info.domain} integration...")
integration_dir = COMPONENT_DIR / info.domain
test_dir = TESTS_DIR / info.domain

replaces = {
"NEW_DOMAIN": info.domain,
"NEW_NAME": info.name,
"NEW_CODEOWNER": info.codeowner,
# Special case because we need to keep the list empty if there is none.
'"MANIFEST_NEW_REQUIREMENT"': (
json.dumps(info.requirement) if info.requirement else ""
),
}

for src_dir, target_dir in (
(TEMPLATE_INTEGRATION, integration_dir),
(TEMPLATE_TESTS, test_dir),
):
# Guard making it for test purposes.
if not target_dir.exists():
target_dir.mkdir()

for source_file in src_dir.glob("**/*"):
content = source_file.read_text()

for to_search, to_replace in replaces.items():
content = content.replace(to_search, to_replace)

target_file = target_dir / source_file.relative_to(src_dir)
print(f"Writing {target_file}")
target_file.write_text(content)

print()
12 changes: 12 additions & 0 deletions script/scaffold/model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""Models for scaffolding."""
import attr


@attr.s
class Info:
"""Info about new integration."""

domain: str = attr.ib()
name: str = attr.ib()
codeowner: str = attr.ib()
requirement: str = attr.ib()
19 changes: 19 additions & 0 deletions script/scaffold/templates/integration/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""The NEW_NAME integration."""

from .const import DOMAIN


async def async_setup(hass, config):
"""Set up the NEW_NAME integration."""
hass.data[DOMAIN] = config.get(DOMAIN, {})
return True


async def async_setup_entry(hass, entry):
"""Set up a config entry for NEW_NAME."""
# TODO forward the entry for each platform that you want to set up.
# hass.async_create_task(
# hass.config_entries.async_forward_entry_setup(entry, "media_player")
# )

return True
57 changes: 57 additions & 0 deletions script/scaffold/templates/integration/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Config flow for NEW_NAME integration."""
import logging

import voluptuous as vol

from homeassistant import core, config_entries

from .const import DOMAIN # pylint:disable=unused-import
from .error import CannotConnect, InvalidAuth

_LOGGER = logging.getLogger(__name__)

# TODO adjust the data schema to the data that you need
DATA_SCHEMA = vol.Schema({"host": str, "username": str, "password": str})


async def validate_input(hass: core.HomeAssistant, data):
"""Validate the user input allows us to connect.

Data has the keys from DATA_SCHEMA with values provided by the user.
"""
# TODO validate the data can be used to set up a connection.
# If you cannot connect:
# throw CannotConnect
# If the authentication is wrong:
# InvalidAuth

# Return some info we want to store in the config entry.
return {"title": "Name of the device"}


class DomainConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for NEW_NAME."""

VERSION = 1
# TODO pick one of the available connection classes
CONNECTION_CLASS = config_entries.CONN_CLASS_UNKNOWN

async def async_step_user(self, user_input=None):
"""Handle the initial step."""
errors = {}
if user_input is not None:
try:
info = await validate_input(self.hass, user_input)

return self.async_create_entry(title=info["title"], data=user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"

return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
)
3 changes: 3 additions & 0 deletions script/scaffold/templates/integration/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Constants for the NEW_NAME integration."""

DOMAIN = "NEW_DOMAIN"
10 changes: 10 additions & 0 deletions script/scaffold/templates/integration/error.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""Errors for the NEW_NAME integration."""
from homeassistant.exceptions import HomeAssistantError


class CannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect."""


class InvalidAuth(HomeAssistantError):
"""Error to indicate there is invalid auth."""
11 changes: 11 additions & 0 deletions script/scaffold/templates/integration/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"domain": "NEW_DOMAIN",
"name": "NEW_NAME",
"config_flow": true,
"documentation": "https://www.home-assistant.io/components/NEW_DOMAIN",
"requirements": ["MANIFEST_NEW_REQUIREMENT"],
"ssdp": {},
"homekit": {},
"dependencies": [],
"codeowners": ["NEW_CODEOWNER"]
}
21 changes: 21 additions & 0 deletions script/scaffold/templates/integration/strings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"config": {
"title": "NEW_NAME",
"step": {
"user": {
"title": "Connect to the device",
"data": {
"host": "Host"
}
}
},
"error": {
"cannot_connect": "Failed to connect, please try again",
"invalid_auth": "Invalid authentication",
"unknown": "Unexpected error"
},
"abort": {
"already_configured": "Device is already configured"
}
}
}
1 change: 1 addition & 0 deletions script/scaffold/templates/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Tests for the NEW_NAME integration."""
Loading