Merge pull request #1901 from home-assistant/dev

Release 232
This commit is contained in:
Pascal Vizeli 2020-08-12 18:44:04 +02:00 committed by GitHub
commit d392b35fdd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 256 additions and 124 deletions

View File

@ -26,7 +26,7 @@ jobs:
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
id: python id: python
uses: actions/setup-python@v2.1.1 uses: actions/setup-python@v2.1.2
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- name: Restore Python virtual environment - name: Restore Python virtual environment
@ -69,7 +69,7 @@ jobs:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v2.1.1 uses: actions/setup-python@v2.1.2
id: python id: python
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
@ -113,7 +113,7 @@ jobs:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v2.1.1 uses: actions/setup-python@v2.1.2
id: python id: python
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
@ -157,7 +157,7 @@ jobs:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v2.1.1 uses: actions/setup-python@v2.1.2
id: python id: python
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
@ -189,7 +189,7 @@ jobs:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v2.1.1 uses: actions/setup-python@v2.1.2
id: python id: python
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
@ -230,7 +230,7 @@ jobs:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v2.1.1 uses: actions/setup-python@v2.1.2
id: python id: python
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
@ -274,7 +274,7 @@ jobs:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v2.1.1 uses: actions/setup-python@v2.1.2
id: python id: python
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
@ -306,7 +306,7 @@ jobs:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v2.1.1 uses: actions/setup-python@v2.1.2
id: python id: python
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
@ -350,7 +350,7 @@ jobs:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2.1.1 uses: actions/setup-python@v2.1.2
id: python id: python
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
@ -391,7 +391,7 @@ jobs:
-o console_output_style=count \ -o console_output_style=count \
tests tests
- name: Upload coverage artifact - name: Upload coverage artifact
uses: actions/upload-artifact@v2.1.3 uses: actions/upload-artifact@v2.1.4
with: with:
name: coverage-${{ matrix.python-version }} name: coverage-${{ matrix.python-version }}
path: .coverage path: .coverage
@ -404,7 +404,7 @@ jobs:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v2.1.1 uses: actions/setup-python@v2.1.2
id: python id: python
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}

3
API.md
View File

@ -45,6 +45,8 @@ Shows the installed add-ons from `addons`.
"arch": "armhf|aarch64|i386|amd64", "arch": "armhf|aarch64|i386|amd64",
"channel": "stable|beta|dev", "channel": "stable|beta|dev",
"timezone": "TIMEZONE", "timezone": "TIMEZONE",
"healthy": "bool",
"supported": "bool",
"logging": "debug|info|warning|error|critical", "logging": "debug|info|warning|error|critical",
"ip_address": "ip address", "ip_address": "ip address",
"wait_boot": "int", "wait_boot": "int",
@ -785,6 +787,7 @@ return:
"machine": "type", "machine": "type",
"arch": "arch", "arch": "arch",
"supported_arch": ["arch1", "arch2"], "supported_arch": ["arch1", "arch2"],
"supported": "bool",
"channel": "stable|beta|dev", "channel": "stable|beta|dev",
"logging": "debug|info|warning|error|critical", "logging": "debug|info|warning|error|critical",
"timezone": "Europe/Zurich" "timezone": "Europe/Zurich"

@ -1 +1 @@
Subproject commit dec1f99a5fbfdafb348d30fa0f7a03f84b0d5d9e Subproject commit 77b25f5132820c0596ccae82dd501ce67f101e72

View File

@ -5,8 +5,8 @@ cchardet==2.1.6
colorlog==4.2.1 colorlog==4.2.1
cpe==1.2.1 cpe==1.2.1
cryptography==3.0 cryptography==3.0
debugpy==1.0.0b12 debugpy==1.0.0rc1
docker==4.2.2 docker==4.3.0
gitpython==3.1.7 gitpython==3.1.7
jinja2==2.11.2 jinja2==2.11.2
packaging==20.4 packaging==20.4

View File

@ -98,6 +98,7 @@ class AddonManager(CoreSysAttributes):
await addon.start() await addon.start()
except Exception as err: # pylint: disable=broad-except except Exception as err: # pylint: disable=broad-except
_LOGGER.warning("Can't start Add-on %s: %s", addon.slug, err) _LOGGER.warning("Can't start Add-on %s: %s", addon.slug, err)
self.sys_capture_exception(err)
await asyncio.sleep(self.sys_config.wait_boot) await asyncio.sleep(self.sys_config.wait_boot)
@ -121,6 +122,7 @@ class AddonManager(CoreSysAttributes):
await addon.stop() await addon.stop()
except Exception as err: # pylint: disable=broad-except except Exception as err: # pylint: disable=broad-except
_LOGGER.warning("Can't stop Add-on %s: %s", addon.slug, err) _LOGGER.warning("Can't stop Add-on %s: %s", addon.slug, err)
self.sys_capture_exception(err)
async def install(self, slug: str) -> None: async def install(self, slug: str) -> None:
"""Install an add-on.""" """Install an add-on."""

View File

@ -424,6 +424,11 @@ def _nested_validate_list(coresys, typ, data_list, key):
"""Validate nested items.""" """Validate nested items."""
options = [] options = []
# Make sure it is a list
if not isinstance(data_list, list):
raise vol.Invalid(f"Invalid list for {key}")
# Process list
for element in data_list: for element in data_list:
# Nested? # Nested?
if isinstance(typ, dict): if isinstance(typ, dict):
@ -439,6 +444,11 @@ def _nested_validate_dict(coresys, typ, data_dict, key):
"""Validate nested items.""" """Validate nested items."""
options = {} options = {}
# Make sure it is a dict
if not isinstance(data_dict, dict):
raise vol.Invalid(f"Invalid dict for {key}")
# Process dict
for c_key, c_value in data_dict.items(): for c_key, c_value in data_dict.items():
# Ignore unknown options / remove from list # Ignore unknown options / remove from list
if c_key not in typ: if c_key not in typ:

View File

@ -14,6 +14,7 @@ from ..const import (
ATTR_LOGGING, ATTR_LOGGING,
ATTR_MACHINE, ATTR_MACHINE,
ATTR_SUPERVISOR, ATTR_SUPERVISOR,
ATTR_SUPPORTED,
ATTR_SUPPORTED_ARCH, ATTR_SUPPORTED_ARCH,
ATTR_TIMEZONE, ATTR_TIMEZONE,
) )
@ -38,6 +39,7 @@ class APIInfo(CoreSysAttributes):
ATTR_MACHINE: self.sys_machine, ATTR_MACHINE: self.sys_machine,
ATTR_ARCH: self.sys_arch.default, ATTR_ARCH: self.sys_arch.default,
ATTR_SUPPORTED_ARCH: self.sys_arch.supported, ATTR_SUPPORTED_ARCH: self.sys_arch.supported,
ATTR_SUPPORTED: self.sys_supported,
ATTR_CHANNEL: self.sys_updater.channel, ATTR_CHANNEL: self.sys_updater.channel,
ATTR_LOGGING: self.sys_config.logging, ATTR_LOGGING: self.sys_config.logging,
ATTR_TIMEZONE: self.sys_timezone, ATTR_TIMEZONE: self.sys_timezone,

View File

@ -191,7 +191,11 @@ class APIIngress(CoreSysAttributes):
async for data in result.content.iter_chunked(4096): async for data in result.content.iter_chunked(4096):
await response.write(data) await response.write(data)
except (aiohttp.ClientError, aiohttp.ClientPayloadError) as err: except (
aiohttp.ClientError,
aiohttp.ClientPayloadError,
ConnectionResetError,
) as err:
_LOGGER.error("Stream error with %s: %s", url, err) _LOGGER.error("Stream error with %s: %s", url, err)
return response return response

View File

@ -1,9 +1,9 @@
try { try {
new Function("import('/api/hassio/app/frontend_latest/entrypoint.a14fe829.js')")(); new Function("import('/api/hassio/app/frontend_latest/entrypoint.855567b9.js')")();
} catch (err) { } catch (err) {
var el = document.createElement('script'); var el = document.createElement('script');
el.src = '/api/hassio/app/frontend_es5/entrypoint.24698450.js'; el.src = '/api/hassio/app/frontend_es5/entrypoint.19035830.js';
document.body.appendChild(el); document.body.appendChild(el);
} }

View File

@ -0,0 +1 @@
{"version":3,"file":"entrypoint.19035830.js","sources":["webpack:///entrypoint.19035830.js"],"mappings":";AAAA","sourceRoot":""}

View File

@ -1 +0,0 @@
{"version":3,"file":"entrypoint.24698450.js","sources":["webpack:///entrypoint.24698450.js"],"mappings":";AAAA","sourceRoot":""}

View File

@ -1,3 +1,3 @@
{ {
"entrypoint.js": "/api/hassio/app/frontend_es5/entrypoint.24698450.js" "entrypoint.js": "/api/hassio/app/frontend_es5/entrypoint.19035830.js"
} }

View File

@ -1,3 +1,3 @@
{ {
"entrypoint.js": "/api/hassio/app/frontend_latest/entrypoint.a14fe829.js" "entrypoint.js": "/api/hassio/app/frontend_latest/entrypoint.855567b9.js"
} }

View File

@ -18,6 +18,7 @@ from ..const import (
ATTR_DEBUG_BLOCK, ATTR_DEBUG_BLOCK,
ATTR_DESCRIPTON, ATTR_DESCRIPTON,
ATTR_DIAGNOSTICS, ATTR_DIAGNOSTICS,
ATTR_HEALTHY,
ATTR_ICON, ATTR_ICON,
ATTR_INSTALLED, ATTR_INSTALLED,
ATTR_IP_ADDRESS, ATTR_IP_ADDRESS,
@ -32,6 +33,7 @@ from ..const import (
ATTR_REPOSITORY, ATTR_REPOSITORY,
ATTR_SLUG, ATTR_SLUG,
ATTR_STATE, ATTR_STATE,
ATTR_SUPPORTED,
ATTR_TIMEZONE, ATTR_TIMEZONE,
ATTR_VERSION, ATTR_VERSION,
ATTR_VERSION_LATEST, ATTR_VERSION_LATEST,
@ -98,6 +100,8 @@ class APISupervisor(CoreSysAttributes):
ATTR_VERSION_LATEST: self.sys_updater.version_supervisor, ATTR_VERSION_LATEST: self.sys_updater.version_supervisor,
ATTR_CHANNEL: self.sys_updater.channel, ATTR_CHANNEL: self.sys_updater.channel,
ATTR_ARCH: self.sys_supervisor.arch, ATTR_ARCH: self.sys_supervisor.arch,
ATTR_SUPPORTED: self.sys_supported,
ATTR_HEALTHY: self.sys_core.healthy,
ATTR_IP_ADDRESS: str(self.sys_supervisor.ip_address), ATTR_IP_ADDRESS: str(self.sys_supervisor.ip_address),
ATTR_WAIT_BOOT: self.sys_config.wait_boot, ATTR_WAIT_BOOT: self.sys_config.wait_boot,
ATTR_TIMEZONE: self.sys_config.timezone, ATTR_TIMEZONE: self.sys_config.timezone,

View File

@ -280,10 +280,11 @@ def supervisor_debugger(coresys: CoreSys) -> None:
def setup_diagnostics(coresys: CoreSys) -> None: def setup_diagnostics(coresys: CoreSys) -> None:
"""Sentry diagnostic backend.""" """Sentry diagnostic backend."""
_LOGGER.info("Initialize Supervisor Sentry")
def filter_data(event, hint): def filter_data(event, hint):
# Ignore issue if system is not supported or diagnostics is disabled # Ignore issue if system is not supported or diagnostics is disabled
if not coresys.config.diagnostics or not coresys.core.healthy: if not coresys.config.diagnostics or not coresys.supported:
return None return None
# Not full startup - missing information # Not full startup - missing information
@ -291,10 +292,9 @@ def setup_diagnostics(coresys: CoreSys) -> None:
return event return event
# Update information # Update information
with sentry_sdk.configure_scope() as scope: event.setdefault("extra", {}).update(
scope.set_context(
"supervisor",
{ {
"supervisor": {
"machine": coresys.machine, "machine": coresys.machine,
"arch": coresys.arch.default, "arch": coresys.arch.default,
"docker": coresys.docker.info.version, "docker": coresys.docker.info.version,
@ -308,23 +308,27 @@ def setup_diagnostics(coresys: CoreSys) -> None:
"dns": coresys.plugins.dns.version, "dns": coresys.plugins.dns.version,
"multicast": coresys.plugins.multicast.version, "multicast": coresys.plugins.multicast.version,
"cli": coresys.plugins.cli.version, "cli": coresys.plugins.cli.version,
}, }
}
) )
scope.set_tag( event.setdefault("tags", {}).update(
"installation_type", {
f"{'os' if coresys.hassos.available else 'supervised'}", "installation_type": "os" if coresys.hassos.available else "supervised",
"machine": coresys.machine,
}
) )
return event return event
# Set log level # Set log level
sentry_logging = LoggingIntegration( sentry_logging = LoggingIntegration(
level=logging.ERROR, event_level=logging.CRITICAL level=logging.WARNING, event_level=logging.CRITICAL
) )
sentry_sdk.init( sentry_sdk.init(
dsn="https://9c6ea70f49234442b4746e447b24747e@o427061.ingest.sentry.io/5370612", dsn="https://9c6ea70f49234442b4746e447b24747e@o427061.ingest.sentry.io/5370612",
before_send=filter_data, before_send=filter_data,
max_breadcrumbs=30,
integrations=[AioHttpIntegration(), sentry_logging], integrations=[AioHttpIntegration(), sentry_logging],
release=SUPERVISOR_VERSION, release=SUPERVISOR_VERSION,
) )

View File

@ -3,8 +3,7 @@ from enum import Enum
from ipaddress import ip_network from ipaddress import ip_network
from pathlib import Path from pathlib import Path
SUPERVISOR_VERSION = "231" SUPERVISOR_VERSION = "232"
URL_HASSIO_ADDONS = "https://github.com/home-assistant/hassio-addons" URL_HASSIO_ADDONS = "https://github.com/home-assistant/hassio-addons"
URL_HASSIO_VERSION = "https://version.home-assistant.io/{channel}.json" URL_HASSIO_VERSION = "https://version.home-assistant.io/{channel}.json"
@ -243,6 +242,8 @@ ATTR_ACTIVE = "active"
ATTR_APPLICATION = "application" ATTR_APPLICATION = "application"
ATTR_INIT = "init" ATTR_INIT = "init"
ATTR_DIAGNOSTICS = "diagnostics" ATTR_DIAGNOSTICS = "diagnostics"
ATTR_HEALTHY = "healthy"
ATTR_SUPPORTED = "supported"
PROVIDE_SERVICE = "provide" PROVIDE_SERVICE = "provide"
NEED_SERVICE = "need" NEED_SERVICE = "need"

View File

@ -19,45 +19,56 @@ class Core(CoreSysAttributes):
"""Initialize Supervisor object.""" """Initialize Supervisor object."""
self.coresys: CoreSys = coresys self.coresys: CoreSys = coresys
self.state: CoreStates = CoreStates.INITIALIZE self.state: CoreStates = CoreStates.INITIALIZE
self.healthy: bool = True self._healthy: bool = True
@property
def healthy(self) -> bool:
"""Return True if system is healthy."""
return self._healthy and self.sys_supported
async def connect(self): async def connect(self):
"""Connect Supervisor container.""" """Connect Supervisor container."""
await self.sys_supervisor.load() await self.sys_supervisor.load()
# If a update is failed? # If host docker is supported?
if self.sys_dev:
self.sys_config.version = self.sys_supervisor.version
elif self.sys_config.version != self.sys_supervisor.version:
self.healthy = False
_LOGGER.critical("Update of Supervisor fails!")
# If local docker is supported?
if not self.sys_docker.info.supported_version: if not self.sys_docker.info.supported_version:
self.healthy = False self.coresys.supported = False
_LOGGER.critical( _LOGGER.error(
"Docker version %s is not supported by Supervisor!", "Docker version %s is not supported by Supervisor!",
self.sys_docker.info.version, self.sys_docker.info.version,
) )
elif self.sys_docker.info.inside_lxc: elif self.sys_docker.info.inside_lxc:
self.healthy = False self.coresys.supported = False
_LOGGER.critical( _LOGGER.error(
"Detected Docker running inside LXC. Running Home Assistant with the Supervisor on LXC is not supported!" "Detected Docker running inside LXC. Running Home Assistant with the Supervisor on LXC is not supported!"
) )
self.sys_docker.info.check_requirements() self.sys_docker.info.check_requirements()
# Dbus available # Dbus available
if not SOCKET_DBUS.exists(): if not SOCKET_DBUS.exists():
self.healthy = False self.coresys.supported = False
_LOGGER.critical( _LOGGER.error(
"DBus is required for Home Assistant. This system is not supported!" "DBus is required for Home Assistant. This system is not supported!"
) )
# Check if system is healthy # Check supervisor version/update
if not self.healthy: if self.sys_dev:
_LOGGER.critical( self.sys_config.version = self.sys_supervisor.version
"System running in a unhealthy state. Please update you OS or software!" elif (
self.sys_config.version == "dev"
or self.sys_supervisor.instance.version == "dev"
):
self.coresys.supported = False
_LOGGER.warning(
"Found a development supervisor outside dev channel (%s)",
self.sys_updater.channel,
)
elif self.sys_config.version != self.sys_supervisor.version:
self._healthy = False
_LOGGER.error(
"Update %s of Supervisor %s fails!",
self.sys_config.version,
self.sys_supervisor.version,
) )
async def setup(self): async def setup(self):
@ -114,6 +125,14 @@ class Core(CoreSysAttributes):
self.state = CoreStates.STARTUP self.state = CoreStates.STARTUP
await self.sys_api.start() await self.sys_api.start()
# Check if system is healthy
if not self.sys_supported:
_LOGGER.critical("System running in a unsupported environment!")
elif not self.healthy:
_LOGGER.critical(
"System running in a unhealthy state and need manual intervention!"
)
# Mark booted partition as healthy # Mark booted partition as healthy
if self.sys_hassos.available: if self.sys_hassos.available:
await self.sys_hassos.mark_healthy() await self.sys_hassos.mark_healthy()

View File

@ -44,10 +44,13 @@ class CoreSys:
def __init__(self): def __init__(self):
"""Initialize coresys.""" """Initialize coresys."""
# Static attributes # Static attributes protected
self._machine_id: Optional[str] = None self._machine_id: Optional[str] = None
self._machine: Optional[str] = None self._machine: Optional[str] = None
# Static attributes
self.supported: bool = True
# External objects # External objects
self._loop: asyncio.BaseEventLoop = asyncio.get_running_loop() self._loop: asyncio.BaseEventLoop = asyncio.get_running_loop()
self._websession: aiohttp.ClientSession = aiohttp.ClientSession() self._websession: aiohttp.ClientSession = aiohttp.ClientSession()
@ -459,6 +462,11 @@ class CoreSysAttributes:
"""Return True if we run dev mode.""" """Return True if we run dev mode."""
return self.coresys.dev return self.coresys.dev
@property
def sys_supported(self) -> bool:
"""Return True if the system is supported."""
return self.coresys.supported
@property @property
def sys_timezone(self) -> str: def sys_timezone(self) -> str:
"""Return timezone.""" """Return timezone."""

View File

@ -0,0 +1,65 @@
"""Test add-ons schema to UI schema convertion."""
import pytest
import voluptuous as vol
from supervisor.addons.validate import validate_options
def test_simple_schema(coresys):
"""Test with simple schema."""
assert validate_options(
coresys,
{"name": "str", "password": "password", "fires": "bool", "alias": "str?"},
)({"name": "Pascal", "password": "1234", "fires": True, "alias": "test"})
assert validate_options(
coresys,
{"name": "str", "password": "password", "fires": "bool", "alias": "str?"},
)({"name": "Pascal", "password": "1234", "fires": True})
with pytest.raises(vol.error.Invalid):
validate_options(
coresys,
{"name": "str", "password": "password", "fires": "bool", "alias": "str?"},
)({"name": "Pascal", "password": "1234", "fires": "hah"})
with pytest.raises(vol.error.Invalid):
validate_options(
coresys,
{"name": "str", "password": "password", "fires": "bool", "alias": "str?"},
)({"name": "Pascal", "fires": True})
def test_complex_schema_list(coresys):
"""Test with complex list schema."""
assert validate_options(
coresys, {"name": "str", "password": "password", "extend": ["str"]},
)({"name": "Pascal", "password": "1234", "extend": ["test", "blu"]})
with pytest.raises(vol.error.Invalid):
validate_options(
coresys, {"name": "str", "password": "password", "extend": ["str"]},
)({"name": "Pascal", "password": "1234", "extend": ["test", 1]})
with pytest.raises(vol.error.Invalid):
validate_options(
coresys, {"name": "str", "password": "password", "extend": ["str"]},
)({"name": "Pascal", "password": "1234", "extend": "test"})
def test_complex_schema_dict(coresys):
"""Test with complex dict schema."""
assert validate_options(
coresys, {"name": "str", "password": "password", "extend": {"test": "int"}},
)({"name": "Pascal", "password": "1234", "extend": {"test": 1}})
with pytest.raises(vol.error.Invalid):
validate_options(
coresys, {"name": "str", "password": "password", "extend": {"test": "int"}},
)({"name": "Pascal", "password": "1234", "extend": {"wrong": 1}})
with pytest.raises(vol.error.Invalid):
validate_options(
coresys, {"name": "str", "password": "password", "extend": ["str"]},
)({"name": "Pascal", "password": "1234", "extend": "test"})