mirror of
https://github.com/home-assistant/core.git
synced 2026-03-12 22:11:58 +01:00
Compare commits
1 Commits
use-availa
...
gj-2025102
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25578987e7 |
@@ -67,14 +67,6 @@
|
||||
"description": "Reauthentication is needed",
|
||||
"title": "[%key:common::config_flow::title::reauth%]"
|
||||
},
|
||||
"config_entry_unique_id_collision": {
|
||||
"description": "There are multiple {domain} config entries with the same unique ID.\nThe config entries are named {titles}.\n\nTo fix this error, [configure the integration]({configure_url}) and remove all except one of the duplicates.\n\nNote: Another group of duplicates may be revealed after removing these duplicates.",
|
||||
"title": "Multiple {domain} config entries with same unique ID"
|
||||
},
|
||||
"config_entry_unique_id_collision_many": {
|
||||
"description": "There are multiple ({number_of_entries}) {domain} config entries with the same unique ID.\nThe first {title_limit} config entries are named {titles}.\n\nTo fix this error, [configure the integration]({configure_url}) and remove all except one of the duplicates.\n\nNote: Another group of duplicates may be revealed after removing these duplicates.",
|
||||
"title": "[%key:component::homeassistant::issues::config_entry_unique_id_collision::title%]"
|
||||
},
|
||||
"country_not_configured": {
|
||||
"description": "No country has been configured, please update the configuration by clicking on the \"learn more\" button below.",
|
||||
"title": "The country has not been configured"
|
||||
|
||||
@@ -2093,9 +2093,15 @@ class ConfigEntries:
|
||||
raise HomeAssistantError(
|
||||
f"An entry with the id {entry.entry_id} already exists."
|
||||
)
|
||||
if entry.unique_id and self._entries.get_entry_by_domain_and_unique_id(
|
||||
entry.domain, entry.unique_id
|
||||
):
|
||||
raise HomeAssistantError(
|
||||
f"An entry for domain {entry.domain} with unique id"
|
||||
f" {entry.unique_id} already exists."
|
||||
)
|
||||
|
||||
self._entries[entry.entry_id] = entry
|
||||
self.async_update_issues()
|
||||
self._async_dispatch(ConfigEntryChange.ADDED, entry)
|
||||
await self.async_setup(entry.entry_id)
|
||||
self._async_schedule_save()
|
||||
@@ -2127,7 +2133,6 @@ class ConfigEntries:
|
||||
del self._entries[entry.entry_id]
|
||||
await entry.async_remove(self.hass)
|
||||
|
||||
self.async_update_issues()
|
||||
self._async_schedule_save()
|
||||
|
||||
return (unload_success, entry)
|
||||
@@ -2197,7 +2202,7 @@ class ConfigEntries:
|
||||
entries[entry_id] = config_entry
|
||||
|
||||
self._entries = entries
|
||||
self.async_update_issues()
|
||||
await self.check_duplicate_unique_ids()
|
||||
|
||||
async def async_setup(self, entry_id: str, _lock: bool = True) -> bool:
|
||||
"""Set up a config entry.
|
||||
@@ -2409,22 +2414,13 @@ class ConfigEntries:
|
||||
and self.async_entry_for_domain_unique_id(entry.domain, unique_id)
|
||||
is not None
|
||||
):
|
||||
report_issue = async_suggest_report_issue(
|
||||
self.hass, integration_domain=entry.domain
|
||||
)
|
||||
_LOGGER.error(
|
||||
(
|
||||
"Unique id of config entry '%s' from integration %s changed to"
|
||||
" '%s' which is already in use, please %s"
|
||||
),
|
||||
entry.title,
|
||||
entry.domain,
|
||||
unique_id,
|
||||
report_issue,
|
||||
raise HomeAssistantError(
|
||||
f"Cannot update config entry '{entry.title}' ({entry.domain})."
|
||||
f" An entry with unique_id '{unique_id}' already exists"
|
||||
)
|
||||
|
||||
# Reindex the entry if the unique_id has changed
|
||||
self._entries.update_unique_id(entry, unique_id)
|
||||
self.async_update_issues()
|
||||
changed = True
|
||||
|
||||
for attr, value in (
|
||||
@@ -2716,80 +2712,36 @@ class ConfigEntries:
|
||||
return False
|
||||
return entry.state is ConfigEntryState.LOADED
|
||||
|
||||
@callback
|
||||
def async_update_issues(self) -> None:
|
||||
"""Update unique id collision issues."""
|
||||
issue_registry = ir.async_get(self.hass)
|
||||
issues: set[str] = set()
|
||||
async def check_duplicate_unique_ids(self) -> None:
|
||||
"""Check duplicate unique ids and remove them."""
|
||||
|
||||
for issue in issue_registry.issues.values():
|
||||
if (
|
||||
issue.domain != HOMEASSISTANT_DOMAIN
|
||||
or not (issue_data := issue.data)
|
||||
or issue_data.get("issue_type") != ISSUE_UNIQUE_ID_COLLISION
|
||||
):
|
||||
continue
|
||||
issues.add(issue.issue_id)
|
||||
|
||||
for (
|
||||
domain,
|
||||
unique_ids,
|
||||
) in self._entries._domain_unique_id_index.items(): # noqa: SLF001
|
||||
for unique_id, entries in unique_ids.items():
|
||||
# We might mutate the list of entries, so we need a copy to not mess up
|
||||
# the index
|
||||
entries_to_remove = []
|
||||
for unique_ids in self._entries._domain_unique_id_index.values(): # noqa: SLF001
|
||||
for entries in unique_ids.values():
|
||||
entries = list(entries)
|
||||
|
||||
# There's no need to raise an issue for ignored entries, we can
|
||||
# safely remove them once we no longer allow unique id collisions.
|
||||
# Iterate over a copy of the copy to allow mutating while iterating
|
||||
for entry in list(entries):
|
||||
if entry.source == SOURCE_IGNORE:
|
||||
entries.remove(entry)
|
||||
if len(entries) > 1:
|
||||
# If more than one entry, remove any ignored entry first
|
||||
for entry in entries:
|
||||
if entry.source == SOURCE_IGNORE:
|
||||
await self._async_remove(entry.entry_id)
|
||||
|
||||
if len(entries) < 2:
|
||||
continue
|
||||
issue_id = f"{ISSUE_UNIQUE_ID_COLLISION}_{domain}_{unique_id}"
|
||||
issues.discard(issue_id)
|
||||
titles = [f"'{entry.title}'" for entry in entries]
|
||||
translation_placeholders = {
|
||||
"domain": domain,
|
||||
"configure_url": f"/config/integrations/integration/{domain}",
|
||||
"unique_id": str(unique_id),
|
||||
}
|
||||
if len(titles) <= UNIQUE_ID_COLLISION_TITLE_LIMIT:
|
||||
translation_key = "config_entry_unique_id_collision"
|
||||
translation_placeholders["titles"] = ", ".join(titles)
|
||||
else:
|
||||
translation_key = "config_entry_unique_id_collision_many"
|
||||
translation_placeholders["number_of_entries"] = str(len(titles))
|
||||
translation_placeholders["titles"] = ", ".join(
|
||||
titles[:UNIQUE_ID_COLLISION_TITLE_LIMIT]
|
||||
)
|
||||
translation_placeholders["title_limit"] = str(
|
||||
UNIQUE_ID_COLLISION_TITLE_LIMIT
|
||||
)
|
||||
|
||||
ir.async_create_issue(
|
||||
self.hass,
|
||||
HOMEASSISTANT_DOMAIN,
|
||||
issue_id,
|
||||
breaks_in_ha_version="2025.11.0",
|
||||
data={
|
||||
"issue_type": ISSUE_UNIQUE_ID_COLLISION,
|
||||
"unique_id": unique_id,
|
||||
},
|
||||
is_fixable=False,
|
||||
issue_domain=domain,
|
||||
severity=ir.IssueSeverity.ERROR,
|
||||
translation_key=translation_key,
|
||||
translation_placeholders=translation_placeholders,
|
||||
titles = ", ".join(f"'{entry.title}'" for entry in entries)
|
||||
domain = entries[0].domain
|
||||
|
||||
_LOGGER.error(
|
||||
(
|
||||
"There are multiple '%s' config entries with the same unique ID."
|
||||
" The config entries named %s have been removed"
|
||||
),
|
||||
domain,
|
||||
titles,
|
||||
)
|
||||
|
||||
break # Only create one issue per domain
|
||||
|
||||
for issue_id in issues:
|
||||
ir.async_delete_issue(self.hass, HOMEASSISTANT_DOMAIN, issue_id)
|
||||
entries_to_remove.extend([entry.entry_id for entry in entries])
|
||||
for entry_id in entries_to_remove:
|
||||
await self._async_remove(entry_id)
|
||||
|
||||
|
||||
@callback
|
||||
|
||||
@@ -23,83 +23,3 @@
|
||||
'version': 1,
|
||||
})
|
||||
# ---
|
||||
# name: test_unique_id_collision_issues
|
||||
IssueRegistryItemSnapshot({
|
||||
'active': True,
|
||||
'breaks_in_ha_version': '2025.11.0',
|
||||
'created': <ANY>,
|
||||
'data': dict({
|
||||
'issue_type': 'config_entry_unique_id_collision',
|
||||
'unique_id': 'group_1',
|
||||
}),
|
||||
'dismissed_version': None,
|
||||
'domain': 'homeassistant',
|
||||
'is_fixable': False,
|
||||
'is_persistent': False,
|
||||
'issue_domain': 'test2',
|
||||
'issue_id': 'config_entry_unique_id_collision_test2_group_1',
|
||||
'learn_more_url': None,
|
||||
'severity': <IssueSeverity.ERROR: 'error'>,
|
||||
'translation_key': 'config_entry_unique_id_collision',
|
||||
'translation_placeholders': dict({
|
||||
'configure_url': '/config/integrations/integration/test2',
|
||||
'domain': 'test2',
|
||||
'titles': "'Mock Title', 'Mock Title', 'Mock Title'",
|
||||
'unique_id': 'group_1',
|
||||
}),
|
||||
})
|
||||
# ---
|
||||
# name: test_unique_id_collision_issues.1
|
||||
IssueRegistryItemSnapshot({
|
||||
'active': True,
|
||||
'breaks_in_ha_version': '2025.11.0',
|
||||
'created': <ANY>,
|
||||
'data': dict({
|
||||
'issue_type': 'config_entry_unique_id_collision',
|
||||
'unique_id': 'not_unique',
|
||||
}),
|
||||
'dismissed_version': None,
|
||||
'domain': 'homeassistant',
|
||||
'is_fixable': False,
|
||||
'is_persistent': False,
|
||||
'issue_domain': 'test3',
|
||||
'issue_id': 'config_entry_unique_id_collision_test3_not_unique',
|
||||
'learn_more_url': None,
|
||||
'severity': <IssueSeverity.ERROR: 'error'>,
|
||||
'translation_key': 'config_entry_unique_id_collision_many',
|
||||
'translation_placeholders': dict({
|
||||
'configure_url': '/config/integrations/integration/test3',
|
||||
'domain': 'test3',
|
||||
'number_of_entries': '6',
|
||||
'title_limit': '5',
|
||||
'titles': "'Mock Title', 'Mock Title', 'Mock Title', 'Mock Title', 'Mock Title'",
|
||||
'unique_id': 'not_unique',
|
||||
}),
|
||||
})
|
||||
# ---
|
||||
# name: test_unique_id_collision_issues.2
|
||||
IssueRegistryItemSnapshot({
|
||||
'active': True,
|
||||
'breaks_in_ha_version': '2025.11.0',
|
||||
'created': <ANY>,
|
||||
'data': dict({
|
||||
'issue_type': 'config_entry_unique_id_collision',
|
||||
'unique_id': 'not_unique',
|
||||
}),
|
||||
'dismissed_version': None,
|
||||
'domain': 'homeassistant',
|
||||
'is_fixable': False,
|
||||
'is_persistent': False,
|
||||
'issue_domain': 'test3',
|
||||
'issue_id': 'config_entry_unique_id_collision_test3_not_unique',
|
||||
'learn_more_url': None,
|
||||
'severity': <IssueSeverity.ERROR: 'error'>,
|
||||
'translation_key': 'config_entry_unique_id_collision',
|
||||
'translation_placeholders': dict({
|
||||
'configure_url': '/config/integrations/integration/test3',
|
||||
'domain': 'test3',
|
||||
'titles': "'Mock Title', 'Mock Title', 'Mock Title', 'Mock Title', 'Mock Title'",
|
||||
'unique_id': 'not_unique',
|
||||
}),
|
||||
})
|
||||
# ---
|
||||
|
||||
@@ -8945,14 +8945,8 @@ async def test_create_entry_reauth_reconfigure_fails(
|
||||
async def test_async_update_entry_unique_id_collision(
|
||||
hass: HomeAssistant,
|
||||
manager: config_entries.ConfigEntries,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
) -> None:
|
||||
"""Test we warn when async_update_entry creates a unique_id collision.
|
||||
|
||||
Also test an issue registry issue is created.
|
||||
"""
|
||||
assert len(issue_registry.issues) == 0
|
||||
"""Test we warn when async_update_entry creates a unique_id collision and raises."""
|
||||
|
||||
entry1 = MockConfigEntry(domain="test", unique_id=None)
|
||||
entry2 = MockConfigEntry(domain="test", unique_id="not none")
|
||||
@@ -8964,106 +8958,76 @@ async def test_async_update_entry_unique_id_collision(
|
||||
entry4.add_to_manager(manager)
|
||||
|
||||
manager.async_update_entry(entry2, unique_id=None)
|
||||
assert len(issue_registry.issues) == 0
|
||||
assert len(caplog.record_tuples) == 0
|
||||
|
||||
manager.async_update_entry(entry4, unique_id="very unique")
|
||||
assert len(issue_registry.issues) == 1
|
||||
assert len(caplog.record_tuples) == 1
|
||||
|
||||
assert (
|
||||
"Unique id of config entry 'Mock Title' from integration test changed to "
|
||||
"'very unique' which is already in use"
|
||||
) in caplog.text
|
||||
|
||||
issue_id = "config_entry_unique_id_collision_test_very unique"
|
||||
assert issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, issue_id)
|
||||
|
||||
|
||||
async def test_unique_id_collision_issues(
|
||||
hass: HomeAssistant,
|
||||
manager: config_entries.ConfigEntries,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test issue registry issues are created and remove on unique id collision."""
|
||||
assert len(issue_registry.issues) == 0
|
||||
|
||||
mock_setup_entry = AsyncMock(return_value=True)
|
||||
for i in range(3):
|
||||
mock_integration(
|
||||
hass, MockModule(f"test{i + 1}", async_setup_entry=mock_setup_entry)
|
||||
)
|
||||
mock_platform(hass, f"test{i + 1}.config_flow", None)
|
||||
|
||||
test2_group_1: list[MockConfigEntry] = []
|
||||
test2_group_2: list[MockConfigEntry] = []
|
||||
test3: list[MockConfigEntry] = []
|
||||
for _ in range(3):
|
||||
await manager.async_add(MockConfigEntry(domain="test1", unique_id=None))
|
||||
test2_group_1.append(MockConfigEntry(domain="test2", unique_id="group_1"))
|
||||
test2_group_2.append(MockConfigEntry(domain="test2", unique_id="group_2"))
|
||||
await manager.async_add(test2_group_1[-1])
|
||||
await manager.async_add(test2_group_2[-1])
|
||||
for _ in range(6):
|
||||
test3.append(MockConfigEntry(domain="test3", unique_id="not_unique"))
|
||||
await manager.async_add(test3[-1])
|
||||
# Add an ignored config entry
|
||||
await manager.async_add(
|
||||
MockConfigEntry(
|
||||
domain="test2", unique_id="group_1", source=config_entries.SOURCE_IGNORE
|
||||
)
|
||||
message = re.escape(
|
||||
"Cannot update config entry 'Mock Title' (test)."
|
||||
" An entry with unique_id 'very unique' already exists"
|
||||
)
|
||||
|
||||
# Check we get one issue for domain test2 and one issue for domain test3
|
||||
assert len(issue_registry.issues) == 2
|
||||
issue_id = "config_entry_unique_id_collision_test2_group_1"
|
||||
assert issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, issue_id) == snapshot
|
||||
issue_id = "config_entry_unique_id_collision_test3_not_unique"
|
||||
assert issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, issue_id) == snapshot
|
||||
with pytest.raises(
|
||||
HomeAssistantError,
|
||||
match=message,
|
||||
):
|
||||
manager.async_update_entry(entry4, unique_id="very unique")
|
||||
|
||||
# Remove one config entry for domain test3, the translations should be updated
|
||||
await manager.async_remove(test3[0].entry_id)
|
||||
assert set(issue_registry.issues) == {
|
||||
(HOMEASSISTANT_DOMAIN, "config_entry_unique_id_collision_test2_group_1"),
|
||||
(HOMEASSISTANT_DOMAIN, "config_entry_unique_id_collision_test3_not_unique"),
|
||||
|
||||
async def test_async_add_entry_unique_id_collision(
|
||||
hass: HomeAssistant,
|
||||
manager: config_entries.ConfigEntries,
|
||||
) -> None:
|
||||
"""Test we warn when async_add creates a unique_id collision and raises."""
|
||||
|
||||
entry1 = MockConfigEntry(domain="test", unique_id="very_unique")
|
||||
entry2 = MockConfigEntry(domain="test", unique_id="very_unique")
|
||||
await manager.async_add(entry1)
|
||||
|
||||
with pytest.raises(
|
||||
HomeAssistantError,
|
||||
match="An entry for domain test with unique id very_unique already exists.",
|
||||
):
|
||||
await manager.async_add(entry2)
|
||||
|
||||
|
||||
async def test_loading_config_entries_duplicated_unique_id_removes_them(
|
||||
hass: HomeAssistant, hass_storage: dict[str, Any], caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Test duplicate unique id's at loading removed both entries."""
|
||||
hass_storage[config_entries.STORAGE_KEY] = {
|
||||
"version": 1,
|
||||
"data": {
|
||||
"entries": [
|
||||
{
|
||||
"version": 5,
|
||||
"domain": "my_domain",
|
||||
"entry_id": "mock-id",
|
||||
"data": {"my": "data"},
|
||||
"source": "user",
|
||||
"title": "Mock title",
|
||||
"unique_id": "very_unique",
|
||||
"system_options": {"disable_new_entities": True},
|
||||
},
|
||||
{
|
||||
"version": 5,
|
||||
"domain": "my_domain",
|
||||
"entry_id": "mock-id2",
|
||||
"data": {"my": "data"},
|
||||
"source": "user",
|
||||
"title": "Mock title 2",
|
||||
"unique_id": "very_unique",
|
||||
"system_options": {"disable_new_entities": True},
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
assert issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, issue_id) == snapshot
|
||||
manager = config_entries.ConfigEntries(hass, {})
|
||||
await manager.async_initialize()
|
||||
|
||||
# Remove all but two config entries for domain test 3
|
||||
for i in range(3):
|
||||
await manager.async_remove(test3[1 + i].entry_id)
|
||||
assert set(issue_registry.issues) == {
|
||||
(HOMEASSISTANT_DOMAIN, "config_entry_unique_id_collision_test2_group_1"),
|
||||
(HOMEASSISTANT_DOMAIN, "config_entry_unique_id_collision_test3_not_unique"),
|
||||
}
|
||||
|
||||
# Remove the last test3 duplicate, the issue is cleared
|
||||
await manager.async_remove(test3[-1].entry_id)
|
||||
assert set(issue_registry.issues) == {
|
||||
(HOMEASSISTANT_DOMAIN, "config_entry_unique_id_collision_test2_group_1"),
|
||||
}
|
||||
|
||||
await manager.async_remove(test2_group_1[0].entry_id)
|
||||
assert set(issue_registry.issues) == {
|
||||
(HOMEASSISTANT_DOMAIN, "config_entry_unique_id_collision_test2_group_1"),
|
||||
}
|
||||
|
||||
# Remove the last test2 group1 duplicate, a new issue is created
|
||||
await manager.async_remove(test2_group_1[1].entry_id)
|
||||
assert set(issue_registry.issues) == {
|
||||
(HOMEASSISTANT_DOMAIN, "config_entry_unique_id_collision_test2_group_2"),
|
||||
}
|
||||
|
||||
await manager.async_remove(test2_group_2[0].entry_id)
|
||||
assert set(issue_registry.issues) == {
|
||||
(HOMEASSISTANT_DOMAIN, "config_entry_unique_id_collision_test2_group_2"),
|
||||
}
|
||||
|
||||
# Remove the last test2 group2 duplicate, the issue is cleared
|
||||
await manager.async_remove(test2_group_2[1].entry_id)
|
||||
assert not issue_registry.issues
|
||||
entries = manager.async_entries()
|
||||
assert len(entries) == 0
|
||||
assert (
|
||||
"There are multiple 'my_domain' config entries with the same unique ID."
|
||||
" The config entries named 'Mock title', 'Mock title 2' have been removed"
|
||||
) in caplog.text
|
||||
|
||||
|
||||
async def test_context_no_leak(hass: HomeAssistant) -> None:
|
||||
|
||||
Reference in New Issue
Block a user