Compare commits

..

63 Commits

Author SHA1 Message Date
J. Nick Koston bf48806ee9 Merge remote-tracking branch 'upstream/cache-split-tests' into cache-split-tests 2026-05-22 14:48:53 -05:00
J. Nick Koston 2c25c5ad26 another round of copilot comments 2026-05-22 14:48:31 -05:00
J. Nick Koston ff1177dde4 Merge branch 'dev' into cache-split-tests 2026-05-22 14:33:56 -05:00
Martin Hjelmare 4306863729 Fix homekit test_reload flaky test (#171878) 2026-05-22 14:33:27 -05:00
J. Nick Koston 1cc91cd3b6 another round of copilot 2026-05-22 13:32:08 -05:00
J. Nick Koston ecac38a359 Merge branch 'dev' into cache-split-tests 2026-05-22 13:20:15 -05:00
Martin Hjelmare ba2f66e751 Remove not needed default force_update in flo (#171854) 2026-05-22 20:15:00 +02:00
J. Nick Koston 8301addc94 bot comments 2026-05-22 13:06:55 -05:00
J. Nick Koston 77bc932cf0 will copilot ever end 2026-05-22 12:57:17 -05:00
J. Nick Koston 11903ac62e dry 2026-05-22 12:54:51 -05:00
J. Nick Koston 878761cb41 preen 2026-05-22 12:49:43 -05:00
J. Nick Koston d7bf7df59f preen 2026-05-22 12:49:16 -05:00
J. Nick Koston e5890172a0 preen 2026-05-22 12:48:39 -05:00
J. Nick Koston e9a58cdd20 restore 2026-05-22 12:47:15 -05:00
J. Nick Koston cab7c41a7f more cleanups 2026-05-22 12:37:54 -05:00
J. Nick Koston 277a2d847a more cleanups 2026-05-22 12:30:30 -05:00
J. Nick Koston 7835a4992a simplify 2026-05-22 11:51:05 -05:00
Manu 94581d8ab6 Move service registration in System Bridge integration to async_setup (#171761)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-22 18:43:22 +02:00
J. Nick Koston 9dc37a2f46 Merge remote-tracking branch 'upstream/cache-split-tests' into cache-split-tests 2026-05-22 11:43:16 -05:00
J. Nick Koston 7534c438c1 fix cache bust 2026-05-22 11:42:22 -05:00
Ingo Fischer 7d6ec7fc58 Bump matter-python-client to 0.7.1 (#171764)
Co-authored-by: TheJulianJES <TheJulianJES@users.noreply.github.com>
2026-05-22 17:34:20 +01:00
J. Nick Koston 69efa8ee1a Merge branch 'dev' into cache-split-tests 2026-05-22 11:28:43 -05:00
Jan Bouwhuis f49de3548e Add MQTT message expiry interval option (#171143) 2026-05-22 18:27:22 +02:00
J. Nick Koston 305b5d6e00 preen 2026-05-22 11:00:46 -05:00
J. Nick Koston d94226260b handle bot review comments 2026-05-22 10:40:23 -05:00
J. Nick Koston ecc8e52f3e make bot happy 2026-05-22 10:33:22 -05:00
J. Nick Koston 5771b0c86c Merge remote-tracking branch 'refs/remotes/upstream/cache-split-tests' into cache-split-tests 2026-05-22 10:31:11 -05:00
J. Nick Koston 3e289da366 drop bad copilot suggest 2026-05-22 10:31:00 -05:00
J. Nick Koston 944fb1ef67 Merge branch 'dev' into cache-split-tests 2026-05-22 10:17:10 -05:00
Manu 49ab42d3a2 Fix dead link in System Bridge service action (#171855) 2026-05-22 17:00:30 +02:00
Franck Nijhof 383f6142f0 Fix ZBT-2 hardware page crash when entry data is missing VID (#171828) 2026-05-22 16:58:01 +02:00
Kamil Breguła 2f120cf604 Fix rgb_color passed as RGBColor NamedTuple instead of plain tuple to light entity turn_on (#171795)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 16:56:34 +02:00
J. Nick Koston 1b6e9f5094 trim 2026-05-22 09:53:51 -05:00
Franck Nijhof 37288849b3 Register Insteon modem device before platform setup (#171839) 2026-05-22 10:23:47 -04:00
J. Nick Koston b2257caeb7 touch ups 2026-05-22 09:11:01 -05:00
J. Nick Koston 0ec0ea30ac single pass 2026-05-22 09:06:15 -05:00
J. Nick Koston 584b32c8b3 address copilot, cleanups 2026-05-22 09:01:39 -05:00
zhangluofeng aa8659f507 Add xthings cloud lock (#171176)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-05-22 15:54:37 +02:00
J. Nick Koston 4033a8b83a Apply suggestions from code review
Co-authored-by: J. Nick Koston <nick+github@koston.org>
2026-05-22 08:53:47 -05:00
J. Nick Koston add8a5f799 Merge branch 'dev' into cache-split-tests 2026-05-22 08:53:27 -05:00
DeerMaximum 40c0d79d1d Replaced duplicate constant with homeassistant.const in NINA (#171852) 2026-05-22 15:41:04 +02:00
J. Nick Koston 7c137b5c73 cleanup 2026-05-22 08:34:32 -05:00
Franck Nijhof bef8632d78 Fix OpenHome config flow crash when UDN is a list (#171841) 2026-05-22 15:23:42 +02:00
Duco Sebel f00decfaa3 Use uptime device class for HomeWizard uptime sensor (#171830) 2026-05-22 15:23:09 +02:00
Manu 42e7add026 Add selector options translations to System Bridge integration (#171771) 2026-05-22 15:22:22 +02:00
Franck Nijhof 263aa3f16e Fix Hue device trigger crash for devices removed from bridge (#171844) 2026-05-22 15:18:00 +02:00
mhuiskes 03b364dcf0 Refactor zeversolar tests: use fixtures, patch at use site, add unique_id (#171697) 2026-05-22 14:58:56 +02:00
Duco Sebel 3b1aaf39af Bumb python homewizard energy 10.1.0 (#171826) 2026-05-22 14:51:58 +02:00
J. Nick Koston 4a6c5b5a22 cleanups 2026-05-22 07:46:31 -05:00
Franck Nijhof b82ba43fa4 Add pylint checker for invalid MDI icon references (#171824)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-22 13:45:40 +02:00
starkillerOG d81ef5593c Bump reolink_aio to 0.20.0: Reolink battery camera support (#171836) 2026-05-22 12:59:33 +02:00
Manu 5c5e50f024 Fix platform unloading in System Bridge integration (#171822) 2026-05-22 12:56:03 +02:00
Lex Postma e796d9c467 Update strings.json to align with HomeWizard app (#171740)
Co-authored-by: Duco Sebel <74970928+DCSBL@users.noreply.github.com>
2026-05-22 12:54:58 +02:00
Karl Beecken 342f23526f Remove empty requirements_test_all.txt (#171530 follow-up) (#171834) 2026-05-22 12:38:58 +02:00
J. Nick Koston 1009ce4180 Merge branch 'dev' into cache-split-tests 2026-05-21 23:09:44 -05:00
J. Nick Koston 22fb68b7a1 Revert "DNM: test cache, touch cloud manifest only"
This reverts commit a8bc244a7a.
2026-05-21 16:53:19 -05:00
J. Nick Koston 81e06539e6 Revert "DNM: test cache bust, touch cloud conftest"
This reverts commit 7c18b67b2e.
2026-05-21 16:53:19 -05:00
J. Nick Koston 7c18b67b2e DNM: test cache bust, touch cloud conftest 2026-05-21 16:48:05 -05:00
J. Nick Koston a8bc244a7a DNM: test cache, touch cloud manifest only 2026-05-21 16:44:43 -05:00
J. Nick Koston 5975f4b179 Skip cache walking when --cache is not passed
Address Copilot review feedback on the cache PR:

* Split collect_tests into _collect_tests_uncached (the original
  directory-based pre-cache flow) and _collect_tests_cached (walks
  the tree to build per-file hashes).  Without --cache we now skip
  the walk + per-file hash entirely.
* A single-file root has no ancestor conftests to walk, so the
  conftest_hash would always be empty and stale counts could survive
  a real conftest change; bypass the cache for the file-root case.
* Save the cache file with explicit utf-8 encoding and
  ensure_ascii=False.
2026-05-21 16:08:44 -05:00
J. Nick Koston 9ed16b63a3 Cache per-file test counts in split_tests
Persist the result of pytest --collect-only between CI runs as a JSON
file keyed by content hash, so unchanged test files are served from
cache and only edited or new files are re-collected.  The cache is
self-healing:

* Missing, corrupt, or wrong-version files fall back to a full collect.
* Any conftest.py change anywhere under the test root invalidates the
  whole cache, so fixture parametrization shifts cannot silently skew
  counts.
* Files pytest returns nothing for (helper modules named test_*.py with
  no test functions) are cached as zero so they don't get re-collected
  forever.

Walking is done once with os.walk (~2x faster than Path.rglob) and
collects test files plus conftests in a single pass.  When the cache
is fully cold we feed pytest top-level directories rather than
thousands of file paths so cold runs stay as fast as before the cache
landed.

Wire the new --cache flag through the prepare-pytest-full job and back
the cache file with actions/cache so PRs can pick up the latest dev
snapshot via restore-keys.  Local timings: cold 11s, warm with no diff
0.4s, warm with one file edited 2.3s.
2026-05-21 15:56:08 -05:00
J. Nick Koston 8dadaa2f9e Filter fan-out children and fail fast on empty batch list
Only pass directories and test_*.py files to pytest --collect-only so
helpers like tests/components/conftest.py and tests/components/common.py
are not treated as explicit collection targets, and bail out with a
clear error if no eligible paths are found instead of running pytest
with no arguments.
2026-05-21 15:17:42 -05:00
J. Nick Koston 4f98c71586 Run pytest --collect-only in parallel batches in split_tests
cProfile showed 99.6% of split_tests.py wall time was spent in the
single pytest --collect-only subprocess.  Fan out the collection across
``os.cpu_count()`` workers; round-robin chunking keeps each batch
roughly equal, and tests/components is expanded one level deeper so
the ~1000 integration subdirectories distribute evenly.  Local wall
time dropped from ~132s to ~11s on an 18-core box.  Bucket output is
unchanged because we still parse the same pytest -qq output, just
aggregated from multiple invocations.
2026-05-21 15:10:01 -05:00
86 changed files with 10692 additions and 690 deletions
+1
View File
@@ -15,6 +15,7 @@ Dockerfile.dev linguist-language=Dockerfile
# Generated files
CODEOWNERS linguist-generated=true
homeassistant/generated/*.py linguist-generated=true
pylint/plugins/pylint_home_assistant/generated/*.py linguist-generated=true
machine/* linguist-generated=true
mypy.ini linguist-generated=true
requirements.txt linguist-generated=true
+38 -1
View File
@@ -917,12 +917,49 @@ jobs:
key: >-
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}
- name: Restore pytest test counts cache
id: cache-pytest-counts
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: pytest_test_counts.json
# Primary key is a sentinel; restore-keys pick the most recent
# prefix match since the real (content-addressed) key isn't
# known until split_tests.py runs below.
key: >-
pytest-counts-${{ runner.os }}-${{ runner.arch }}-${{
steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}-restore-sentinel
restore-keys: |
pytest-counts-${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }}-
- name: Run split_tests.py
env:
TEST_GROUP_COUNT: ${{ needs.info.outputs.test_group_count }}
run: |
. venv/bin/activate
python -m script.split_tests ${TEST_GROUP_COUNT} tests
python -m script.split_tests \
--cache pytest_test_counts.json \
${TEST_GROUP_COUNT} tests
- name: Hash pytest test counts cache
id: cache-pytest-counts-hash
run: |
echo "hash=$(sha256sum pytest_test_counts.json | cut -d' ' -f1)" \
>> "$GITHUB_OUTPUT"
- name: Save pytest test counts cache
# Content-addressed key: identical content reuses the same entry.
# Skip the save when the restore already matched that hash.
if: >-
!endsWith(
steps.cache-pytest-counts.outputs.cache-matched-key,
steps.cache-pytest-counts-hash.outputs.hash
)
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: pytest_test_counts.json
key: >-
pytest-counts-${{ runner.os }}-${{ runner.arch }}-${{
steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}-${{
steps.cache-pytest-counts-hash.outputs.hash }}
- name: Upload pytest_buckets
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
-1
View File
@@ -10,7 +10,6 @@ from .coordinator import FloDeviceDataUpdateCoordinator
class FloEntity(Entity):
"""A base class for Flo entities."""
_attr_force_update = False
_attr_has_entity_name = True
_attr_should_poll = False
@@ -19,7 +19,9 @@ EXPECTED_ENTRY_VERSION = (
@callback
def async_info(hass: HomeAssistant) -> list[HardwareInfo]:
"""Return board info."""
entries = hass.config_entries.async_entries(DOMAIN)
entries = hass.config_entries.async_entries(
DOMAIN, include_ignore=False, include_disabled=False
)
return [
HardwareInfo(
board=None,
@@ -13,6 +13,6 @@
"iot_class": "local_polling",
"loggers": ["homewizard_energy"],
"quality_scale": "platinum",
"requirements": ["python-homewizard-energy==10.0.1"],
"requirements": ["python-homewizard-energy==10.1.0"],
"zeroconf": ["_hwenergy._tcp.local.", "_homewizard._tcp.local."]
}
+2 -10
View File
@@ -35,7 +35,6 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util.dt import utcnow
from homeassistant.util.variance import ignore_variance
from .const import DOMAIN
from .coordinator import HomeWizardConfigEntry, HWEnergyDeviceUpdateCoordinator
@@ -66,13 +65,6 @@ def to_percentage(value: float | None) -> float | None:
return value * 100 if value is not None else None
def uptime_to_datetime(value: int) -> datetime:
"""Convert seconds to datetime timestamp."""
return utcnow().replace(microsecond=0) - timedelta(seconds=value)
uptime_to_stable_datetime = ignore_variance(uptime_to_datetime, timedelta(minutes=5))
SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
HomeWizardSensorEntityDescription(
key="smr_version",
@@ -643,7 +635,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
HomeWizardSensorEntityDescription(
key="uptime",
translation_key="uptime",
device_class=SensorDeviceClass.TIMESTAMP,
device_class=SensorDeviceClass.UPTIME,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
has_fn=(
@@ -651,7 +643,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
),
value_fn=(
lambda data: (
uptime_to_stable_datetime(data.system.uptime_s)
utcnow() - timedelta(seconds=data.system.uptime_s)
if data.system is not None and data.system.uptime_s is not None
else None
)
@@ -61,13 +61,14 @@
},
"select": {
"battery_group_mode": {
"name": "Battery group mode",
"name": "Battery group charging strategy",
"state": {
"predictive": "Smart charging",
"standby": "Standby",
"to_full": "Manual charge mode",
"zero": "Zero mode",
"zero_charge_only": "Zero mode (charge only)",
"zero_discharge_only": "Zero mode (discharge only)"
"to_full": "One-time full charge",
"zero": "Net zero",
"zero_charge_only": "Net zero (charge only)",
"zero_discharge_only": "Net zero (discharge only)"
}
}
},
@@ -87,6 +87,8 @@ def async_get_triggers(
# Get Hue device id from device identifier
hue_dev_id = get_hue_device_id(device_entry)
if hue_dev_id is None or hue_dev_id not in api.devices:
return []
# extract triggers from all button resources of this Hue device
triggers: list[dict[str, Any]] = []
model_id = api.devices[hue_dev_id].product_data.product_name
+2 -2
View File
@@ -118,6 +118,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
device = devices.add_x10_device(housecode, unitcode, x10_type, steps)
create_insteon_device(hass, devices.modem, entry.entry_id)
await hass.config_entries.async_forward_entry_setups(entry, INSTEON_PLATFORMS)
for address in devices:
@@ -131,8 +133,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
register_new_device_callback(hass)
async_setup_services(hass)
create_insteon_device(hass, devices.modem, entry.entry_id)
entry.async_create_background_task(
hass, async_get_device_config(hass, entry), "insteon-get-device-config"
)
+1 -1
View File
@@ -241,7 +241,7 @@ def preprocess_turn_on_alternatives(
if (color_name := params.pop(ATTR_COLOR_NAME, None)) is not None:
try:
params[ATTR_RGB_COLOR] = color_util.color_name_to_rgb(color_name)
params[ATTR_RGB_COLOR] = tuple(color_util.color_name_to_rgb(color_name))
except ValueError:
_LOGGER.warning("Got unknown color %s, falling back to white", color_name)
params[ATTR_RGB_COLOR] = (255, 255, 255)
+1 -1
View File
@@ -60,7 +60,7 @@ def get_matter_device_info(
return None
return MatterDeviceInfo(
unique_id=node.device_info.uniqueID,
unique_id=node.device_info.uniqueID or "",
vendor_id=hex(node.device_info.vendorID),
product_id=hex(node.device_info.productID),
)
@@ -6,13 +6,14 @@ from typing import Any
from chip.clusters import Objects
from matter_server.common.helpers.util import dataclass_to_dict, parse_attribute_path
from homeassistant.components.diagnostics import REDACTED
from homeassistant.components.diagnostics import REDACTED, async_redact_data
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from .helpers import MatterConfigEntry, get_matter, get_node_from_device_entry
ATTRIBUTES_TO_REDACT = {Objects.BasicInformation.Attributes.Location}
SERVER_INFO_TO_REDACT = {"wifi_ssid"}
def redact_matter_attributes(node_data: dict[str, Any]) -> dict[str, Any]:
@@ -44,6 +45,7 @@ async def async_get_config_entry_diagnostics(
matter = get_matter(hass)
server_diagnostics = await matter.matter_client.get_diagnostics()
data = dataclass_to_dict(server_diagnostics)
data["info"] = async_redact_data(data["info"], SERVER_INFO_TO_REDACT)
nodes = [redact_matter_attributes(node_data) for node_data in data["nodes"]]
data["nodes"] = nodes
@@ -59,7 +61,9 @@ async def async_get_device_diagnostics(
node = get_node_from_device_entry(hass, device)
return {
"server_info": dataclass_to_dict(server_diagnostics.info),
"server_info": async_redact_data(
dataclass_to_dict(server_diagnostics.info), SERVER_INFO_TO_REDACT
),
"node": redact_matter_attributes(
remove_serialization_type(dataclass_to_dict(node.node_data) if node else {})
),
@@ -8,6 +8,6 @@
"documentation": "https://www.home-assistant.io/integrations/matter",
"integration_type": "hub",
"iot_class": "local_push",
"requirements": ["matter-python-client==0.6.0"],
"requirements": ["matter-python-client==0.7.1"],
"zeroconf": ["_matter._tcp.local.", "_matterc._udp.local."]
}
@@ -108,6 +108,7 @@ ABBREVIATIONS = {
"mode_stat_t": "mode_state_topic",
"mode_stat_tpl": "mode_state_template",
"modes": "modes",
"msg_exp_int": "message_expiry_interval",
"name": "name",
"o": "origin",
"off_dly": "off_delay",
@@ -120,6 +120,8 @@ from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.json import json_dumps
from homeassistant.helpers.selector import (
BooleanSelector,
DurationSelector,
DurationSelectorConfig,
FileSelector,
FileSelectorConfig,
NumberSelector,
@@ -227,6 +229,7 @@ from .const import (
CONF_LAST_RESET_VALUE_TEMPLATE,
CONF_MAX,
CONF_MAX_KELVIN,
CONF_MESSAGE_EXPIRY_INTERVAL,
CONF_MIN,
CONF_MIN_KELVIN,
CONF_MODE_COMMAND_TEMPLATE,
@@ -3721,6 +3724,11 @@ MQTT_DEVICE_PLATFORM_FIELDS = {
default=DEFAULT_QOS,
section="mqtt_settings",
),
CONF_MESSAGE_EXPIRY_INTERVAL: PlatformField(
selector=DurationSelector(DurationSelectorConfig(enable_day=True)),
required=False,
section="mqtt_settings",
),
}
+1
View File
@@ -49,6 +49,7 @@ CONF_IMAGE_TOPIC = "image_topic"
CONF_JSON_ATTRS_TOPIC = "json_attributes_topic"
CONF_JSON_ATTRS_TEMPLATE = "json_attributes_template"
CONF_KEEPALIVE = "keepalive"
CONF_MESSAGE_EXPIRY_INTERVAL = "message_expiry_interval"
CONF_ORIGIN = "origin"
CONF_QOS = ATTR_QOS
CONF_RETAIN = ATTR_RETAIN
+12 -2
View File
@@ -17,7 +17,7 @@ from .models import DATA_MQTT, PublishPayloadType
STORED_MESSAGES = 10
@dataclass
@dataclass(frozen=True, slots=True)
class TimestampedPublishMessage:
"""MQTT Message."""
@@ -26,6 +26,8 @@ class TimestampedPublishMessage:
qos: int
retain: bool
timestamp: float
encoding: str | None
kwargs: dict[str, Any]
def log_message(
@@ -35,6 +37,8 @@ def log_message(
payload: PublishPayloadType,
qos: int,
retain: bool,
encoding: str | None,
**kwargs: Any,
) -> None:
"""Log an outgoing MQTT message."""
entity_info = hass.data[DATA_MQTT].debug_info_entities.setdefault(
@@ -45,7 +49,13 @@ def log_message(
"messages": deque(maxlen=STORED_MESSAGES),
}
msg = TimestampedPublishMessage(
topic, payload, qos, retain, timestamp=time.monotonic()
topic,
payload,
qos,
retain,
timestamp=time.monotonic(),
encoding=encoding,
kwargs=kwargs,
)
entity_info["transmitted"][topic]["messages"].append(msg)
+12 -26
View File
@@ -84,6 +84,7 @@ from .const import (
CONF_JSON_ATTRS_TEMPLATE,
CONF_JSON_ATTRS_TOPIC,
CONF_MANUFACTURER,
CONF_MESSAGE_EXPIRY_INTERVAL,
CONF_PAYLOAD_AVAILABLE,
CONF_PAYLOAD_NOT_AVAILABLE,
CONF_QOS,
@@ -94,7 +95,6 @@ from .const import (
CONF_SW_VERSION,
CONF_TOPIC,
CONF_VIA_DEVICE,
DEFAULT_ENCODING,
DOMAIN,
MQTT_CONNECTION_STATE,
)
@@ -153,6 +153,8 @@ MQTT_ATTRIBUTES_BLOCKED = {
"unit_of_measurement",
}
PUBLISH_KWARGS = (CONF_MESSAGE_EXPIRY_INTERVAL,)
@callback
def async_handle_schema_error(
@@ -1539,36 +1541,20 @@ class MqttEntity(
await MqttDiscoveryUpdateMixin.async_will_remove_from_hass(self)
debug_info.remove_entity_data(self.hass, self.entity_id)
async def async_publish(
self,
topic: str,
payload: PublishPayloadType,
qos: int = 0,
retain: bool = False,
encoding: str | None = DEFAULT_ENCODING,
) -> None:
"""Publish message to an MQTT topic."""
log_message(self.hass, self.entity_id, topic, payload, qos, retain)
await async_publish(
self.hass,
topic,
payload,
qos,
retain,
encoding,
)
async def async_publish_with_config(
self, topic: str, payload: PublishPayloadType
) -> None:
"""Publish payload to a topic using config."""
await self.async_publish(
topic,
payload,
self._config[CONF_QOS],
self._config[CONF_RETAIN],
self._config[CONF_ENCODING],
kwargs: dict[str, Any] = {
key: value for key, value in self._config.items() if key in PUBLISH_KWARGS
}
qos: int = self._config[CONF_QOS]
retain: bool = self._config[CONF_RETAIN]
encoding: str = self._config[CONF_ENCODING]
log_message(
self.hass, self.entity_id, topic, payload, qos, retain, encoding, **kwargs
)
await async_publish(self.hass, topic, payload, qos, retain, encoding, **kwargs)
@staticmethod
@abstractmethod
+10
View File
@@ -509,10 +509,20 @@ class MqttComponentConfig:
discovery_payload: MQTTDiscoveryPayload
class MessageExpiryInterval(TypedDict, total=False):
"""Hold the Message Expiry Interval."""
days: float
hours: float
minutes: float
seconds: float
class DeviceMqttOptions(TypedDict, total=False):
"""Hold the shared MQTT specific options for an MQTT device."""
qos: int
message_expiry_interval: MessageExpiryInterval
class MqttDeviceData(TypedDict, total=False):
+12
View File
@@ -40,6 +40,7 @@ from .const import (
CONF_JSON_ATTRS_TEMPLATE,
CONF_JSON_ATTRS_TOPIC,
CONF_MANUFACTURER,
CONF_MESSAGE_EXPIRY_INTERVAL,
CONF_ORIGIN,
CONF_PAYLOAD_AVAILABLE,
CONF_PAYLOAD_NOT_AVAILABLE,
@@ -66,6 +67,7 @@ SHARED_OPTIONS = [
CONF_AVAILABILITY_TOPIC,
CONF_COMMAND_TOPIC,
CONF_ENCODING,
CONF_MESSAGE_EXPIRY_INTERVAL,
CONF_PAYLOAD_AVAILABLE,
CONF_PAYLOAD_NOT_AVAILABLE,
CONF_STATE_TOPIC,
@@ -161,6 +163,14 @@ MQTT_ORIGIN_INFO_SCHEMA = vol.All(
),
)
def valid_message_expiry_interval(value: Any) -> int:
"""Return Message Expiry Interval in seconds."""
if isinstance(value, int):
return cv.positive_int(value) # type: ignore[no-any-return]
return int(cv.positive_time_period_dict(value).total_seconds())
MQTT_ENTITY_COMMON_SCHEMA = _MQTT_AVAILABILITY_SCHEMA.extend(
{
vol.Optional(CONF_DEVICE): MQTT_ENTITY_DEVICE_INFO_SCHEMA,
@@ -172,6 +182,7 @@ MQTT_ENTITY_COMMON_SCHEMA = _MQTT_AVAILABILITY_SCHEMA.extend(
vol.Optional(CONF_JSON_ATTRS_TOPIC): valid_subscribe_topic,
vol.Optional(CONF_JSON_ATTRS_TEMPLATE): cv.template,
vol.Optional(CONF_DEFAULT_ENTITY_ID): cv.string,
vol.Optional(CONF_MESSAGE_EXPIRY_INTERVAL): valid_message_expiry_interval,
vol.Optional(CONF_UNIQUE_ID): cv.string,
}
)
@@ -203,6 +214,7 @@ DEVICE_DISCOVERY_SCHEMA = _MQTT_AVAILABILITY_SCHEMA.extend(
vol.Required(CONF_ORIGIN): MQTT_ORIGIN_INFO_SCHEMA,
vol.Optional(CONF_STATE_TOPIC): valid_subscribe_topic,
vol.Optional(CONF_COMMAND_TOPIC): valid_publish_topic,
vol.Optional(CONF_MESSAGE_EXPIRY_INTERVAL): valid_message_expiry_interval,
vol.Optional(CONF_QOS): valid_qos_schema,
vol.Optional(CONF_ENCODING): cv.string,
}
@@ -197,9 +197,11 @@
},
"mqtt_settings": {
"data": {
"message_expiry_interval": "Message Expiry Interval",
"qos": "QoS"
},
"data_description": {
"message_expiry_interval": "Retention time interval for published message.",
"qos": "The Quality of Service value the device's entities should use."
},
"name": "MQTT settings"
@@ -6,6 +6,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.const import ATTR_ID
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -14,7 +15,6 @@ from .const import (
ATTR_DESCRIPTION,
ATTR_EXPIRES,
ATTR_HEADLINE,
ATTR_ID,
ATTR_RECOMMENDED_ACTIONS,
ATTR_SENDER,
ATTR_SENT,
-2
View File
@@ -29,8 +29,6 @@ ATTR_SEVERITY: str = "severity"
ATTR_RECOMMENDED_ACTIONS: str = "recommended_actions"
ATTR_AFFECTED_AREAS: str = "affected_areas"
ATTR_WEB: str = "web"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_ID: str = "id"
ATTR_SENT: str = "sent"
ATTR_START: str = "start"
ATTR_EXPIRES: str = "expires"
@@ -3,7 +3,7 @@
import asyncio
from typing import Any
from rf_protocols.commands.novy import NovyCookerHoodCommand
from rf_protocols.codes.novy.cooker_hood import get_codes_for_code
import voluptuous as vol
from homeassistant.components.radio_frequency import (
@@ -128,8 +128,10 @@ class NovyCookerHoodConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Toggle the hood light on then off so it ends in its starting state."""
assert self._transmitter_entity_id is not None
command = NovyCookerHoodCommand(channel=self._code, key=COMMAND_LIGHT)
try:
command = await get_codes_for_code(self._code).async_load_command(
COMMAND_LIGHT
)
await async_send_command(self.hass, self._transmitter_entity_id, command)
await asyncio.sleep(_TOGGLE_GAP)
await async_send_command(self.hass, self._transmitter_entity_id, command)
@@ -1,10 +1,9 @@
"""Fan platform for the Novy Cooker Hood (calibrated speed control)."""
import asyncio
import math
from typing import Any
from rf_protocols.commands.novy import NovyCookerHoodCommand
from rf_protocols.codes.novy.cooker_hood import get_codes_for_code
from homeassistant.components.fan import ATTR_PERCENTAGE, FanEntity, FanEntityFeature
from homeassistant.components.radio_frequency import async_send_command
@@ -26,9 +25,6 @@ PARALLEL_UPDATES = 1
_SPEED_RANGE = (1, SPEED_COUNT)
# Novy hood expects at least 150ms between RF commands
_COMMAND_DELAY = 0.2
async def async_setup_entry(
hass: HomeAssistant,
@@ -53,7 +49,7 @@ class NovyCookerHoodFan(NovyCookerHoodEntity, FanEntity, RestoreEntity):
def __init__(self, entry: ConfigEntry) -> None:
"""Initialize the fan."""
super().__init__(entry)
self._code: int = entry.data[CONF_CODE]
self._codes = get_codes_for_code(entry.data[CONF_CODE])
self._level = 0
self._attr_unique_id = entry.entry_id
@@ -107,16 +103,18 @@ class NovyCookerHoodFan(NovyCookerHoodEntity, FanEntity, RestoreEntity):
async def async_increase_speed(self, percentage_step: int | None = None) -> None:
"""Bump speed up by N hardware levels (no recalibration)."""
steps = self._steps_from_percentage(percentage_step)
plus = NovyCookerHoodCommand(channel=self._code, key=COMMAND_PLUS)
await self._async_send_repeated(plus, steps)
plus = await self._codes.async_load_command(COMMAND_PLUS)
for _ in range(steps):
await self._async_send(plus)
self._level = min(SPEED_COUNT, self._level + steps)
self.async_write_ha_state()
async def async_decrease_speed(self, percentage_step: int | None = None) -> None:
"""Bump speed down by N hardware levels (no recalibration)."""
steps = self._steps_from_percentage(percentage_step)
minus = NovyCookerHoodCommand(channel=self._code, key=COMMAND_MINUS)
await self._async_send_repeated(minus, steps)
minus = await self._codes.async_load_command(COMMAND_MINUS)
for _ in range(steps):
await self._async_send(minus)
self._level = max(0, self._level - steps)
self.async_write_ha_state()
@@ -129,25 +127,17 @@ class NovyCookerHoodFan(NovyCookerHoodEntity, FanEntity, RestoreEntity):
async def _async_set_level(self, level: int) -> None:
"""Reset to off with `SPEED_COUNT` minus presses, then climb to level."""
minus = NovyCookerHoodCommand(channel=self._code, key=COMMAND_MINUS)
await self._async_send_repeated(minus, SPEED_COUNT)
minus = await self._codes.async_load_command(COMMAND_MINUS)
for _ in range(SPEED_COUNT):
await self._async_send(minus)
if level > 0:
await asyncio.sleep(_COMMAND_DELAY)
plus = NovyCookerHoodCommand(channel=self._code, key=COMMAND_PLUS)
await self._async_send_repeated(plus, level)
plus = await self._codes.async_load_command(COMMAND_PLUS)
for _ in range(level):
await self._async_send(plus)
self._level = level
self.async_write_ha_state()
async def _async_send_repeated(
self, command: NovyCookerHoodCommand, count: int
) -> None:
"""Send the same RF command N times, pausing between presses."""
for i in range(count):
if i > 0:
await asyncio.sleep(_COMMAND_DELAY)
await self._async_send(command)
async def _async_send(self, command: NovyCookerHoodCommand) -> None:
async def _async_send(self, command: Any) -> None:
"""Send a single RF command via the configured transmitter."""
await async_send_command(
self.hass, self._transmitter, command, context=self._context
@@ -2,7 +2,7 @@
from typing import Any
from rf_protocols.commands.novy import NovyCookerHoodCommand
from rf_protocols.codes.novy.cooker_hood import get_codes_for_code
from homeassistant.components.light import ColorMode, LightEntity
from homeassistant.components.radio_frequency import async_send_command
@@ -37,7 +37,7 @@ class NovyCookerHoodLight(NovyCookerHoodEntity, LightEntity, RestoreEntity):
def __init__(self, entry: ConfigEntry) -> None:
"""Initialize the light."""
super().__init__(entry)
self._code = entry.data[CONF_CODE]
self._codes = get_codes_for_code(entry.data[CONF_CODE])
self._attr_unique_id = entry.entry_id
async def async_added_to_hass(self) -> None:
@@ -58,9 +58,9 @@ class NovyCookerHoodLight(NovyCookerHoodEntity, LightEntity, RestoreEntity):
self._attr_is_on = False
self.async_write_ha_state()
async def _async_send_command(self, key: str) -> None:
"""Build the named command and send it via the configured transmitter."""
command = NovyCookerHoodCommand(channel=self._code, key=key)
async def _async_send_command(self, name: str) -> None:
"""Load the named command and send it via the configured transmitter."""
command = await self._codes.async_load_command(name)
await async_send_command(
self.hass, self._transmitter, command, context=self._context
)
@@ -37,11 +37,15 @@ class OpenhomeConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.debug("async_step_ssdp: Incomplete discovery, ignoring")
return self.async_abort(reason="incomplete_discovery")
_LOGGER.debug(
"async_step_ssdp: setting unique id %s", discovery_info.upnp[ATTR_UPNP_UDN]
)
udn = discovery_info.upnp[ATTR_UPNP_UDN]
if isinstance(udn, list):
if not udn:
return self.async_abort(reason="incomplete_discovery")
udn = udn[0]
await self.async_set_unique_id(discovery_info.upnp[ATTR_UPNP_UDN])
_LOGGER.debug("async_step_ssdp: setting unique id %s", udn)
await self.async_set_unique_id(udn)
self._abort_if_unique_id_configured({CONF_HOST: discovery_info.ssdp_location})
_LOGGER.debug(
@@ -20,5 +20,5 @@
"iot_class": "local_push",
"loggers": ["reolink_aio"],
"quality_scale": "platinum",
"requirements": ["reolink-aio==0.19.1"]
"requirements": ["reolink-aio==0.20.0"]
}
@@ -1,9 +1,7 @@
"""The System Bridge integration."""
import asyncio
from dataclasses import asdict
import logging
from typing import Any
from systembridgeconnector.exceptions import (
AuthenticationException,
@@ -11,71 +9,34 @@ from systembridgeconnector.exceptions import (
ConnectionErrorException,
DataMissingException,
)
from systembridgeconnector.models.keyboard_key import KeyboardKey
from systembridgeconnector.models.keyboard_text import KeyboardText
from systembridgeconnector.models.modules.processes import Process
from systembridgeconnector.models.open_path import OpenPath
from systembridgeconnector.models.open_url import OpenUrl
from systembridgeconnector.version import Version
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_API_KEY,
CONF_COMMAND,
CONF_ENTITY_ID,
CONF_HOST,
CONF_ID,
CONF_NAME,
CONF_PATH,
CONF_PORT,
CONF_TOKEN,
CONF_URL,
Platform,
)
from homeassistant.core import (
HomeAssistant,
ServiceCall,
ServiceResponse,
SupportsResponse,
)
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
HomeAssistantError,
ServiceValidationError,
)
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
discovery,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType
from .config_flow import SystemBridgeConfigFlow
from .const import DATA_WAIT_TIMEOUT, DOMAIN, MODULES
from .coordinator import SystemBridgeConfigEntry, SystemBridgeDataUpdateCoordinator
def _get_coordinator(
hass: HomeAssistant, entry_id: str
) -> SystemBridgeDataUpdateCoordinator:
"""Return the coordinator for a config entry id."""
entry: SystemBridgeConfigEntry | None = hass.config_entries.async_get_entry(
entry_id
)
if entry is None or entry.state is not ConfigEntryState.LOADED:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="device_not_found",
translation_placeholders={"device": entry_id},
)
return entry.runtime_data
from .services import async_setup_services
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.MEDIA_PLAYER,
@@ -84,26 +45,12 @@ PLATFORMS = [
Platform.UPDATE,
]
CONF_BRIDGE = "bridge"
CONF_KEY = "key"
CONF_TEXT = "text"
SERVICE_GET_PROCESS_BY_ID = "get_process_by_id"
SERVICE_GET_PROCESSES_BY_NAME = "get_processes_by_name"
SERVICE_OPEN_PATH = "open_path"
SERVICE_POWER_COMMAND = "power_command"
SERVICE_OPEN_URL = "open_url"
SERVICE_SEND_KEYPRESS = "send_keypress"
SERVICE_SEND_TEXT = "send_text"
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the System Bridge services."""
POWER_COMMAND_MAP = {
"hibernate": "power_hibernate",
"lock": "power_lock",
"logout": "power_logout",
"restart": "power_restart",
"shutdown": "power_shutdown",
"sleep": "power_sleep",
}
async_setup_services(hass)
return True
async def async_setup_entry(
@@ -231,219 +178,6 @@ async def async_setup_entry(
)
)
if hass.services.has_service(DOMAIN, SERVICE_OPEN_URL):
return True
def valid_device(device: str) -> str:
"""Check device is valid."""
device_registry = dr.async_get(hass)
device_entry = device_registry.async_get(device)
if device_entry is not None:
try:
return next(
entry.entry_id
for entry in hass.config_entries.async_entries(DOMAIN)
if entry.entry_id in device_entry.config_entries
)
except StopIteration as exception:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="device_not_found",
translation_placeholders={"device": device},
) from exception
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="device_not_found",
translation_placeholders={"device": device},
)
async def handle_get_process_by_id(service_call: ServiceCall) -> ServiceResponse:
"""Handle the get process by id service call."""
_LOGGER.debug("Get process by id: %s", service_call.data)
coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE])
processes: list[Process] = coordinator.data.processes
# Find process.id from list, raise ServiceValidationError if not found
try:
return asdict(
next(
process
for process in processes
if process.id == service_call.data[CONF_ID]
)
)
except StopIteration as exception:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="process_not_found",
translation_placeholders={"id": service_call.data[CONF_ID]},
) from exception
async def handle_get_processes_by_name(
service_call: ServiceCall,
) -> ServiceResponse:
"""Handle the get process by name service call."""
_LOGGER.debug("Get process by name: %s", service_call.data)
coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE])
# Find processes from list
items: list[dict[str, Any]] = [
asdict(process)
for process in coordinator.data.processes
if process.name is not None
and service_call.data[CONF_NAME].lower() in process.name.lower()
]
return {
"count": len(items),
"processes": list(items),
}
async def handle_open_path(service_call: ServiceCall) -> ServiceResponse:
"""Handle the open path service call."""
_LOGGER.debug("Open path: %s", service_call.data)
coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE])
response = await coordinator.websocket_client.open_path(
OpenPath(path=service_call.data[CONF_PATH])
)
return asdict(response)
async def handle_power_command(service_call: ServiceCall) -> ServiceResponse:
"""Handle the power command service call."""
_LOGGER.debug("Power command: %s", service_call.data)
coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE])
response = await getattr(
coordinator.websocket_client,
POWER_COMMAND_MAP[service_call.data[CONF_COMMAND]],
)()
return asdict(response)
async def handle_open_url(service_call: ServiceCall) -> ServiceResponse:
"""Handle the open url service call."""
_LOGGER.debug("Open URL: %s", service_call.data)
coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE])
response = await coordinator.websocket_client.open_url(
OpenUrl(url=service_call.data[CONF_URL])
)
return asdict(response)
async def handle_send_keypress(service_call: ServiceCall) -> ServiceResponse:
"""Handle the send_keypress service call."""
coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE])
response = await coordinator.websocket_client.keyboard_keypress(
KeyboardKey(key=service_call.data[CONF_KEY])
)
return asdict(response)
async def handle_send_text(service_call: ServiceCall) -> ServiceResponse:
"""Handle the send_keypress service call."""
coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE])
response = await coordinator.websocket_client.keyboard_text(
KeyboardText(text=service_call.data[CONF_TEXT])
)
return asdict(response)
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
hass.services.async_register(
DOMAIN,
SERVICE_GET_PROCESS_BY_ID,
handle_get_process_by_id,
schema=vol.Schema(
{
vol.Required(CONF_BRIDGE): valid_device,
vol.Required(CONF_ID): cv.positive_int,
},
),
supports_response=SupportsResponse.ONLY,
)
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
hass.services.async_register(
DOMAIN,
SERVICE_GET_PROCESSES_BY_NAME,
handle_get_processes_by_name,
schema=vol.Schema(
{
vol.Required(CONF_BRIDGE): valid_device,
vol.Required(CONF_NAME): cv.string,
},
),
supports_response=SupportsResponse.ONLY,
)
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
hass.services.async_register(
DOMAIN,
SERVICE_OPEN_PATH,
handle_open_path,
schema=vol.Schema(
{
vol.Required(CONF_BRIDGE): valid_device,
vol.Required(CONF_PATH): cv.string,
},
),
supports_response=SupportsResponse.ONLY,
)
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
hass.services.async_register(
DOMAIN,
SERVICE_POWER_COMMAND,
handle_power_command,
schema=vol.Schema(
{
vol.Required(CONF_BRIDGE): valid_device,
vol.Required(CONF_COMMAND): vol.In(POWER_COMMAND_MAP),
},
),
supports_response=SupportsResponse.ONLY,
)
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
hass.services.async_register(
DOMAIN,
SERVICE_OPEN_URL,
handle_open_url,
schema=vol.Schema(
{
vol.Required(CONF_BRIDGE): valid_device,
vol.Required(CONF_URL): cv.string,
},
),
supports_response=SupportsResponse.ONLY,
)
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
hass.services.async_register(
DOMAIN,
SERVICE_SEND_KEYPRESS,
handle_send_keypress,
schema=vol.Schema(
{
vol.Required(CONF_BRIDGE): valid_device,
vol.Required(CONF_KEY): cv.string,
},
),
supports_response=SupportsResponse.ONLY,
description_placeholders={
"syntax_keys_documentation_url": "http://robotjs.io/docs/syntax#keys"
},
)
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
hass.services.async_register(
DOMAIN,
SERVICE_SEND_TEXT,
handle_send_text,
schema=vol.Schema(
{
vol.Required(CONF_BRIDGE): valid_device,
vol.Required(CONF_TEXT): cv.string,
},
),
supports_response=SupportsResponse.ONLY,
)
# Reload entry when its updated.
entry.async_on_unload(entry.add_update_listener(async_reload_entry))
@@ -454,9 +188,7 @@ async def async_unload_entry(
hass: HomeAssistant, entry: SystemBridgeConfigEntry
) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(
entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY]
)
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
coordinator = entry.runtime_data
@@ -0,0 +1,269 @@
"""Service registration for System Bridge integration."""
from dataclasses import asdict
import logging
from typing import Any
from systembridgeconnector.models.keyboard_key import KeyboardKey
from systembridgeconnector.models.keyboard_text import KeyboardText
from systembridgeconnector.models.modules.processes import Process
from systembridgeconnector.models.open_path import OpenPath
from systembridgeconnector.models.open_url import OpenUrl
import voluptuous as vol
from homeassistant.const import CONF_COMMAND, CONF_ID, CONF_NAME, CONF_PATH, CONF_URL
from homeassistant.core import (
HomeAssistant,
ServiceCall,
ServiceResponse,
SupportsResponse,
callback,
)
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
service,
)
from .const import DOMAIN
from .coordinator import SystemBridgeConfigEntry, SystemBridgeDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
CONF_BRIDGE = "bridge"
CONF_KEY = "key"
CONF_TEXT = "text"
POWER_COMMAND_MAP = {
"hibernate": "power_hibernate",
"lock": "power_lock",
"logout": "power_logout",
"restart": "power_restart",
"shutdown": "power_shutdown",
"sleep": "power_sleep",
}
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up services for System Bridge integration."""
hass.services.async_register(
DOMAIN,
"get_process_by_id",
handle_get_process_by_id,
schema=vol.Schema(
{
vol.Required(CONF_BRIDGE): cv.string,
vol.Required(CONF_ID): cv.positive_int,
},
),
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
"get_processes_by_name",
handle_get_processes_by_name,
schema=vol.Schema(
{
vol.Required(CONF_BRIDGE): cv.string,
vol.Required(CONF_NAME): cv.string,
},
),
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
"open_path",
handle_open_path,
schema=vol.Schema(
{
vol.Required(CONF_BRIDGE): cv.string,
vol.Required(CONF_PATH): cv.string,
},
),
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
"power_command",
handle_power_command,
schema=vol.Schema(
{
vol.Required(CONF_BRIDGE): cv.string,
vol.Required(CONF_COMMAND): vol.In(POWER_COMMAND_MAP),
},
),
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
"open_url",
handle_open_url,
schema=vol.Schema(
{
vol.Required(CONF_BRIDGE): cv.string,
vol.Required(CONF_URL): cv.string,
},
),
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
"send_keypress",
handle_send_keypress,
schema=vol.Schema(
{
vol.Required(CONF_BRIDGE): cv.string,
vol.Required(CONF_KEY): cv.string,
},
),
supports_response=SupportsResponse.ONLY,
description_placeholders={
"syntax_keys_documentation_url": "https://robotjs.dev/docs/syntax#keys"
},
)
hass.services.async_register(
DOMAIN,
"send_text",
handle_send_text,
schema=vol.Schema(
{
vol.Required(CONF_BRIDGE): cv.string,
vol.Required(CONF_TEXT): cv.string,
},
),
supports_response=SupportsResponse.ONLY,
)
def _get_coordinator(
hass: HomeAssistant, device_id: str
) -> SystemBridgeDataUpdateCoordinator:
"""Return the coordinator for a device id."""
device_registry = dr.async_get(hass)
device_entry = device_registry.async_get(device_id)
if device_entry is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="device_not_found",
translation_placeholders={"device": device_id},
)
try:
entry_id = next(
entry.entry_id
for entry in hass.config_entries.async_entries(DOMAIN)
if entry.entry_id in device_entry.config_entries
)
except StopIteration as e:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="device_not_found",
translation_placeholders={"device": device_id},
) from e
entry: SystemBridgeConfigEntry = service.async_get_config_entry(
hass, DOMAIN, entry_id
)
return entry.runtime_data
async def handle_get_process_by_id(service_call: ServiceCall) -> ServiceResponse:
"""Handle the get process by id service call."""
_LOGGER.debug("Get process by id: %s", service_call.data)
coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE])
processes: list[Process] = coordinator.data.processes
# Find process.id from list, raise ServiceValidationError if not found
try:
return asdict(
next(
process
for process in processes
if process.id == service_call.data[CONF_ID]
)
)
except StopIteration as e:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="process_not_found",
translation_placeholders={"id": service_call.data[CONF_ID]},
) from e
async def handle_get_processes_by_name(
service_call: ServiceCall,
) -> ServiceResponse:
"""Handle the get process by name service call."""
_LOGGER.debug("Get process by name: %s", service_call.data)
coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE])
# Find processes from list
items: list[dict[str, Any]] = [
asdict(process)
for process in coordinator.data.processes
if process.name is not None
and service_call.data[CONF_NAME].lower() in process.name.lower()
]
return {
"count": len(items),
"processes": list(items),
}
async def handle_open_path(service_call: ServiceCall) -> ServiceResponse:
"""Handle the open path service call."""
_LOGGER.debug("Open path: %s", service_call.data)
coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE])
response = await coordinator.websocket_client.open_path(
OpenPath(path=service_call.data[CONF_PATH])
)
return asdict(response)
async def handle_power_command(service_call: ServiceCall) -> ServiceResponse:
"""Handle the power command service call."""
_LOGGER.debug("Power command: %s", service_call.data)
coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE])
response = await getattr(
coordinator.websocket_client,
POWER_COMMAND_MAP[service_call.data[CONF_COMMAND]],
)()
return asdict(response)
async def handle_open_url(service_call: ServiceCall) -> ServiceResponse:
"""Handle the open url service call."""
_LOGGER.debug("Open URL: %s", service_call.data)
coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE])
response = await coordinator.websocket_client.open_url(
OpenUrl(url=service_call.data[CONF_URL])
)
return asdict(response)
async def handle_send_keypress(service_call: ServiceCall) -> ServiceResponse:
"""Handle the send_keypress service call."""
coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE])
response = await coordinator.websocket_client.keyboard_keypress(
KeyboardKey(key=service_call.data[CONF_KEY])
)
return asdict(response)
async def handle_send_text(service_call: ServiceCall) -> ServiceResponse:
"""Handle the send_text service call."""
coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE])
response = await coordinator.websocket_client.keyboard_text(
KeyboardText(text=service_call.data[CONF_TEXT])
)
return asdict(response)
@@ -89,3 +89,4 @@ power_command:
- "restart"
- "shutdown"
- "sleep"
translation_key: "power_command"
@@ -178,6 +178,18 @@
"title": "System Bridge upgrade required"
}
},
"selector": {
"power_command": {
"options": {
"hibernate": "Hibernate",
"lock": "Lock",
"logout": "Logout",
"restart": "[%key:common::action::restart%]",
"shutdown": "Shutdown",
"sleep": "Sleep"
}
}
},
"services": {
"get_process_by_id": {
"description": "Gets a process by the ID.",
@@ -21,4 +21,4 @@ CONF_INSTANCE_ID = "instance_id"
# Polling interval (seconds)
DEFAULT_SCAN_INTERVAL = 1800
PLATFORMS: list[Platform] = [Platform.LIGHT, Platform.SWITCH]
PLATFORMS: list[Platform] = [Platform.LIGHT, Platform.LOCK, Platform.SWITCH]
@@ -0,0 +1,47 @@
"""Lock platform for Xthings Cloud."""
from typing import Any
from homeassistant.components.lock import LockEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import XthingsCloudConfigEntry
from .entity import XthingsCloudEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: XthingsCloudConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up lock platform."""
coordinator = entry.runtime_data
entities = [
XthingsCloudLock(coordinator, device_id, device_data)
for device_id, device_data in coordinator.data.items()
if device_data["type"] == "lock"
]
async_add_entities(entities)
class XthingsCloudLock(XthingsCloudEntity, LockEntity):
"""Xthings Cloud lock entity."""
@property
def is_locked(self) -> bool | None:
"""Return true if lock is locked."""
return self.device_data["status"].get("locked")
@property
def is_jammed(self) -> bool | None:
"""Return true if lock is jammed."""
return self.device_data["status"].get("jammed")
async def async_lock(self, **kwargs: Any) -> None:
"""Lock the device."""
await self.coordinator.client.async_lock_lock(self._device_id)
async def async_unlock(self, **kwargs: Any) -> None:
"""Unlock the device."""
await self.coordinator.client.async_lock_unlock(self._device_id)
@@ -0,0 +1,113 @@
"""Checker for invalid MDI icon references.
Validates that ``mdi:`` icon references in integration code and
``icons.json`` files refer to icons that actually exist in the
Material Design Icons set.
- ``E7409``: MDI icon reference not found in Python code
- ``E7410``: MDI icon reference not found in icons.json
"""
import re
from astroid import nodes
from pylint.checkers import BaseChecker
from pylint.lint import PyLinter
from pylint_home_assistant.generated.mdi_icons import MDI_ICONS
from pylint_home_assistant.helpers.icons import collect_mdi_icons, load_icons
from pylint_home_assistant.helpers.module_info import parse_module
# Matches strings that look like intentional icon name attempts
# (letters, digits, hyphens, underscores). Rejects format templates
# (%s, {}, {name}), empty names, and other dynamic patterns.
_LOOKS_LIKE_ICON_NAME = re.compile(r"^[a-zA-Z][a-zA-Z0-9_-]*[a-zA-Z0-9]$")
class MdiIconsChecker(BaseChecker):
"""Checker for invalid MDI icon references."""
name = "home_assistant_mdi_icons"
priority = -1
msgs = {
"E7409": (
"MDI icon '%s' does not exist in the Material Design Icons set",
"home-assistant-mdi-icon-not-found",
"Used when an integration references an MDI icon in Python "
"code that does not exist. Check the icon name at "
"https://pictogrammers.com/library/mdi/",
),
"E7410": (
"MDI icon '%s' in icons.json does not exist in the "
"Material Design Icons set",
"home-assistant-mdi-icon-json-not-found",
"Used when an integration's icons.json references an MDI "
"icon that does not exist. Check the icon name at "
"https://pictogrammers.com/library/mdi/",
),
}
options = ()
_in_integration: bool
_checked_icons_json: set[str]
def open(self) -> None:
"""Initialize per-run state."""
self._checked_icons_json = set()
def visit_module(self, node: nodes.Module) -> None:
"""Check icons.json and track integration context."""
parsed = parse_module(node.name)
self._in_integration = parsed is not None
if parsed is None:
return
# Only check icons.json once per integration
if parsed.domain in self._checked_icons_json:
return
self._checked_icons_json.add(parsed.domain)
icons_data = load_icons(node)
if icons_data is None:
return
mdi_refs = collect_mdi_icons(icons_data)
for icon_ref in sorted(mdi_refs):
icon_name = icon_ref[4:] # Strip "mdi:" prefix
if icon_name not in MDI_ICONS:
self.add_message(
"home-assistant-mdi-icon-json-not-found",
node=node,
args=(icon_ref,),
)
def visit_const(self, node: nodes.Const) -> None:
"""Check string constants for invalid MDI icon references."""
if not self._in_integration:
return
if not isinstance(node.value, str):
return
if not node.value.startswith("mdi:"):
return
icon_name = node.value[4:] # Strip "mdi:" prefix
# Only check names that look like intentional icon name attempts.
# This skips f-string fragments, format templates (%s, {}),
# partial names, and other dynamic patterns.
if not _LOOKS_LIKE_ICON_NAME.match(icon_name):
return
if icon_name not in MDI_ICONS:
self.add_message(
"home-assistant-mdi-icon-not-found",
node=node,
args=(node.value,),
)
def register(linter: PyLinter) -> None:
"""Register the checker."""
linter.register_checker(MdiIconsChecker(linter))
@@ -0,0 +1 @@
"""Generated files for the pylint Home Assistant plugin."""
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,60 @@
"""Helpers for reading integration icon files."""
import contextlib
from astroid import nodes
import orjson
from .integration import get_integration_dir
_icons_cache: dict[str, dict | None] = {}
def clear_icons_cache() -> None:
"""Clear the icons cache (used by tests)."""
_icons_cache.clear()
def load_icons(module: nodes.Module) -> dict | None:
"""Load and cache the icons.json for the current integration.
Returns the parsed JSON as a dict, or ``None`` if not found.
"""
integration_dir = get_integration_dir(module)
if integration_dir is None:
return None
cache_key = str(integration_dir)
if cache_key in _icons_cache:
return _icons_cache[cache_key]
icons_path = integration_dir / "icons.json"
result: dict | None = None
if icons_path.exists():
with contextlib.suppress(orjson.JSONDecodeError, OSError):
parsed = orjson.loads(icons_path.read_bytes())
if isinstance(parsed, dict):
result = parsed
_icons_cache[cache_key] = result
return result
def collect_mdi_icons(
data: dict | list | str, icons: set[str] | None = None
) -> set[str]:
"""Recursively collect all mdi: icon references from a data structure."""
if icons is None:
icons = set()
if isinstance(data, str):
if data.startswith("mdi:"):
icons.add(data)
elif isinstance(data, dict):
for value in data.values():
collect_mdi_icons(value, icons)
elif isinstance(data, list):
for item in data:
collect_mdi_icons(item, icons)
return icons
+3 -3
View File
@@ -1516,7 +1516,7 @@ lxml==6.0.1
matrix-nio==0.25.2
# homeassistant.components.matter
matter-python-client==0.6.0
matter-python-client==0.7.1
# homeassistant.components.maxcube
maxcube-api==0.4.3
@@ -2641,7 +2641,7 @@ python-google-weather-api==0.0.6
python-homeassistant-analytics==0.9.0
# homeassistant.components.homewizard
python-homewizard-energy==10.0.1
python-homewizard-energy==10.1.0
# homeassistant.components.hp_ilo
python-hpilo==4.4.3
@@ -2871,7 +2871,7 @@ renault-api==0.5.10
renson-endura-delta==1.7.2
# homeassistant.components.reolink
reolink-aio==0.19.1
reolink-aio==0.20.0
# homeassistant.components.radio_frequency
rf-protocols==3.2.0
View File
+2
View File
@@ -23,6 +23,7 @@ from . import (
json,
labs,
manifest,
mdi_icons,
metadata,
mqtt,
mypy_config,
@@ -65,6 +66,7 @@ INTEGRATION_PLUGINS = [
HASS_PLUGINS = [
core_files,
docker,
mdi_icons,
mypy_config,
metadata,
]
+76
View File
@@ -0,0 +1,76 @@
"""Generate MDI icons file for the pylint plugin."""
from importlib.metadata import PackageNotFoundError, version
from importlib.resources import files
import json
from .model import Config, Integration
from .serializer import format_python_namespace
_TARGET = "pylint/plugins/pylint_home_assistant/generated/mdi_icons.py"
def _get_frontend_version() -> str | None:
"""Get the installed home-assistant-frontend version."""
try:
return version("home-assistant-frontend")
except PackageNotFoundError:
return None
def _load_mdi_icons() -> set[str]:
"""Load the MDI icon names from the frontend package."""
try:
mdi_dir = files("hass_frontend") / "static" / "mdi"
icon_list_path = mdi_dir / "iconList.json"
data = json.loads(icon_list_path.read_text(encoding="utf-8"))
return {icon["name"] for icon in data}
except ImportError, FileNotFoundError, json.JSONDecodeError, KeyError:
return set()
def validate(integrations: dict[str, Integration], config: Config) -> None:
"""Validate the generated MDI icons file is up to date."""
frontend_version = _get_frontend_version()
if frontend_version is None:
return
icons = _load_mdi_icons()
if not icons:
config.add_error(
"mdi_icons",
"Could not load MDI icons from home-assistant-frontend",
)
return
content = format_python_namespace(
{
"FRONTEND_VERSION": frontend_version,
"MDI_ICONS": icons,
},
annotations={
"FRONTEND_VERSION": "Final[str]",
"MDI_ICONS": "Final[set[str]]",
},
)
config.cache["mdi_icons_content"] = content
if config.specific_integrations:
return
target_path = config.root / _TARGET
if not target_path.exists() or target_path.read_text() != content:
config.add_error(
"mdi_icons",
f"File {_TARGET} is not up to date. Run python3 -m script.hassfest",
fixable=True,
)
def generate(integrations: dict[str, Integration], config: Config) -> None:
"""Generate MDI icons file."""
if "mdi_icons_content" not in config.cache:
return
target_path = config.root / _TARGET
target_path.write_text(config.cache["mdi_icons_content"])
+402 -62
View File
@@ -2,9 +2,14 @@
"""Helper script to split test into n buckets."""
import argparse
from collections.abc import Iterator
from concurrent.futures import ProcessPoolExecutor
from dataclasses import dataclass, field
from contextlib import suppress
from dataclasses import dataclass, field, replace
import hashlib
import json
from math import ceil
from operator import attrgetter, itemgetter
import os
from pathlib import Path
import subprocess
@@ -15,13 +20,21 @@ from typing import Final
# place to subdivide to keep each pytest invocation roughly equal in size.
_FAN_OUT_DIRS: Final = frozenset({"components"})
# Cache file format version; bump on any incompatible schema change so old
# caches are ignored rather than misread.
_CACHE_VERSION: Final = 3
# Fall back from file-level to directory-level pytest collection when
# misses make up more than this fraction of the tree; past that point
# the per-file argv overhead pytest pays outweighs the cost of letting
# it re-walk dirs and re-collect the hits.
_DIR_LEVEL_MISS_RATIO: Final = 0.3
class Bucket:
"""Class to hold bucket."""
def __init__(
self,
):
def __init__(self) -> None:
"""Initialize bucket."""
self.total_tests = 0
self._paths: list[str] = []
@@ -47,43 +60,56 @@ class BucketHolder:
self._buckets: list[Bucket] = [Bucket() for _ in range(bucket_count)]
def split_tests(self, test_folder: TestFolder) -> None:
"""Split tests into buckets."""
"""Place atomic units via best-fit; oversized ones go to the smallest bucket."""
digits = len(str(test_folder.total_tests))
sorted_tests = sorted(
test_folder.get_all_flatten(), reverse=True, key=lambda x: x.total_tests
)
for tests in sorted_tests:
if tests.added_to_bucket:
# Already added to bucket
continue
by_load = attrgetter("total_tests")
units = sorted(self._atomic_units(test_folder), key=itemgetter(0), reverse=True)
for size, items in units:
for item in items:
tag = " (same bucket)" if item is not items[0] else ""
print(f"{item.total_tests:>{digits}} tests in {item.path}{tag}")
fits = [
b
for b in self._buckets
if b.total_tests + size <= self._tests_per_bucket
]
bucket = max(fits, key=by_load) if fits else min(self._buckets, key=by_load)
for item in items:
bucket.add(item)
print(f"{tests.total_tests:>{digits}} tests in {tests.path}")
smallest_bucket = min(self._buckets, key=lambda x: x.total_tests)
is_file = isinstance(tests, TestFile)
if (
smallest_bucket.total_tests + tests.total_tests < self._tests_per_bucket
) or is_file:
smallest_bucket.add(tests)
# Ensure all files from the same folder are in the same bucket
# to ensure that syrupy correctly identifies unused snapshots
if is_file:
for other_test in tests.parent.children.values():
if other_test is tests or isinstance(other_test, TestFolder):
continue
print(
f"{other_test.total_tests:>{digits}}"
f" tests in {other_test.path}"
" (same bucket)"
)
smallest_bucket.add(other_test)
# verify that all tests are added to a bucket
if not test_folder.added_to_bucket:
raise ValueError("Not all tests are added to a bucket")
def create_ouput_file(self) -> None:
def _atomic_units(
self, folder: TestFolder
) -> Iterator[tuple[int, list[TestFolder | TestFile]]]:
"""Yield ``(size, items)`` placement units.
A folder that fits is one unit; otherwise same-dir files form
a unit only when the folder has syrupy snapshots, else each
file stands alone. Sub-folders recurse independently.
"""
if folder.total_tests <= self._tests_per_bucket:
yield folder.total_tests, [folder]
return
sibling_files = [c for c in folder.children.values() if isinstance(c, TestFile)]
if sibling_files:
if _has_snapshots(folder.path):
yield (
sum(f.total_tests for f in sibling_files),
list(sibling_files),
)
else:
for file in sibling_files:
yield file.total_tests, [file]
for child in folder.children.values():
if isinstance(child, TestFolder):
yield from self._atomic_units(child)
def create_output_file(self) -> None:
"""Create output file."""
with Path("pytest_buckets.txt").open("w") as file:
with Path("pytest_buckets.txt").open("w", encoding="utf-8") as file:
for idx, bucket in enumerate(self._buckets):
print(f"Bucket {idx + 1} has {bucket.total_tests} tests")
file.write(bucket.get_paths_line())
@@ -170,6 +196,15 @@ class TestFolder:
return result
def _has_snapshots(folder_path: Path) -> bool:
"""Return True when ``folder_path/snapshots`` holds ``.ambr`` files.
Same-dir tests must share a pytest run so syrupy can spot unused
snapshots; without snapshots that constraint doesn't apply.
"""
return any((folder_path / "snapshots").glob("*.ambr"))
def _collect_batch(paths: list[Path]) -> tuple[str, str, int]:
"""Run pytest --collect-only on a batch of paths."""
result = subprocess.run(
@@ -216,44 +251,343 @@ def _enumerate_batch_paths(path: Path) -> list[Path]:
return paths
def collect_tests(path: Path) -> TestFolder:
"""Collect all tests."""
batch_paths = _enumerate_batch_paths(path)
if not batch_paths:
print(f"No eligible test paths found under {path}")
sys.exit(1)
workers = min(len(batch_paths), os.cpu_count() or 1) or 1
# Round-robin chunking keeps batches roughly balanced when path
# ordering correlates with test size.
batches = [batch_paths[i::workers] for i in range(workers)]
def _hash_file(path: Path) -> str:
"""Return a short content hash for ``path``."""
return hashlib.sha256(path.read_bytes()).hexdigest()[:16]
def _walk_test_tree(root: Path) -> tuple[list[Path], list[Path]]:
"""Walk ``root`` once and return (test files, fixture files).
Fixtures are every non-``test_*.py`` ``.py``: conftests and helpers
like ``common.py`` that drive parametrize imports. Uses ``os.walk``
(~2x faster than ``Path.rglob`` on this tree) and prunes ``.``/``_``
subdirs.
"""
test_files: list[Path] = []
fixtures: list[Path] = []
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if not d.startswith((".", "_"))]
base = Path(dirpath)
for name in filenames:
if not name.endswith(".py"):
continue
if name.startswith("test_"):
test_files.append(base / name)
else:
fixtures.append(base / name)
test_files.sort()
fixtures.sort()
return test_files, fixtures
_PROJECT_ROOT_MARKERS: Final = frozenset(
{"pyproject.toml", "setup.py", "setup.cfg", "pytest.ini", "tox.ini"}
)
def _find_ancestor_fixtures(root: Path) -> list[Path]:
"""Return non-``test_*.py`` Python files above ``root``, up to the project root.
Includes conftests and helper modules (eg ``common.py``); subtree
runs need both so shared ancestor helpers like
``tests/components/common.py`` still invalidate descendants.
Stops at the first ancestor containing a project-root marker so we
don't read unrelated ``.py`` files outside the repo or trip on
dirs we can't list.
"""
fixtures: list[Path] = []
current = root.resolve().parent
while True:
with suppress(OSError):
fixtures.extend(
entry
for entry in current.glob("*.py")
if not entry.name.startswith("test_")
)
if any((current / marker).exists() for marker in _PROJECT_ROOT_MARKERS):
break
if current == current.parent:
break
current = current.parent
return fixtures
def _build_fixtures_by_dir(
root: Path, descendants: list[Path]
) -> dict[Path, list[Path]]:
"""Bucket descendants plus ancestor fixtures by resolved parent dir."""
by_dir: dict[Path, list[Path]] = {}
for fixture in (*_find_ancestor_fixtures(root), *descendants):
by_dir.setdefault(fixture.parent.resolve(), []).append(fixture)
return by_dir
def _file_fixture_hash(
test_file: Path,
root: Path,
fixtures_by_dir: dict[Path, list[Path]],
blob_cache: dict[Path, bytes] | None = None,
dir_cache: dict[Path, str] | None = None,
) -> str:
"""Hash every ``.py`` fixture on the test file's ancestor path.
Catches conftests and helper modules (``common.py`` etc.) at any
level so parametrize imports from shared helpers invalidate
descendants, while sibling subtrees stay warm. Pass shared
``blob_cache``/``dir_cache`` dicts to memoize across many files.
"""
test_dir = test_file.parent.resolve()
if dir_cache is not None and (cached := dir_cache.get(test_dir)) is not None:
return cached
relevant: list[Path] = []
current = test_dir
while True:
relevant.extend(fixtures_by_dir.get(current, ()))
parent = current.parent
if parent == current:
break
current = parent
relevant.sort()
digest = hashlib.sha256()
for fixture in relevant:
blob = blob_cache.get(fixture) if blob_cache is not None else None
if blob is None:
# relpath keeps the hash machine-stable across ancestor paths.
blob = (
os.path.relpath(fixture, root).encode()
+ b"\0"
+ fixture.read_bytes()
+ b"\0"
)
if blob_cache is not None:
blob_cache[fixture] = blob
digest.update(blob)
result = digest.hexdigest()
if dir_cache is not None:
dir_cache[test_dir] = result
return result
@dataclass
class _CacheEntry:
"""Cached test count plus its scope hash for a single file."""
hash: str
fixture_hash: str
count: int
@dataclass
class _Cache:
"""Mapping of test file path → cached entry."""
entries: dict[str, _CacheEntry]
@classmethod
def load(cls, path: Path) -> _Cache:
"""Load cache; any drift (missing, bad, version, malformed) returns empty."""
try:
raw = json.loads(path.read_bytes())
except OSError, ValueError:
raw = None
if not (
isinstance(raw, dict)
and raw.get("version") == _CACHE_VERSION
and isinstance(raw.get("files"), dict)
):
return cls(entries={})
entries: dict[str, _CacheEntry] = {}
for key, value in raw["files"].items():
if not isinstance(value, dict):
continue
hash_value = value.get("hash")
fixture_hash = value.get("fixture_hash")
count = value.get("count")
# bool is an int subclass; reject true/false and negatives so
# corrupted JSON can't feed bucket sizing a bogus weight.
if (
not isinstance(hash_value, str)
or not isinstance(fixture_hash, str)
or not isinstance(count, int)
or isinstance(count, bool)
or count < 0
):
continue
entries[key] = _CacheEntry(
hash=hash_value, fixture_hash=fixture_hash, count=count
)
return cls(entries=entries)
def save(self, path: Path) -> None:
"""Write the cache to ``path``, creating parent dirs as needed."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(
{
"version": _CACHE_VERSION,
"files": {
key: {
"hash": entry.hash,
"fixture_hash": entry.fixture_hash,
"count": entry.count,
}
for key, entry in sorted(self.entries.items())
},
},
indent=2,
ensure_ascii=False,
)
+ "\n",
encoding="utf-8",
)
def _resolve_entries(
test_files: list[Path],
cache: _Cache,
root: Path,
fixtures_by_dir: dict[Path, list[Path]],
) -> tuple[dict[Path, _CacheEntry], list[Path]]:
"""Build an entry for every file; return ``(entries, misses)``.
Hits reuse the stored entry; misses get fresh hashes with a
count=0 placeholder for the caller to fill in after pytest runs.
Shared caches memoize fixture blobs and per-dir hashes so each
fixture file is read once and each unique dir hashed once.
"""
blob_cache: dict[Path, bytes] = {}
dir_cache: dict[Path, str] = {}
entries: dict[Path, _CacheEntry] = {}
misses: list[Path] = []
for file in test_files:
file_hash = _hash_file(file)
fixture_hash = _file_fixture_hash(
file, root, fixtures_by_dir, blob_cache, dir_cache
)
cached = cache.entries.get(str(file.relative_to(root)))
if (
cached is not None
and cached.hash == file_hash
and cached.fixture_hash == fixture_hash
):
entries[file] = cached
else:
entries[file] = _CacheEntry(
hash=file_hash, fixture_hash=fixture_hash, count=0
)
misses.append(file)
return entries, misses
def _run_collect_batches(paths: list[Path]) -> list[tuple[str, str, int]]:
"""Run pytest --collect-only across ``paths`` using a process pool."""
workers = min(len(paths), os.cpu_count() or 1) or 1
batches = [paths[i::workers] for i in range(workers)]
if workers == 1:
results = [_collect_batch(batches[0])]
else:
with ProcessPoolExecutor(max_workers=workers) as executor:
results = list(executor.map(_collect_batch, batches))
return [_collect_batch(batches[0])]
with ProcessPoolExecutor(max_workers=workers) as executor:
return list(executor.map(_collect_batch, batches))
folder = TestFolder(path)
for stdout, stderr, returncode in results:
def _parse_collect_output(stdout: str) -> dict[Path, int]:
"""Parse ``pytest --collect-only -qq`` output into ``{path: count}``."""
counts: dict[Path, int] = {}
for line in stdout.splitlines():
if not line.strip():
continue
file_path, _, total_tests = line.partition(": ")
if not file_path or not total_tests:
raise ValueError(f"Unexpected line: {line}")
counts[Path(file_path)] = int(total_tests)
return counts
def _run_pytest_collect(paths: list[Path]) -> dict[Path, int]:
"""Run pytest --collect-only across ``paths`` and parse the output."""
counts: dict[Path, int] = {}
for stdout, stderr, returncode in _run_collect_batches(paths):
if returncode != 0:
print("Failed to collect tests:")
print(stderr)
print(stdout)
sys.exit(1)
for line in stdout.splitlines():
if not line.strip():
continue
file_path, _, total_tests = line.partition(": ")
if not file_path or not total_tests:
print(f"Unexpected line: {line}")
sys.exit(1)
# Surface stderr from successful runs too; pytest puts deprecation
# and import warnings here that would otherwise vanish.
if stderr.strip():
sys.stderr.write(stderr)
try:
counts.update(_parse_collect_output(stdout))
except ValueError as err:
print(err)
sys.exit(1)
return counts
file = TestFile(int(total_tests), Path(file_path))
folder.add_test_file(file)
def _build_folder(root: Path, counts: dict[Path, int]) -> TestFolder:
"""Build a ``TestFolder`` from ``{path: count}``; zero-count files are skipped."""
folder = TestFolder(root)
for file_path, count in counts.items():
if count:
folder.add_test_file(TestFile(count, file_path))
return folder
def _exit_if_empty(paths: list[Path], root: Path) -> None:
"""Exit with a clear message when no eligible test paths were found."""
if not paths:
print(f"No eligible test paths found under {root}")
sys.exit(1)
def _collect_tests_uncached(path: Path) -> TestFolder:
"""Hand pytest the top-level dirs; the pre-cache path when ``--cache`` is unset."""
batch_paths = _enumerate_batch_paths(path)
_exit_if_empty(batch_paths, path)
return _build_folder(path, _run_pytest_collect(batch_paths))
def _collect_tests_cached(path: Path, cache_path: Path) -> TestFolder:
"""Collect tests using an on-disk cache for incremental updates."""
all_test_files, fixtures = _walk_test_tree(path)
_exit_if_empty(all_test_files, path)
fixtures_by_dir = _build_fixtures_by_dir(path, fixtures)
cache = _Cache.load(cache_path)
entries, misses = _resolve_entries(all_test_files, cache, path, fixtures_by_dir)
hits = len(all_test_files) - len(misses)
print(f"Cache: {hits} hits / {len(misses)} misses / {len(all_test_files)} total")
if misses:
# Past _DIR_LEVEL_MISS_RATIO the per-file argv overhead beats
# re-walking the dirs, so fall back to dir-level collection.
if not hits or len(misses) > len(all_test_files) * _DIR_LEVEL_MISS_RATIO:
collect_paths = _enumerate_batch_paths(path)
else:
collect_paths = misses
new_counts = _run_pytest_collect(collect_paths)
# Files pytest returned no count for stay at 0; cached so they
# aren't re-collected next run.
for file in misses:
entries[file] = replace(entries[file], count=new_counts.get(file, 0))
_Cache(entries={str(f.relative_to(path)): e for f, e in entries.items()}).save(
cache_path
)
return _build_folder(path, {f: e.count for f, e in entries.items()})
def collect_tests(path: Path, cache_path: Path | None = None) -> TestFolder:
"""Collect all tests, using an on-disk cache when ``cache_path`` is set."""
if cache_path is None:
return _collect_tests_uncached(path)
if path.is_file():
# No fixture tree to scope against; bypass cache to avoid stale hits.
print(f"--cache ignored: {path} is a single file")
return _collect_tests_uncached(path)
return _collect_tests_cached(path, cache_path)
def main() -> None:
"""Execute script."""
parser = argparse.ArgumentParser(description="Split tests into n buckets.")
@@ -276,11 +610,17 @@ def main() -> None:
help="Path to the test files to split into buckets",
type=Path,
)
parser.add_argument(
"--cache",
help="Path to a JSON file used to cache per-file test counts",
type=Path,
default=None,
)
arguments = parser.parse_args()
print("Collecting tests...")
tests = collect_tests(arguments.path)
tests = collect_tests(arguments.path, arguments.cache)
tests_per_bucket = ceil(tests.total_tests / arguments.bucket_count)
bucket_holder = BucketHolder(tests_per_bucket, arguments.bucket_count)
@@ -290,7 +630,7 @@ def main() -> None:
print(f"Total tests: {tests.total_tests}")
print(f"Estimated tests per bucket: {tests_per_bucket}")
bucket_holder.create_ouput_file()
bucket_holder.create_output_file()
if __name__ == "__main__":
@@ -2,6 +2,7 @@
from homeassistant.components.homeassistant_connect_zbt2.const import DOMAIN
from homeassistant.components.usb import DOMAIN as USB_DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
@@ -65,3 +66,66 @@ async def test_hardware_info(
}
]
}
async def test_hardware_info_ignored_entry(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, addon_store_info
) -> None:
"""Test ignored discovery entries don't crash hardware info.
Regression test for https://github.com/home-assistant/core/issues/170270
"""
assert await async_setup_component(hass, USB_DOMAIN, {})
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
# Setup the normal entry so the hardware platform is loaded
normal_entry = MockConfigEntry(
data=CONFIG_ENTRY_DATA,
domain=DOMAIN,
options={},
title="Home Assistant Connect ZBT-2",
unique_id="normal_1",
version=1,
minor_version=1,
)
normal_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(normal_entry.entry_id)
# Setup an ignored config entry without USB data
ignored_entry = MockConfigEntry(
data={},
domain=DOMAIN,
options={},
title="Home Assistant Connect ZBT-2",
unique_id="ignored_1",
version=1,
minor_version=2,
source="ignore",
)
ignored_entry.add_to_hass(hass)
assert ignored_entry.state is ConfigEntryState.NOT_LOADED
client = await hass_ws_client(hass)
await client.send_json({"id": 1, "type": "hardware/info"})
msg = await client.receive_json()
assert msg["id"] == 1
assert msg["success"]
assert msg["result"] == {
"hardware": [
{
"board": None,
"config_entries": [normal_entry.entry_id],
"dongle": {
"vid": "303A",
"pid": "4001",
"serial_number": "80B54EEFAE18",
"manufacturer": "Nabu Casa",
"description": "ZBT-2",
},
"name": "Home Assistant Connect ZBT-2",
"url": "https://support.nabucasa.com/hc/en-us/categories/24734620813469-Home-Assistant-Connect-ZBT-1",
}
]
}
+5
View File
@@ -2363,6 +2363,11 @@ async def test_reload(mock_port_available: MagicMock, hass: HomeAssistant) -> No
devices=[],
)
# Unload while async_port_is_available is still patched so the hass fixture
# teardown does not block on the real port check loop in async_unload_entry.
await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
@pytest.mark.usefixtures("mock_async_zeroconf")
async def test_homekit_start_in_accessory_mode(
@@ -0,0 +1,9 @@
{
"mode": "zero",
"permissions": ["charge_allowed", "discharge_allowed"],
"battery_count": 2,
"power_w": -404,
"target_power_w": -400,
"max_consumption_w": 1600,
"max_production_w": 800
}
@@ -0,0 +1,82 @@
{
"wifi_ssid": "My Wi-Fi",
"wifi_strength": 100,
"smr_version": 50,
"meter_model": "ISKRA 2M550T-101",
"unique_id": "00112233445566778899AABBCCDDEEFF",
"active_tariff": 2,
"total_power_import_kwh": 13779.338,
"total_power_import_t1_kwh": 10830.511,
"total_power_import_t2_kwh": 2948.827,
"total_power_import_t3_kwh": 2948.827,
"total_power_import_t4_kwh": 2948.827,
"total_power_export_kwh": 13086.777,
"total_power_export_t1_kwh": 4321.333,
"total_power_export_t2_kwh": 8765.444,
"total_power_export_t3_kwh": 8765.444,
"total_power_export_t4_kwh": 8765.444,
"active_power_w": -123,
"active_power_l1_w": -123,
"active_power_l2_w": 456,
"active_power_l3_w": 123.456,
"active_voltage_l1_v": 230.111,
"active_voltage_l2_v": 230.222,
"active_voltage_l3_v": 230.333,
"active_current_l1_a": -4,
"active_current_l2_a": 2,
"active_current_l3_a": 0,
"active_frequency_hz": 50,
"voltage_sag_l1_count": 1,
"voltage_sag_l2_count": 2,
"voltage_sag_l3_count": 3,
"voltage_swell_l1_count": 4,
"voltage_swell_l2_count": 5,
"voltage_swell_l3_count": 6,
"any_power_fail_count": 4,
"long_power_fail_count": 5,
"total_gas_m3": 1122.333,
"gas_timestamp": 210314112233,
"gas_unique_id": "01FFEEDDCCBBAA99887766554433221100",
"active_power_average_w": 123.0,
"montly_power_peak_w": 1111.0,
"montly_power_peak_timestamp": 230101080010,
"active_liter_lpm": 12.345,
"total_liter_m3": 1234.567,
"external": [
{
"unique_id": "47303031",
"type": "gas_meter",
"timestamp": 230125220957,
"value": 111.111,
"unit": "m3"
},
{
"unique_id": "57303031",
"type": "water_meter",
"timestamp": 230125220957,
"value": 222.222,
"unit": "m3"
},
{
"unique_id": "5757303031",
"type": "warm_water_meter",
"timestamp": 230125220957,
"value": 333.333,
"unit": "m3"
},
{
"unique_id": "48303031",
"type": "heat_meter",
"timestamp": 230125220957,
"value": 444.444,
"unit": "GJ"
},
{
"unique_id": "4948303031",
"type": "inlet_heat_meter",
"timestamp": 230125220957,
"value": 555.555,
"unit": "m3"
}
]
}
@@ -0,0 +1,7 @@
{
"product_type": "HWE-P1",
"product_name": "P1 meter",
"serial": "5c2fafabcdef",
"firmware_version": "4.19",
"api_version": "2.3.0"
}
@@ -0,0 +1,3 @@
{
"cloud_enabled": true
}
@@ -1,8 +1,8 @@
# serializer version: 1
# name: test_select_entity_snapshots[HWE-P1-select.device_battery_group_mode]
# name: test_select_entity_snapshots[HWE-P1-select.device_battery_group_charging_strategy]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Device Battery group mode',
'friendly_name': 'Device Battery group charging strategy',
'options': list([
'standby',
'to_full',
@@ -10,14 +10,14 @@
]),
}),
'context': <ANY>,
'entity_id': 'select.device_battery_group_mode',
'entity_id': 'select.device_battery_group_charging_strategy',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'zero',
})
# ---
# name: test_select_entity_snapshots[HWE-P1-select.device_battery_group_mode].1
# name: test_select_entity_snapshots[HWE-P1-select.device_battery_group_charging_strategy].1
EntityRegistryEntrySnapshot({
'aliases': list([
None,
@@ -37,7 +37,7 @@
'disabled_by': None,
'domain': 'select',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'select.device_battery_group_mode',
'entity_id': 'select.device_battery_group_charging_strategy',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -45,12 +45,12 @@
'labels': set({
}),
'name': None,
'object_id_base': 'Battery group mode',
'object_id_base': 'Battery group charging strategy',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Battery group mode',
'original_name': 'Battery group charging strategy',
'platform': 'homewizard',
'previous_unique_id': None,
'suggested_object_id': None,
@@ -60,7 +60,7 @@
'unit_of_measurement': None,
})
# ---
# name: test_select_entity_snapshots[HWE-P1-select.device_battery_group_mode].2
# name: test_select_entity_snapshots[HWE-P1-select.device_battery_group_charging_strategy].2
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
@@ -798,7 +798,7 @@
'object_id_base': 'Uptime',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'original_device_class': <SensorDeviceClass.UPTIME: 'uptime'>,
'original_icon': None,
'original_name': 'Uptime',
'platform': 'homewizard',
@@ -813,7 +813,7 @@
# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_uptime:state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'timestamp',
'device_class': 'uptime',
'friendly_name': 'Device Uptime',
}),
'context': <ANY>,
+34 -16
View File
@@ -33,31 +33,31 @@ pytestmark = [
(
"HWE-WTR",
[
"select.device_battery_group_mode",
"select.device_battery_group_charging_strategy",
],
),
(
"SDM230",
[
"select.device_battery_group_mode",
"select.device_battery_group_charging_strategy",
],
),
(
"SDM630",
[
"select.device_battery_group_mode",
"select.device_battery_group_charging_strategy",
],
),
(
"HWE-KWH1",
[
"select.device_battery_group_mode",
"select.device_battery_group_charging_strategy",
],
),
(
"HWE-KWH3",
[
"select.device_battery_group_mode",
"select.device_battery_group_charging_strategy",
],
),
],
@@ -74,7 +74,7 @@ async def test_entities_not_created_for_device(
@pytest.mark.parametrize(
("device_fixture", "entity_id"),
[
("HWE-P1", "select.device_battery_group_mode"),
("HWE-P1", "select.device_battery_group_charging_strategy"),
],
)
async def test_select_entity_snapshots(
@@ -101,17 +101,28 @@ async def test_select_entity_snapshots(
[
(
"HWE-P1",
"select.device_battery_group_mode",
"select.device_battery_group_charging_strategy",
"standby",
Batteries.Mode.STANDBY,
),
(
"HWE-P1",
"select.device_battery_group_mode",
"select.device_battery_group_charging_strategy",
"to_full",
Batteries.Mode.TO_FULL,
),
("HWE-P1", "select.device_battery_group_mode", "zero", Batteries.Mode.ZERO),
(
"HWE-P1",
"select.device_battery_group_charging_strategy",
"zero",
Batteries.Mode.ZERO,
),
(
"HWE-P1-predictive",
"select.device_battery_group_charging_strategy",
"predictive",
Batteries.Mode.PREDICTIVE,
),
],
)
async def test_select_set_option(
@@ -134,12 +145,19 @@ async def test_select_set_option(
mock_homewizardenergy.batteries.assert_called_with(mode=expected_mode)
@pytest.mark.parametrize("device_fixture", ["HWE-P1-predictive"])
async def test_select_predictive_mode_is_available(hass: HomeAssistant) -> None:
"""Test that predictive mode is available when supported by the device."""
assert (state := hass.states.get("select.device_battery_group_charging_strategy"))
assert "predictive" in state.attributes["options"]
@pytest.mark.parametrize(
("device_fixture", "entity_id", "option"),
[
("HWE-P1", "select.device_battery_group_mode", "zero"),
("HWE-P1", "select.device_battery_group_mode", "standby"),
("HWE-P1", "select.device_battery_group_mode", "to_full"),
("HWE-P1", "select.device_battery_group_charging_strategy", "zero"),
("HWE-P1", "select.device_battery_group_charging_strategy", "standby"),
("HWE-P1", "select.device_battery_group_charging_strategy", "to_full"),
],
)
async def test_select_request_error(
@@ -168,7 +186,7 @@ async def test_select_request_error(
@pytest.mark.parametrize(
("device_fixture", "entity_id", "option"),
[
("HWE-P1", "select.device_battery_group_mode", "to_full"),
("HWE-P1", "select.device_battery_group_charging_strategy", "to_full"),
],
)
async def test_select_unauthorized_error(
@@ -203,7 +221,7 @@ async def test_select_unauthorized_error(
@pytest.mark.parametrize(
("entity_id", "method"),
[
("select.device_battery_group_mode", "combined"),
("select.device_battery_group_charging_strategy", "combined"),
],
)
async def test_select_unreachable(
@@ -226,7 +244,7 @@ async def test_select_unreachable(
@pytest.mark.parametrize(
("device_fixture", "entity_id"),
[
("HWE-P1", "select.device_battery_group_mode"),
("HWE-P1", "select.device_battery_group_charging_strategy"),
],
)
async def test_select_multiple_state_changes(
@@ -275,7 +293,7 @@ async def test_select_multiple_state_changes(
(
"HWE-P1-no-batteries",
[
"select.device_battery_group_mode",
"select.device_battery_group_charging_strategy",
],
),
],
@@ -116,3 +116,30 @@ async def test_get_triggers(
]
assert triggers == unordered(expected_triggers)
async def test_get_triggers_for_removed_device(
hass: HomeAssistant,
mock_bridge_v2: Mock,
v2_resources_test_data: JsonArrayType,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test triggers for a device removed from the bridge.
Regression test for https://github.com/home-assistant/core/issues/152937
"""
await mock_bridge_v2.api.load_test_data(v2_resources_test_data)
await setup_platform(
hass, mock_bridge_v2, [Platform.BINARY_SENSOR, Platform.SENSOR]
)
# Create a device entry with a Hue ID that doesn't exist on the bridge
orphaned_device = device_registry.async_get_or_create(
config_entry_id=mock_bridge_v2.config_entry.entry_id,
identifiers={(hue.DOMAIN, "non-existent-hue-device-id")},
)
triggers = await async_get_device_automations(
hass, DeviceAutomationType.TRIGGER, orphaned_device.id
)
assert triggers == []
+49 -45
View File
@@ -1,5 +1,6 @@
"""The tests for the Light component."""
from typing import Any
from unittest.mock import MagicMock, mock_open, patch
import pytest
@@ -1708,42 +1709,50 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
assert data == {"brightness": 128, "color_temp_kelvin": 3451}
async def test_light_service_call_color_conversion_named_tuple(
@pytest.mark.parametrize(
"color_input",
[
pytest.param(
{"color_name": "maroon"},
id="color_name",
),
pytest.param(
{"rgb_color": color_util.RGBColor(128, 0, 0)},
id="rgb_color_named_tuple",
),
],
)
async def test_light_turn_on_rgb_color_is_plain_tuple(
hass: HomeAssistant,
color_input: dict[str, Any],
) -> None:
"""Test a named tuple (RGBColor) is handled correctly."""
"""Test that rgb_color passed to entity turn_on is always a plain tuple.
Covers two input paths that both resolve to the same RGB value (128, 0, 0):
- color_name: goes through color_name_to_rgb (returns RGBColor NamedTuple),
bypassing the service schema vol.Coerce(tuple) coercion.
- rgb_color: RGBColor NamedTuple passed directly, converted by the schema.
"""
entities = [
MockLight("Test_hs", STATE_ON),
MockLight("Test_rgb", STATE_ON),
MockLight("Test_xy", STATE_ON),
MockLight("Test_all", STATE_ON),
MockLight("Test_rgbw", STATE_ON),
MockLight("Test_rgbww", STATE_ON),
MockLight("Test_hs", STATE_ON, supported_color_modes={light.ColorMode.HS}),
MockLight("Test_rgb", STATE_ON, supported_color_modes={light.ColorMode.RGB}),
MockLight("Test_xy", STATE_ON, supported_color_modes={light.ColorMode.XY}),
MockLight(
"Test_all",
STATE_ON,
supported_color_modes={
light.ColorMode.HS,
light.ColorMode.RGB,
light.ColorMode.XY,
},
),
MockLight("Test_rgbw", STATE_ON, supported_color_modes={light.ColorMode.RGBW}),
MockLight(
"Test_rgbww", STATE_ON, supported_color_modes={light.ColorMode.RGBWW}
),
]
setup_test_component_platform(hass, light.DOMAIN, entities)
entity0 = entities[0]
entity0.supported_color_modes = {light.ColorMode.HS}
entity1 = entities[1]
entity1.supported_color_modes = {light.ColorMode.RGB}
entity2 = entities[2]
entity2.supported_color_modes = {light.ColorMode.XY}
entity3 = entities[3]
entity3.supported_color_modes = {
light.ColorMode.HS,
light.ColorMode.RGB,
light.ColorMode.XY,
}
entity4 = entities[4]
entity4.supported_color_modes = {light.ColorMode.RGBW}
entity5 = entities[5]
entity5.supported_color_modes = {light.ColorMode.RGBWW}
assert await async_setup_component(hass, "light", {"light": {"platform": "test"}})
await hass.async_block_till_done()
@@ -1751,30 +1760,25 @@ async def test_light_service_call_color_conversion_named_tuple(
"light",
"turn_on",
{
"entity_id": [
entity0.entity_id,
entity1.entity_id,
entity2.entity_id,
entity3.entity_id,
entity4.entity_id,
entity5.entity_id,
],
"entity_id": [entity.entity_id for entity in entities],
"brightness_pct": 25,
"rgb_color": color_util.RGBColor(128, 0, 0),
**color_input,
},
blocking=True,
)
_, data = entity0.last_call("turn_on")
_, data = entities[0].last_call("turn_on")
assert data == {"brightness": 64, "hs_color": (0.0, 100.0)}
_, data = entity1.last_call("turn_on")
_, data = entities[1].last_call("turn_on")
assert data == {"brightness": 64, "rgb_color": (128, 0, 0)}
_, data = entity2.last_call("turn_on")
assert type(data["rgb_color"]) is tuple
_, data = entities[2].last_call("turn_on")
assert data == {"brightness": 64, "xy_color": (0.701, 0.299)}
_, data = entity3.last_call("turn_on")
_, data = entities[3].last_call("turn_on")
assert data == {"brightness": 64, "rgb_color": (128, 0, 0)}
_, data = entity4.last_call("turn_on")
assert type(data["rgb_color"]) is tuple
_, data = entities[4].last_call("turn_on")
assert data == {"brightness": 64, "rgbw_color": (128, 0, 0, 0)}
_, data = entity5.last_call("turn_on")
_, data = entities[5].last_call("turn_on")
assert data == {"brightness": 64, "rgbww_color": (128, 0, 0, 0, 0)}
@@ -7,7 +7,9 @@
"wifi_credentials_set": true,
"thread_credentials_set": false,
"min_supported_schema_version": 1,
"bluetooth_enabled": false
"bluetooth_enabled": false,
"wifi_ssid": "test_ssid",
"ble_proxy_enabled": false
},
"nodes": [
{
@@ -8,7 +8,9 @@
"wifi_credentials_set": true,
"thread_credentials_set": false,
"min_supported_schema_version": 1,
"bluetooth_enabled": false
"bluetooth_enabled": false,
"wifi_ssid": "**REDACTED**",
"ble_proxy_enabled": false
},
"nodes": [
{
+6 -2
View File
@@ -9,8 +9,12 @@ from matter_server.common.helpers.util import dataclass_from_dict
from matter_server.common.models import ServerDiagnostics
import pytest
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.components.matter.const import DOMAIN
from homeassistant.components.matter.diagnostics import redact_matter_attributes
from homeassistant.components.matter.diagnostics import (
SERVER_INFO_TO_REDACT,
redact_matter_attributes,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
@@ -85,7 +89,7 @@ async def test_device_diagnostics(
"""Test the device diagnostics."""
system_info_dict = config_entry_diagnostics["info"]
device_diagnostics_redacted = {
"server_info": system_info_dict,
"server_info": async_redact_data(system_info_dict, SERVER_INFO_TO_REDACT),
"node": redact_matter_attributes(device_diagnostics),
}
server_diagnostics_response = {
+24 -2
View File
@@ -749,7 +749,18 @@ MOCK_SUBENTRY_DEVICE_DATA = {
}
MOCK_NOTIFY_SUBENTRY_DATA_MULTI = {
"device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 2}},
"device": MOCK_SUBENTRY_DEVICE_DATA
| {
"mqtt_settings": {
"qos": 2.0,
"message_expiry_interval": {
"days": 0,
"hours": 0,
"minutes": 1,
"seconds": 30,
},
}
},
"components": MOCK_SUBENTRY_NOTIFY_COMPONENT1 | MOCK_SUBENTRY_NOTIFY_COMPONENT2,
} | MOCK_SUBENTRY_AVAILABILITY_DATA
@@ -882,7 +893,18 @@ MOCK_SUBENTRY_DATA_BAD_COMPONENT_SCHEMA = {
"components": MOCK_SUBENTRY_NOTIFY_BAD_SCHEMA,
}
MOCK_SUBENTRY_DATA_SET_MIX = {
"device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}},
"device": MOCK_SUBENTRY_DEVICE_DATA
| {
"mqtt_settings": {
"qos": 0,
"message_expiry_interval": {
"days": 0,
"hours": 0,
"minutes": 1,
"seconds": 30,
},
}
},
"components": MOCK_SUBENTRY_NOTIFY_COMPONENT1
| MOCK_SUBENTRY_NOTIFY_COMPONENT2
| MOCK_SUBENTRY_LIGHT_BASIC_KELVIN_COMPONENT
+60
View File
@@ -88,6 +88,66 @@ async def test_sending_mqtt_commands(
assert state.state == "2021-11-08T13:31:44+00:00"
@pytest.mark.freeze_time("2021-11-08 13:31:44+00:00")
@pytest.mark.parametrize(
"hass_config",
[
{
mqtt.DOMAIN: {
button.DOMAIN: {
"command_topic": "command-topic",
"name": "test",
"default_entity_id": "button.test_button",
"payload_press": "beer press",
"qos": "2",
"message_expiry_interval": {
"days": 0,
"hours": 0,
"minutes": 1,
"seconds": 30,
},
}
}
},
{
mqtt.DOMAIN: {
button.DOMAIN: {
"command_topic": "command-topic",
"name": "test",
"default_entity_id": "button.test_button",
"payload_press": "beer press",
"qos": "2",
"message_expiry_interval": 90,
}
}
},
],
)
async def test_sending_mqtt_commands_with_message_expiry_interval(
hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator
) -> None:
"""Test the sending MQTT command with message expiry interval."""
mqtt_mock = await mqtt_mock_entry()
state = hass.states.get("button.test_button")
assert state.state == STATE_UNKNOWN
assert state.attributes.get(ATTR_FRIENDLY_NAME) == "test"
await hass.services.async_call(
button.DOMAIN,
button.SERVICE_PRESS,
{ATTR_ENTITY_ID: "button.test_button"},
blocking=True,
)
mqtt_mock.async_publish.assert_called_once_with(
"command-topic", "beer press", 2, False, message_expiry_interval=90
)
mqtt_mock.async_publish.reset_mock()
state = hass.states.get("button.test_button")
assert state.state == "2021-11-08T13:31:44+00:00"
@pytest.mark.parametrize(
"hass_config",
[
+3 -2
View File
@@ -1502,12 +1502,13 @@ async def test_publish_error(
async def test_subscribe_error(
hass: HomeAssistant,
setup_with_birth_msg_client_mock: MqttMockPahoClient,
mqtt_mock_entry: MqttMockHAClientGenerator,
mqtt_client_mock: MqttMockPahoClient,
record_calls: MessageCallbackType,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test publish error."""
mqtt_client_mock = setup_with_birth_msg_client_mock
await mqtt_mock_entry()
mqtt_client_mock.reset_mock()
# simulate client is not connected error before subscribing
mqtt_client_mock.subscribe.side_effect = lambda *args, **kwargs: (4, None)
+23 -2
View File
@@ -5196,7 +5196,14 @@ async def test_subentry_reconfigure_update_device_properties(
.schema["mqtt_settings"]
.schema.schema.items()
}
assert mqtt_settings_key_descriptions == {"qos": {"suggested_value": 2}}
assert mqtt_settings_key_descriptions == {
"qos": {
"suggested_value": 2,
},
"message_expiry_interval": {
"suggested_value": {"days": 0, "hours": 0, "minutes": 1, "seconds": 30}
},
}
assert result["data_schema"].schema["mqtt_settings"].options == {"collapsed": False}
# Update the device details
@@ -5209,7 +5216,15 @@ async def test_subentry_reconfigure_update_device_properties(
"model_id": "bn003",
"manufacturer": "Beer Masters",
"configuration_url": "https://example.com",
"mqtt_settings": {"qos": 1},
"mqtt_settings": {
"qos": 1,
"message_expiry_interval": {
"days": 0,
"hours": 0,
"minutes": 0,
"seconds": 30,
},
},
},
)
assert result["type"] is FlowResultType.MENU
@@ -5232,6 +5247,12 @@ async def test_subentry_reconfigure_update_device_properties(
assert device["sw_version"] == "1.1"
assert device["manufacturer"] == "Beer Masters"
assert device["mqtt_settings"]["qos"] == 1
assert device["mqtt_settings"]["message_expiry_interval"] == {
"days": 0,
"hours": 0,
"minutes": 0,
"seconds": 30,
}
assert "qos" not in device
+27 -6
View File
@@ -1,9 +1,10 @@
"""Common fixtures for the Novy Cooker Hood tests."""
from collections.abc import Iterator
from unittest.mock import patch
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from rf_protocols.loader import CodeCollection
from homeassistant.components.novy_cooker_hood.const import CONF_TRANSMITTER, DOMAIN
from homeassistant.const import CONF_CODE
@@ -11,16 +12,36 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry
from tests.components.radio_frequency.common import MockRadioFrequencyEntity
from tests.components.radio_frequency.common import (
MockRadioFrequencyCommand,
MockRadioFrequencyEntity,
)
TRANSMITTER_ENTITY_ID = "radio_frequency.test_rf_transmitter"
@pytest.fixture(autouse=True)
def mock_command_delay() -> Iterator[None]:
"""Drop the inter-command delay so tests don't spend real time waiting."""
with patch("homeassistant.components.novy_cooker_hood.fan._COMMAND_DELAY", 0):
yield
def mock_get_codes() -> Iterator[MagicMock]:
"""Patch the bundled-codes loader so tests don't hit the filesystem."""
fake_collection = MagicMock(spec=CodeCollection)
fake_collection.async_load_command = AsyncMock(
side_effect=lambda name: MockRadioFrequencyCommand()
)
with (
patch(
"homeassistant.components.novy_cooker_hood.light.get_codes_for_code",
return_value=fake_collection,
),
patch(
"homeassistant.components.novy_cooker_hood.fan.get_codes_for_code",
return_value=fake_collection,
),
patch(
"homeassistant.components.novy_cooker_hood.config_flow.get_codes_for_code",
return_value=fake_collection,
),
):
yield fake_collection
@pytest.fixture
@@ -1,7 +1,7 @@
"""Test the Novy Hood config flow."""
from collections.abc import Iterator
from unittest.mock import patch
from unittest.mock import MagicMock, patch
import pytest
@@ -49,6 +49,7 @@ async def _start_user_flow(hass: HomeAssistant, code: str = "1") -> dict:
async def test_user_flow_test_then_finish(
hass: HomeAssistant,
mock_get_codes: MagicMock,
mock_rf_entity: MockRadioFrequencyEntity,
entity_registry: er.EntityRegistry,
) -> None:
@@ -57,10 +58,8 @@ async def test_user_flow_test_then_finish(
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "test_light"
mock_get_codes.async_load_command.assert_awaited_with(COMMAND_LIGHT)
assert len(mock_rf_entity.send_command_calls) == 2
sent = mock_rf_entity.send_command_calls[0].command
assert sent.key == COMMAND_LIGHT
assert sent.channel == 3
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"next_step_id": "finish"}
@@ -78,6 +77,7 @@ async def test_user_flow_test_then_finish(
async def test_user_flow_retry_picks_different_code(
hass: HomeAssistant,
mock_get_codes: MagicMock,
mock_rf_entity: MockRadioFrequencyEntity,
entity_registry: er.EntityRegistry,
) -> None:
@@ -99,13 +99,9 @@ async def test_user_flow_retry_picks_different_code(
},
)
assert result["type"] is FlowResultType.MENU
# One load per test x two tests; two sends per test x two tests.
assert mock_get_codes.async_load_command.await_count == 2
assert len(mock_rf_entity.send_command_calls) == 4
assert [c.command.channel for c in mock_rf_entity.send_command_calls] == [
1,
1,
7,
7,
]
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"next_step_id": "finish"}
@@ -131,6 +127,7 @@ async def test_user_flow_test_transmit_failure(
async def test_recover_after_transmit_failure(
hass: HomeAssistant,
mock_get_codes: MagicMock,
mock_rf_entity: MockRadioFrequencyEntity,
) -> None:
"""The user can Retry from test_failed and complete the flow."""
@@ -186,6 +183,7 @@ async def test_unique_id_already_configured(
async def test_same_transmitter_different_code_is_allowed(
hass: HomeAssistant,
mock_get_codes: MagicMock,
mock_config_entry: MockConfigEntry,
mock_rf_entity: MockRadioFrequencyEntity,
entity_registry: er.EntityRegistry,
@@ -207,6 +205,7 @@ async def test_same_transmitter_different_code_is_allowed(
async def test_reconfigure_updates_entry(
hass: HomeAssistant,
mock_get_codes: MagicMock,
init_novy_cooker_hood: MockConfigEntry,
mock_rf_entity: MockRadioFrequencyEntity,
entity_registry: er.EntityRegistry,
@@ -225,9 +224,7 @@ async def test_reconfigure_updates_entry(
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "test_light"
sent = mock_rf_entity.send_command_calls[-1].command
assert sent.key == COMMAND_LIGHT
assert sent.channel == 4
mock_get_codes.async_load_command.assert_awaited_with(COMMAND_LIGHT)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"next_step_id": "finish"}
@@ -242,6 +239,7 @@ async def test_reconfigure_updates_entry(
async def test_reconfigure_frees_old_unique_id(
hass: HomeAssistant,
mock_get_codes: MagicMock,
init_novy_cooker_hood: MockConfigEntry,
mock_rf_entity: MockRadioFrequencyEntity,
) -> None:
@@ -297,6 +295,7 @@ async def test_reconfigure_aborts_on_collision(
async def test_reconfigure_retry_returns_to_picker(
hass: HomeAssistant,
mock_get_codes: MagicMock,
init_novy_cooker_hood: MockConfigEntry,
mock_rf_entity: MockRadioFrequencyEntity,
) -> None:
@@ -327,6 +326,7 @@ async def test_no_transmitters(hass: HomeAssistant) -> None:
async def test_recover_after_no_transmitters(
hass: HomeAssistant,
mock_get_codes: MagicMock,
) -> None:
"""User can re-init the flow after the radio_frequency integration loads."""
result = await hass.config_entries.flow.async_init(
@@ -1,5 +1,7 @@
"""Tests for the Novy Hood light platform."""
from unittest.mock import MagicMock, call
from homeassistant.components.light import (
DOMAIN as LIGHT_DOMAIN,
SERVICE_TURN_OFF,
@@ -26,6 +28,7 @@ ENTITY_ID = "light.novy_cooker_hood_light"
async def test_turn_on_and_off_send_light_once_each(
hass: HomeAssistant,
mock_get_codes: MagicMock,
mock_rf_entity: MockRadioFrequencyEntity,
init_novy_cooker_hood: MockConfigEntry,
) -> None:
@@ -63,11 +66,11 @@ async def test_turn_on_and_off_send_light_once_each(
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state == STATE_OFF
assert len(mock_rf_entity.send_command_calls) == 2
assert [c.command.key for c in mock_rf_entity.send_command_calls] == [
COMMAND_LIGHT,
COMMAND_LIGHT,
assert mock_get_codes.async_load_command.await_args_list == [
call(COMMAND_LIGHT),
call(COMMAND_LIGHT),
]
assert len(mock_rf_entity.send_command_calls) == 2
async def test_restore_state(
@@ -116,3 +116,54 @@ async def test_host_updated(hass: HomeAssistant) -> None:
assert result["reason"] == "already_configured"
assert entry.data[CONF_HOST] == MOCK_SSDP_LOCATION
async def test_ssdp_udn_as_list(hass: HomeAssistant) -> None:
"""Test SSDP discovery when UDN is a list instead of a string.
Regression test for https://github.com/home-assistant/core/issues/171837
"""
list_udn_discovery = SsdpServiceInfo(
ssdp_usn="usn",
ssdp_st="st",
ssdp_location=MOCK_SSDP_LOCATION,
upnp={
ATTR_UPNP_FRIENDLY_NAME: MOCK_FRIENDLY_NAME,
ATTR_UPNP_UDN: [MOCK_UDN, "uuid:other"],
},
)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={CONF_SOURCE: SOURCE_SSDP},
data=list_udn_discovery,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "confirm"
assert result["description_placeholders"] == {CONF_NAME: MOCK_FRIENDLY_NAME}
result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == MOCK_FRIENDLY_NAME
assert result2["data"] == {CONF_HOST: MOCK_SSDP_LOCATION}
async def test_ssdp_udn_as_empty_list(hass: HomeAssistant) -> None:
"""Test SSDP discovery when UDN is an empty list."""
empty_udn_discovery = SsdpServiceInfo(
ssdp_usn="usn",
ssdp_st="st",
ssdp_location=MOCK_SSDP_LOCATION,
upnp={
ATTR_UPNP_FRIENDLY_NAME: MOCK_FRIENDLY_NAME,
ATTR_UPNP_UDN: [],
},
)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={CONF_SOURCE: SOURCE_SSDP},
data=empty_udn_discovery,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "incomplete_discovery"
@@ -126,6 +126,36 @@ def mock_websocket_client(
message="Data listener registered",
data={EventKey.MODULES: register_data_listener_model.modules},
)
websocket_client.open_url.return_value = Response(
id=FIXTURE_REQUEST_ID,
type=EventType.OPENED,
message="Opened url",
data={"url": "https://example.com"},
)
websocket_client.open_path.return_value = Response(
id=FIXTURE_REQUEST_ID,
type=EventType.OPENED,
message="Opened file",
data={"path": "/home/user/documents"},
)
websocket_client.power_shutdown.return_value = Response(
id=FIXTURE_REQUEST_ID,
type=EventType.POWER_SHUTDOWN,
message="Shutdown",
data={},
)
websocket_client.keyboard_keypress.return_value = Response(
id=FIXTURE_REQUEST_ID,
type=EventType.KEYBOARD_KEY_PRESSED,
message="Keyboard key pressed",
data={"key": "backspace"},
)
websocket_client.keyboard_text.return_value = Response(
id=FIXTURE_REQUEST_ID,
type=EventType.KEYBOARD_TEXT_SENT,
message="Keyboard text sent",
data={"text": "Hello world"},
)
# Trigger callback when listener is registered
websocket_client.listen.side_effect = mock_data_listener
@@ -0,0 +1,91 @@
# serializer version: 1
# name: test_get_process_services[get_process_by_id]
dict({
'cpu_usage': 12.3,
'created': 12.3,
'id': 1234,
'memory_usage': 12.3,
'name': 'name',
'path': '/path',
'status': 'running',
'username': 'username',
'working_directory': '/working/directory',
})
# ---
# name: test_get_process_services[get_processes_by_name]
dict({
'count': 1,
'processes': list([
dict({
'cpu_usage': 12.3,
'created': 12.3,
'id': 1234,
'memory_usage': 12.3,
'name': 'name',
'path': '/path',
'status': 'running',
'username': 'username',
'working_directory': '/working/directory',
}),
]),
})
# ---
# name: test_services[open_path]
dict({
'data': dict({
'path': '/home/user/documents',
}),
'id': 'test',
'message': 'Opened file',
'module': None,
'subtype': None,
'type': <EventType.OPENED: 'OPENED'>,
})
# ---
# name: test_services[open_url]
dict({
'data': dict({
'url': 'https://example.com',
}),
'id': 'test',
'message': 'Opened url',
'module': None,
'subtype': None,
'type': <EventType.OPENED: 'OPENED'>,
})
# ---
# name: test_services[power_command_shutdown]
dict({
'data': dict({
}),
'id': 'test',
'message': 'Shutdown',
'module': None,
'subtype': None,
'type': <EventType.POWER_SHUTDOWN: 'POWER_SHUTDOWN'>,
})
# ---
# name: test_services[send_keypress]
dict({
'data': dict({
'key': 'backspace',
}),
'id': 'test',
'message': 'Keyboard key pressed',
'module': None,
'subtype': None,
'type': <EventType.KEYBOARD_KEY_PRESSED: 'KEYBOARD_KEY_PRESSED'>,
})
# ---
# name: test_services[send_text]
dict({
'data': dict({
'text': 'Hello world',
}),
'id': 'test',
'message': 'Keyboard text sent',
'module': None,
'subtype': None,
'type': <EventType.KEYBOARD_TEXT_SENT: 'KEYBOARD_TEXT_SENT'>,
})
# ---
@@ -2,6 +2,8 @@
from unittest.mock import patch
import pytest
from homeassistant.components.system_bridge.config_flow import SystemBridgeConfigFlow
from homeassistant.components.system_bridge.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
@@ -13,6 +15,23 @@ from . import FIXTURE_USER_INPUT, FIXTURE_UUID
from tests.common import MockConfigEntry
@pytest.mark.usefixtures("mock_version", "mock_websocket_client")
async def test_entry_setup_unload(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test integration setup and unload."""
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
assert await hass.config_entries.async_unload(mock_config_entry.entry_id)
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
async def test_migration_minor_1_to_2(hass: HomeAssistant) -> None:
"""Test migration."""
config_entry = MockConfigEntry(
@@ -0,0 +1,155 @@
"""Tests for System Bridge actions."""
from typing import Any
import pytest
from syrupy.assertion import SnapshotAssertion
from systembridgeconnector.models.keyboard_key import KeyboardKey
from systembridgeconnector.models.keyboard_text import KeyboardText
from systembridgeconnector.models.open_path import OpenPath
from systembridgeconnector.models.open_url import OpenUrl
from homeassistant.components.system_bridge.const import DOMAIN
from homeassistant.components.system_bridge.services import (
CONF_BRIDGE,
CONF_KEY,
CONF_TEXT,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_COMMAND, CONF_ID, CONF_NAME, CONF_PATH, CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from . import FIXTURE_UUID
from tests.common import AsyncMock, MockConfigEntry
@pytest.mark.parametrize(
("service", "service_data", "call_method", "call_args"),
[
(
"open_path",
{CONF_PATH: "/home/user/documents"},
"open_path",
[OpenPath(path="/home/user/documents")],
),
(
"open_url",
{CONF_URL: "https://example.com"},
"open_url",
[OpenUrl(url="https://example.com")],
),
(
"power_command",
{CONF_COMMAND: "shutdown"},
"power_shutdown",
[],
),
(
"send_keypress",
{CONF_KEY: "backspace"},
"keyboard_keypress",
[KeyboardKey(key="backspace")],
),
(
"send_text",
{CONF_TEXT: "Hello world"},
"keyboard_text",
[KeyboardText(text="Hello world")],
),
],
ids=[
"open_path",
"open_url",
"power_command_shutdown",
"send_keypress",
"send_text",
],
)
@pytest.mark.usefixtures("mock_version")
async def test_services(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_websocket_client: AsyncMock,
snapshot: SnapshotAssertion,
device_registry: dr.DeviceRegistry,
service: str,
service_data: dict[str, Any],
call_method: str,
call_args: list[Any],
) -> None:
"""Test System Bridge service action calls."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, FIXTURE_UUID)}
)
assert device_entry
resp = await hass.services.async_call(
DOMAIN,
service,
{
CONF_BRIDGE: device_entry.id,
**service_data,
},
blocking=True,
return_response=True,
)
getattr(mock_websocket_client, call_method).assert_awaited_once_with(*call_args)
assert resp == snapshot
@pytest.mark.parametrize(
("service", "service_data"),
[
(
"get_process_by_id",
{CONF_ID: 1234},
),
(
"get_processes_by_name",
{CONF_NAME: "name"},
),
],
ids=["get_process_by_id", "get_processes_by_name"],
)
@pytest.mark.usefixtures("mock_version", "mock_websocket_client")
async def test_get_process_services(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
device_registry: dr.DeviceRegistry,
service: str,
service_data: dict[str, Any],
) -> None:
"""Test System Bridge get process service action calls."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, FIXTURE_UUID)}
)
assert device_entry
resp = await hass.services.async_call(
DOMAIN,
service,
{
CONF_BRIDGE: device_entry.id,
**service_data,
},
blocking=True,
return_response=True,
)
assert resp == snapshot
@@ -50,6 +50,7 @@ def device_fixtures() -> list[str]:
"XT-LT200",
"XT-PL50",
"XT-PL100",
"XT-LK50",
]
@@ -0,0 +1,13 @@
{
"id": "dev_lock_001",
"name": "Front Door Lock",
"type": "lock",
"model": "XT-LK50",
"version": "1.0.0",
"online": true,
"status": {
"locked": true,
"jammed": false,
"battery": 85
}
}
@@ -154,3 +154,34 @@
'via_device_id': None,
})
# ---
# name: test_devices[XT-LK50]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'xthings_cloud',
'dev_lock_001',
),
}),
'labels': set({
}),
'manufacturer': 'Xthings',
'model': 'XT-LK50',
'model_id': None,
'name': 'Front Door Lock',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': '1.0.0',
'via_device_id': None,
})
# ---
@@ -0,0 +1,52 @@
# serializer version: 1
# name: test_locks[lock.front_door_lock-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'lock',
'entity_category': None,
'entity_id': 'lock.front_door_lock',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'xthings_cloud',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'dev_lock_001',
'unit_of_measurement': None,
})
# ---
# name: test_locks[lock.front_door_lock-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Front Door Lock',
'supported_features': <LockEntityFeature: 0>,
}),
'context': <ANY>,
'entity_id': 'lock.front_door_lock',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'locked',
})
# ---
@@ -27,9 +27,10 @@ from .const import (
from tests.common import MockConfigEntry
@pytest.mark.usefixtures("mock_setup_entry")
async def test_user_flow_success(
hass: HomeAssistant, mock_api_client: AsyncMock
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_api_client: AsyncMock,
) -> None:
"""Test successful user login flow."""
result = await hass.config_entries.flow.async_init(
@@ -61,9 +62,9 @@ async def test_user_flow_success(
(RuntimeError("unexpected"), "unknown"),
],
)
@pytest.mark.usefixtures("mock_setup_entry")
async def test_user_flow_error_and_recover(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_api_client: AsyncMock,
side_effect: Exception,
expected_error: str,
@@ -90,9 +91,11 @@ async def test_user_flow_error_and_recover(
assert result["type"] is FlowResultType.CREATE_ENTRY
@pytest.mark.usefixtures("mock_setup_entry")
async def test_user_flow_already_configured(
hass: HomeAssistant, mock_api_client: AsyncMock, mock_config_entry: MockConfigEntry
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_api_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test user flow aborts if same account already configured."""
mock_config_entry.add_to_hass(hass)
+105
View File
@@ -0,0 +1,105 @@
"""Tests for Xthings Cloud lock platform."""
from unittest.mock import AsyncMock, patch
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.lock import (
DOMAIN as LOCK_DOMAIN,
SERVICE_LOCK,
SERVICE_UNLOCK,
)
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import get_device_by_id, setup_integration
from tests.common import MockConfigEntry, snapshot_platform
async def test_locks(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_api_client: AsyncMock,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test lock entities are created correctly."""
with patch("homeassistant.components.xthings_cloud.PLATFORMS", [Platform.LOCK]):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(
hass, entity_registry, snapshot, mock_config_entry.entry_id
)
@pytest.mark.parametrize(
("service", "method"),
[
(SERVICE_LOCK, "async_lock_lock"),
(SERVICE_UNLOCK, "async_lock_unlock"),
],
)
async def test_lock_lock_unlock(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_api_client: AsyncMock,
service: str,
method: str,
) -> None:
"""Test locking and unlocking a lock."""
with patch("homeassistant.components.xthings_cloud.PLATFORMS", [Platform.LOCK]):
await setup_integration(hass, mock_config_entry)
await hass.services.async_call(
LOCK_DOMAIN,
service,
{ATTR_ENTITY_ID: "lock.front_door_lock"},
blocking=True,
)
getattr(mock_api_client, method).assert_called_once_with("dev_lock_001")
async def test_lock_unavailable_when_offline(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_api_client: AsyncMock,
) -> None:
"""Test lock shows unavailable when device is offline."""
get_device_by_id(mock_api_client, "dev_lock_001")["online"] = False
with patch("homeassistant.components.xthings_cloud.PLATFORMS", [Platform.LOCK]):
await setup_integration(hass, mock_config_entry)
state = hass.states.get("lock.front_door_lock")
assert state is not None
assert state.state == STATE_UNAVAILABLE
async def test_updating_state(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_api_client: AsyncMock,
mock_websocket: AsyncMock,
) -> None:
"""Test updating state."""
with patch("homeassistant.components.xthings_cloud.PLATFORMS", [Platform.LOCK]):
await setup_integration(hass, mock_config_entry)
state = hass.states.get("lock.front_door_lock")
assert state is not None
assert state.state == "locked"
mock_websocket.call_args[1]["on_device_status"](
"dev_lock_001",
{
"locked": False,
"jammed": False,
"battery": 80,
},
)
state = hass.states.get("lock.front_door_lock")
assert state is not None
assert state.state == "unlocked"
-48
View File
@@ -1,52 +1,4 @@
"""Tests for the Zeversolar integration."""
from unittest.mock import patch
from zeversolar import StatusEnum, ZeverSolarData
from homeassistant.components.zeversolar.const import DOMAIN
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
MOCK_HOST_ZEVERSOLAR = "zeversolar-fake-host"
MOCK_PORT_ZEVERSOLAR = 10200
MOCK_SERIAL_NUMBER = "123456778"
async def init_integration(hass: HomeAssistant) -> MockConfigEntry:
"""Mock integration setup."""
zeverData = ZeverSolarData(
wifi_enabled=False,
serial_or_registry_id="EAB9615C0001",
registry_key="WSMQKHTQ3JVYQWA9",
hardware_version="M10",
software_version="19703-826R+17511-707R",
reported_datetime="19900101 23:01:45",
communication_status=StatusEnum.OK,
num_inverters=1,
serial_number=MOCK_SERIAL_NUMBER,
pac=1234,
energy_today=123.4,
status=StatusEnum.OK,
meter_status=StatusEnum.OK,
)
with (
patch("zeversolar.ZeverSolarClient.get_data", return_value=zeverData),
):
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: MOCK_HOST_ZEVERSOLAR,
CONF_PORT: MOCK_PORT_ZEVERSOLAR,
},
entry_id="my_id",
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
return entry
+40 -12
View File
@@ -1,35 +1,33 @@
"""Define mocks and test objects."""
from collections.abc import AsyncGenerator, Generator
from unittest.mock import MagicMock, patch
import pytest
from zeversolar import StatusEnum, ZeverSolarData
from homeassistant.components.zeversolar.const import DOMAIN
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from . import MOCK_HOST_ZEVERSOLAR, MOCK_SERIAL_NUMBER
from tests.common import MockConfigEntry
MOCK_HOST_ZEVERSOLAR = "zeversolar-fake-host"
MOCK_PORT_ZEVERSOLAR = 10200
@pytest.fixture
def config_entry() -> MockConfigEntry:
"""Create a mock config entry."""
return MockConfigEntry(
data={
CONF_HOST: MOCK_HOST_ZEVERSOLAR,
CONF_PORT: MOCK_PORT_ZEVERSOLAR,
},
domain=DOMAIN,
unique_id="my_id_2",
data={CONF_HOST: MOCK_HOST_ZEVERSOLAR},
unique_id=MOCK_SERIAL_NUMBER,
)
@pytest.fixture
def zeversolar_data() -> ZeverSolarData:
"""Create a ZeverSolarData structure for tests."""
return ZeverSolarData(
wifi_enabled=False,
serial_or_registry_id="1223",
@@ -39,9 +37,39 @@ def zeversolar_data() -> ZeverSolarData:
reported_datetime="19900101 23:00",
communication_status=StatusEnum.OK,
num_inverters=1,
serial_number="123456778",
serial_number=MOCK_SERIAL_NUMBER,
pac=1234,
energy_today=123,
status=StatusEnum.OK,
meter_status=StatusEnum.OK,
)
@pytest.fixture
def mock_zeversolar_client(zeversolar_data: ZeverSolarData) -> Generator[MagicMock]:
"""Mock the ZeverSolar client."""
with (
patch(
"homeassistant.components.zeversolar.coordinator.zeversolar.ZeverSolarClient",
autospec=True,
) as mock_client,
patch(
"homeassistant.components.zeversolar.config_flow.zeversolar.ZeverSolarClient",
new=mock_client,
),
):
mock_client.return_value.get_data.return_value = zeversolar_data
yield mock_client.return_value
@pytest.fixture
async def init_integration(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_zeversolar_client: MagicMock,
) -> AsyncGenerator[MockConfigEntry]:
"""Set up the Zeversolar integration for testing."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
return config_entry
@@ -10,16 +10,16 @@
# name: test_entry_diagnostics
dict({
'communication_status': 'OK',
'energy_today': 123.4,
'energy_today': 123,
'hardware_version': 'M10',
'meter_status': 'OK',
'num_inverters': 1,
'pac': 1234,
'registry_key': 'WSMQKHTQ3JVYQWA9',
'reported_datetime': '19900101 23:01:45',
'registry_key': 'A-2',
'reported_datetime': '19900101 23:00',
'serial_number': '123456778',
'serial_or_registry_id': 'EAB9615C0001',
'software_version': '19703-826R+17511-707R',
'serial_or_registry_id': '1223',
'software_version': '123-23',
'status': 'OK',
'wifi_enabled': False,
})
@@ -54,7 +54,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '123.4',
'state': '123',
})
# ---
# name: test_sensors[sensor.zeversolar_sensor_power-entry]
+35 -41
View File
@@ -18,7 +18,7 @@ from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
async def test_form(hass: HomeAssistant) -> None:
async def test_form(hass: HomeAssistant, mock_zeversolar_client: MagicMock) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
@@ -26,7 +26,9 @@ async def test_form(hass: HomeAssistant) -> None:
assert result["type"] is FlowResultType.FORM
assert result["errors"] is None
await _set_up_zeversolar(hass=hass, flow_id=result["flow_id"])
await _set_up_zeversolar(
hass=hass, flow_id=result["flow_id"], mock_client=mock_zeversolar_client
)
@pytest.mark.parametrize(
@@ -52,6 +54,7 @@ async def test_form(hass: HomeAssistant) -> None:
)
async def test_form_errors(
hass: HomeAssistant,
mock_zeversolar_client: MagicMock,
side_effect: Exception,
errors: dict,
) -> None:
@@ -60,32 +63,30 @@ async def test_form_errors(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"zeversolar.ZeverSolarClient.get_data",
side_effect=side_effect,
):
result2 = await hass.config_entries.flow.async_configure(
flow_id=result["flow_id"],
user_input={
CONF_HOST: "test_ip",
},
)
mock_zeversolar_client.get_data.side_effect = side_effect
result2 = await hass.config_entries.flow.async_configure(
flow_id=result["flow_id"],
user_input={
CONF_HOST: "test_ip",
},
)
mock_zeversolar_client.get_data.side_effect = None
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == errors
await _set_up_zeversolar(hass=hass, flow_id=result["flow_id"])
async def test_abort_already_configured(hass: HomeAssistant) -> None:
"""Test we abort when the device is already configured."""
entry = MockConfigEntry(
domain=DOMAIN,
title="Zeversolar",
data={CONF_HOST: "test_ip"},
unique_id="test_serial",
await _set_up_zeversolar(
hass=hass, flow_id=result["flow_id"], mock_client=mock_zeversolar_client
)
entry.add_to_hass(hass)
async def test_abort_already_configured(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_zeversolar_client: MagicMock,
) -> None:
"""Test we abort when the device is already configured."""
config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
@@ -94,14 +95,9 @@ async def test_abort_already_configured(hass: HomeAssistant) -> None:
assert result.get("errors") is None
assert "flow_id" in result
mock_data = MagicMock()
mock_data.serial_number = "test_serial"
with (
patch("zeversolar.ZeverSolarClient.get_data", return_value=mock_data),
patch(
"homeassistant.components.zeversolar.async_setup_entry",
) as mock_setup_entry,
):
with patch(
"homeassistant.components.zeversolar.async_setup_entry",
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
flow_id=result["flow_id"],
user_input={
@@ -115,17 +111,15 @@ async def test_abort_already_configured(hass: HomeAssistant) -> None:
assert len(mock_setup_entry.mock_calls) == 0
async def _set_up_zeversolar(hass: HomeAssistant, flow_id: str) -> None:
async def _set_up_zeversolar(
hass: HomeAssistant, flow_id: str, mock_client: MagicMock
) -> None:
"""Reusable successful setup of Zeversolar sensor."""
mock_data = MagicMock()
mock_data.serial_number = "test_serial"
with (
patch("zeversolar.ZeverSolarClient.get_data", return_value=mock_data),
patch(
"homeassistant.components.zeversolar.async_setup_entry",
return_value=True,
) as mock_setup_entry,
):
mock_client.get_data.return_value.serial_number = "test_serial"
with patch(
"homeassistant.components.zeversolar.async_setup_entry",
return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
flow_id=flow_id,
user_input={
@@ -6,8 +6,9 @@ from homeassistant.components.zeversolar.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from . import MOCK_SERIAL_NUMBER, init_integration
from . import MOCK_SERIAL_NUMBER
from tests.common import MockConfigEntry
from tests.components.diagnostics import (
get_diagnostics_for_config_entry,
get_diagnostics_for_device,
@@ -19,12 +20,13 @@ async def test_entry_diagnostics(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
snapshot: SnapshotAssertion,
init_integration: MockConfigEntry,
) -> None:
"""Test config entry diagnostics."""
entry = await init_integration(hass)
assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == snapshot
assert (
await get_diagnostics_for_config_entry(hass, hass_client, init_integration)
== snapshot
)
async def test_device_diagnostics(
@@ -32,15 +34,14 @@ async def test_device_diagnostics(
hass_client: ClientSessionGenerator,
device_registry: dr.DeviceRegistry,
snapshot: SnapshotAssertion,
init_integration: MockConfigEntry,
) -> None:
"""Test device diagnostics."""
entry = await init_integration(hass)
device = device_registry.async_get_device(
identifiers={(DOMAIN, MOCK_SERIAL_NUMBER)}
)
assert (
await get_diagnostics_for_device(hass, hass_client, entry, device) == snapshot
await get_diagnostics_for_device(hass, hass_client, init_integration, device)
== snapshot
)
+9 -15
View File
@@ -1,8 +1,7 @@
"""Test the init file code."""
from unittest.mock import patch
from unittest.mock import MagicMock, patch
from zeversolar import ZeverSolarData
from zeversolar.exceptions import ZeverSolarTimeout
from homeassistant.config_entries import ConfigEntryState
@@ -12,28 +11,23 @@ from tests.common import MockConfigEntry
async def test_async_setup_entry_fails(
hass: HomeAssistant, config_entry: MockConfigEntry, zeversolar_data: ZeverSolarData
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_zeversolar_client: MagicMock,
) -> None:
"""Test to load/unload the integration."""
config_entry.add_to_hass(hass)
with (
patch("zeversolar.ZeverSolarClient.get_data", side_effect=ZeverSolarTimeout),
):
await hass.config_entries.async_setup(config_entry.entry_id)
mock_zeversolar_client.get_data.side_effect = ZeverSolarTimeout
await hass.config_entries.async_setup(config_entry.entry_id)
assert config_entry.state is ConfigEntryState.SETUP_RETRY
with (
patch("homeassistant.components.zeversolar.PLATFORMS", []),
patch("zeversolar.ZeverSolarClient.get_data", return_value=zeversolar_data),
):
mock_zeversolar_client.get_data.side_effect = None
with patch("homeassistant.components.zeversolar.PLATFORMS", []):
hass.config_entries.async_schedule_reload(config_entry.entry_id)
assert config_entry.state is ConfigEntryState.LOADED
with (
patch("homeassistant.components.zeversolar.PLATFORMS", []),
):
with patch("homeassistant.components.zeversolar.PLATFORMS", []):
result = await hass.config_entries.async_unload(config_entry.entry_id)
assert result is True
assert config_entry.state is ConfigEntryState.NOT_LOADED
+8 -8
View File
@@ -8,20 +8,20 @@ from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import init_integration
from tests.common import snapshot_platform
from tests.common import MockConfigEntry, snapshot_platform
async def test_sensors(
hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
init_integration: MockConfigEntry,
) -> None:
"""Test sensors."""
with patch(
"homeassistant.components.zeversolar.PLATFORMS",
[Platform.SENSOR],
):
entry = await init_integration(hass)
await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id)
await snapshot_platform(
hass, entity_registry, snapshot, init_integration.entry_id
)
+3 -1
View File
@@ -1074,7 +1074,9 @@ def mqtt_client_mock(hass: HomeAssistant) -> Generator[MqttMockPahoClient]:
@ha.callback
def _async_fire_mqtt_message(topic, payload, qos, retain, properties=None):
async_fire_mqtt_message(hass, topic, payload or b"", qos, retain)
async_fire_mqtt_message(
hass, topic, payload or b"", qos, retain, properties=properties
)
mid = get_mid()
hass.loop.call_soon(
mock_client.on_publish, Mock(), 0, mid, MockMqttReasonCode(), None
+274
View File
@@ -0,0 +1,274 @@
"""Tests for the MDI icons checker."""
import json
from pathlib import Path
import astroid
from pylint.testutils import UnittestLinter
from pylint.utils.ast_walker import ASTWalker
from pylint_home_assistant.checkers.mdi_icons import MdiIconsChecker
from pylint_home_assistant.helpers.icons import clear_icons_cache
import pytest
from . import assert_no_messages
@pytest.fixture(name="mdi_checker")
def mdi_checker_fixture(linter: UnittestLinter) -> MdiIconsChecker:
"""Fixture to provide an MDI icons checker."""
clear_icons_cache()
checker = MdiIconsChecker(linter)
checker.open()
return checker
def _make_integration(tmp_path: Path, icons: dict | None = None) -> Path:
"""Create a fake integration with optional icons.json."""
integration_dir = tmp_path / "homeassistant" / "components" / "test_int"
integration_dir.mkdir(parents=True)
if icons is not None:
(integration_dir / "icons.json").write_text(json.dumps(icons))
return integration_dir
# --- Python code tests ---
@pytest.mark.parametrize(
"code",
[
pytest.param(
'icon="mdi:thermometer"',
id="valid_icon",
),
pytest.param(
'icon="mdi:lightning-bolt"',
id="valid_icon_with_hyphen",
),
pytest.param(
'ICON = "mdi:home"',
id="valid_icon_constant",
),
pytest.param(
'device_class = "temperature"',
id="non_mdi_string",
),
pytest.param(
'icon = "mdi:%s" % icon_name',
id="percent_format_template",
),
pytest.param(
'icon = "mdi:{}".format(icon_name)',
id="str_format_template",
),
pytest.param(
'icon = f"mdi:{icon_name}"',
id="fstring_template",
),
pytest.param(
'icon = "mdi:fan-speed-" + suffix',
id="partial_with_concatenation",
),
],
)
def test_python_no_warning(
linter: UnittestLinter,
mdi_checker: MdiIconsChecker,
code: str,
) -> None:
"""Test that valid MDI icons in Python code pass."""
root_node = astroid.parse(code, "homeassistant.components.test_integration.sensor")
walker = ASTWalker(linter)
walker.add_checker(mdi_checker)
with assert_no_messages(linter):
walker.walk(root_node)
@pytest.mark.parametrize(
("icon", "code"),
[
pytest.param(
"mdi:nonexistent-icon-name",
'icon="mdi:nonexistent-icon-name"',
id="nonexistent_icon",
),
pytest.param(
"mdi:typo-thremometer",
'ICON = "mdi:typo-thremometer"',
id="typo_in_icon",
),
pytest.param(
"mdi:bad_icon",
'icon = "mdi:bad_icon"',
id="underscore_in_name",
),
pytest.param(
"mdi:Bad-Icon",
'icon = "mdi:Bad-Icon"',
id="uppercase_in_name",
),
],
)
def test_python_invalid_icon_flagged(
linter: UnittestLinter,
mdi_checker: MdiIconsChecker,
icon: str,
code: str,
) -> None:
"""Test that invalid MDI icons in Python code are flagged."""
root_node = astroid.parse(code, "homeassistant.components.test_integration.sensor")
walker = ASTWalker(linter)
walker.add_checker(mdi_checker)
walker.walk(root_node)
messages = linter.release_messages()
assert len(messages) == 1
assert messages[0].msg_id == "home-assistant-mdi-icon-not-found"
assert icon in messages[0].args[0]
def test_python_not_integration_ignored(
linter: UnittestLinter,
mdi_checker: MdiIconsChecker,
) -> None:
"""Test that non-integration modules are ignored."""
root_node = astroid.parse(
'ICON = "mdi:nonexistent-icon"',
"tests.components.test_integration",
)
walker = ASTWalker(linter)
walker.add_checker(mdi_checker)
with assert_no_messages(linter):
walker.walk(root_node)
# --- icons.json tests ---
def test_icons_json_valid(
linter: UnittestLinter,
mdi_checker: MdiIconsChecker,
tmp_path: Path,
) -> None:
"""Test that valid icons.json passes."""
integration_dir = _make_integration(
tmp_path,
{
"entity": {
"sensor": {
"temperature": {"default": "mdi:thermometer"},
}
},
"services": {
"my_service": {"service": "mdi:cog"},
},
},
)
root_node = astroid.parse(
"DOMAIN = 'test_int'",
"homeassistant.components.test_int.__init__",
)
root_node.file = str(integration_dir / "__init__.py")
walker = ASTWalker(linter)
walker.add_checker(mdi_checker)
with assert_no_messages(linter):
walker.walk(root_node)
def test_icons_json_invalid_flagged(
linter: UnittestLinter,
mdi_checker: MdiIconsChecker,
tmp_path: Path,
) -> None:
"""Test that invalid icons in icons.json are flagged."""
integration_dir = _make_integration(
tmp_path,
{
"entity": {
"sensor": {
"temperature": {"default": "mdi:nonexistent-sensor-icon"},
}
},
},
)
root_node = astroid.parse(
"DOMAIN = 'test_int'",
"homeassistant.components.test_int.__init__",
)
root_node.file = str(integration_dir / "__init__.py")
walker = ASTWalker(linter)
walker.add_checker(mdi_checker)
walker.walk(root_node)
messages = linter.release_messages()
assert len(messages) == 1
assert messages[0].msg_id == "home-assistant-mdi-icon-json-not-found"
assert "nonexistent-sensor-icon" in messages[0].args[0]
def test_icons_json_no_file_no_warning(
linter: UnittestLinter,
mdi_checker: MdiIconsChecker,
tmp_path: Path,
) -> None:
"""Test that missing icons.json doesn't cause warnings."""
integration_dir = _make_integration(tmp_path)
root_node = astroid.parse(
"DOMAIN = 'test_int'",
"homeassistant.components.test_int.__init__",
)
root_node.file = str(integration_dir / "__init__.py")
walker = ASTWalker(linter)
walker.add_checker(mdi_checker)
with assert_no_messages(linter):
walker.walk(root_node)
def test_icons_json_nested_invalid_flagged(
linter: UnittestLinter,
mdi_checker: MdiIconsChecker,
tmp_path: Path,
) -> None:
"""Test that deeply nested invalid icons are caught."""
integration_dir = _make_integration(
tmp_path,
{
"entity": {
"light": {
"my_light": {
"state_attributes": {
"effect": {
"state": {
"sparkle": "mdi:does-not-exist",
}
}
}
}
}
},
},
)
root_node = astroid.parse(
"DOMAIN = 'test_int'",
"homeassistant.components.test_int.__init__",
)
root_node.file = str(integration_dir / "__init__.py")
walker = ASTWalker(linter)
walker.add_checker(mdi_checker)
walker.walk(root_node)
messages = linter.release_messages()
assert len(messages) == 1
assert "does-not-exist" in messages[0].args[0]
+678
View File
@@ -0,0 +1,678 @@
"""Tests for the split_tests cache logic."""
from collections.abc import Callable
import json
from pathlib import Path
from unittest.mock import patch
import pytest
from script import split_tests
@pytest.fixture
def tree(tmp_path: Path) -> Path:
"""Build a tree: root conftest, two integrations, a ``common.py`` helper."""
# Bound the ancestor-fixture walk so it doesn't escape tmp_path.
(tmp_path / "pyproject.toml").write_text("")
(tmp_path / "conftest.py").write_text("# tests/conftest.py\n")
(tmp_path / "common.py").write_text("# helper module\n")
alpha_dir = tmp_path / "components" / "alpha"
alpha_dir.mkdir(parents=True)
(alpha_dir / "conftest.py").write_text("# alpha conftest\n")
(alpha_dir / "test_one.py").write_text("def test_a():\n pass\n")
(alpha_dir / "test_two.py").write_text("def test_b():\n pass\n")
beta_dir = tmp_path / "components" / "beta"
beta_dir.mkdir()
(beta_dir / "test_x.py").write_text("def test_x():\n pass\n")
return tmp_path
def test_iter_eligible_children_filters_helpers(tree: Path) -> None:
"""Helper files like conftest.py and common.py are not collection targets."""
children = split_tests._iter_eligible_children(tree)
names = {p.name for p in children}
assert "common.py" not in names
assert "conftest.py" not in names
# components/ is a dir, gets included.
assert "components" in names
def test_enumerate_batch_paths_fans_out_components(tree: Path) -> None:
"""tests/components fans out one level deeper into per-integration paths."""
paths = split_tests._enumerate_batch_paths(tree)
rel = {p.relative_to(tree).as_posix() for p in paths}
assert rel == {"components/beta", "components/alpha"}
def test_enumerate_batch_paths_for_single_file(tmp_path: Path) -> None:
"""A test file passed directly is returned as-is."""
file = tmp_path / "test_solo.py"
file.write_text("def test_x(): pass\n")
assert split_tests._enumerate_batch_paths(file) == [file]
def _fixture_hash_for(tree: Path, file: Path) -> str:
"""Compute the fixture scope hash for ``file`` rooted at ``tree``."""
_, fixtures = split_tests._walk_test_tree(tree)
fixtures_by_dir = split_tests._build_fixtures_by_dir(tree, fixtures)
return split_tests._file_fixture_hash(file, tree, fixtures_by_dir)
def _prime_cache(
cache_path: Path,
tree: Path,
hits: dict[Path, int] | None = None,
extra_entries: dict[str, split_tests._CacheEntry] | None = None,
) -> None:
"""Save a cache for ``tree`` keyed on real file and fixture hashes.
``hits`` maps file cached count (hashed for real, so the next
run resolves as a hit). ``extra_entries`` injects raw entries
whose path may not exist on disk (eg ghost files).
"""
entries: dict[str, split_tests._CacheEntry] = {
str(file.relative_to(tree)): split_tests._CacheEntry(
hash=split_tests._hash_file(file),
fixture_hash=_fixture_hash_for(tree, file),
count=count,
)
for file, count in (hits or {}).items()
}
if extra_entries:
entries.update(extra_entries)
split_tests._Cache(entries=entries).save(cache_path)
def _echo_one_test_each(
skip: set[Path] | None = None,
) -> Callable[[list[Path]], list[tuple[str, str, int]]]:
"""Fake ``_run_collect_batches``: 1 test per path; ``skip`` paths drop out."""
skip = skip or set()
def fake(paths: list[Path]) -> list[tuple[str, str, int]]:
emitted = [p for p in paths if p not in skip]
return [("\n".join(f"{p}: 1" for p in emitted) + "\n", "", 0)]
return fake
def test_file_fixture_hash_changes_when_ancestor_conftest_changes(tree: Path) -> None:
"""A conftest edit in the file's ancestor chain busts that file's hash."""
alpha_one = tree / "components" / "alpha" / "test_one.py"
before = _fixture_hash_for(tree, alpha_one)
# Same-dir conftest is an ancestor of alpha_one.
(tree / "components" / "alpha" / "conftest.py").write_text("# changed\n")
after = _fixture_hash_for(tree, alpha_one)
assert before != after
def test_file_fixture_hash_changes_when_same_dir_helper_changes(tree: Path) -> None:
"""A non-conftest helper in the same dir busts the file's hash."""
alpha_dir = tree / "components" / "alpha"
(alpha_dir / "common.py").write_text("# helper v1\n")
alpha_one = alpha_dir / "test_one.py"
before = _fixture_hash_for(tree, alpha_one)
(alpha_dir / "common.py").write_text("# helper v2\n")
after = _fixture_hash_for(tree, alpha_one)
assert before != after
def test_file_fixture_hash_isolated_from_sibling_dir(tree: Path) -> None:
"""A helper change in a sibling subtree leaves this file's hash alone."""
alpha_one = tree / "components" / "alpha" / "test_one.py"
before = _fixture_hash_for(tree, alpha_one)
# beta is a sibling of alpha (not an ancestor), so its helper edit
# must not affect alpha_one's fixture hash.
(tree / "components" / "beta" / "common.py").write_text("# beta v2\n")
after = _fixture_hash_for(tree, alpha_one)
assert before == after
def test_file_fixture_hash_changes_when_ancestor_helper_changes(tree: Path) -> None:
"""A helper edit anywhere on the ancestor path busts the file's hash.
Test files often import VALUES for ``@pytest.mark.parametrize`` from
shared helpers like ``tests/components/common.py``; any ancestor
``.py`` change has to invalidate descendants so cached counts don't
drift after edits to those sources.
"""
alpha_one = tree / "components" / "alpha" / "test_one.py"
# Seed a shared helper one level up from alpha.
components_common = tree / "components" / "common.py"
components_common.write_text("# helper v1\n")
before = _fixture_hash_for(tree, alpha_one)
components_common.write_text("# helper v2\n")
after = _fixture_hash_for(tree, alpha_one)
assert before != after
def test_file_fixture_hash_stable_for_test_changes(tree: Path) -> None:
"""Test-file edits do not invalidate the file's fixture hash."""
alpha_one = tree / "components" / "alpha" / "test_one.py"
before = _fixture_hash_for(tree, alpha_one)
alpha_one.write_text("def test_a():\n pass\n\ndef test_c():\n pass\n")
after = _fixture_hash_for(tree, alpha_one)
assert before == after
def test_find_ancestor_fixtures_stops_at_project_root(tmp_path: Path) -> None:
"""A project-root marker bounds the ancestor walk."""
project = tmp_path / "project"
project.mkdir()
(project / "pyproject.toml").write_text("")
(project / "common.py").write_text("# included\n")
nested = project / "tests" / "x"
nested.mkdir(parents=True)
# Above the project root: must NOT be picked up.
(tmp_path / "outside.py").write_text("# excluded\n")
found = {p.name for p in split_tests._find_ancestor_fixtures(nested)}
assert "common.py" in found
assert "outside.py" not in found
def test_find_ancestor_fixtures_walks_through_gaps(tmp_path: Path) -> None:
"""Ancestor conftests + helpers are collected across intermediate gaps."""
(tmp_path / "pyproject.toml").write_text("") # bound the walk
nested = tmp_path / "a" / "b" / "c"
nested.mkdir(parents=True)
# ``a/b`` has no fixtures, but ``a`` has both a conftest and a helper.
(tmp_path / "a" / "conftest.py").write_text("# a\n")
(tmp_path / "a" / "common.py").write_text("# a helper\n")
(tmp_path / "a" / "b" / "c" / "conftest.py").write_text("# c\n")
found = {
p.relative_to(tmp_path).as_posix()
for p in split_tests._find_ancestor_fixtures(nested)
}
# The walk starts at ``nested.parent`` (a/b); a/b/c/conftest.py is
# not an ancestor. Both ``a/conftest.py`` and ``a/common.py`` must
# be found despite a/b having no fixtures of its own.
assert "a/conftest.py" in found
assert "a/common.py" in found
assert "a/b/c/conftest.py" not in found
def test_file_fixture_hash_picks_up_ancestor_helper_above_root(
tmp_path: Path,
) -> None:
"""An ancestor non-conftest helper above root still busts descendant hashes.
A subtree run on ``components/`` must still invalidate when a shared
helper one level up (eg ``tests/components/common.py``) changes.
"""
(tmp_path / "pyproject.toml").write_text("") # bound the walk
(tmp_path / "common.py").write_text("# v1\n")
subtree = tmp_path / "components"
subtree.mkdir()
test_file = subtree / "test_x.py"
test_file.write_text("def test_x(): pass\n")
before = _fixture_hash_for(subtree, test_file)
(tmp_path / "common.py").write_text("# v2\n")
after = _fixture_hash_for(subtree, test_file)
assert before != after
def test_file_fixture_hash_picks_up_ancestor_conftest_across_gap(
tmp_path: Path,
) -> None:
"""An ancestor conftest across a gap still busts the descendant's hash."""
(tmp_path / "pyproject.toml").write_text("") # bound the walk
nested = tmp_path / "a" / "b"
nested.mkdir(parents=True)
(tmp_path / "a" / "conftest.py").write_text("# v1\n")
test_file = nested / "test_x.py"
test_file.write_text("def test_x(): pass\n")
before = _fixture_hash_for(nested, test_file)
(tmp_path / "a" / "conftest.py").write_text("# v2\n")
after = _fixture_hash_for(nested, test_file)
assert before != after
def test_file_fixture_hash_includes_ancestor_above_root(tmp_path: Path) -> None:
"""An ancestor conftest above root must still scope a subtree file."""
(tmp_path / "pyproject.toml").write_text("") # bound the walk
(tmp_path / "conftest.py").write_text("# parent\n")
subtree = tmp_path / "components"
subtree.mkdir()
test_file = subtree / "test_x.py"
test_file.write_text("def test_x(): pass\n")
before = _fixture_hash_for(subtree, test_file)
(tmp_path / "conftest.py").write_text("# parent changed\n")
after = _fixture_hash_for(subtree, test_file)
assert before != after
def test_walk_test_tree_separates_tests_from_fixtures(tree: Path) -> None:
"""The walker returns test_*.py files and every other .py as fixtures."""
test_files, fixtures = split_tests._walk_test_tree(tree)
test_names = {p.name for p in test_files}
fixture_paths = {p.relative_to(tree).as_posix() for p in fixtures}
assert test_names == {"test_one.py", "test_two.py", "test_x.py"}
assert fixture_paths == {
"conftest.py",
"common.py",
"components/alpha/conftest.py",
}
def test_walk_test_tree_skips_hidden_and_dunder_dirs(tmp_path: Path) -> None:
"""Hidden/dunder directories are pruned from the walk."""
(tmp_path / "__pycache__").mkdir()
(tmp_path / "__pycache__" / "test_ghost.py").write_text("def test_g(): pass\n")
(tmp_path / ".hidden").mkdir()
(tmp_path / ".hidden" / "test_invisible.py").write_text("def test_h(): pass\n")
(tmp_path / "test_real.py").write_text("def test_r(): pass\n")
test_files, _ = split_tests._walk_test_tree(tmp_path)
assert {p.name for p in test_files} == {"test_real.py"}
def test_collect_tests_skips_cache_for_single_file_root(tmp_path: Path) -> None:
"""Single-file root bypasses caching.
Otherwise the invalidation hash would be constant and stale counts
could survive conftest edits.
"""
cache_path = tmp_path / "cache.json"
file = tmp_path / "test_solo.py"
file.write_text("def test_x(): pass\n")
with (
patch.object(split_tests, "_collect_tests_uncached") as uncached,
patch.object(split_tests, "_collect_tests_cached") as cached,
):
split_tests.collect_tests(file, cache_path)
uncached.assert_called_once_with(file)
cached.assert_not_called()
assert not cache_path.exists()
def test_cache_roundtrip(tmp_path: Path) -> None:
"""A cache survives save → load."""
cache_path = tmp_path / "cache.json"
cache = split_tests._Cache(
entries={
"tests/alpha/test_a.py": split_tests._CacheEntry(
hash="h1", fixture_hash="f1", count=5
)
},
)
cache.save(cache_path)
loaded = split_tests._Cache.load(cache_path)
assert loaded.entries == cache.entries
def test_cache_load_missing_returns_empty(tmp_path: Path) -> None:
"""A missing cache file degrades gracefully to an empty cache."""
cache = split_tests._Cache.load(tmp_path / "missing.json")
assert cache.entries == {}
def test_cache_load_invalid_json_returns_empty(tmp_path: Path) -> None:
"""Corrupt JSON is treated as a cache miss instead of crashing."""
path = tmp_path / "broken.json"
path.write_text("{not json")
cache = split_tests._Cache.load(path)
assert cache.entries == {}
def test_cache_load_wrong_version_returns_empty(tmp_path: Path) -> None:
"""An older cache schema is discarded rather than misread."""
path = tmp_path / "old.json"
path.write_text(json.dumps({"version": 0, "files": {}}))
cache = split_tests._Cache.load(path)
assert cache.entries == {}
def test_cache_load_drops_malformed_entries(tmp_path: Path) -> None:
"""Malformed per-file entries are skipped, valid ones are kept."""
path = tmp_path / "cache.json"
path.write_text(
json.dumps(
{
"version": split_tests._CACHE_VERSION,
"files": {
"good.py": {"hash": "h1", "fixture_hash": "f1", "count": 3},
"bad_count.py": {
"hash": "h2",
"fixture_hash": "f2",
"count": "three",
},
"missing_hash.py": {"fixture_hash": "f3", "count": 4},
"missing_fixture_hash.py": {"hash": "h4", "count": 4},
"not_dict.py": 5,
# bool is an int subclass; reject so True isn't read as 1.
"bool_count.py": {
"hash": "h5",
"fixture_hash": "f5",
"count": True,
},
"negative_count.py": {
"hash": "h6",
"fixture_hash": "f6",
"count": -1,
},
},
}
)
)
cache = split_tests._Cache.load(path)
assert set(cache.entries) == {"good.py"}
def test_cache_save_creates_parent_dir(tmp_path: Path) -> None:
"""Save mkdirs missing parent dirs so ``--cache foo/bar.json`` works."""
cache_path = tmp_path / "nested" / "subdir" / "cache.json"
split_tests._Cache(entries={}).save(cache_path)
assert cache_path.is_file()
def _resolve(
test_files: list[Path], cache: split_tests._Cache, tree: Path
) -> tuple[dict[Path, split_tests._CacheEntry], list[Path]]:
"""Run resolve_entries with a freshly indexed fixtures_by_dir."""
_, fixtures = split_tests._walk_test_tree(tree)
return split_tests._resolve_entries(
test_files,
cache,
tree,
split_tests._build_fixtures_by_dir(tree, fixtures),
)
def test_resolve_entries_hits_and_misses(tree: Path) -> None:
"""Files with matching content + fixture hashes are hits."""
alpha_one = tree / "components" / "alpha" / "test_one.py"
alpha_two = tree / "components" / "alpha" / "test_two.py"
beta_x = tree / "components" / "beta" / "test_x.py"
alpha_one_hash = split_tests._hash_file(alpha_one)
alpha_one_fixture = _fixture_hash_for(tree, alpha_one)
cache = split_tests._Cache(
entries={
str(alpha_one.relative_to(tree)): split_tests._CacheEntry(
hash=alpha_one_hash, fixture_hash=alpha_one_fixture, count=1
),
str(alpha_two.relative_to(tree)): split_tests._CacheEntry(
hash="stale", fixture_hash=alpha_one_fixture, count=99
),
},
)
entries, misses = _resolve([alpha_one, alpha_two, beta_x], cache, tree)
# Hit: cached entry passed through verbatim.
assert entries[alpha_one] == split_tests._CacheEntry(
hash=alpha_one_hash, fixture_hash=alpha_one_fixture, count=1
)
# Misses: fresh hashes plus a count=0 placeholder.
assert set(misses) == {alpha_two, beta_x}
assert entries[alpha_two].count == 0
assert entries[alpha_two].hash == split_tests._hash_file(alpha_two)
assert entries[beta_x].count == 0
assert entries[beta_x].hash == split_tests._hash_file(beta_x)
def test_resolve_entries_misses_on_fixture_drift(tree: Path) -> None:
"""A file with unchanged content but changed scope counts as a miss."""
alpha_one = tree / "components" / "alpha" / "test_one.py"
cache = split_tests._Cache(
entries={
str(alpha_one.relative_to(tree)): split_tests._CacheEntry(
hash=split_tests._hash_file(alpha_one),
fixture_hash="stale-fixture-hash",
count=1,
),
},
)
_, misses = _resolve([alpha_one], cache, tree)
assert misses == [alpha_one]
def test_resolve_entries_isolates_unrelated_dirs(tree: Path) -> None:
"""Editing a helper in one dir leaves files in other dirs as hits."""
alpha_dir = tree / "components" / "alpha"
beta_dir = tree / "components" / "beta"
# Helpers per dir, so a change in alpha doesn't bust beta.
(alpha_dir / "common.py").write_text("# alpha helper v1\n")
(beta_dir / "common.py").write_text("# beta helper v1\n")
alpha_one = alpha_dir / "test_one.py"
beta_x = beta_dir / "test_x.py"
# Snapshot cache entries with the v1 fixture state.
cache = split_tests._Cache(
entries={
str(alpha_one.relative_to(tree)): split_tests._CacheEntry(
hash=split_tests._hash_file(alpha_one),
fixture_hash=_fixture_hash_for(tree, alpha_one),
count=1,
),
str(beta_x.relative_to(tree)): split_tests._CacheEntry(
hash=split_tests._hash_file(beta_x),
fixture_hash=_fixture_hash_for(tree, beta_x),
count=2,
),
},
)
# Now bust beta's helper; alpha's scope is unchanged, beta's isn't.
(beta_dir / "common.py").write_text("# beta helper v2\n")
_, misses = _resolve([alpha_one, beta_x], cache, tree)
assert misses == [beta_x]
def test_collect_tests_hashes_each_file_once(tree: Path) -> None:
"""Hits reuse the stored hash, misses reuse the resolve-time hash.
Guards against regressing the double-read on cache-miss rebuilds:
each test file should pass through _hash_file at most once per run.
"""
cache_path = tree / "cache.json"
alpha_one = tree / "components" / "alpha" / "test_one.py"
# Prime with one hit so we exercise the file-level (not directory-level) miss path.
_prime_cache(cache_path, tree, hits={alpha_one: 1})
real_hash = split_tests._hash_file
counts: dict[Path, int] = {}
def counting_hash(path: Path) -> str:
counts[path] = counts.get(path, 0) + 1
return real_hash(path)
# Pin the threshold so the tiny tree stays on the file-level path.
with (
patch.object(split_tests, "_DIR_LEVEL_MISS_RATIO", 1.0),
patch.object(split_tests, "_hash_file", side_effect=counting_hash),
patch.object(
split_tests, "_run_collect_batches", side_effect=_echo_one_test_each()
),
):
split_tests.collect_tests(tree, cache_path)
assert all(n == 1 for n in counts.values()), counts
def test_collect_tests_warm_cache_skips_pytest(tree: Path) -> None:
"""A warm cache with no diffs should skip the pytest subprocess entirely."""
cache_path = tree / "cache.json"
alpha_one = tree / "components" / "alpha" / "test_one.py"
alpha_two = tree / "components" / "alpha" / "test_two.py"
beta_x = tree / "components" / "beta" / "test_x.py"
_prime_cache(cache_path, tree, hits={alpha_one: 1, alpha_two: 2, beta_x: 3})
with patch.object(split_tests, "_run_collect_batches") as run_batches:
folder = split_tests.collect_tests(tree, cache_path)
run_batches.assert_not_called()
assert folder.total_tests == 6
def test_collect_tests_cold_cache_collects_only_missing(tree: Path) -> None:
"""A partial cache should only re-collect the files that changed."""
cache_path = tree / "cache.json"
alpha_one = tree / "components" / "alpha" / "test_one.py"
alpha_two = tree / "components" / "alpha" / "test_two.py"
beta_x = tree / "components" / "beta" / "test_x.py"
_prime_cache(cache_path, tree, hits={alpha_one: 1})
with (
patch.object(split_tests, "_DIR_LEVEL_MISS_RATIO", 1.0),
patch.object(
split_tests, "_run_collect_batches", side_effect=_echo_one_test_each()
) as run_batches,
):
folder = split_tests.collect_tests(tree, cache_path)
assert run_batches.call_count == 1
requested = set(run_batches.call_args.args[0])
assert requested == {alpha_two, beta_x}
assert folder.total_tests == 3
# Cache should now contain entries for every test file.
saved = json.loads(cache_path.read_text())
assert set(saved["files"]) == {
str(alpha_one.relative_to(tree)),
str(alpha_two.relative_to(tree)),
str(beta_x.relative_to(tree)),
}
def test_collect_tests_falls_back_to_dirs_when_misses_dominate(tree: Path) -> None:
"""Heavy misses should switch back to dir-level invocation."""
cache_path = tree / "cache.json"
alpha_one = tree / "components" / "alpha" / "test_one.py"
_prime_cache(cache_path, tree, hits={alpha_one: 1})
# 2 misses / 3 total = 67% miss, above the 30% default threshold; this
# also covers the new-directory PR case (mostly-new test files).
with patch.object(
split_tests, "_run_collect_batches", side_effect=_echo_one_test_each()
) as run_batches:
split_tests.collect_tests(tree, cache_path)
# We expect the dir-level batch paths, not the individual miss files.
requested = set(run_batches.call_args.args[0])
assert requested == set(split_tests._enumerate_batch_paths(tree))
def test_collect_tests_caches_files_with_no_collected_tests(tree: Path) -> None:
"""Files pytest returns nothing for are cached as 0 so we stop re-collecting them.
Helper modules named test_*.py with no actual test functions look like
test files to the walker but pytest reports no tests for them. We
want the cache to remember that and skip them on subsequent runs.
"""
cache_path = tree / "cache.json"
alpha_one = tree / "components" / "alpha" / "test_one.py"
alpha_two = tree / "components" / "alpha" / "test_two.py"
beta_x = tree / "components" / "beta" / "test_x.py"
# Prime the cache with one hit so collect_tests takes the file-level
# diff path; the cold-cache path hands pytest top-level directories
# rather than individual file paths.
_prime_cache(cache_path, tree, hits={alpha_one: 1})
with (
patch.object(split_tests, "_DIR_LEVEL_MISS_RATIO", 1.0),
patch.object(
split_tests,
"_run_collect_batches",
side_effect=_echo_one_test_each(skip={alpha_two}),
),
):
split_tests.collect_tests(tree, cache_path)
saved = json.loads(cache_path.read_text())
assert saved["files"][str(alpha_two.relative_to(tree))]["count"] == 0
assert saved["files"][str(alpha_one.relative_to(tree))]["count"] == 1
assert saved["files"][str(beta_x.relative_to(tree))]["count"] == 1
# Re-running with the same content should now be a full cache hit
# even though alpha_two has no tests.
with patch.object(split_tests, "_run_collect_batches") as run_batches:
folder = split_tests.collect_tests(tree, cache_path)
run_batches.assert_not_called()
# alpha_two contributes 0, only alpha_one + beta_x count.
assert folder.total_tests == 2
def test_collect_tests_drops_deleted_files_from_cache(tree: Path) -> None:
"""Files that disappear from disk are dropped from the saved cache."""
cache_path = tree / "cache.json"
alpha_one = tree / "components" / "alpha" / "test_one.py"
ghost_rel = "components/alpha/test_ghost.py"
_prime_cache(
cache_path,
tree,
hits={alpha_one: 1},
extra_entries={
ghost_rel: split_tests._CacheEntry(
hash="dead", fixture_hash="dead", count=42
)
},
)
with (
patch.object(split_tests, "_DIR_LEVEL_MISS_RATIO", 1.0),
patch.object(
split_tests, "_run_collect_batches", side_effect=_echo_one_test_each()
),
):
split_tests.collect_tests(tree, cache_path)
saved = json.loads(cache_path.read_text())
assert ghost_rel not in saved["files"]
def _build_folder(tree: Path, counts: dict[Path, int]) -> split_tests.TestFolder:
"""Build a TestFolder for ``tree`` populated with ``counts``."""
folder = split_tests.TestFolder(tree)
for path, n in counts.items():
folder.add_test_file(split_tests.TestFile(n, path))
return folder
def test_split_tests_keeps_siblings_together_when_snapshots_present(
tmp_path: Path,
) -> None:
"""Same-dir files stay together when the folder has syrupy snapshots."""
one = tmp_path / "alpha" / "test_one.py"
two = tmp_path / "alpha" / "test_two.py"
one.parent.mkdir(parents=True)
one.touch()
two.touch()
# Add a snapshot so the syrupy constraint kicks in.
snapshots = tmp_path / "alpha" / "snapshots"
snapshots.mkdir()
(snapshots / "test_one.ambr").write_text("")
folder = _build_folder(tmp_path, {one: 60, two: 60})
holder = split_tests.BucketHolder(tests_per_bucket=50, bucket_count=3)
holder.split_tests(folder)
# Both files must end up in one bucket; the other two stay empty.
sizes = sorted(b.total_tests for b in holder._buckets)
assert sizes == [0, 0, 120]
def test_split_tests_splits_siblings_when_no_snapshots(tmp_path: Path) -> None:
"""Same-dir files split freely across buckets when no snapshots exist."""
one = tmp_path / "alpha" / "test_one.py"
two = tmp_path / "alpha" / "test_two.py"
one.parent.mkdir(parents=True)
one.touch()
two.touch()
# No snapshots dir → free to split.
folder = _build_folder(tmp_path, {one: 60, two: 60})
holder = split_tests.BucketHolder(tests_per_bucket=70, bucket_count=2)
holder.split_tests(folder)
sizes = sorted(b.total_tests for b in holder._buckets)
assert sizes == [60, 60]