Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions custom_components/scribe/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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)
Expand Down
52 changes: 52 additions & 0 deletions tests/test_filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading