Skip to content

Commit 81974e9

Browse files
authored
feat: migrate ignored items/files to SelectSelector chip input (#310)
1 parent 6ebeeb1 commit 81974e9

10 files changed

Lines changed: 541 additions & 52 deletions

File tree

custom_components/watchman/__init__.py

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""The Watchman integration."""
22

3+
from copy import deepcopy
34
from dataclasses import dataclass
45
from pathlib import Path
56

@@ -224,19 +225,23 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
224225
"Start Watchman configuration entry migration to version 2. Source data: %s",
225226
config_entry.options,
226227
)
227-
data = DEFAULT_OPTIONS
228+
data = deepcopy(DEFAULT_OPTIONS)
228229

229230
data[CONF_IGNORED_STATES] = config_entry.options.get(CONF_IGNORED_STATES, [])
230231

231232
if CONF_IGNORED_ITEMS in config_entry.options:
232-
data[CONF_IGNORED_ITEMS] = ",".join(
233-
str(x) for x in config_entry.options[CONF_IGNORED_ITEMS]
234-
)
233+
data[CONF_IGNORED_ITEMS] = [
234+
str(x).strip()
235+
for x in config_entry.options[CONF_IGNORED_ITEMS]
236+
if str(x).strip()
237+
]
235238

236239
if CONF_IGNORED_FILES in config_entry.options:
237-
data[CONF_IGNORED_FILES] = ",".join(
238-
str(x) for x in config_entry.options[CONF_IGNORED_FILES]
239-
)
240+
data[CONF_IGNORED_FILES] = [
241+
str(x).strip()
242+
for x in config_entry.options[CONF_IGNORED_FILES]
243+
if str(x).strip()
244+
]
240245

241246
if CONF_FRIENDLY_NAMES in config_entry.options:
242247
data[CONF_SECTION_APPEARANCE_LOCATION][CONF_FRIENDLY_NAMES] = (
@@ -315,6 +320,16 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
315320
data[CONF_ENFORCE_FILE_SIZE] = DEFAULT_OPTIONS.get(CONF_ENFORCE_FILE_SIZE, True)
316321
current_minor = 5
317322

323+
if current_minor < 6:
324+
_LOGGER.info(
325+
"Migrating Watchman entry to minor version 6: converting ignored_items and ignored_files from str to list"
326+
)
327+
for key in (CONF_IGNORED_ITEMS, CONF_IGNORED_FILES):
328+
val = data.get(key, "")
329+
if isinstance(val, str):
330+
data[key] = [x.strip() for x in val.split(",") if x.strip()]
331+
current_minor = 6
332+
318333
if current_minor != config_entry.minor_version:
319334
hass.config_entries.async_update_entry(
320335
config_entry,

custom_components/watchman/config_flow.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,18 +48,24 @@
4848

4949

5050
def _get_data_schema() -> vol.Schema:
51-
select = selector.TextSelector(selector.TextSelectorConfig(multiline=True))
51+
chip_selector = selector.SelectSelector(
52+
selector.SelectSelectorConfig(
53+
options=[],
54+
multiple=True,
55+
custom_value=True,
56+
)
57+
)
5258
return vol.Schema(
5359
{
5460
vol.Optional(
5561
CONF_IGNORED_ITEMS,
56-
): select,
62+
): chip_selector,
5763
vol.Optional(
5864
CONF_IGNORED_STATES,
5965
): cv.multi_select(MONITORED_STATES),
6066
vol.Optional(
6167
CONF_IGNORED_FILES,
62-
): select,
68+
): chip_selector,
6369
vol.Optional(
6470
CONF_IGNORED_LABELS,
6571
): selector.LabelSelector(
@@ -194,13 +200,13 @@ async def async_step_init(
194200
CONF_IGNORED_FILES in self.config_entry.data
195201
and CONF_IGNORED_FILES not in user_input
196202
):
197-
user_input[CONF_IGNORED_FILES] = ""
203+
user_input[CONF_IGNORED_FILES] = []
198204

199205
if (
200206
CONF_IGNORED_ITEMS in self.config_entry.data
201207
and CONF_IGNORED_ITEMS not in user_input
202208
):
203-
user_input[CONF_IGNORED_ITEMS] = ""
209+
user_input[CONF_IGNORED_ITEMS] = []
204210

205211
# see met.no code, without update_entry the EXISTING entry
206212
# will not be updated with user input, but entry.options will do

custom_components/watchman/const.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515

1616
CONFIG_ENTRY_VERSION = 2
17-
CONFIG_ENTRY_MINOR_VERSION = 5
17+
CONFIG_ENTRY_MINOR_VERSION = 6
1818

1919
DEFAULT_REPORT_FILENAME = f"{DOMAIN}_report.txt"
2020
DB_FILENAME = f"{DOMAIN}_v2.db"
@@ -157,10 +157,10 @@
157157
PLATFORMS = [Platform.SENSOR, Platform.TEXT, Platform.BUTTON]
158158

159159
DEFAULT_OPTIONS = {
160-
CONF_IGNORED_ITEMS: "",
160+
CONF_IGNORED_ITEMS: [],
161161
CONF_IGNORED_STATES: [],
162162
CONF_EXCLUDE_DISABLED_AUTOMATION: True,
163-
CONF_IGNORED_FILES: "",
163+
CONF_IGNORED_FILES: [],
164164
CONF_STARTUP_DELAY: 30,
165165
CONF_LOG_OBFUSCATE: True,
166166
CONF_ENFORCE_FILE_SIZE: True,

custom_components/watchman/translations/en.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,9 @@
3030
},
3131
"data_description": {
3232
"included_folders": "Comma-separated list of folders where watchman should look for config files",
33-
"ignored_items": "Comma-separated list of entities and actions excluded from tracking",
33+
"ignored_items": "Add entities and actions to exclude from tracking. Supports glob patterns (e.g. switch.*)",
3434
"ignored_states": "Comma-separated list of the states excluded from tracking",
35-
"ignored_files": "Comma-separated list of config files excluded from tracking",
35+
"ignored_files": "Add file glob patterns to exclude from tracking (e.g. */esphome/*)",
3636
"ignored_labels": "Use labels to exclude entities from tracking",
3737
"log_obfuscate": "Whether to mask entity and action names in debug logs for privacy",
3838
"exclude_disabled_automation": "Exclude entities used only by disabled automations",

custom_components/watchman/translations/fr.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,11 @@
3030
},
3131
"data_description": {
3232
"included_folders": "Liste séparée par des virgules des dossiers dans lesquels Watchman doit rechercher les fichiers de configuration",
33-
"ignored_items": "Liste séparée par des virgules des entités et des actions exclues du suivi",
33+
"ignored_items": "Ajoutez des entités et des actions à exclure du suivi. Prend en charge les motifs glob (ex. switch.*)",
3434
"ignored_states": "Liste séparée par des virgules des États exclus du suivi",
3535
"exclude_disabled_automation": "Exclure les entités utilisées uniquement par des automatisations désactivées",
3636
"enforce_file_size": "Désactivez cette option pour analyser les fichiers de plus de {max_size_kb}Ko. Cela pourrait augmenter le temps d'analyse et l'utilisation de la mémoire.",
37-
"ignored_files": "Liste séparée par des virgules des fichiers de configuration exclus du suivi",
37+
"ignored_files": "Ajoutez des motifs glob de fichiers à exclure du suivi (ex. */esphome/*)",
3838
"ignored_labels": "Utiliser des étiquettes pour exclure des entités du suivi",
3939
"log_obfuscate": "Masquer les noms d'entités et de services dans les journaux pour la confidentialité"
4040
},

custom_components/watchman/translations/pt.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,11 @@
3030
},
3131
"data_description": {
3232
"included_folders": "Lista de pastas separadas por vírgulas onde o watchman deve procurar arquivos de configuração",
33-
"ignored_items": "Lista de entidades e ações separadas por vírgulas excluídas do rastreamento",
33+
"ignored_items": "Adicione entidades e ações a excluir do rastreamento. Suporta padrões glob (ex. switch.*)",
3434
"ignored_states": "Lista de estados separados por vírgulas excluídos do rastreamento",
3535
"exclude_disabled_automation": "Excluir entidades usadas apenas por automações desativadas",
3636
"enforce_file_size": "Desative isto para analisar arquivos maiores que {max_size_kb}KB. Isso pode aumentar o tempo de análise e o uso de memória.",
37-
"ignored_files": "Lista de arquivos de configuração separados por vírgulas excluídos do rastreamento",
37+
"ignored_files": "Adicione padrões glob de arquivos a excluir do rastreamento (ex. */esphome/*)",
3838
"ignored_labels": "Usar etiquetas para excluir entidades do rastreamento",
3939
"log_obfuscate": "Se os nomes de entidades e serviços devem ser ocultados nos logs para privacidade"
4040
},

custom_components/watchman/translations/sk.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,11 @@
3030
},
3131
"data_description": {
3232
"included_folders": "Čiarkami oddelený zoznam priečinkov, kde by mal watchman hľadať konfiguračné súbory",
33-
"ignored_items": "Čiarkami oddelený zoznam entít a akcií vylúčených zo sledovania",
33+
"ignored_items": "Pridajte entity a akcie na vylúčenie zo sledovania. Podporuje glob vzory (napr. switch.*)",
3434
"ignored_states": "Čiarkami oddelený zoznam stavov vylúčených zo sledovania",
3535
"exclude_disabled_automation": "Vylúčiť entity použité iba v deaktivovaných automatizáciách",
3636
"enforce_file_size": "Vypnite túto možnosť, ak chcete analyzovať súbory väčšie ako {max_size_kb}KB. Môže to zvýšiť čas analýzy a využitie pamäte.",
37-
"ignored_files": "Čiarkami oddelený zoznam konfiguračných súborov vylúčených zo sledovania",
37+
"ignored_files": "Pridajte glob vzory súborov na vylúčenie zo sledovania (napr. */esphome/*)",
3838
"ignored_labels": "Použiť štítky na vylúčenie entít zo sledovania",
3939
"log_obfuscate": "Či sa majú v logoch z dôvodu ochrany osobných údajov maskovať názvy entít a služieb"
4040
},

tests/test_ignored_files_storage.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,25 @@
22
import asyncio
33
import os
44
from pathlib import Path
5+
from unittest.mock import patch
56

67
from custom_components.watchman.utils.parser_core import WatchmanParser
78
import pytest
9+
from custom_components.watchman.const import (
10+
CONF_IGNORED_FILES,
11+
CONF_IGNORED_STATES,
12+
CONF_SECTION_APPEARANCE_LOCATION,
13+
CONF_REPORT_PATH,
14+
CONF_HEADER,
15+
CONF_COLUMNS_WIDTH,
16+
CONF_STARTUP_DELAY,
17+
DOMAIN,
18+
STATE_IDLE,
19+
STATE_SAFE_MODE,
20+
)
21+
from homeassistant.core import HomeAssistant
22+
from homeassistant.data_entry_flow import FlowResultType
23+
from tests import async_init_integration
824

925
@pytest.fixture
1026
def parser_client(tmp_path):
@@ -62,3 +78,137 @@ def test_storage_ignored_files(parser_client, tmp_path):
6278

6379
# THIS SHOULD FAIL INITIALLY
6480
assert not any("lovelace.dashboard_test" in p for p in processed_paths), "lovelace.dashboard_test should be IGNORED"
81+
82+
83+
async def _force_parse_and_wait(hass: HomeAssistant, config_entry_id: str) -> None:
84+
"""Force an immediate re-parse on the current coordinator and wait for it to finish.
85+
86+
After an options-flow reload, the coordinator is in STATE_PENDING waiting on a
87+
call_later(0) handle that the asyncio test loop does not automatically drain.
88+
Calling request_parser_rescan(force=True) cancels the pending timer and creates a
89+
real asyncio Task that async_block_till_done() can observe.
90+
"""
91+
coordinator = hass.data[DOMAIN][config_entry_id]
92+
coordinator.request_parser_rescan(force=True)
93+
await hass.async_block_till_done()
94+
# Executor jobs (hass.async_add_executor_job) may not all be captured by a single
95+
# async_block_till_done; use a short polling loop as a safety net.
96+
for _ in range(50):
97+
if coordinator.status in [STATE_IDLE, STATE_SAFE_MODE]:
98+
break
99+
await asyncio.sleep(0.1)
100+
else:
101+
raise TimeoutError(f"Coordinator stuck in '{coordinator.status}' after forced parse")
102+
103+
104+
@pytest.mark.asyncio
105+
async def test_options_flow_add_ignored_files(hass: HomeAssistant):
106+
"""Test that adding an ignored file pattern via options flow hides its entities."""
107+
# Init with default settings (basic_config.yaml is included, sensor.skylight is reported)
108+
config_entry = await async_init_integration(
109+
hass,
110+
add_params={
111+
CONF_IGNORED_FILES: [],
112+
CONF_IGNORED_STATES: [],
113+
},
114+
)
115+
116+
try:
117+
coordinator = hass.data[DOMAIN][config_entry.entry_id]
118+
entity_ids_before = [e["id"] for e in coordinator.data.get("entity_attrs", [])]
119+
assert "sensor.skylight" in entity_ids_before, (
120+
"sensor.skylight must be present before ignoring basic_config.yaml"
121+
)
122+
123+
# Open options flow and ignore basic_config.yaml
124+
result = await hass.config_entries.options.async_init(config_entry.entry_id)
125+
assert result["type"] == FlowResultType.FORM
126+
127+
user_input = {
128+
CONF_IGNORED_FILES: ["*/basic_config.yaml"],
129+
CONF_STARTUP_DELAY: 0,
130+
CONF_SECTION_APPEARANCE_LOCATION: {
131+
CONF_REPORT_PATH: hass.config.path("watchman_report.txt"),
132+
CONF_HEADER: "-== Watchman Report ==-",
133+
CONF_COLUMNS_WIDTH: "30, 8, 60",
134+
},
135+
}
136+
137+
with patch("custom_components.watchman.config_flow.async_is_valid_path", return_value=True):
138+
result = await hass.config_entries.options.async_configure(
139+
result["flow_id"], user_input=user_input
140+
)
141+
await hass.async_block_till_done()
142+
143+
assert result["type"] == FlowResultType.CREATE_ENTRY
144+
assert config_entry.data[CONF_IGNORED_FILES] == ["*/basic_config.yaml"]
145+
146+
# Force an immediate re-parse (which skips basic_config.yaml) and wait
147+
await _force_parse_and_wait(hass, config_entry.entry_id)
148+
149+
coordinator = hass.data[DOMAIN][config_entry.entry_id]
150+
entity_ids_after = [e["id"] for e in coordinator.data.get("entity_attrs", [])]
151+
assert "sensor.skylight" not in entity_ids_after, (
152+
"sensor.skylight must be absent after ignoring basic_config.yaml"
153+
)
154+
assert "switch.skylight" not in entity_ids_after, (
155+
"switch.skylight must be absent after ignoring basic_config.yaml"
156+
)
157+
finally:
158+
await hass.config_entries.async_unload(config_entry.entry_id)
159+
await hass.async_block_till_done()
160+
161+
162+
@pytest.mark.asyncio
163+
async def test_options_flow_clear_ignored_files(hass: HomeAssistant):
164+
"""Test that clearing ignored files via options flow restores their entities."""
165+
# Init with basic_config.yaml already ignored — sensor.skylight is NOT parsed
166+
config_entry = await async_init_integration(
167+
hass,
168+
add_params={
169+
CONF_IGNORED_FILES: ["*/basic_config.yaml"],
170+
CONF_IGNORED_STATES: [],
171+
},
172+
)
173+
174+
try:
175+
coordinator = hass.data[DOMAIN][config_entry.entry_id]
176+
entity_ids_before = [e["id"] for e in coordinator.data.get("entity_attrs", [])]
177+
assert "sensor.skylight" not in entity_ids_before, (
178+
"sensor.skylight must be absent when basic_config.yaml is ignored"
179+
)
180+
181+
# Open options flow and clear ignored files
182+
result = await hass.config_entries.options.async_init(config_entry.entry_id)
183+
assert result["type"] == FlowResultType.FORM
184+
185+
user_input = {
186+
CONF_IGNORED_FILES: [],
187+
CONF_STARTUP_DELAY: 0,
188+
CONF_SECTION_APPEARANCE_LOCATION: {
189+
CONF_REPORT_PATH: hass.config.path("watchman_report.txt"),
190+
CONF_HEADER: "-== Watchman Report ==-",
191+
CONF_COLUMNS_WIDTH: "30, 8, 60",
192+
},
193+
}
194+
195+
with patch("custom_components.watchman.config_flow.async_is_valid_path", return_value=True):
196+
result = await hass.config_entries.options.async_configure(
197+
result["flow_id"], user_input=user_input
198+
)
199+
await hass.async_block_till_done()
200+
201+
assert result["type"] == FlowResultType.CREATE_ENTRY
202+
assert config_entry.data[CONF_IGNORED_FILES] == []
203+
204+
# Force an immediate re-parse (which now includes basic_config.yaml) and wait
205+
await _force_parse_and_wait(hass, config_entry.entry_id)
206+
207+
coordinator = hass.data[DOMAIN][config_entry.entry_id]
208+
entity_ids_after = [e["id"] for e in coordinator.data.get("entity_attrs", [])]
209+
assert "sensor.skylight" in entity_ids_after, (
210+
"sensor.skylight must reappear after clearing ignored files"
211+
)
212+
finally:
213+
await hass.config_entries.async_unload(config_entry.entry_id)
214+
await hass.async_block_till_done()

0 commit comments

Comments
 (0)