diff --git a/homeassistant/const.py b/homeassistant/const.py index e1e9757dd02bb6cb5c58bbf6c7fb4e087365c40b..449e7a90087b83702207c87a575f80e5a1f56c5e 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -40,6 +40,8 @@ CONF_BELOW = "below" CONF_BINARY_SENSORS = "binary_sensors" CONF_BLACKLIST = "blacklist" CONF_BRIGHTNESS = "brightness" +CONF_CLIENT_ID = "client_id" +CONF_CLIENT_SECRET = "client_secret" CONF_CODE = "code" CONF_COLOR_TEMP = "color_temp" CONF_COMMAND = "command" diff --git a/script/scaffold/__main__.py b/script/scaffold/__main__.py index 2258840f430d927f537c974eb9c161f456bf5c3c..78490b84ba3d9b1fbc429ca9afdf1cf4195cd13e 100644 --- a/script/scaffold/__main__.py +++ b/script/scaffold/__main__.py @@ -48,34 +48,46 @@ def main(): args = get_arguments() info = gather_info.gather_info(args) + print() + + # If we are calling scaffold on a non-existing integration, + # We're going to first make it. If we're making an integration, + # we will also make a config flow to go with it. + + if info.is_new: + generate.generate("integration", info) - generate.generate(args.template, info) + # If it's a new integration and it's not a config flow, + # create a config flow too. + if not args.template.startswith("config_flow"): + if info.oauth2: + template = "config_flow_oauth2" + elif info.authentication or not info.discoverable: + template = "config_flow" + else: + template = "config_flow_discovery" - # If creating new integration, create config flow too - if args.template == "integration": - if info.authentication or not info.discoverable: - template = "config_flow" - else: - template = "config_flow_discovery" + generate.generate(template, info) - generate.generate(template, info) + # If we wanted a new integration, we've already done our work. + if args.template != "integration": + generate.generate(args.template, info) + + pipe_null = "" if args.develop else "> /dev/null" print("Running hassfest to pick up new information.") - subprocess.run("python -m script.hassfest", shell=True) + subprocess.run(f"python -m script.hassfest {pipe_null}", shell=True) print() - print("Running tests") - print(f"$ pytest -vvv tests/components/{info.domain}") - if ( - subprocess.run( - f"pytest -vvv tests/components/{info.domain}", shell=True - ).returncode - != 0 - ): - return 1 + print("Running gen_requirements_all to pick up new information.") + subprocess.run(f"python -m script.gen_requirements_all {pipe_null}", shell=True) print() - print(f"Done!") + if args.develop: + print("Running tests") + print(f"$ pytest -vvv tests/components/{info.domain}") + subprocess.run(f"pytest -vvv tests/components/{info.domain}", shell=True) + print() docs.print_relevant_docs(args.template, info) diff --git a/script/scaffold/docs.py b/script/scaffold/docs.py index bb119c0e42e4b3cf2dbca6f39d74b71ed4057e09..5df663fec0b2409a2f7724aa6a2bab9243773853 100644 --- a/script/scaffold/docs.py +++ b/script/scaffold/docs.py @@ -2,72 +2,76 @@ from .model import Info -def print_relevant_docs(template: str, info: Info) -> None: - """Print relevant docs.""" - if template == "integration": - print( - f""" -Your integration has been created at {info.integration_dir} . Next step is to fill in the blanks for the code marked with TODO. - -For a breakdown of each file, check the developer documentation at: -https://developers.home-assistant.io/docs/en/creating_integration_file_structure.html -""" - ) +DATA = { + "config_flow": { + "title": "Config Flow", + "docs": "https://developers.home-assistant.io/docs/en/config_entries_config_flow_handler.html", + }, + "config_flow_discovery": { + "title": "Discoverable Config Flow", + "docs": "https://developers.home-assistant.io/docs/en/config_entries_config_flow_handler.html#discoverable-integrations-that-require-no-authentication", + }, + "config_flow_oauth2": { + "title": "OAuth2 Config Flow", + "docs": "https://developers.home-assistant.io/docs/en/next/config_entries_config_flow_handler.html#configuration-via-oauth2", + }, + "device_action": { + "title": "Device Action", + "docs": "https://developers.home-assistant.io/docs/en/device_automation_action.html", + }, + "device_condition": { + "title": "Device Condition", + "docs": "https://developers.home-assistant.io/docs/en/device_automation_condition.html", + }, + "device_trigger": { + "title": "Device Trigger", + "docs": "https://developers.home-assistant.io/docs/en/device_automation_trigger.html", + }, + "integration": { + "title": "Integration", + "docs": "https://developers.home-assistant.io/docs/en/creating_integration_file_structure.html", + }, + "reproduce_state": { + "title": "Reproduce State", + "docs": "https://developers.home-assistant.io/docs/en/reproduce_state_index.html", + "extra": "You will now need to update the code to make sure that every attribute that can occur in the state will cause the right service to be called.", + }, +} - elif template == "config_flow": - print( - f""" -The config flow has been added to the {info.domain} integration. Next step is to fill in the blanks for the code marked with TODO. -""" - ) - - elif template == "reproduce_state": - print( - f""" -Reproduce state code has been added to the {info.domain} integration: - - {info.integration_dir / "reproduce_state.py"} - - {info.tests_dir / "test_reproduce_state.py"} -You will now need to update the code to make sure that every attribute -that can occur in the state will cause the right service to be called. -""" - ) +def print_relevant_docs(template: str, info: Info) -> None: + """Print relevant docs.""" + data = DATA[template] - elif template == "device_trigger": - print( - f""" -Device trigger base has been added to the {info.domain} integration: - - {info.integration_dir / "device_trigger.py"} - - {info.integration_dir / "strings.json"} (translations) - - {info.tests_dir / "test_device_trigger.py"} + print() + print("**************************") + print() + print() + print(f"{data['title']} code has been generated") + print() + if info.files_added: + print("Added the following files:") + for file in info.files_added: + print(f"- {file}") + print() -You will now need to update the code to make sure that relevant triggers -are exposed. -""" - ) + if info.tests_added: + print("Added the following tests:") + for file in info.tests_added: + print(f"- {file}") + print() - elif template == "device_condition": + if info.examples_added: print( - f""" -Device condition base has been added to the {info.domain} integration: - - {info.integration_dir / "device_condition.py"} - - {info.integration_dir / "strings.json"} (translations) - - {info.tests_dir / "test_device_condition.py"} - -You will now need to update the code to make sure that relevant condtions -are exposed. -""" + "Because some files already existed, we added the following example files. Please copy the relevant code to the existing files." ) + for file in info.examples_added: + print(f"- {file}") + print() - elif template == "device_action": - print( - f""" -Device action base has been added to the {info.domain} integration: - - {info.integration_dir / "device_action.py"} - - {info.integration_dir / "strings.json"} (translations) - - {info.tests_dir / "test_device_action.py"} + print( + f"The next step is to look at the files and deal with all areas marked as TODO." + ) -You will now need to update the code to make sure that relevant services -are exposed as actions. -""" - ) + if "extra" in data: + print(data["extra"]) diff --git a/script/scaffold/gather_info.py b/script/scaffold/gather_info.py index a7263daaf41982026a2f1e8df20f3fdb97d9d6ac..12cb319d188ce0d41c0d43f63390179d91e8f402 100644 --- a/script/scaffold/gather_info.py +++ b/script/scaffold/gather_info.py @@ -13,36 +13,14 @@ CHECK_EMPTY = ["Cannot be empty", lambda value: value] def gather_info(arguments) -> Info: """Gather info.""" - existing = arguments.template != "integration" - - if arguments.develop: + if arguments.integration: + info = {"domain": arguments.integration} + elif arguments.develop: print("Running in developer mode. Automatically filling in info.") print() - - if existing: - if arguments.develop: - return _load_existing_integration("develop") - - if arguments.integration: - return _load_existing_integration(arguments.integration) - - return gather_existing_integration() - - if arguments.develop: - return Info( - domain="develop", - name="Develop Hub", - codeowner="@developer", - requirement="aiodevelop==1.2.3", - ) - - return gather_new_integration() - - -def gather_new_integration() -> Info: - """Gather info about new integration from user.""" - return Info( - **_gather_info( + info = {"domain": "develop"} + else: + info = _gather_info( { "domain": { "prompt": "What is the domain?", @@ -52,84 +30,87 @@ def gather_new_integration() -> Info: "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: not value or "==" in value, - ] - ], - }, + } + } + ) + + info["is_new"] = not (COMPONENT_DIR / info["domain"] / "manifest.json").exists() + + if not info["is_new"]: + return _load_existing_integration(info["domain"]) + + if arguments.develop: + info.update( + { + "name": "Develop Hub", + "codeowner": "@developer", + "requirement": "aiodevelop==1.2.3", + "oauth2": True, + } + ) + else: + info.update(gather_new_integration(arguments.template == "integration")) + + return Info(**info) + + +YES_NO = { + "validators": [["Type either 'yes' or 'no'", lambda value: value in ("yes", "no")]], + "convertor": lambda value: value == "yes", +} + + +def gather_new_integration(determine_auth: bool) -> Info: + """Gather info about new integration from user.""" + fields = { + "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: not value or "==" in value, + ] + ], + }, + } + + if determine_auth: + fields.update( + { "authentication": { "prompt": "Does Home Assistant need the user to authenticate to control the device/service? (yes/no)", "default": "yes", - "validators": [ - [ - "Type either 'yes' or 'no'", - lambda value: value in ("yes", "no"), - ] - ], - "convertor": lambda value: value == "yes", + **YES_NO, }, "discoverable": { "prompt": "Is the device/service discoverable on the local network? (yes/no)", "default": "no", - "validators": [ - [ - "Type either 'yes' or 'no'", - lambda value: value in ("yes", "no"), - ] - ], - "convertor": lambda value: value == "yes", + **YES_NO, + }, + "oauth2": { + "prompt": "Can the user authenticate the device using OAuth2? (yes/no)", + "default": "no", + **YES_NO, }, } ) - ) - - -def gather_existing_integration() -> Info: - """Gather info about existing integration from user.""" - answers = _gather_info( - { - "domain": { - "prompt": "What is the domain?", - "validators": [ - CHECK_EMPTY, - [ - "Domains cannot contain spaces or special characters.", - lambda value: value == slugify(value), - ], - [ - "This integration does not exist.", - lambda value: (COMPONENT_DIR / value).exists(), - ], - ], - } - } - ) - return _load_existing_integration(answers["domain"]) + return _gather_info(fields) def _load_existing_integration(domain) -> Info: @@ -179,5 +160,4 @@ def _gather_info(fields) -> dict: value = info["convertor"](value) answers[key] = value - print() return answers diff --git a/script/scaffold/generate.py b/script/scaffold/generate.py index e16316fd76b129dce34884889a6c3bfc8aced7bb..a04cdb3ef5e0bd6cd254cf71cf6ac1210e6cede0 100644 --- a/script/scaffold/generate.py +++ b/script/scaffold/generate.py @@ -1,7 +1,6 @@ """Generate an integration.""" from pathlib import Path -from .error import ExitApp from .model import Info TEMPLATE_DIR = Path(__file__).parent / "templates" @@ -11,8 +10,6 @@ TEMPLATE_TESTS = TEMPLATE_DIR / "tests" def generate(template: str, info: Info) -> None: """Generate a template.""" - _validate(template, info) - print(f"Scaffolding {template} for the {info.domain} integration...") _ensure_tests_dir_exists(info) _generate(TEMPLATE_DIR / template / "integration", info.integration_dir, info) @@ -21,13 +18,6 @@ def generate(template: str, info: Info) -> None: print() -def _validate(template, info): - """Validate we can run this task.""" - if template == "config_flow": - if (info.integration_dir / "config_flow.py").exists(): - raise ExitApp(f"Integration {info.domain} already has a config flow.") - - def _generate(src_dir, target_dir, info: Info) -> None: """Generate an integration.""" replaces = {"NEW_DOMAIN": info.domain, "NEW_NAME": info.name} @@ -42,6 +32,20 @@ def _generate(src_dir, target_dir, info: Info) -> None: content = content.replace(to_search, to_replace) target_file = target_dir / source_file.relative_to(src_dir) + + # If the target file exists, create our template as EXAMPLE_<filename>. + # Exception: If we are creating a new integration, we can end up running integration base + # and a config flows on top of one another. In that case, we want to override the files. + if not info.is_new and target_file.exists(): + new_name = f"EXAMPLE_{target_file.name}" + print(f"File {target_file} already exists, creating {new_name} instead.") + target_file = target_file.parent / new_name + info.examples_added.add(target_file) + elif src_dir.name == "integration": + info.files_added.add(target_file) + else: + info.tests_added.add(target_file) + print(f"Writing {target_file}") target_file.write_text(content) @@ -58,6 +62,11 @@ def _ensure_tests_dir_exists(info: Info) -> None: ) +def _append(path: Path, text): + """Append some text to a path.""" + path.write_text(path.read_text() + text) + + def _custom_tasks(template, info) -> None: """Handle custom tasks for templates.""" if template == "integration": @@ -68,7 +77,7 @@ def _custom_tasks(template, info) -> None: info.update_manifest(**changes) - if template == "device_trigger": + elif template == "device_trigger": info.update_strings( device_automation={ **info.strings().get("device_automation", {}), @@ -79,7 +88,7 @@ def _custom_tasks(template, info) -> None: } ) - if template == "device_condition": + elif template == "device_condition": info.update_strings( device_automation={ **info.strings().get("device_automation", {}), @@ -90,7 +99,7 @@ def _custom_tasks(template, info) -> None: } ) - if template == "device_action": + elif template == "device_action": info.update_strings( device_automation={ **info.strings().get("device_automation", {}), @@ -101,7 +110,7 @@ def _custom_tasks(template, info) -> None: } ) - if template == "config_flow": + elif template == "config_flow": info.update_manifest(config_flow=True) info.update_strings( config={ @@ -118,7 +127,7 @@ def _custom_tasks(template, info) -> None: } ) - if template == "config_flow_discovery": + elif template == "config_flow_discovery": info.update_manifest(config_flow=True) info.update_strings( config={ @@ -136,19 +145,28 @@ def _custom_tasks(template, info) -> None: } ) - if template in ("config_flow", "config_flow_discovery"): - init_file = info.integration_dir / "__init__.py" - init_file.write_text( - init_file.read_text() - + """ - -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 -""" + elif template == "config_flow_oauth2": + info.update_manifest(config_flow=True) + info.update_strings( + config={ + "title": info.name, + "step": { + "pick_implementation": {"title": "Pick Authentication Method"} + }, + "abort": { + "missing_configuration": "The Somfy component is not configured. Please follow the documentation." + }, + "create_entry": { + "default": f"Successfully authenticated with {info.name}." + }, + } + ) + _append( + info.integration_dir / "const.py", + """ + +# TODO Update with your own urls +OAUTH2_AUTHORIZE = "https://www.example.com/auth/authorize" +OAUTH2_TOKEN = "https://www.example.com/auth/token" +""", ) diff --git a/script/scaffold/model.py b/script/scaffold/model.py index 68ab771122e0ab0479f274191c7ebf6b445eb197..bfbcfa52544958d1f88c4bd5db4258ff163cfff9 100644 --- a/script/scaffold/model.py +++ b/script/scaffold/model.py @@ -1,6 +1,7 @@ """Models for scaffolding.""" import json from pathlib import Path +from typing import Set import attr @@ -13,10 +14,16 @@ class Info: domain: str = attr.ib() name: str = attr.ib() + is_new: bool = attr.ib() codeowner: str = attr.ib(default=None) requirement: str = attr.ib(default=None) authentication: str = attr.ib(default=None) discoverable: str = attr.ib(default=None) + oauth2: str = attr.ib(default=None) + + files_added: Set[Path] = attr.ib(factory=set) + tests_added: Set[Path] = attr.ib(factory=set) + examples_added: Set[Path] = attr.ib(factory=set) @property def integration_dir(self) -> Path: diff --git a/script/scaffold/templates/config_flow/integration/__init__.py b/script/scaffold/templates/config_flow/integration/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..403453a1f6b4e6f7ab59f3c33992bc86d4082e0d --- /dev/null +++ b/script/scaffold/templates/config_flow/integration/__init__.py @@ -0,0 +1,49 @@ +"""The NEW_NAME integration.""" +import asyncio + +import voluptuous as vol + +from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntry + +from .const import DOMAIN + +CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA) + +# TODO List the platforms that you want to support. +# For your initial PR, limit it to 1 platform. +PLATFORMS = ["light"] + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the NEW_NAME component.""" + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Somfy from a config entry.""" + # TODO Store an API object for your platforms to access + # hass.data[DOMAIN][entry.entry_id] = MyApi(…) + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/script/scaffold/templates/config_flow_discovery/integration/__init__.py b/script/scaffold/templates/config_flow_discovery/integration/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..403453a1f6b4e6f7ab59f3c33992bc86d4082e0d --- /dev/null +++ b/script/scaffold/templates/config_flow_discovery/integration/__init__.py @@ -0,0 +1,49 @@ +"""The NEW_NAME integration.""" +import asyncio + +import voluptuous as vol + +from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntry + +from .const import DOMAIN + +CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA) + +# TODO List the platforms that you want to support. +# For your initial PR, limit it to 1 platform. +PLATFORMS = ["light"] + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the NEW_NAME component.""" + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Somfy from a config entry.""" + # TODO Store an API object for your platforms to access + # hass.data[DOMAIN][entry.entry_id] = MyApi(…) + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/script/scaffold/templates/config_flow_oauth2/integration/__init__.py b/script/scaffold/templates/config_flow_oauth2/integration/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..43b4c6f31cd2d9543e8b6f77b42424d76539a1f7 --- /dev/null +++ b/script/scaffold/templates/config_flow_oauth2/integration/__init__.py @@ -0,0 +1,94 @@ +"""The NEW_NAME integration.""" +import asyncio + +import voluptuous as vol + +from homeassistant.core import HomeAssistant +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.helpers import ( + config_validation as cv, + config_entry_oauth2_flow, + aiohttp_client, +) +from homeassistant.config_entries import ConfigEntry + +from .const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN +from . import api, config_flow + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_CLIENT_ID): cv.string, + vol.Required(CONF_CLIENT_SECRET): cv.string, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + +# TODO List the platforms that you want to support. +# For your initial PR, limit it to 1 platform. +PLATFORMS = ["light"] + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the NEW_NAME component.""" + hass.data[DOMAIN] = {} + + if DOMAIN not in config: + return True + + config_flow.OAuth2FlowHandler.async_register_implementation( + hass, + config_entry_oauth2_flow.LocalOAuth2Implementation( + hass, + DOMAIN, + config[DOMAIN][CONF_CLIENT_ID], + config[DOMAIN][CONF_CLIENT_SECRET], + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, + ), + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Somfy from a config entry.""" + implementation = await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) + + session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) + + # If using a requests-based API lib + hass.data[DOMAIN][entry.entry_id] = api.ConfigEntryAuth(hass, entry, session) + + # If using an aiohttp-based API lib + hass.data[DOMAIN][entry.entry_id] = api.AsyncConfigEntryAuth( + aiohttp_client.async_get_clientsession(hass), session + ) + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/script/scaffold/templates/config_flow_oauth2/integration/api.py b/script/scaffold/templates/config_flow_oauth2/integration/api.py new file mode 100644 index 0000000000000000000000000000000000000000..c5aa4a81ebe0f1a3cc3a880437c394bc4e88b00d --- /dev/null +++ b/script/scaffold/templates/config_flow_oauth2/integration/api.py @@ -0,0 +1,58 @@ +"""API for NEW_NAME bound to HASS OAuth.""" +from asyncio import run_coroutine_threadsafe + +from aiohttp import ClientSession +import my_pypi_package + +from homeassistant import core, config_entries +from homeassistant.helpers import config_entry_oauth2_flow + +# TODO the following two API examples are based on our suggested best practices +# for libraries using OAuth2 with requests or aiohttp. Delete the one you won't use. +# For more info see the docs at <insert url>. + + +class ConfigEntryAuth(my_pypi_package.AbstractAuth): + """Provide NEW_NAME authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + hass: core.HomeAssistant, + config_entry: config_entries.ConfigEntry, + implementation: config_entry_oauth2_flow.AbstractOAuth2Implementation, + ): + """Initialize NEW_NAME Auth.""" + self.hass = hass + self.config_entry = config_entry + self.session = config_entry_oauth2_flow.OAuth2Session( + hass, config_entry, implementation + ) + super().__init__(self.session.token) + + def refresh_tokens(self) -> dict: + """Refresh and return new NEW_NAME tokens using Home Assistant OAuth2 session.""" + run_coroutine_threadsafe( + self.session.async_ensure_token_valid(), self.hass.loop + ).result() + + return self.session.token + + +class AsyncConfigEntryAuth(my_pypi_package.AbstractAuth): + """Provide NEW_NAME authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + websession: ClientSession, + oauth_session: config_entry_oauth2_flow.OAuth2Session, + ): + """Initialize NEW_NAME auth.""" + super().__init__(websession) + self._oauth_session = oauth_session + + async def async_get_access_token(self): + """Return a valid access token.""" + if not self._oauth_session.is_valid: + await self._oauth_session.async_ensure_token_valid() + + return self._oauth_session.token diff --git a/script/scaffold/templates/config_flow_oauth2/integration/config_flow.py b/script/scaffold/templates/config_flow_oauth2/integration/config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..1112a404e6087b45e1531cbff23ce9c39da4fcb4 --- /dev/null +++ b/script/scaffold/templates/config_flow_oauth2/integration/config_flow.py @@ -0,0 +1,23 @@ +"""Config flow for NEW_NAME.""" +import logging + +from homeassistant import config_entries +from homeassistant.helpers import config_entry_oauth2_flow +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class OAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle NEW_NAME OAuth2 authentication.""" + + DOMAIN = DOMAIN + # TODO Pick one from config_entries.CONN_CLASS_* + CONNECTION_CLASS = config_entries.CONN_CLASS_UNKNOWN + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) diff --git a/script/scaffold/templates/config_flow_oauth2/tests/test_config_flow.py b/script/scaffold/templates/config_flow_oauth2/tests/test_config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..7e61bcbfb1b91372906c78ce71fa7149afe41412 --- /dev/null +++ b/script/scaffold/templates/config_flow_oauth2/tests/test_config_flow.py @@ -0,0 +1,60 @@ +"""Test the NEW_NAME config flow.""" +from homeassistant import config_entries, setup, data_entry_flow +from homeassistant.components.NEW_DOMAIN.const import ( + DOMAIN, + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, +) +from homeassistant.helpers import config_entry_oauth2_flow + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" + + +async def test_full_flow(hass, aiohttp_client, aioclient_mock): + """Check full flow.""" + assert await setup.async_setup_component( + hass, + "NEW_DOMAIN", + { + "NEW_DOMAIN": { + "type": "oauth2", + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + }, + "http": {"base_url": "https://example.com"}, + }, + ) + + result = await hass.config_entries.flow.async_init( + "NEW_DOMAIN", context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt(hass, {"flow_id": result["flow_id"]}) + + assert result["type"] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + ) + + client = await aiohttp_client(hass.http.app) + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert entry.data["type"] == "oauth2" diff --git a/script/scaffold/templates/integration/integration/__init__.py b/script/scaffold/templates/integration/integration/__init__.py index 7ab8b736782f62b7bb4993820ac9518f1729c439..c2ae59aaad44178b3257204b466eb4e41b7ce979 100644 --- a/script/scaffold/templates/integration/integration/__init__.py +++ b/script/scaffold/templates/integration/integration/__init__.py @@ -1,12 +1,14 @@ """The NEW_NAME integration.""" import voluptuous as vol +from homeassistant.core import HomeAssistant + from .const import DOMAIN -CONFIG_SCHEMA = vol.Schema({vol.Optional(DOMAIN): {}}) +CONFIG_SCHEMA = vol.Schema({vol.Optional(DOMAIN): {}}, extra=vol.ALLOW_EXTRA) -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistant, config: dict): """Set up the NEW_NAME integration.""" return True