dashboard: fix subprocesses blocking the event loop

- break apart the util module
- adds a new util to run subprocesses with asyncio
This commit is contained in:
J. Nick Koston 2023-11-15 15:50:52 -06:00
parent c795dbde26
commit 74e9196dad
No known key found for this signature in database
9 changed files with 101 additions and 70 deletions

View File

@ -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:

View File

@ -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:

View File

@ -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)), [])

View File

View 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)), [])

View 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()

View 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

View 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)

View File

@ -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)