Skip to content

Commit cbbb4b1

Browse files
committed
Merge remote-tracking branch 'origin/main' into copilot/fix-invalid-private-key-error
# Conflicts: # coordinator.py
2 parents f0b43f6 + 6659db4 commit cbbb4b1

8 files changed

Lines changed: 86 additions & 99 deletions

File tree

__init__.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22

33
from __future__ import annotations
44

5+
from pathlib import Path
56
from typing import Any
67

78
import voluptuous as vol
8-
from aiofiles.ospath import exists
99

1010
from homeassistant.config_entries import ConfigEntry
1111
from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_HOST, CONF_COMMAND, CONF_TIMEOUT
@@ -20,7 +20,7 @@
2020
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) # pylint: disable=invalid-name
2121

2222

23-
async def _validate_service_data(data: dict[str, Any]) -> None:
23+
async def _validate_service_data(hass: HomeAssistant, data: dict[str, Any]) -> None:
2424
has_password: bool = bool(data.get(CONF_PASSWORD))
2525
has_key_file: bool = bool(data.get(CONF_KEY_FILE))
2626

@@ -41,7 +41,7 @@ async def _validate_service_data(data: dict[str, Any]) -> None:
4141
translation_key="command_or_input",
4242
)
4343

44-
if has_key_file and not await exists(data[CONF_KEY_FILE]):
44+
if has_key_file and not await hass.async_add_executor_job(Path(data[CONF_KEY_FILE]).exists):
4545
raise ServiceValidationError(
4646
"Could not find key file.",
4747
translation_domain=DOMAIN,
@@ -80,7 +80,7 @@ async def async_setup(hass: HomeAssistant, _config: ConfigType) -> bool:
8080
hass.data.setdefault(DOMAIN, {})
8181

8282
async def async_execute(service_call: ServiceCall) -> ServiceResponse:
83-
await _validate_service_data(service_call.data)
83+
await _validate_service_data(hass, service_call.data)
8484
# ssh_command is a single-instance integration (enforced by single_instance_allowed
8585
# in the config flow), so there is at most one coordinator in hass.data[DOMAIN].
8686
coordinator = next(iter(hass.data.get(DOMAIN, {}).values()), None)

coordinator.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,6 @@
1515
from pathlib import Path
1616
from typing import Any
1717

18-
from aiofiles import open as aioopen
19-
from aiofiles.ospath import exists
2018
from asyncssh import HostKeyNotVerifiable, KeyImportError, PermissionDenied, connect, read_known_hosts
2119

2220
from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_HOST, CONF_COMMAND, CONF_TIMEOUT
@@ -64,9 +62,8 @@ async def async_execute(self, data: dict[str, Any]) -> dict[str, Any]:
6462
timeout = data.get(CONF_TIMEOUT, CONST_DEFAULT_TIMEOUT)
6563

6664
if input_data:
67-
if await exists(input_data):
68-
async with aioopen(input_data, 'r') as sf:
69-
input_data = await sf.read()
65+
if await self.hass.async_add_executor_job(Path(input_data).exists):
66+
input_data = await self.hass.async_add_executor_job(Path(input_data).read_text)
7067

7168
conn_kwargs = {
7269
CONF_HOST: host,
@@ -138,6 +135,6 @@ async def _resolve_known_hosts(self, check_known_hosts: bool, known_hosts: str |
138135
return None
139136
if not known_hosts:
140137
known_hosts = str(Path("~", ".ssh", "known_hosts").expanduser())
141-
if await exists(known_hosts):
138+
if await self.hass.async_add_executor_job(Path(known_hosts).exists):
142139
return await self.hass.async_add_executor_job(read_known_hosts, known_hosts)
143140
return known_hosts

manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"iot_class": "calculated",
1212
"issue_tracker": "https://github.com/gensyn/ssh_command/issues",
1313
"quality_scale": "bronze",
14-
"requirements": ["asyncssh==2.22.0", "aiofiles==25.1.0"],
14+
"requirements": ["asyncssh==2.22.0"],
1515
"ssdp": [],
1616
"version": "0.0.0",
1717
"zeroconf": []

requirements.txt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1 @@
1-
aiofiles==25.1.0
21
asyncssh==2.22.0

tests/integration_tests/test_integration.py

Lines changed: 24 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ async def test_execute_returns_stdout(self, hass: HomeAssistant) -> None:
225225
mock_conn = _make_mock_conn(stdout="hello\n", stderr="", exit_status=0)
226226
with patch("custom_components.ssh_command.coordinator.connect",
227227
return_value=_MockConnect(mock_conn)):
228-
with patch("custom_components.ssh_command.coordinator.exists", return_value=False):
228+
with patch("pathlib.Path.exists", return_value=False):
229229
result = await hass.services.async_call(
230230
DOMAIN,
231231
SERVICE_EXECUTE,
@@ -245,7 +245,7 @@ async def test_execute_returns_stderr(self, hass: HomeAssistant) -> None:
245245
mock_conn = _make_mock_conn(stdout="", stderr="some error", exit_status=1)
246246
with patch("custom_components.ssh_command.coordinator.connect",
247247
return_value=_MockConnect(mock_conn)):
248-
with patch("custom_components.ssh_command.coordinator.exists", return_value=False):
248+
with patch("pathlib.Path.exists", return_value=False):
249249
result = await hass.services.async_call(
250250
DOMAIN,
251251
SERVICE_EXECUTE,
@@ -265,7 +265,7 @@ async def test_execute_with_password_auth(self, hass: HomeAssistant) -> None:
265265
data = {**SERVICE_DATA_BASE, "password": "mysecret"}
266266
with patch("custom_components.ssh_command.coordinator.connect",
267267
return_value=_MockConnect(mock_conn)) as mock_connect:
268-
with patch("custom_components.ssh_command.coordinator.exists", return_value=False):
268+
with patch("pathlib.Path.exists", return_value=False):
269269
await hass.services.async_call(
270270
DOMAIN,
271271
SERVICE_EXECUTE,
@@ -291,15 +291,14 @@ async def test_execute_with_key_file_auth(self, hass: HomeAssistant) -> None:
291291
}
292292
with patch("custom_components.ssh_command.coordinator.connect",
293293
return_value=_MockConnect(mock_conn)) as mock_connect:
294-
with patch("custom_components.ssh_command.coordinator.exists", return_value=True):
295-
with patch("custom_components.ssh_command.exists", return_value=True):
296-
await hass.services.async_call(
297-
DOMAIN,
298-
SERVICE_EXECUTE,
299-
data,
300-
blocking=True,
301-
return_response=True,
302-
)
294+
with patch("pathlib.Path.exists", return_value=True):
295+
await hass.services.async_call(
296+
DOMAIN,
297+
SERVICE_EXECUTE,
298+
data,
299+
blocking=True,
300+
return_response=True,
301+
)
303302

304303
call_kwargs = mock_connect.call_args[1]
305304
assert call_kwargs["client_keys"] == "/home/user/.ssh/id_rsa"
@@ -312,7 +311,7 @@ async def test_execute_with_inline_input(self, hass: HomeAssistant) -> None:
312311
data = {**SERVICE_DATA_BASE, "input": "inline input"}
313312
with patch("custom_components.ssh_command.coordinator.connect",
314313
return_value=_MockConnect(mock_conn)):
315-
with patch("custom_components.ssh_command.coordinator.exists", return_value=False):
314+
with patch("pathlib.Path.exists", return_value=False):
316315
await hass.services.async_call(
317316
DOMAIN,
318317
SERVICE_EXECUTE,
@@ -337,7 +336,7 @@ async def test_execute_with_input_file(self, hass: HomeAssistant) -> None:
337336
data = {**SERVICE_DATA_BASE, "command": "cat", "input": tf_path}
338337
with patch("custom_components.ssh_command.coordinator.connect",
339338
return_value=_MockConnect(mock_conn)):
340-
with patch("custom_components.ssh_command.coordinator.exists", return_value=True):
339+
with patch("pathlib.Path.exists", return_value=True):
341340
await hass.services.async_call(
342341
DOMAIN,
343342
SERVICE_EXECUTE,
@@ -359,7 +358,7 @@ async def test_execute_with_custom_timeout(self, hass: HomeAssistant) -> None:
359358
data = {**SERVICE_DATA_BASE, "timeout": 60}
360359
with patch("custom_components.ssh_command.coordinator.connect",
361360
return_value=_MockConnect(mock_conn)):
362-
with patch("custom_components.ssh_command.coordinator.exists", return_value=False):
361+
with patch("pathlib.Path.exists", return_value=False):
363362
await hass.services.async_call(
364363
DOMAIN,
365364
SERVICE_EXECUTE,
@@ -387,7 +386,7 @@ async def test_check_known_hosts_false_passes_none(self, hass: HomeAssistant) ->
387386
mock_conn = _make_mock_conn()
388387
with patch("custom_components.ssh_command.coordinator.connect",
389388
return_value=_MockConnect(mock_conn)) as mock_connect:
390-
with patch("custom_components.ssh_command.coordinator.exists", return_value=False):
389+
with patch("pathlib.Path.exists", return_value=False):
391390
await hass.services.async_call(
392391
DOMAIN,
393392
SERVICE_EXECUTE,
@@ -412,7 +411,7 @@ async def test_check_known_hosts_true_with_custom_file(self, hass: HomeAssistant
412411
}
413412
with patch("custom_components.ssh_command.coordinator.connect",
414413
return_value=_MockConnect(mock_conn)) as mock_connect:
415-
with patch("custom_components.ssh_command.coordinator.exists", return_value=True):
414+
with patch("pathlib.Path.exists", return_value=True):
416415
with patch("custom_components.ssh_command.coordinator.read_known_hosts",
417416
return_value=mock_known_hosts) as mock_rkh:
418417
await hass.services.async_call(
@@ -440,7 +439,7 @@ async def test_check_known_hosts_true_with_missing_file(self, hass: HomeAssistan
440439
}
441440
with patch("custom_components.ssh_command.coordinator.connect",
442441
return_value=_MockConnect(mock_conn)) as mock_connect:
443-
with patch("custom_components.ssh_command.coordinator.exists", return_value=False):
442+
with patch("pathlib.Path.exists", return_value=False):
444443
await hass.services.async_call(
445444
DOMAIN,
446445
SERVICE_EXECUTE,
@@ -463,7 +462,7 @@ async def test_check_known_hosts_true_uses_default_path_when_missing(
463462
data = {**SERVICE_DATA_BASE, "check_known_hosts": True}
464463
with patch("custom_components.ssh_command.coordinator.connect",
465464
return_value=_MockConnect(mock_conn)) as mock_connect:
466-
with patch("custom_components.ssh_command.coordinator.exists", return_value=False):
465+
with patch("pathlib.Path.exists", return_value=False):
467466
await hass.services.async_call(
468467
DOMAIN,
469468
SERVICE_EXECUTE,
@@ -521,7 +520,7 @@ async def test_key_file_not_found_raises(self, hass: HomeAssistant) -> None:
521520
entry = _make_entry()
522521
await _setup_entry(hass, entry)
523522

524-
with patch("custom_components.ssh_command.exists", return_value=False):
523+
with patch("pathlib.Path.exists", return_value=False):
525524
with pytest.raises(ServiceValidationError) as exc_info:
526525
await hass.services.async_call(
527526
DOMAIN,
@@ -592,7 +591,7 @@ async def test_host_key_not_verifiable(self, hass: HomeAssistant) -> None:
592591

593592
with patch("custom_components.ssh_command.coordinator.connect",
594593
return_value=_MockConnectRaises(HostKeyNotVerifiable("test"))):
595-
with patch("custom_components.ssh_command.coordinator.exists", return_value=False):
594+
with patch("pathlib.Path.exists", return_value=False):
596595
with pytest.raises(ServiceValidationError) as exc_info:
597596
await hass.services.async_call(
598597
DOMAIN,
@@ -610,7 +609,7 @@ async def test_permission_denied(self, hass: HomeAssistant) -> None:
610609

611610
with patch("custom_components.ssh_command.coordinator.connect",
612611
return_value=_MockConnectRaises(PermissionDenied("auth failed"))):
613-
with patch("custom_components.ssh_command.coordinator.exists", return_value=False):
612+
with patch("pathlib.Path.exists", return_value=False):
614613
with pytest.raises(ServiceValidationError) as exc_info:
615614
await hass.services.async_call(
616615
DOMAIN,
@@ -628,7 +627,7 @@ async def test_timeout(self, hass: HomeAssistant) -> None:
628627

629628
with patch("custom_components.ssh_command.coordinator.connect",
630629
return_value=_MockConnectRaises(TimeoutError())):
631-
with patch("custom_components.ssh_command.coordinator.exists", return_value=False):
630+
with patch("pathlib.Path.exists", return_value=False):
632631
with pytest.raises(ServiceValidationError) as exc_info:
633632
await hass.services.async_call(
634633
DOMAIN,
@@ -647,7 +646,7 @@ async def test_host_not_reachable(self, hass: HomeAssistant) -> None:
647646

648647
with patch("custom_components.ssh_command.coordinator.connect",
649648
return_value=_MockConnectRaises(err)):
650-
with patch("custom_components.ssh_command.coordinator.exists", return_value=False):
649+
with patch("pathlib.Path.exists", return_value=False):
651650
with pytest.raises(ServiceValidationError) as exc_info:
652651
await hass.services.async_call(
653652
DOMAIN,
@@ -666,7 +665,7 @@ async def test_other_oserror_is_reraised(self, hass: HomeAssistant) -> None:
666665

667666
with patch("custom_components.ssh_command.coordinator.connect",
668667
return_value=_MockConnectRaises(err)):
669-
with patch("custom_components.ssh_command.coordinator.exists", return_value=False):
668+
with patch("pathlib.Path.exists", return_value=False):
670669
with pytest.raises(OSError):
671670
await hass.services.async_call(
672671
DOMAIN,

tests/unit_tests/test_async_execute.py

Lines changed: 16 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,7 @@ async def test_success(self):
8585
service_call = self._make_service_call(SERVICE_DATA_BASE)
8686

8787
with patch("ssh_command.coordinator.connect", return_value=_MockConnect(mock_conn)):
88-
with patch("ssh_command.coordinator.exists", return_value=False):
89-
result = await self.handler(service_call)
88+
result = await self.handler(service_call)
9089

9190
self.assertEqual(result[CONF_OUTPUT], "hello\n")
9291
self.assertEqual(result[CONF_ERROR], "")
@@ -96,9 +95,8 @@ async def test_host_key_not_verifiable(self):
9695
service_call = self._make_service_call(SERVICE_DATA_BASE)
9796

9897
with patch("ssh_command.coordinator.connect", return_value=_MockConnectRaises(HostKeyNotVerifiable("test"))):
99-
with patch("ssh_command.coordinator.exists", return_value=False):
100-
with self.assertRaises(ServiceValidationError) as ctx:
101-
await self.handler(service_call)
98+
with self.assertRaises(ServiceValidationError) as ctx:
99+
await self.handler(service_call)
102100

103101
self.assertEqual(ctx.exception.translation_key, "host_key_not_verifiable")
104102

@@ -116,19 +114,17 @@ async def test_permission_denied(self):
116114
service_call = self._make_service_call(SERVICE_DATA_BASE)
117115

118116
with patch("ssh_command.coordinator.connect", return_value=_MockConnectRaises(PermissionDenied("auth failed"))):
119-
with patch("ssh_command.coordinator.exists", return_value=False):
120-
with self.assertRaises(ServiceValidationError) as ctx:
121-
await self.handler(service_call)
117+
with self.assertRaises(ServiceValidationError) as ctx:
118+
await self.handler(service_call)
122119

123120
self.assertEqual(ctx.exception.translation_key, "login_failed")
124121

125122
async def test_timeout(self):
126123
service_call = self._make_service_call(SERVICE_DATA_BASE)
127124

128125
with patch("ssh_command.coordinator.connect", return_value=_MockConnectRaises(TimeoutError())):
129-
with patch("ssh_command.coordinator.exists", return_value=False):
130-
with self.assertRaises(ServiceValidationError) as ctx:
131-
await self.handler(service_call)
126+
with self.assertRaises(ServiceValidationError) as ctx:
127+
await self.handler(service_call)
132128

133129
self.assertEqual(ctx.exception.translation_key, "connection_timed_out")
134130

@@ -137,9 +133,8 @@ async def test_name_resolution_failure(self):
137133
service_call = self._make_service_call(SERVICE_DATA_BASE)
138134

139135
with patch("ssh_command.coordinator.connect", return_value=_MockConnectRaises(err)):
140-
with patch("ssh_command.coordinator.exists", return_value=False):
141-
with self.assertRaises(ServiceValidationError) as ctx:
142-
await self.handler(service_call)
136+
with self.assertRaises(ServiceValidationError) as ctx:
137+
await self.handler(service_call)
143138

144139
self.assertEqual(ctx.exception.translation_key, "host_not_reachable")
145140

@@ -148,9 +143,8 @@ async def test_other_oserror_is_reraised(self):
148143
service_call = self._make_service_call(SERVICE_DATA_BASE)
149144

150145
with patch("ssh_command.coordinator.connect", return_value=_MockConnectRaises(err)):
151-
with patch("ssh_command.coordinator.exists", return_value=False):
152-
with self.assertRaises(OSError):
153-
await self.handler(service_call)
146+
with self.assertRaises(OSError):
147+
await self.handler(service_call)
154148

155149
async def test_input_from_file(self):
156150
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as tf:
@@ -163,8 +157,7 @@ async def test_input_from_file(self):
163157
service_call = self._make_service_call(data)
164158

165159
with patch("ssh_command.coordinator.connect", return_value=_MockConnect(mock_conn)):
166-
with patch("ssh_command.coordinator.exists", return_value=True):
167-
await self.handler(service_call)
160+
await self.handler(service_call)
168161

169162
call_kwargs = mock_conn.run.call_args[1]
170163
self.assertEqual(call_kwargs["input"], "file content\n")
@@ -177,8 +170,7 @@ async def test_input_string_not_file(self):
177170
service_call = self._make_service_call(data)
178171

179172
with patch("ssh_command.coordinator.connect", return_value=_MockConnect(mock_conn)):
180-
with patch("ssh_command.coordinator.exists", return_value=False):
181-
await self.handler(service_call)
173+
await self.handler(service_call)
182174

183175
call_kwargs = mock_conn.run.call_args[1]
184176
self.assertEqual(call_kwargs["input"], "inline input")
@@ -188,8 +180,7 @@ async def test_check_known_hosts_false(self):
188180
service_call = self._make_service_call(SERVICE_DATA_BASE)
189181

190182
with patch("ssh_command.coordinator.connect", return_value=_MockConnect(mock_conn)) as mock_connect:
191-
with patch("ssh_command.coordinator.exists", return_value=False):
192-
await self.handler(service_call)
183+
await self.handler(service_call)
193184

194185
call_kwargs = mock_connect.call_args[1]
195186
self.assertIsNone(call_kwargs["known_hosts"])
@@ -201,7 +192,7 @@ async def test_known_hosts_file_exists(self):
201192
service_call = self._make_service_call(data)
202193

203194
with patch("ssh_command.coordinator.connect", return_value=_MockConnect(mock_conn)) as mock_connect:
204-
with patch("ssh_command.coordinator.exists", return_value=True):
195+
with patch("pathlib.Path.exists", return_value=True):
205196
with patch("ssh_command.coordinator.read_known_hosts", return_value=mock_known_hosts) as mock_rkh:
206197
await self.handler(service_call)
207198

@@ -215,7 +206,7 @@ async def test_check_known_hosts_default_path_missing(self):
215206
service_call = self._make_service_call(data)
216207

217208
with patch("ssh_command.coordinator.connect", return_value=_MockConnect(mock_conn)) as mock_connect:
218-
with patch("ssh_command.coordinator.exists", return_value=False):
209+
with patch("pathlib.Path.exists", return_value=False):
219210
await self.handler(service_call)
220211

221212
call_kwargs = mock_connect.call_args[1]

0 commit comments

Comments
 (0)