Updated apps
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Initialize utils."""
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,8 @@
|
||||
"""Custom logger for HACS."""
|
||||
|
||||
import logging
|
||||
|
||||
from ..const import PACKAGE_NAME
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(PACKAGE_NAME)
|
||||
INDENT = " "
|
||||
@@ -0,0 +1,81 @@
|
||||
import re
|
||||
|
||||
"""Constants for the parser."""
|
||||
|
||||
# file extensions supported by parser
|
||||
# .json is not parsed as they typically contains unrelevant false positive entries
|
||||
YAML_FILE_EXTS = {'.yaml', '.yml'}
|
||||
JSON_FILE_EXTS = {'.config_entries'}
|
||||
|
||||
# .storage folder is ignored completely if in _IGNORED_DIRS
|
||||
# but whitelisted files in .storage are detected dynamically by the parser
|
||||
STORAGE_WHITELIST_PATTERNS = {'core.config_entries', 'lovelace*'}
|
||||
MAX_FILE_SIZE = 500 * 1024 # 500 KB
|
||||
|
||||
# A fallback list of Home Assistant entity platforms for the CLI parser.
|
||||
# This list is used ONLY when the 'homeassistant' library is not importable.
|
||||
# If the library is present, this list is overwritten by the official constants.
|
||||
# DO NOT EXTEND this list for missing integration domains; use HA_DOMAINS instead.
|
||||
PLATFORMS = [
|
||||
"ai_task", "air_quality", "alarm_control_panel", "assist_satellite", "binary_sensor", "button",
|
||||
"calendar", "camera", "climate", "conversation", "cover", "date", "datetime", "device_tracker",
|
||||
"event", "fan", "geo_location", "humidifier", "image", "image_processing", "lawn_mower",
|
||||
"light", "lock", "media_player", "notify", "number", "remote", "scene", "select", "sensor",
|
||||
"siren", "stt", "switch", "text", "time", "todo", "tts", "update", "vacuum", "valve",
|
||||
"wake_word", "water_heater", "weather"
|
||||
]
|
||||
|
||||
|
||||
# Integration domains used as a fallback for the standalone CLI parser.
|
||||
# In runtime, this list is merged with `hass.config.components`.
|
||||
# It includes standard domains (e.g., 'automation') and common integrations for testing.
|
||||
HA_DOMAINS = [
|
||||
"automation", "script", "group", "zone", "person", "sun", "input_boolean", "input_button",
|
||||
"input_datetime", "input_number", "input_select", "input_text", "timer", "counter",
|
||||
"shell_command", "persistent_notification", "homeassistant", "system_log", "logger",
|
||||
"recorder", "history", "logbook", "map", "mobile_app", "tag", "webhook", "websocket_api",
|
||||
"ble_monitor", "hassio", "mqtt", "python_script", "speedtestdotnet", "telegram_bot",
|
||||
"xiaomi_miio", "yeelight", "alert", "plant", "proximity", "schedule", "template"
|
||||
]
|
||||
|
||||
# following patterns are ignored by watchman as they are neither entities, nor actions
|
||||
BUNDLED_IGNORED_ITEMS = [
|
||||
"timer.cancelled", "timer.finished", "timer.started", "timer.restarted",
|
||||
"timer.paused", "event.*", "date.*", "time.*", "map.*", "homeassistant.*"
|
||||
]
|
||||
|
||||
|
||||
# Path which includes this string is considered as ESPHome folder
|
||||
ESPHOME_PATH_SEGMENT = "esphome"
|
||||
# Allowed keys for ESPHome files to be considered as HA entities/services
|
||||
ESPHOME_ALLOWED_KEYS = {'service', 'action', 'entity_id'}
|
||||
|
||||
# Keys which values (whole hierarchy) should be ignored
|
||||
IGNORED_BRANCH_KEYS = {'url', 'example', 'description', 'event_type', 'logger'}
|
||||
|
||||
# Keys identifying an action/service call
|
||||
ACTION_KEYS = {'service', 'action', 'service_template', 'perform_action'}
|
||||
|
||||
# Keys where the parser ignores the immediate string value (to avoid false positives)
|
||||
# but continues recursion if the value is a complex structure
|
||||
IGNORED_VALUE_KEYS = {'trigger', 'triggers'}
|
||||
|
||||
# Domains to parse in core.config_entries
|
||||
CONFIG_ENTRY_DOMAINS = {'group', 'template'}
|
||||
|
||||
# Directories to skip during recursive scan
|
||||
IGNORED_DIRS = {'.git', '__pycache__', '.venv', 'venv', 'deps', 'backups', 'custom_components', '.cache', '.esphome', '.storage', 'tmp', 'blueprints', 'media', 'share', 'www', 'trash'}
|
||||
|
||||
# Regex building blocks for entity detection
|
||||
|
||||
# Forbidden prefixes: letters, numbers, _, ., /, \, @, $, %, &, |, -
|
||||
|
||||
REGEX_ENTITY_BOUNDARY = r"(?:^|[^a-zA-Z0-9_./\\@$%&|-])"
|
||||
|
||||
REGEX_OPTIONAL_STATES = r"(?:states\.)?"
|
||||
|
||||
REGEX_ENTITY_SUFFIX = r"\.[a-z0-9_]+"
|
||||
|
||||
|
||||
|
||||
REGEX_STRICT_SERVICE = re.compile(r"^[a-z0-9_]+\.[a-z0-9_]+$", re.IGNORECASE)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,327 @@
|
||||
"""Reporting function of Watchman."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from textwrap import wrap
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from prettytable import PrettyTable
|
||||
import pytz
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from ..const import (
|
||||
CONF_ACTION_NAME,
|
||||
CONF_COLUMNS_WIDTH,
|
||||
CONF_FRIENDLY_NAMES,
|
||||
CONF_HEADER,
|
||||
COORD_DATA_IGNORED_FILES,
|
||||
COORD_DATA_PROCESSED_FILES,
|
||||
DEFAULT_HEADER,
|
||||
REPORT_ENTRY_TYPE_ENTITY,
|
||||
REPORT_ENTRY_TYPE_SERVICE,
|
||||
)
|
||||
from .logger import _LOGGER
|
||||
from .utils import get_config, get_entity_state, get_entry, is_action
|
||||
|
||||
|
||||
async def parsing_stats(hass: HomeAssistant, start_time: float) -> tuple[str, float, float, float]:
|
||||
"""Separate func for test mocking."""
|
||||
|
||||
def get_timezone(hass: HomeAssistant) -> Any:
|
||||
return pytz.timezone(hass.config.time_zone)
|
||||
|
||||
timezone = await hass.async_add_executor_job(get_timezone, hass)
|
||||
coordinator = get_entry(hass).runtime_data.coordinator
|
||||
parse_duration = await coordinator.async_get_last_parse_duration()
|
||||
return (
|
||||
datetime.now(timezone).strftime("%d %b %Y %H:%M:%S"),
|
||||
parse_duration,
|
||||
coordinator.last_check_duration,
|
||||
time.time() - start_time,
|
||||
)
|
||||
|
||||
|
||||
async def report(
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
render: Callable[[HomeAssistant, str, dict[str, Any], dict[str, Any]], str]
|
||||
| None = None,
|
||||
chunk_size: int | None = None,
|
||||
parse_config: bool | None = None,
|
||||
) -> list[str]:
|
||||
"""Generate a report of missing entities and services."""
|
||||
from ..const import CONF_EXCLUDE_DISABLED_AUTOMATION
|
||||
from ..coordinator import renew_missing_items_list
|
||||
|
||||
start_time = time.time()
|
||||
entry = get_entry(hass)
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
|
||||
if parse_config:
|
||||
coordinator.request_parser_rescan(reason="service call")
|
||||
|
||||
# OPTIMIZATION: One-Pass Data Retrieval
|
||||
all_items = await coordinator.hub.async_get_all_items()
|
||||
service_list = all_items["services"]
|
||||
entity_list = all_items["entities"]
|
||||
|
||||
# Build filter context once
|
||||
ctx = coordinator._build_filter_context()
|
||||
|
||||
missing_services = renew_missing_items_list(
|
||||
hass,
|
||||
service_list,
|
||||
ctx,
|
||||
item_type="action",
|
||||
)
|
||||
|
||||
missing_entities = renew_missing_items_list(
|
||||
hass,
|
||||
entity_list,
|
||||
ctx,
|
||||
item_type="entity",
|
||||
)
|
||||
|
||||
header = get_config(hass, CONF_HEADER, DEFAULT_HEADER)
|
||||
files_parsed = coordinator.data.get(COORD_DATA_PROCESSED_FILES, 0)
|
||||
files_ignored = coordinator.data.get(COORD_DATA_IGNORED_FILES, 0)
|
||||
|
||||
rep = f"{header} \n"
|
||||
if missing_services:
|
||||
rep += f"\n-== Missing {len(missing_services)} action(s) from "
|
||||
rep += f"{len(service_list)} found in your config:\n"
|
||||
if render:
|
||||
rep += render(hass, REPORT_ENTRY_TYPE_SERVICE, missing_services, service_list)
|
||||
rep += "\n"
|
||||
elif len(service_list) > 0:
|
||||
rep += f"\n-== Congratulations, all {len(service_list)} actions from "
|
||||
rep += "your config are available!\n"
|
||||
else:
|
||||
rep += "\n-== No actions found in configuration files!\n"
|
||||
|
||||
if missing_entities:
|
||||
rep += f"\n-== Missing {len(missing_entities)} entity(ies) from "
|
||||
rep += f"{len(entity_list)} found in your config:\n"
|
||||
if render:
|
||||
rep += render(hass, REPORT_ENTRY_TYPE_ENTITY, missing_entities, entity_list)
|
||||
rep += "\n"
|
||||
|
||||
elif len(entity_list) > 0:
|
||||
rep += f"\n-== Congratulations, all {len(entity_list)} entities from "
|
||||
rep += "your config are available!\n"
|
||||
else:
|
||||
rep += "\n-== No entities found in configuration files!\n"
|
||||
|
||||
(
|
||||
report_datetime,
|
||||
parse_duration,
|
||||
check_duration,
|
||||
render_duration,
|
||||
) = await parsing_stats(hass, start_time)
|
||||
|
||||
rep += f"\n-== Report created on {report_datetime}\n"
|
||||
rep += (
|
||||
f"-== Parsed {files_parsed} files in {parse_duration:.2f}s., "
|
||||
f"ignored {files_ignored} files \n"
|
||||
)
|
||||
rep += f"-== Generated in: {render_duration:.2f}s. Validated in: {check_duration:.2f}s."
|
||||
report_chunks = []
|
||||
chunk = ""
|
||||
chunk_size = chunk_size or 0
|
||||
for line in iter(rep.splitlines()):
|
||||
chunk += f"{line}\n"
|
||||
if chunk_size > 0 and len(chunk) > chunk_size:
|
||||
report_chunks.append(chunk)
|
||||
chunk = ""
|
||||
if chunk:
|
||||
report_chunks.append(chunk)
|
||||
return report_chunks
|
||||
|
||||
|
||||
def table_renderer(
|
||||
hass: HomeAssistant,
|
||||
entry_type: str,
|
||||
missing_items: dict[str, Any],
|
||||
parsed_list: dict[str, Any],
|
||||
) -> str:
|
||||
"""Render ASCII tables in the report."""
|
||||
table = PrettyTable()
|
||||
columns_width = get_config(hass, CONF_COLUMNS_WIDTH, None)
|
||||
columns_width = get_columns_width(columns_width)
|
||||
if entry_type == REPORT_ENTRY_TYPE_SERVICE:
|
||||
table.field_names = ["Action ID", "State", "Location"]
|
||||
for service in missing_items:
|
||||
row = [
|
||||
fill(service, columns_width[0]),
|
||||
fill("missing", columns_width[1]),
|
||||
format_occurrences(parsed_list[service]["occurrences"], columns_width[2]),
|
||||
]
|
||||
table.add_row(row)
|
||||
table.align = "l"
|
||||
return table.get_string()
|
||||
if entry_type == REPORT_ENTRY_TYPE_ENTITY:
|
||||
friendly_names = get_config(hass, CONF_FRIENDLY_NAMES, False)
|
||||
header = ["Entity ID", "State", "Location"]
|
||||
table.field_names = header
|
||||
for entity in missing_items:
|
||||
state, name = get_entity_state(hass, entity, friendly_names=friendly_names)
|
||||
table.add_row(
|
||||
[
|
||||
fill(entity, columns_width[0], name),
|
||||
fill(state, columns_width[1]),
|
||||
format_occurrences(parsed_list[entity]["occurrences"], columns_width[2]),
|
||||
]
|
||||
)
|
||||
|
||||
table.align = "l"
|
||||
return table.get_string()
|
||||
|
||||
return f"Table render error: unknown entry type: {entry_type}"
|
||||
|
||||
|
||||
def text_renderer(
|
||||
hass: HomeAssistant,
|
||||
entry_type: str,
|
||||
missing_items: dict[str, Any],
|
||||
parsed_list: dict[str, Any],
|
||||
) -> str:
|
||||
"""Render plain lists in the report."""
|
||||
result = ""
|
||||
if entry_type == REPORT_ENTRY_TYPE_SERVICE:
|
||||
for service in missing_items:
|
||||
loc = format_occurrences(parsed_list[service]["occurrences"], 0)
|
||||
result += f"{service} in {loc}\n"
|
||||
return result
|
||||
if entry_type == REPORT_ENTRY_TYPE_ENTITY:
|
||||
friendly_names = get_config(hass, CONF_FRIENDLY_NAMES, False)
|
||||
for entity in missing_items:
|
||||
state, name = get_entity_state(hass, entity, friendly_names=friendly_names)
|
||||
entity_col = entity if not name else f"{entity} ('{name}')"
|
||||
loc = format_occurrences(parsed_list[entity]["occurrences"], 0)
|
||||
result += f"{entity_col} [{state}] in: {loc}\n"
|
||||
|
||||
return result
|
||||
return f"Text render error: unknown entry type: {entry_type}"
|
||||
|
||||
|
||||
def format_occurrences(occurrences: list[dict[str, Any]], width: int) -> str:
|
||||
"""Format occurrence locations, handling UI helpers gracefully."""
|
||||
helpers = set()
|
||||
files = {}
|
||||
|
||||
for occ in occurrences:
|
||||
context = occ.get("context")
|
||||
path = occ["path"]
|
||||
line = occ["line"]
|
||||
|
||||
# Check for UI Helper
|
||||
if context and context.get("parent_type", "").startswith("helper_"):
|
||||
p_type = context["parent_type"].replace("helper_", "").capitalize()
|
||||
alias = context.get("parent_alias") or "Unknown"
|
||||
|
||||
emoji = ""
|
||||
if p_type == "Group":
|
||||
emoji = "👥"
|
||||
elif p_type == "Template":
|
||||
emoji = "🧩"
|
||||
|
||||
helpers.add(f'{emoji} {p_type}: "{alias}"')
|
||||
else:
|
||||
# Standard File
|
||||
if path not in files:
|
||||
files[path] = []
|
||||
files[path].append(str(line))
|
||||
|
||||
lines = sorted(helpers)
|
||||
for path, line_numer_list in files.items():
|
||||
lines.append(f"📄 {path}:{','.join(line_numer_list)}")
|
||||
|
||||
out = "\n".join(lines)
|
||||
|
||||
if width > 0:
|
||||
wrapped_lines = []
|
||||
for line in out.split("\n"):
|
||||
wrapped_lines.extend(wrap(line, width))
|
||||
return "\n".join([line.ljust(width) for line in wrapped_lines])
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def fill(data: Any, width: int, extra: str | None = None) -> str:
|
||||
"""Arrange data by table column width."""
|
||||
if data and isinstance(data, dict):
|
||||
lines = []
|
||||
for key, val in data.items():
|
||||
lines.append(f"{key}:{','.join([str(v) for v in val])}")
|
||||
out = "\n".join(lines)
|
||||
else:
|
||||
out = str(data) if not extra else f"{data} ('{extra}')"
|
||||
|
||||
if width > 0:
|
||||
wrapped_lines = []
|
||||
for line in out.split("\n"):
|
||||
wrapped_lines.extend(wrap(line, width))
|
||||
return "\n".join([line.ljust(width) for line in wrapped_lines])
|
||||
return out
|
||||
|
||||
|
||||
def get_columns_width(user_width: list[int] | None) -> list[int]:
|
||||
"""Define width of the report columns."""
|
||||
default_width = [30, 7, 60]
|
||||
if not user_width:
|
||||
return default_width
|
||||
try:
|
||||
return [max(user_width[i], 7) for i in range(3)]
|
||||
except (TypeError, IndexError):
|
||||
_LOGGER.error(
|
||||
"Invalid configuration for table column widths, default values" " used %s",
|
||||
default_width,
|
||||
)
|
||||
return default_width
|
||||
|
||||
|
||||
async def async_report_to_file(hass: HomeAssistant, path: str) -> None:
|
||||
"""Save report to a file."""
|
||||
report_chunks = await report(hass, render=table_renderer, chunk_size=0)
|
||||
|
||||
def write(path: str) -> None:
|
||||
with Path(path).open("w", encoding="utf-8") as report_file:
|
||||
report_file.writelines(report_chunks)
|
||||
|
||||
await hass.async_add_executor_job(write, path)
|
||||
_LOGGER.debug(f"Report saved to {path}")
|
||||
|
||||
|
||||
async def async_report_to_notification(
|
||||
hass: HomeAssistant, action_str: str, service_data: dict[str, Any], chunk_size: int
|
||||
) -> None:
|
||||
"""Send report via notification action."""
|
||||
if not action_str:
|
||||
raise HomeAssistantError(f"Missing `{CONF_ACTION_NAME}` parameter.")
|
||||
|
||||
if action_str and not isinstance(action_str, str):
|
||||
raise HomeAssistantError(
|
||||
f"`action` parameter should be a string, got {action_str}"
|
||||
)
|
||||
|
||||
if not is_action(hass, action_str):
|
||||
raise HomeAssistantError(f"{action_str} is not a valid action for notification")
|
||||
|
||||
domain = action_str.split(".", maxsplit=1)[0]
|
||||
action = ".".join(action_str.split(".")[1:])
|
||||
|
||||
data = {} if service_data is None else service_data.copy()
|
||||
if "notification_id" not in data:
|
||||
data["notification_id"] = "watchman_report"
|
||||
|
||||
_LOGGER.debug(f"SERVICE_DATA {data}")
|
||||
|
||||
report_chunks = await report(hass, render=text_renderer, chunk_size=chunk_size)
|
||||
for msg_chunk in report_chunks:
|
||||
data["message"] = msg_chunk
|
||||
# blocking=True ensures send order
|
||||
await hass.services.async_call(domain, action, data, blocking=True)
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Miscellaneous support functions for Watchman."""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
import fnmatch
|
||||
import os
|
||||
import re
|
||||
from types import MappingProxyType
|
||||
from typing import Any
|
||||
|
||||
import anyio
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, split_entity_id
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from ..const import (
|
||||
CONF_COLUMNS_WIDTH,
|
||||
CONF_EXCLUDE_DISABLED_AUTOMATION,
|
||||
CONF_FRIENDLY_NAMES,
|
||||
CONF_HEADER,
|
||||
CONF_IGNORED_FILES,
|
||||
CONF_IGNORED_ITEMS,
|
||||
CONF_IGNORED_STATES,
|
||||
CONF_INCLUDED_FOLDERS,
|
||||
CONF_LOG_OBFUSCATE,
|
||||
CONF_REPORT_PATH,
|
||||
CONF_SECTION_APPEARANCE_LOCATION,
|
||||
CONF_STARTUP_DELAY,
|
||||
DEFAULT_OPTIONS,
|
||||
DOMAIN_DATA,
|
||||
)
|
||||
from .logger import _LOGGER, INDENT
|
||||
|
||||
_OBFUSCATE_ENABLED = True
|
||||
|
||||
|
||||
def set_obfuscation_config(enabled: bool) -> None:
|
||||
"""Set the global obfuscation enabled state."""
|
||||
global _OBFUSCATE_ENABLED
|
||||
_OBFUSCATE_ENABLED = enabled
|
||||
|
||||
|
||||
def get_val(
|
||||
options: MappingProxyType[str, Any], key: str, section: str | None = None
|
||||
) -> Any:
|
||||
"""Return value of a key."""
|
||||
val = None
|
||||
if section:
|
||||
try:
|
||||
val = options[section][key]
|
||||
except KeyError:
|
||||
_LOGGER.error(
|
||||
"Key %s is missing in secion %s, return default value", key, section
|
||||
)
|
||||
val = DEFAULT_OPTIONS[section][key]
|
||||
else:
|
||||
val = options.get(key, DEFAULT_OPTIONS[key])
|
||||
return val
|
||||
|
||||
|
||||
def to_lists(
|
||||
options: MappingProxyType[str, Any] | dict[str, Any], key: str, section: str | None = None
|
||||
) -> list[str]:
|
||||
"""Transform configuration value to the list of strings."""
|
||||
val = get_val(options, key, section)
|
||||
if isinstance(val, list):
|
||||
return val
|
||||
if not val:
|
||||
return []
|
||||
return [x.strip() for x in val.split(",") if x.strip()]
|
||||
|
||||
|
||||
def to_listi(
|
||||
options: MappingProxyType[str, Any] | dict[str, Any], key: str, section: str | None = None
|
||||
) -> list[int]:
|
||||
"""Transform configuration value to the list of integers."""
|
||||
val = get_val(options, key, section)
|
||||
return [int(x) for x in val.split(",") if x.strip()]
|
||||
|
||||
|
||||
def get_entry(hass: HomeAssistant) -> Any:
|
||||
"""Return Watchman's ConfigEntry instance."""
|
||||
if DOMAIN_DATA not in hass.data:
|
||||
return None
|
||||
return hass.config_entries.async_get_entry(
|
||||
hass.data[DOMAIN_DATA]["config_entry_id"]
|
||||
)
|
||||
|
||||
|
||||
def get_config(hass: HomeAssistant, key: str, default: Any | None = None) -> Any: # noqa: PLR0911
|
||||
"""Get configuration value from ConfigEntry."""
|
||||
if DOMAIN_DATA not in hass.data:
|
||||
return default
|
||||
|
||||
entry = hass.config_entries.async_get_entry(
|
||||
hass.data[DOMAIN_DATA]["config_entry_id"]
|
||||
)
|
||||
|
||||
if not isinstance(entry, ConfigEntry):
|
||||
return default
|
||||
|
||||
if key in [
|
||||
CONF_INCLUDED_FOLDERS,
|
||||
CONF_IGNORED_ITEMS,
|
||||
CONF_IGNORED_FILES,
|
||||
]:
|
||||
return to_lists(entry.data, key)
|
||||
|
||||
if key in [
|
||||
CONF_IGNORED_STATES,
|
||||
CONF_EXCLUDE_DISABLED_AUTOMATION,
|
||||
CONF_STARTUP_DELAY,
|
||||
CONF_LOG_OBFUSCATE,
|
||||
]:
|
||||
return get_val(entry.data, key)
|
||||
|
||||
if key in [CONF_HEADER, CONF_REPORT_PATH, CONF_COLUMNS_WIDTH, CONF_FRIENDLY_NAMES]:
|
||||
section_name = CONF_SECTION_APPEARANCE_LOCATION
|
||||
if key == CONF_COLUMNS_WIDTH:
|
||||
return to_listi(entry.data, CONF_COLUMNS_WIDTH, section_name)
|
||||
return get_val(entry.data, key, section_name)
|
||||
|
||||
return default
|
||||
|
||||
|
||||
async def async_is_valid_path(path: str) -> bool:
|
||||
"""Validate the report path."""
|
||||
folder, f_name = os.path.split(path)
|
||||
if is_valid := (
|
||||
folder.strip() and f_name.strip() and await anyio.Path(folder).exists()
|
||||
):
|
||||
is_valid = not await anyio.Path(path).is_dir()
|
||||
return is_valid
|
||||
|
||||
|
||||
async def async_get_next_file(
|
||||
folder_tuples: list[tuple[str, str]], ignored_files: list[str]
|
||||
) -> AsyncGenerator[tuple[str, bool]]:
|
||||
"""Return next file from scan queue."""
|
||||
if not ignored_files:
|
||||
ignored_files = ""
|
||||
else:
|
||||
ignored_files = "|".join([f"({fnmatch.translate(f)})" for f in ignored_files])
|
||||
ignored_files_re = re.compile(ignored_files)
|
||||
for folder_name, glob_pattern in folder_tuples:
|
||||
_LOGGER.debug(
|
||||
f"{INDENT}Scan folder {folder_name} with pattern {glob_pattern} for configuration files"
|
||||
)
|
||||
async for filename in anyio.Path(folder_name).glob(glob_pattern):
|
||||
yield (
|
||||
str(filename),
|
||||
(ignored_files and ignored_files_re.match(str(filename))),
|
||||
)
|
||||
|
||||
|
||||
def get_included_folders(hass: HomeAssistant) -> list[tuple[str, str]]:
|
||||
"""Gather the list of folders to parse."""
|
||||
folders = []
|
||||
|
||||
included = get_config(hass, CONF_INCLUDED_FOLDERS, None)
|
||||
if not included:
|
||||
# Default to config dir if nothing specified
|
||||
folders.append((hass.config.config_dir, "**"))
|
||||
else:
|
||||
for fld in included:
|
||||
folders.append((fld, "**"))
|
||||
|
||||
return folders
|
||||
|
||||
|
||||
def is_action(hass: HomeAssistant, entry: str) -> bool:
|
||||
"""Check whether config entry is an action."""
|
||||
if not isinstance(entry, str):
|
||||
return False
|
||||
try:
|
||||
domain, service = split_entity_id(entry)
|
||||
except ValueError:
|
||||
return False
|
||||
return bool(service) and hass.services.has_service(domain, service)
|
||||
|
||||
|
||||
def get_entity_state(
|
||||
hass: HomeAssistant,
|
||||
entry: str,
|
||||
*,
|
||||
friendly_names: bool = False,
|
||||
registry_entry: er.RegistryEntry | None = None,
|
||||
) -> tuple[str, str | None]:
|
||||
"""Return entity state or 'missing' if entity does not exist."""
|
||||
entity_state = hass.states.get(entry)
|
||||
|
||||
name = None
|
||||
if entity_state and entity_state.attributes.get("friendly_name", None):
|
||||
if friendly_names:
|
||||
name = entity_state.name
|
||||
|
||||
if not entity_state:
|
||||
state = "missing"
|
||||
if registry_entry is None:
|
||||
entity_registry = er.async_get(hass)
|
||||
registry_entry = entity_registry.async_get(entry)
|
||||
|
||||
if registry_entry and registry_entry.disabled_by:
|
||||
state = "disabled"
|
||||
else:
|
||||
state = str(entity_state.state).replace("unavailable", "unavail")
|
||||
if split_entity_id(entry)[0] == "input_button" and state == "unknown":
|
||||
state = "available"
|
||||
|
||||
return state, name
|
||||
|
||||
|
||||
def obfuscate_id(item_id: Any) -> Any:
|
||||
"""Obfuscate entity or action ID for logging."""
|
||||
if not _OBFUSCATE_ENABLED:
|
||||
return item_id
|
||||
|
||||
if isinstance(item_id, (list, tuple, set)):
|
||||
return ", ".join([str(obfuscate_id(x)) for x in item_id])
|
||||
|
||||
if not isinstance(item_id, str) or "." not in item_id:
|
||||
return item_id
|
||||
|
||||
parts = item_id.split(".", 1)
|
||||
domain = parts[0]
|
||||
name = parts[1]
|
||||
|
||||
if len(name) <= 3:
|
||||
return f"{domain}.{name}"
|
||||
|
||||
if len(name) > 15:
|
||||
# Truncate to 15 chars: 3 visible + 11 stars + '~'
|
||||
return f"{domain}.{name[:3]}***********~"
|
||||
|
||||
prefix = name[:3]
|
||||
suffix = name[3:]
|
||||
|
||||
masked_suffix = ""
|
||||
for char in suffix:
|
||||
if char.isalnum():
|
||||
masked_suffix += "*"
|
||||
else:
|
||||
masked_suffix += char
|
||||
|
||||
return f"{domain}.{prefix}{masked_suffix}"
|
||||
@@ -0,0 +1,71 @@
|
||||
"""YAML Loader for Watchman."""
|
||||
from typing import Any, Self
|
||||
|
||||
import yaml
|
||||
|
||||
# Custom YAML Loader with Line Numbers
|
||||
|
||||
class StringWithLine(str):
|
||||
"""String subclass that holds the line number, tag info, and scalar style."""
|
||||
|
||||
def __new__(
|
||||
cls, value: str, line: int, *, is_tag: bool = False, style: str | None = None
|
||||
) -> Self:
|
||||
obj = str.__new__(cls, value)
|
||||
obj.line = line
|
||||
obj.is_tag = is_tag
|
||||
obj.style = style # Store the style (e.g., '"', "'", '>', '|')
|
||||
return obj
|
||||
|
||||
class LineLoader(yaml.SafeLoader):
|
||||
"""Custom YAML loader that attaches line numbers to scalars."""
|
||||
|
||||
def construct_scalar(self, node: yaml.ScalarNode) -> Any:
|
||||
value = super().construct_scalar(node)
|
||||
if isinstance(value, str):
|
||||
# Pass node.style to the string object
|
||||
return StringWithLine(value, node.start_mark.line + 1, style=node.style)
|
||||
return value
|
||||
|
||||
def flatten_mapping(self, node: yaml.MappingNode) -> None:
|
||||
"""Override flatten_mapping to handle merge keys ('<<') safely."""
|
||||
merge = []
|
||||
index = 0
|
||||
while index < len(node.value):
|
||||
key_node, value_node = node.value[index]
|
||||
if key_node.tag == 'tag:yaml.org,2002:merge':
|
||||
del node.value[index]
|
||||
if isinstance(value_node, yaml.MappingNode):
|
||||
self.flatten_mapping(value_node)
|
||||
merge.extend(value_node.value)
|
||||
elif isinstance(value_node, yaml.SequenceNode):
|
||||
submerge = []
|
||||
for subnode in value_node.value:
|
||||
if isinstance(subnode, yaml.MappingNode):
|
||||
self.flatten_mapping(subnode)
|
||||
submerge.append(subnode)
|
||||
elif isinstance(subnode, yaml.ScalarNode):
|
||||
continue
|
||||
for subnode in reversed(submerge):
|
||||
merge.extend(subnode.value)
|
||||
elif isinstance(value_node, yaml.ScalarNode):
|
||||
continue
|
||||
elif key_node.tag == 'tag:yaml.org,2002:value':
|
||||
key_node.tag = 'tag:yaml.org,2002:str'
|
||||
index += 1
|
||||
else:
|
||||
index += 1
|
||||
if merge:
|
||||
node.value = merge + node.value
|
||||
|
||||
LineLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_SCALAR_TAG, LineLoader.construct_scalar)
|
||||
|
||||
# Handle custom HA tags by ignoring them or treating as string
|
||||
def default_ctor(loader: yaml.Loader, tag_suffix: str, node: yaml.ScalarNode) -> Any:
|
||||
value = loader.construct_scalar(node)
|
||||
if isinstance(value, str):
|
||||
# Pass node.style here as well
|
||||
return StringWithLine(value, node.start_mark.line + 1, is_tag=True, style=node.style)
|
||||
return value
|
||||
|
||||
yaml.add_multi_constructor('!', default_ctor, Loader=LineLoader)
|
||||
Reference in New Issue
Block a user