Skip to content

Commit 4b8f3d0

Browse files
danieldotnlclaude
andcommitted
Address review: deduplicate scraper/entity IDs and add type hints
- Add _deduplicate_id() for scraper names that collide (appends _2, _3, etc.) - Add _deduplicate_entity_key() for entity names that collide within a scraper - Use enumerate index for unnamed scrapers without a resource URL - Add proper TYPE_CHECKING type hints for ScraperInstance fields - Add contains() method to ScraperRegistry - Add tests for duplicate scraper names and duplicate entity names Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d1a95a3 commit 4b8f3d0

4 files changed

Lines changed: 123 additions & 8 deletions

File tree

custom_components/multiscrape/__init__.py

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,20 +79,22 @@ async def _async_process_config(hass: HomeAssistant, config) -> bool:
7979
load_tasks = []
8080
registry: ScraperRegistry = hass.data[DOMAIN]
8181

82-
for conf in config[DOMAIN]:
82+
for scraper_idx, conf in enumerate(config[DOMAIN]):
8383
config_name = conf.get(CONF_NAME)
8484
if config_name is None:
8585
resource = conf.get(CONF_RESOURCE) or ""
86-
config_name = f"scraper_{slugify(resource)}" if resource else "scraper_unnamed"
86+
config_name = (
87+
f"scraper_{slugify(resource)}" if resource else f"scraper_unnamed_{scraper_idx}"
88+
)
8789
_LOGGER.debug(
88-
"# Found no name for scraper, generated a unique name: %s", config_name
90+
"# Found no name for scraper, generated name: %s", config_name
8991
)
9092

9193
_LOGGER.debug(
9294
"%s # Setting up multiscrape with config:\n %s", config_name, conf
9395
)
9496

95-
scraper_id = config_name
97+
scraper_id = _deduplicate_id(registry, config_name)
9698

9799
file_manager = await create_file_manager(hass, config_name, conf.get(CONF_LOG_RESPONSE))
98100
session = create_http_session(config_name, conf, hass, file_manager)
@@ -130,7 +132,9 @@ async def _shutdown_session(_event, _session=session):
130132
entity_name = platform_conf.get(CONF_NAME, "")
131133
entity_key = slugify(entity_name) if entity_name else f"entity_{id(platform_conf)}"
132134

133-
instance.platform_configs.setdefault(platform_domain, {})[entity_key] = platform_conf
135+
platform_dict = instance.platform_configs.setdefault(platform_domain, {})
136+
entity_key = _deduplicate_entity_key(platform_dict, entity_key)
137+
platform_dict[entity_key] = platform_conf
134138

135139
load = discovery.async_load_platform(
136140
hass,
@@ -151,6 +155,34 @@ async def _shutdown_session(_event, _session=session):
151155
return True
152156

153157

158+
def _deduplicate_id(registry: ScraperRegistry, base_id: str) -> str:
159+
"""Return a unique scraper ID, appending a suffix if needed."""
160+
if not registry.contains(base_id):
161+
return base_id
162+
suffix = 2
163+
while registry.contains(f"{base_id}_{suffix}"):
164+
suffix += 1
165+
deduped = f"{base_id}_{suffix}"
166+
_LOGGER.warning(
167+
"Duplicate scraper name '%s', using '%s' instead", base_id, deduped
168+
)
169+
return deduped
170+
171+
172+
def _deduplicate_entity_key(platform_dict: dict, base_key: str) -> str:
173+
"""Return a unique entity key within a platform, appending a suffix if needed."""
174+
if base_key not in platform_dict:
175+
return base_key
176+
suffix = 2
177+
while f"{base_key}_{suffix}" in platform_dict:
178+
suffix += 1
179+
deduped = f"{base_key}_{suffix}"
180+
_LOGGER.warning(
181+
"Duplicate entity name '%s', using '%s' instead", base_key, deduped
182+
)
183+
return deduped
184+
185+
154186
async def async_get_config_and_coordinator(hass, platform_domain, discovery_info):
155187
"""Get the config and coordinator for the platform from discovery."""
156188
registry: ScraperRegistry = hass.data[DOMAIN]

custom_components/multiscrape/registry.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,24 @@
33

44
import logging
55
from dataclasses import dataclass, field
6+
from typing import TYPE_CHECKING
67

78
from homeassistant.const import Platform
89

10+
if TYPE_CHECKING:
11+
from .coordinator import MultiscrapeDataUpdateCoordinator
12+
from .scraper import Scraper
13+
914
_LOGGER = logging.getLogger(__name__)
1015

1116

1217
@dataclass
1318
class ScraperInstance:
14-
"""Holds all data for a single scraper configuration."""
19+
"""Hold all data for a single scraper configuration."""
1520

1621
scraper_id: str
17-
scraper: object
18-
coordinator: object
22+
scraper: Scraper
23+
coordinator: MultiscrapeDataUpdateCoordinator
1924
platform_configs: dict[Platform, dict[str, dict]] = field(default_factory=dict)
2025

2126

@@ -26,6 +31,10 @@ def __init__(self):
2631
"""Initialize an empty registry."""
2732
self._scrapers: dict[str, ScraperInstance] = {}
2833

34+
def contains(self, scraper_id: str) -> bool:
35+
"""Check if a scraper ID is already registered."""
36+
return scraper_id in self._scrapers
37+
2938
def register(self, instance: ScraperInstance) -> None:
3039
"""Register a scraper instance by its unique ID."""
3140
if instance.scraper_id in self._scrapers:

tests/test_init.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,3 +437,70 @@ async def test_async_process_config_skips_platforms_not_in_config(hass: HomeAssi
437437
assert len(instance.platform_configs.get(Platform.SENSOR, {})) == 1
438438
assert len(instance.platform_configs.get(Platform.BINARY_SENSOR, {})) == 0
439439
assert len(instance.platform_configs.get(Platform.BUTTON, {})) == 0
440+
441+
442+
@pytest.mark.integration
443+
@pytest.mark.async_test
444+
@pytest.mark.timeout(10)
445+
@pytest.mark.respx
446+
async def test_duplicate_scraper_names_are_deduplicated(hass: HomeAssistant):
447+
"""Test that duplicate scraper names get a suffix instead of crashing."""
448+
# Arrange
449+
_async_setup_shared_data(hass)
450+
config = {
451+
DOMAIN: [
452+
{
453+
CONF_NAME: "my_scraper",
454+
CONF_RESOURCE: "https://example.com/1",
455+
Platform.SENSOR: [{CONF_NAME: "sensor1", "select": ".v1"}],
456+
},
457+
{
458+
CONF_NAME: "my_scraper",
459+
CONF_RESOURCE: "https://example.com/2",
460+
Platform.SENSOR: [{CONF_NAME: "sensor2", "select": ".v2"}],
461+
},
462+
]
463+
}
464+
465+
# Act
466+
result = await _async_process_config(hass, config)
467+
468+
# Assert - both scrapers should be registered with distinct IDs
469+
assert result is True
470+
registry: ScraperRegistry = hass.data[DOMAIN]
471+
assert len(registry.get_all()) == 2
472+
assert registry.get("my_scraper") is not None
473+
assert registry.get("my_scraper_2") is not None
474+
475+
476+
@pytest.mark.integration
477+
@pytest.mark.async_test
478+
@pytest.mark.timeout(10)
479+
@pytest.mark.respx
480+
async def test_duplicate_entity_names_are_deduplicated(hass: HomeAssistant):
481+
"""Test that duplicate entity names within a scraper get a suffix."""
482+
# Arrange
483+
_async_setup_shared_data(hass)
484+
config = {
485+
DOMAIN: [
486+
{
487+
CONF_NAME: "my_scraper",
488+
CONF_RESOURCE: "https://example.com",
489+
Platform.SENSOR: [
490+
{CONF_NAME: "temperature", "select": ".temp1"},
491+
{CONF_NAME: "temperature", "select": ".temp2"},
492+
],
493+
}
494+
]
495+
}
496+
497+
# Act
498+
await _async_process_config(hass, config)
499+
500+
# Assert - both sensors should be stored with distinct keys
501+
registry: ScraperRegistry = hass.data[DOMAIN]
502+
instance = registry.get("my_scraper")
503+
sensor_configs = instance.platform_configs[Platform.SENSOR]
504+
assert len(sensor_configs) == 2
505+
assert "temperature" in sensor_configs
506+
assert "temperature_2" in sensor_configs

tests/test_registry.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,10 @@ def test_platform_configs_access(registry, make_instance):
8787

8888
retrieved = registry.get("my_scraper")
8989
assert retrieved.platform_configs[Platform.SENSOR]["temp_sensor"] is sensor_config
90+
91+
92+
def test_contains(registry, make_instance):
93+
"""Test contains checks for registered IDs."""
94+
assert registry.contains("my_scraper") is False
95+
registry.register(make_instance("my_scraper"))
96+
assert registry.contains("my_scraper") is True

0 commit comments

Comments
 (0)