mirror of
https://github.com/esphome/esphome.git
synced 2024-11-10 09:17:46 +01:00
dashboard: fix subprocesses blocking the event loop (#5772)
* dashboard: fix subprocesses blocking the event loop - break apart the util module - adds a new util to run subprocesses with asyncio * take a list
This commit is contained in:
parent
4e3170dc95
commit
3644853d38
9 changed files with 101 additions and 70 deletions
|
@ -10,7 +10,7 @@ from esphome.helpers import get_bool_env
|
|||
from esphome.storage_json import ext_storage_path
|
||||
|
||||
from .entries import DashboardEntry
|
||||
from .util import password_hash
|
||||
from .util.password import password_hash
|
||||
|
||||
|
||||
class DashboardSettings:
|
||||
|
|
|
@ -7,22 +7,15 @@ from typing import cast
|
|||
from ..core import DASHBOARD
|
||||
from ..entries import DashboardEntry
|
||||
from ..core import list_dashboard_entries
|
||||
from ..util import chunked
|
||||
from ..util.itertools import chunked
|
||||
from ..util.subprocess import async_system_command_status
|
||||
|
||||
|
||||
async def _async_ping_host(host: str) -> bool:
|
||||
"""Ping a host."""
|
||||
ping_command = ["ping", "-n" if os.name == "nt" else "-c", "1"]
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*ping_command,
|
||||
host,
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
close_fds=False,
|
||||
return await async_system_command_status(
|
||||
["ping", "-n" if os.name == "nt" else "-c", "1", host]
|
||||
)
|
||||
await process.wait()
|
||||
return process.returncode == 0
|
||||
|
||||
|
||||
class PingStatus:
|
||||
|
|
|
@ -1,52 +0,0 @@
|
|||
import hashlib
|
||||
import unicodedata
|
||||
from collections.abc import Iterable
|
||||
from functools import partial
|
||||
from itertools import islice
|
||||
from typing import Any
|
||||
|
||||
from esphome.const import ALLOWED_NAME_CHARS
|
||||
|
||||
|
||||
def password_hash(password: str) -> bytes:
|
||||
"""Create a hash of a password to transform it to a fixed-length digest.
|
||||
|
||||
Note this is not meant for secure storage, but for securely comparing passwords.
|
||||
"""
|
||||
return hashlib.sha256(password.encode()).digest()
|
||||
|
||||
|
||||
def strip_accents(value):
|
||||
return "".join(
|
||||
c
|
||||
for c in unicodedata.normalize("NFD", str(value))
|
||||
if unicodedata.category(c) != "Mn"
|
||||
)
|
||||
|
||||
|
||||
def friendly_name_slugify(value):
|
||||
value = (
|
||||
strip_accents(value)
|
||||
.lower()
|
||||
.replace(" ", "-")
|
||||
.replace("_", "-")
|
||||
.replace("--", "-")
|
||||
.strip("-")
|
||||
)
|
||||
return "".join(c for c in value if c in ALLOWED_NAME_CHARS)
|
||||
|
||||
|
||||
def take(take_num: int, iterable: Iterable) -> list[Any]:
|
||||
"""Return first n items of the iterable as a list.
|
||||
|
||||
From itertools recipes
|
||||
"""
|
||||
return list(islice(iterable, take_num))
|
||||
|
||||
|
||||
def chunked(iterable: Iterable, chunked_num: int) -> Iterable[Any]:
|
||||
"""Break *iterable* into lists of length *n*.
|
||||
|
||||
From more-itertools
|
||||
"""
|
||||
return iter(partial(take, chunked_num, iter(iterable)), [])
|
0
esphome/dashboard/util/__init__.py
Normal file
0
esphome/dashboard/util/__init__.py
Normal file
22
esphome/dashboard/util/itertools.py
Normal file
22
esphome/dashboard/util/itertools.py
Normal file
|
@ -0,0 +1,22 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from functools import partial
|
||||
from itertools import islice
|
||||
from typing import Any
|
||||
|
||||
|
||||
def take(take_num: int, iterable: Iterable) -> list[Any]:
|
||||
"""Return first n items of the iterable as a list.
|
||||
|
||||
From itertools recipes
|
||||
"""
|
||||
return list(islice(iterable, take_num))
|
||||
|
||||
|
||||
def chunked(iterable: Iterable, chunked_num: int) -> Iterable[Any]:
|
||||
"""Break *iterable* into lists of length *n*.
|
||||
|
||||
From more-itertools
|
||||
"""
|
||||
return iter(partial(take, chunked_num, iter(iterable)), [])
|
11
esphome/dashboard/util/password.py
Normal file
11
esphome/dashboard/util/password.py
Normal file
|
@ -0,0 +1,11 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
|
||||
|
||||
def password_hash(password: str) -> bytes:
|
||||
"""Create a hash of a password to transform it to a fixed-length digest.
|
||||
|
||||
Note this is not meant for secure storage, but for securely comparing passwords.
|
||||
"""
|
||||
return hashlib.sha256(password.encode()).digest()
|
31
esphome/dashboard/util/subprocess.py
Normal file
31
esphome/dashboard/util/subprocess.py
Normal file
|
@ -0,0 +1,31 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Iterable
|
||||
|
||||
|
||||
async def async_system_command_status(command: Iterable[str]) -> bool:
|
||||
"""Run a system command checking only the status."""
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*command,
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
close_fds=False,
|
||||
)
|
||||
await process.wait()
|
||||
return process.returncode == 0
|
||||
|
||||
|
||||
async def async_run_system_command(command: Iterable[str]) -> tuple[bool, bytes, bytes]:
|
||||
"""Run a system command and return a tuple of returncode, stdout, stderr."""
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*command,
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
close_fds=False,
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
await process.wait()
|
||||
return process.returncode, stdout, stderr
|
25
esphome/dashboard/util/text.py
Normal file
25
esphome/dashboard/util/text.py
Normal file
|
@ -0,0 +1,25 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import unicodedata
|
||||
|
||||
from esphome.const import ALLOWED_NAME_CHARS
|
||||
|
||||
|
||||
def strip_accents(value):
|
||||
return "".join(
|
||||
c
|
||||
for c in unicodedata.normalize("NFD", str(value))
|
||||
if unicodedata.category(c) != "Mn"
|
||||
)
|
||||
|
||||
|
||||
def friendly_name_slugify(value):
|
||||
value = (
|
||||
strip_accents(value)
|
||||
.lower()
|
||||
.replace(" ", "-")
|
||||
.replace("_", "-")
|
||||
.replace("--", "-")
|
||||
.strip("-")
|
||||
)
|
||||
return "".join(c for c in value if c in ALLOWED_NAME_CHARS)
|
|
@ -31,13 +31,14 @@ import yaml
|
|||
from tornado.log import access_log
|
||||
|
||||
from esphome import const, platformio_api, yaml_util
|
||||
from esphome.helpers import get_bool_env, mkdir_p, run_system_command
|
||||
from esphome.helpers import get_bool_env, mkdir_p
|
||||
from esphome.storage_json import StorageJSON, ext_storage_path, trash_storage_path
|
||||
from esphome.util import get_serial_ports, shlex_quote
|
||||
|
||||
from .core import DASHBOARD, list_dashboard_entries
|
||||
from .entries import DashboardEntry
|
||||
from .util import friendly_name_slugify
|
||||
from .util.text import friendly_name_slugify
|
||||
from .util.subprocess import async_run_system_command
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
@ -522,7 +523,7 @@ class DownloadListRequestHandler(BaseHandler):
|
|||
class DownloadBinaryRequestHandler(BaseHandler):
|
||||
@authenticated
|
||||
@bind_config
|
||||
def get(self, configuration=None):
|
||||
async def get(self, configuration=None):
|
||||
compressed = self.get_argument("compressed", "0") == "1"
|
||||
|
||||
storage_path = ext_storage_path(configuration)
|
||||
|
@ -548,7 +549,7 @@ class DownloadBinaryRequestHandler(BaseHandler):
|
|||
|
||||
if not Path(path).is_file():
|
||||
args = ["esphome", "idedata", settings.rel_path(configuration)]
|
||||
rc, stdout, _ = run_system_command(*args)
|
||||
rc, stdout, _ = await async_run_system_command(args)
|
||||
|
||||
if rc != 0:
|
||||
self.send_error(404 if rc == 2 else 500)
|
||||
|
@ -902,7 +903,7 @@ SafeLoaderIgnoreUnknown.add_constructor(
|
|||
class JsonConfigRequestHandler(BaseHandler):
|
||||
@authenticated
|
||||
@bind_config
|
||||
def get(self, configuration=None):
|
||||
async def get(self, configuration=None):
|
||||
filename = settings.rel_path(configuration)
|
||||
if not os.path.isfile(filename):
|
||||
self.send_error(404)
|
||||
|
@ -910,7 +911,7 @@ class JsonConfigRequestHandler(BaseHandler):
|
|||
|
||||
args = ["esphome", "config", filename, "--show-secrets"]
|
||||
|
||||
rc, stdout, _ = run_system_command(*args)
|
||||
rc, stdout, _ = await async_run_system_command(args)
|
||||
|
||||
if rc != 0:
|
||||
self.send_error(422)
|
||||
|
|
Loading…
Reference in a new issue