From 7265bf7e1b40b950bc80bf6c848a7c188e07e425 Mon Sep 17 00:00:00 2001 From: SAY-5 Date: Tue, 28 Apr 2026 06:10:38 -0700 Subject: [PATCH] fix(filter): exclude_entity_globs now overrides include_entity_globs (#33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #33. Home Assistant's `generate_filter` (case 4a in homeassistant.helpers.entityfilter) lets `include_entity_globs` short-circuit *over* `exclude_entity_globs`: when an entity matches an include glob, the exclude globs are never checked. Per the issue, scribe users expect the opposite — an `exclude_entity_globs` match should be a hard reject, even when an `include_entity_globs` entry would otherwise have matched. Wrap the result of `generate_filter` with a small `_build_exclude_priority_filter` that: 1. checks `exclude_entities` and `exclude_entity_globs` first, returning False on any match, 2. otherwise defers to the upstream filter. The existing HA semantics for everything else (domain include/exclude, entity-level include, no-filter pass-through) are preserved exactly. The wrapper is a no-op when neither `exclude_entities` nor `exclude_entity_globs` is configured, so the common-case fast path is unchanged. Tests: * test_exclude_entity_globs_overrides_include_entity_globs — exact reproduction from the issue (`include: sensor.*_temperature`, `exclude: sensor.processor_*`): `sensor.processor_temperature` is now excluded, `sensor.living_room_temperature` is still included, and `sensor.processor_use` (excluded only) stays excluded. --- custom_components/scribe/__init__.py | 56 ++++++++++++++++++++++++++++ tests/test_filter.py | 52 ++++++++++++++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/custom_components/scribe/__init__.py b/custom_components/scribe/__init__.py index b3b49ab..1f161f7 100644 --- a/custom_components/scribe/__init__.py +++ b/custom_components/scribe/__init__.py @@ -124,6 +124,46 @@ extra=vol.ALLOW_EXTRA, ) +def _build_exclude_priority_filter( + base_filter, + exclude_entities, + exclude_entity_globs, +): + """Wrap ``base_filter`` so an exclude-glob match always rejects. + + Home Assistant's ``generate_filter`` (case 4a) short-circuits on + ``include_entity_globs`` — when an entity matches an include glob the + exclude globs are never checked. Scribe users expect the opposite: + ``exclude_entity_globs`` should be a hard reject regardless of what + the include configuration looks like. + + The wrapper checks ``exclude_entities`` and ``exclude_entity_globs`` + first; if either matches, the entity is rejected. Otherwise the call + falls through to the upstream filter, preserving all other + Home-Assistant filter semantics (domain include/exclude, the + no-filter pass-through, etc.). + """ + import fnmatch + + exclude_entities_set = set(exclude_entities or []) + glob_patterns = list(exclude_entity_globs or []) + + if not exclude_entities_set and not glob_patterns: + return base_filter + + def _excluded(entity_id: str) -> bool: + if entity_id in exclude_entities_set: + return True + return any(fnmatch.fnmatchcase(entity_id, pat) for pat in glob_patterns) + + def _filter(entity_id: str) -> bool: + if _excluded(entity_id): + return False + return base_filter(entity_id) + + return _filter + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Scribe component from YAML. @@ -235,6 +275,22 @@ def get_config(key, default): include_entity_globs, exclude_entity_globs, ) + + # Home Assistant's `generate_filter` (case 4a) lets `include_entity_globs` + # short-circuit *over* `exclude_entity_globs`: when an entity matches an + # include glob, the exclude globs are never checked. Scribe users expect + # the opposite — `exclude_entity_globs` should override + # `include_entity_globs`, mirroring how `exclude_entities` already takes + # precedence over `include_entity_globs`. + # + # Wrap the filter so an exclude-glob match (or an exclude-entity match) + # is always a hard reject, then defer to the upstream filter for + # everything else. See https://github.com/jonathan-gtd/scribe/issues/33. + entity_filter = _build_exclude_priority_filter( + entity_filter, + exclude_entities, + exclude_entity_globs, + ) # SSL configuration db_ssl = get_config(CONF_DB_SSL, DEFAULT_DB_SSL) diff --git a/tests/test_filter.py b/tests/test_filter.py index 06bb542..df3667f 100644 --- a/tests/test_filter.py +++ b/tests/test_filter.py @@ -119,3 +119,55 @@ async def test_include_entity_globs(hass: HomeAssistant, mock_writer): }) await hass.async_block_till_done() assert mock_writer.enqueue.call_count == 0 + + +async def test_exclude_entity_globs_overrides_include_entity_globs(hass: HomeAssistant, mock_writer): + """Regression for jonathan-gtd/scribe#33. + + Home Assistant's ``generate_filter`` lets ``include_entity_globs`` short- + circuit over ``exclude_entity_globs`` (case 4a). Scribe wraps the + upstream filter so an exclude-glob match is always a hard reject — + matching the user-visible expectation that excludes win over includes. + """ + config = { + DOMAIN: { + CONF_DB_URL: "postgresql://user:pass@localhost/db", + CONF_INCLUDE_ENTITY_GLOBS: ["sensor.*_temperature"], + CONF_EXCLUDE_ENTITY_GLOBS: ["sensor.processor_*"], + } + } + + with patch("custom_components.scribe.ScribeWriter", return_value=mock_writer): + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + # Matches include_entity_globs only — should be recorded. + hass.bus.async_fire(EVENT_STATE_CHANGED, { + "entity_id": "sensor.living_room_temperature", + "new_state": Mock(state="22", attributes={}) + }) + await hass.async_block_till_done() + assert mock_writer.enqueue.call_count == 1 + mock_writer.enqueue.reset_mock() + + # Matches BOTH include_entity_globs (sensor.*_temperature) AND + # exclude_entity_globs (sensor.processor_*). Pre-fix, the upstream + # filter recorded this entity because the include glob short- + # circuited. Post-fix, the exclude glob wins. + hass.bus.async_fire(EVENT_STATE_CHANGED, { + "entity_id": "sensor.processor_temperature", + "new_state": Mock(state="62", attributes={}) + }) + await hass.async_block_till_done() + assert mock_writer.enqueue.call_count == 0, ( + "sensor.processor_temperature must be excluded — exclude_entity_globs " + "should override include_entity_globs" + ) + + # Matches exclude_entity_globs only — must remain excluded. + hass.bus.async_fire(EVENT_STATE_CHANGED, { + "entity_id": "sensor.processor_use", + "new_state": Mock(state="42", attributes={}) + }) + await hass.async_block_till_done() + assert mock_writer.enqueue.call_count == 0