forked from homeassistant-ai/ha-mcp
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_embedded_server.py
More file actions
4313 lines (3638 loc) · 180 KB
/
Copy pathtest_embedded_server.py
File metadata and controls
4313 lines (3638 loc) · 180 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Unit tests for :class:`EmbeddedServerManager` (issue #1527).
Covers package-ensure gating, worker-thread env staging, HA-token provisioning
(create / reuse / revoke), the readiness probe, and start/stop idempotency.
Home Assistant and aiohttp are stubbed via ``_embedded_stubs`` (imported first so
the fakes are installed before the component modules bind them). ``ha_mcp`` is
never imported here — the manager only imports it inside the worker thread, which
these tests never actually run.
"""
from __future__ import annotations
import asyncio
import importlib.metadata
import os
import sys
import threading
import time
from types import ModuleType, SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
from urllib.parse import urlparse
import pytest
from ._embedded_stubs import RequirementsNotFound, install
# Install stubs + put the component package on sys.path before importing the
# integration modules below. Also an isort barrier so the imports below are never
# reordered above it (which would import embedded_server before the stubs exist).
install()
import custom_components.ha_mcp_tools.embedded_server as es # noqa: E402
# HA's wheels index, as a HOST — the installer's extra-index retry drops a
# failing index by parsed hostname, and the fakes below match it the same way.
_WHEELS_HOST = "wheels.home-assistant.io"
from custom_components.ha_mcp_tools.const import ( # noqa: E402
CHANNEL_DEV,
CHANNEL_STABLE,
DATA_ACCESS_TOKEN,
DATA_LAST_PIP_SPEC,
DATA_PENDING_INSTALL_VERSION,
DATA_REFRESH_TOKEN_ID,
DATA_SECRET_PATH,
DATA_SERVER_USER_ID,
DEFAULT_PIP_SPEC,
DEV_PIP_SPEC,
DIST_NAME_DEV,
DIST_NAME_STABLE,
OPT_AUTO_UPDATE,
OPT_BIND_HOST,
OPT_CHANNEL,
OPT_PIP_SPEC,
OPT_SERVER_PORT,
OPT_SERVER_URL,
SERVER_TOKEN_CLIENT_NAME,
)
# GROUP_ID_ADMIN / the LLAT token-type come from the homeassistant stub the
# manager imports; the string values are pinned in _embedded_stubs.
_GROUP_ID_ADMIN = es.GROUP_ID_ADMIN
_TOKEN_TYPE_LLAT = es.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN
def _make_hass(tmp_path) -> MagicMock:
hass = MagicMock(name="hass")
hass.config.skip_pip = False
hass.config.path = lambda sub: str(tmp_path / sub)
async def _executor(func, *args):
return func(*args)
hass.async_add_executor_job = AsyncMock(side_effect=_executor)
# Auth surface: async_get_user / async_create_user / async_create_refresh_token
# / async_remove_user are coroutines; async_get_refresh_token /
# async_create_access_token / async_remove_refresh_token are @callback (sync).
hass.auth.async_get_user = AsyncMock(return_value=None)
hass.auth.async_create_user = AsyncMock()
hass.auth.async_create_refresh_token = AsyncMock()
hass.auth.async_remove_user = AsyncMock()
hass.auth.async_get_refresh_token = MagicMock(return_value=None)
hass.auth.async_create_access_token = MagicMock(return_value="access-token-xyz")
hass.auth.async_remove_refresh_token = MagicMock()
def _update_entry(entry, *, data=None, **_kw):
if data is not None:
entry.data = data
hass.config_entries.async_update_entry = MagicMock(side_effect=_update_entry)
return hass
def _make_entry(*, options=None, data=None) -> MagicMock:
entry = MagicMock(name="entry")
entry.options = {} if options is None else dict(options)
# ``data=None`` ⇒ the default (secret present); ``data={}`` ⇒ explicitly no
# secret (distinct cases: ``{} or default`` would wrongly pick the default).
entry.data = {DATA_SECRET_PATH: "/private_secret"} if data is None else dict(data)
return entry
def _manager(tmp_path, *, options=None, data=None):
hass = _make_hass(tmp_path)
entry = _make_entry(options=options, data=data)
return es.EmbeddedServerManager(hass, entry), hass, entry
def _user(uid="user-1", refresh_tokens=None):
return SimpleNamespace(id=uid, refresh_tokens=refresh_tokens or {})
def _rt(rt_id="rt-1", user=None, client_name="", token_type=""):
return SimpleNamespace(
id=rt_id,
user=user or _user(),
client_name=client_name,
token_type=token_type,
)
def _stub_ha_mcp_surface(monkeypatch, *, mcp, landing_mod=None) -> None:
"""Install a minimal in-memory ``ha_mcp`` package so ``_serve`` runs hermetically.
Wires a non-sentinel connection (so ``_serve`` passes its refuse-to-serve
guard), a server whose ``.mcp`` is ``mcp``, no-op settings routes, and a stub
uvicorn. Pass ``landing_mod`` to stub ``ha_mcp.browser_landing``; omit it to
simulate an OLDER installed server without the landing helper — modeled as a
module missing the ``register_browser_landing`` attribute, so the from-import
in ``_serve`` raises the same ImportError class its guard catches. Injection
(a sys.modules hit) is the only hermetic way to force that failure: deleting
the entry is NOT enough, because the editable install (``uv sync``) adds a
meta-path finder that resolves ``ha_mcp.*`` by name and would re-import the
REAL module even though the parent ``ha_mcp`` is faked with an empty
``__path__`` (live-found in CI).
"""
settings = SimpleNamespace(
homeassistant_url="http://127.0.0.1:8123", homeassistant_token="jwt"
)
ha_mcp_mod = ModuleType("ha_mcp")
ha_mcp_mod.__path__ = [] # package semantics for submodule imports
cfg = ModuleType("ha_mcp.config")
cfg.reset_global_settings = lambda: None
cfg.set_embedded_connection = lambda u, t: None
cfg.OAUTH_MODE_URL = "__sentinel_url__"
cfg.OAUTH_MODE_TOKEN = "__sentinel_token__"
cfg.get_global_settings = lambda: settings
server_mod = ModuleType("ha_mcp.server")
server_mod.HomeAssistantSmartMCPServer = lambda: SimpleNamespace(mcp=mcp)
ui_mod = ModuleType("ha_mcp.settings_ui")
ui_mod.register_settings_routes = lambda *a, **k: None
uvicorn_mod = ModuleType("uvicorn")
uvicorn_mod.Config = lambda *a, **k: SimpleNamespace()
uvicorn_mod.Server = lambda config: SimpleNamespace(should_exit=False)
ha_mcp_mod.config = cfg
ha_mcp_mod.server = server_mod
ha_mcp_mod.settings_ui = ui_mod
mods = {
"ha_mcp": ha_mcp_mod,
"ha_mcp.config": cfg,
"ha_mcp.server": server_mod,
"ha_mcp.settings_ui": ui_mod,
"uvicorn": uvicorn_mod,
}
if landing_mod is None:
# Older-server stand-in: module present, helper attribute absent — the
# from-import raises ImportError, same class as a missing module.
landing_mod = ModuleType("ha_mcp.browser_landing")
ha_mcp_mod.browser_landing = landing_mod
mods["ha_mcp.browser_landing"] = landing_mod
for name, mod in mods.items():
monkeypatch.setitem(sys.modules, name, mod)
# ---------------------------------------------------------------------------
# Construction / option parsing
# ---------------------------------------------------------------------------
class TestConstruction:
def test_defaults(self, tmp_path):
mgr, _hass, _entry = _manager(tmp_path)
assert mgr.port == 9584
# LAN-reachable by default (owner decision: add-on parity - the
# secret path is the credential, same as the add-on's port).
assert mgr._bind_host == "0.0.0.0"
assert mgr._server_url == "http://127.0.0.1:8123"
# Stable is unpinned now: the bare distribution name (auto-updates).
assert mgr._pip_spec == "ha-mcp"
assert mgr.is_running is False
def test_option_overrides(self, tmp_path):
mgr, _hass, _entry = _manager(
tmp_path,
options={
OPT_SERVER_PORT: 9999,
OPT_BIND_HOST: "0.0.0.0",
OPT_SERVER_URL: "http://ha.local:8123/", # trailing slash trimmed
OPT_PIP_SPEC: "ha-mcp @ https://example/tarball.tgz",
},
)
assert mgr.port == 9999
assert mgr._bind_host == "0.0.0.0"
assert mgr._server_url == "http://ha.local:8123"
assert mgr._pip_spec == "ha-mcp @ https://example/tarball.tgz"
class TestLoopbackDerivation:
"""Issue #1890: the default loopback URL honors the http integration's real
port and SSL configuration (``hass.config.api``) instead of hardcoding
``http://127.0.0.1:8123`` — which spoke plaintext into a TLS socket on any
instance with ``http.ssl_certificate`` configured, killing every HA
round-trip while the MCP handshake kept working."""
def test_no_api_object_falls_back_to_constant(self, tmp_path):
hass = _make_hass(tmp_path)
hass.config.api = None
assert es._derive_loopback_url(hass) == ("http://127.0.0.1:8123", None)
def test_ssl_enabled_derives_https_with_verify_off(self, tmp_path):
hass = _make_hass(tmp_path)
hass.config.api = SimpleNamespace(port=8123, use_ssl=True)
assert es._derive_loopback_url(hass) == ("https://127.0.0.1:8123", False)
def test_custom_port_is_honored(self, tmp_path):
hass = _make_hass(tmp_path)
hass.config.api = SimpleNamespace(port=8444, use_ssl=False)
assert es._derive_loopback_url(hass) == ("http://127.0.0.1:8444", None)
def test_unusable_port_falls_back_to_8123(self, tmp_path):
hass = _make_hass(tmp_path)
hass.config.api = SimpleNamespace(port=object(), use_ssl=True)
assert es._derive_loopback_url(hass) == ("https://127.0.0.1:8123", False)
def test_manager_derives_when_no_override(self, tmp_path):
hass = _make_hass(tmp_path)
hass.config.api = SimpleNamespace(port=8443, use_ssl=True)
mgr = es.EmbeddedServerManager(hass, _make_entry())
assert mgr._server_url == "https://127.0.0.1:8443"
assert mgr._loopback_verify_ssl is False
def test_manager_explicit_override_wins_verbatim(self, tmp_path):
hass = _make_hass(tmp_path)
hass.config.api = SimpleNamespace(port=8443, use_ssl=True)
mgr = es.EmbeddedServerManager(
hass, _make_entry(options={OPT_SERVER_URL: "http://ha.local:8123/"})
)
assert mgr._server_url == "http://ha.local:8123"
assert mgr._loopback_verify_ssl is None
def test_manager_treats_stored_default_as_no_override(self, tmp_path):
# Older options forms pre-filled DEFAULT_LOOPBACK_URL as
# suggested_value, so entries whose owner never chose an override
# carry it verbatim — it must not pin the scheme/port.
hass = _make_hass(tmp_path)
hass.config.api = SimpleNamespace(port=8123, use_ssl=True)
mgr = es.EmbeddedServerManager(
hass, _make_entry(options={OPT_SERVER_URL: "http://127.0.0.1:8123"})
)
assert mgr._server_url == "https://127.0.0.1:8123"
assert mgr._loopback_verify_ssl is False
class TestChannelResolution:
def test_default_channel_is_stable_unpinned(self, tmp_path):
mgr, _hass, _entry = _manager(tmp_path)
assert mgr._channel == CHANNEL_STABLE
assert mgr._pip_spec == DEFAULT_PIP_SPEC
# Unpinned: the stable channel installs the bare distribution name so
# each install resolves the newest stable release.
assert mgr._pip_spec == DIST_NAME_STABLE
def test_dev_channel_uses_dev_dist(self, tmp_path):
mgr, _hass, _entry = _manager(tmp_path, options={OPT_CHANNEL: CHANNEL_DEV})
assert mgr._pip_spec == DEV_PIP_SPEC
def test_explicit_override_wins_over_channel(self, tmp_path):
# A real override (a tarball URL) beats the channel selector even on dev.
mgr, _hass, _entry = _manager(
tmp_path,
options={
OPT_CHANNEL: CHANNEL_DEV,
OPT_PIP_SPEC: "ha-mcp @ https://example/tarball.tgz",
},
)
assert mgr._pip_spec == "ha-mcp @ https://example/tarball.tgz"
def test_default_pip_spec_is_not_an_override(self, tmp_path):
# The pinned default in the pip-spec field means "no override": a dev
# entry that stored it must still resolve to the dev distribution.
mgr, _hass, _entry = _manager(
tmp_path,
options={OPT_CHANNEL: CHANNEL_DEV, OPT_PIP_SPEC: DEFAULT_PIP_SPEC},
)
assert mgr._pip_spec == DEV_PIP_SPEC
def test_conflicting_dist_name_by_channel(self, tmp_path):
stable, _h, _e = _manager(tmp_path, options={OPT_CHANNEL: CHANNEL_STABLE})
dev, _h2, _e2 = _manager(tmp_path, options={OPT_CHANNEL: CHANNEL_DEV})
assert stable._conflicting_dist_name() == DIST_NAME_DEV
assert dev._conflicting_dist_name() == DIST_NAME_STABLE
def test_conflicting_dist_name_none_for_override(self, tmp_path):
mgr, _hass, _entry = _manager(
tmp_path,
options={OPT_CHANNEL: CHANNEL_DEV, OPT_PIP_SPEC: "ha-mcp==7.8.0"},
)
assert mgr._conflicting_dist_name() is None
def test_auto_update_off_pins_stable_to_installed(self, tmp_path):
# Auto-update off: a non-override channel pins to the passed installed
# version so reloads keep exactly that build. The version is passed in
# (not read) so _resolve_pip_spec never blocks the event loop.
mgr, _hass, _entry = _manager(tmp_path, options={OPT_AUTO_UPDATE: False})
# Construction defers the read: the initial spec is the bare dist.
assert mgr._pip_spec == DIST_NAME_STABLE
assert mgr._resolve_pip_spec("7.9.0") == f"{DIST_NAME_STABLE}==7.9.0"
def test_auto_update_off_pins_dev_to_installed(self, tmp_path):
mgr, _hass, _entry = _manager(
tmp_path, options={OPT_CHANNEL: CHANNEL_DEV, OPT_AUTO_UPDATE: False}
)
assert mgr._pip_spec == DEV_PIP_SPEC
assert mgr._resolve_pip_spec("7.9.0.dev5") == f"{DEV_PIP_SPEC}==7.9.0.dev5"
async def test_ensure_package_repins_stable_from_installed_when_auto_off(
self, tmp_path, monkeypatch
):
# The executor-read version of the TARGET dist re-pins the spec inside
# _async_ensure_package (off-loop), so the forced install targets the
# exact installed build.
mgr, _hass, _entry = _manager(
tmp_path,
options={OPT_AUTO_UPDATE: False},
data={DATA_SECRET_PATH: "/p", DATA_LAST_PIP_SPEC: "stale"},
)
monkeypatch.setattr(es, "async_process_requirements", AsyncMock())
monkeypatch.setattr(es, "_force_install_package", MagicMock(return_value=True))
monkeypatch.setattr(es, "pip_kwargs", lambda cfg: {})
monkeypatch.setattr(es, "_installed_ha_mcp_version", lambda: "7.12.1")
monkeypatch.setattr(es, "_installed_dist_version", lambda dist: "7.12.1")
monkeypatch.setattr(
es, "_dist_installed", lambda name: name == DIST_NAME_STABLE
)
monkeypatch.setattr(es, "_uninstall_distribution", MagicMock())
await mgr._async_ensure_package()
assert mgr._pip_spec == f"{DIST_NAME_STABLE}==7.12.1"
async def test_ensure_package_channel_switch_auto_off_stays_unpinned(
self, tmp_path, monkeypatch
):
# Regression: dev->stable with auto-update off. The old dev dist is still
# installed when the re-pin reads, but the pin must come from the TARGET
# (stable) dist — which is not installed yet — so the spec stays unpinned
# and installs the newest stable, rather than pinning ha-mcp to a
# dev-only version that does not exist (a failed bring-up).
mgr, _hass, _entry = _manager(
tmp_path,
options={OPT_CHANNEL: CHANNEL_STABLE, OPT_AUTO_UPDATE: False},
data={DATA_SECRET_PATH: "/p", DATA_LAST_PIP_SPEC: "stale"},
)
monkeypatch.setattr(es, "async_process_requirements", AsyncMock())
monkeypatch.setattr(es, "_force_install_package", MagicMock(return_value=True))
monkeypatch.setattr(es, "pip_kwargs", lambda cfg: {})
# Whichever-present read sees the old dev build; the target-dist read
# sees nothing (stable not installed on this machine yet).
monkeypatch.setattr(es, "_installed_ha_mcp_version", lambda: "8.0.0.dev3")
monkeypatch.setattr(
es,
"_installed_dist_version",
lambda dist: "8.0.0.dev3" if dist == DEV_PIP_SPEC else None,
)
monkeypatch.setattr(es, "_dist_installed", lambda name: True)
monkeypatch.setattr(es, "_uninstall_distribution", MagicMock())
await mgr._async_ensure_package()
assert mgr._pip_spec == DIST_NAME_STABLE
def test_auto_update_off_falls_back_to_unpinned_on_first_setup(
self, tmp_path, monkeypatch
):
# Nothing installed yet ⇒ no version to pin to ⇒ install the unpinned
# dist once (the newest), then later reloads pin to whatever landed.
monkeypatch.setattr(es, "_installed_dist_version", lambda dist: None)
mgr, _hass, _entry = _manager(tmp_path, options={OPT_AUTO_UPDATE: False})
assert mgr._pip_spec == DIST_NAME_STABLE
def test_auto_update_default_on_never_pins(self, tmp_path, monkeypatch):
# Default (option absent) is auto-update ON: the spec stays unpinned even
# when a version is installed, and no version is read at construction.
reader = MagicMock(return_value="7.9.0")
monkeypatch.setattr(es, "_installed_dist_version", reader)
mgr, _hass, _entry = _manager(tmp_path)
assert mgr._pip_spec == DIST_NAME_STABLE
reader.assert_not_called()
def test_explicit_override_wins_over_auto_update_off(self, tmp_path, monkeypatch):
# An override still wins even with auto-update off (no pinning applied).
monkeypatch.setattr(es, "_installed_dist_version", lambda dist: "7.9.0")
mgr, _hass, _entry = _manager(
tmp_path,
options={OPT_AUTO_UPDATE: False, OPT_PIP_SPEC: "ha-mcp==7.8.0"},
)
assert mgr._pip_spec == "ha-mcp==7.8.0"
# ---------------------------------------------------------------------------
# Package-ensure gating
# ---------------------------------------------------------------------------
class TestEnsurePackage:
async def test_skip_pip_uses_compatible_externally_managed_package(
self, tmp_path, monkeypatch
):
"""skip_pip must bypass every package mutation and preserve markers."""
data = {
DATA_SECRET_PATH: "/p",
DATA_LAST_PIP_SPEC: "ha-mcp==7.11.0",
DATA_PENDING_INSTALL_VERSION: "7.12.0",
}
mgr, hass, entry = _manager(
tmp_path,
options={OPT_PIP_SPEC: "ha-mcp==99.0.0"},
data=data,
)
hass.config.skip_pip = True
process = AsyncMock(side_effect=AssertionError("requirements mutation"))
force_install = MagicMock(side_effect=AssertionError("package install"))
uninstall = MagicMock(side_effect=AssertionError("package uninstall"))
monkeypatch.setattr(es, "async_process_requirements", process)
monkeypatch.setattr(es, "_force_install_package", force_install)
monkeypatch.setattr(es, "_uninstall_distribution", uninstall)
monkeypatch.setattr(es, "_installed_ha_mcp_version", lambda: "7.12.1")
monkeypatch.setattr(
es,
"_installed_dist_version",
lambda dist: "7.12.1" if dist == DIST_NAME_STABLE else None,
)
version = await mgr._async_ensure_package()
assert version == "7.12.1"
assert entry.data == data
@pytest.mark.parametrize(
("channel", "installed_dist", "installed_version", "expected_dist"),
[
(
CHANNEL_STABLE,
DIST_NAME_DEV,
"7.12.1.dev1",
DIST_NAME_STABLE,
),
(CHANNEL_DEV, DIST_NAME_STABLE, "7.12.1", DIST_NAME_DEV),
],
)
async def test_skip_pip_rejects_package_from_other_channel(
self,
tmp_path,
monkeypatch,
channel,
installed_dist,
installed_version,
expected_dist,
):
mgr, hass, _entry = _manager(tmp_path, options={OPT_CHANNEL: channel})
hass.config.skip_pip = True
versions = {installed_dist: installed_version}
monkeypatch.setattr(es, "_installed_ha_mcp_version", lambda: installed_version)
monkeypatch.setattr(es, "_installed_dist_version", versions.get)
with pytest.raises(
es.EmbeddedServerError,
match=rf"configured {channel} channel expects {expected_dist}.*{installed_dist}",
):
await mgr._async_ensure_package()
async def test_skip_pip_reports_missing_externally_managed_package(
self, tmp_path, monkeypatch
):
mgr, hass, _entry = _manager(tmp_path)
hass.config.skip_pip = True
monkeypatch.setattr(es, "_installed_ha_mcp_version", lambda: None)
monkeypatch.setattr(es, "_installed_dist_version", lambda _dist: None)
with pytest.raises(
es.EmbeddedServerError,
match=r"skip_pip.*system package manager.*7\.10\.0",
) as exc_info:
await mgr._async_ensure_package()
assert exc_info.value.kind == "package"
async def test_skip_pip_reports_incompatible_externally_managed_package(
self, tmp_path, monkeypatch
):
mgr, hass, _entry = _manager(tmp_path)
hass.config.skip_pip = True
monkeypatch.setattr(es, "_installed_ha_mcp_version", lambda: "7.9.0")
monkeypatch.setattr(
es,
"_installed_dist_version",
lambda dist: "7.9.0" if dist == DIST_NAME_STABLE else None,
)
with pytest.raises(
es.EmbeddedServerError,
match=r"externally managed ha-mcp 7\.9\.0.*7\.10\.0 or newer",
) as exc_info:
await mgr._async_ensure_package()
assert exc_info.value.kind == "package"
async def test_skip_pip_reports_ambiguous_externally_managed_packages(
self, tmp_path, monkeypatch
):
mgr, hass, _entry = _manager(tmp_path)
hass.config.skip_pip = True
versions = {
DIST_NAME_STABLE: "7.12.1",
DIST_NAME_DEV: "7.13.0.dev1",
}
monkeypatch.setattr(es, "_installed_ha_mcp_version", lambda: "7.12.1")
monkeypatch.setattr(es, "_installed_dist_version", versions.get)
with pytest.raises(
es.EmbeddedServerError,
match=r"Both ha-mcp 7\.12\.1 and ha-mcp-dev 7\.13\.0\.dev1",
) as exc_info:
await mgr._async_ensure_package()
assert exc_info.value.kind == "package"
async def test_fast_path_only_for_unchanged_override(self, tmp_path, monkeypatch):
# The fast path is reserved for an explicit pip-spec override: an
# unchanged, already-installed pin delegates the "already satisfied?"
# decision to HA's requirements manager (a pin does not move, so no
# forced reinstall).
mgr, _hass, _entry = _manager(
tmp_path,
options={OPT_PIP_SPEC: "ha-mcp==7.12.1"},
data={DATA_SECRET_PATH: "/p", DATA_LAST_PIP_SPEC: "ha-mcp==7.12.1"},
)
proc = AsyncMock()
install_pkg = MagicMock(return_value=True)
monkeypatch.setattr(es, "async_process_requirements", proc)
monkeypatch.setattr(es, "_force_install_package", install_pkg)
monkeypatch.setattr(es, "_installed_ha_mcp_version", lambda: "7.12.1")
await mgr._async_ensure_package()
proc.assert_awaited_once()
assert proc.await_args.args[2] == ["ha-mcp==7.12.1"]
install_pkg.assert_not_called()
async def test_unchanged_url_override_never_takes_fast_path(
self, tmp_path, monkeypatch
):
"""A URL override must force-install even when nothing changed.
The fast path hands the spec to HA's requirements manager, and
homeassistant.util.package.is_installed() returns False for every
requirement carrying a URL ("we cannot verify versions") — so
async_process_requirements always reaches install_package(), whose
upgrade default appends a bare --upgrade that re-resolves the whole
graph and replaces packages HA only floors. That is the #2135/#2146
tear, and for a URL override it would recur on EVERY restart, since
the stored spec matches from the second bring-up onward.
"""
spec = "ha-mcp @ file:///config/ha_mcp-8.1.0-py3-none-any.whl"
mgr, _hass, _entry = _manager(
tmp_path,
options={OPT_PIP_SPEC: spec},
data={DATA_SECRET_PATH: "/p", DATA_LAST_PIP_SPEC: spec},
)
proc = AsyncMock()
install_pkg = MagicMock(return_value=True)
monkeypatch.setattr(es, "async_process_requirements", proc)
monkeypatch.setattr(es, "_force_install_package", install_pkg)
monkeypatch.setattr(es, "_installed_ha_mcp_version", lambda: "8.1.0")
await mgr._async_ensure_package()
proc.assert_not_awaited()
install_pkg.assert_called_once()
assert install_pkg.call_args.args[0] == spec
async def test_auto_update_off_takes_fast_path_when_pinned_unchanged(
self, tmp_path, monkeypatch
):
# Auto-update off pins to the installed version; an unchanged pin takes
# the fast path (like an explicit override) — no forced upgrade churn.
monkeypatch.setattr(es, "_installed_dist_version", lambda dist: "7.12.1")
mgr, _hass, _entry = _manager(
tmp_path,
options={OPT_AUTO_UPDATE: False},
data={DATA_SECRET_PATH: "/p", DATA_LAST_PIP_SPEC: "ha-mcp==7.12.1"},
)
proc = AsyncMock()
install_pkg = MagicMock(return_value=True)
monkeypatch.setattr(es, "async_process_requirements", proc)
monkeypatch.setattr(es, "_force_install_package", install_pkg)
monkeypatch.setattr(es, "_installed_ha_mcp_version", lambda: "7.12.1")
await mgr._async_ensure_package()
proc.assert_awaited_once()
assert proc.await_args.args[2] == ["ha-mcp==7.12.1"]
install_pkg.assert_not_called()
async def test_stable_non_override_always_forces_install(
self, tmp_path, monkeypatch
):
# Stable is unpinned and auto-updates: even when the stored spec matches
# and the package is present, a non-override spec takes the force-install
# path (upgrade=True) so every reload pulls the newest stable build.
mgr, _hass, _entry = _manager(
tmp_path,
data={DATA_SECRET_PATH: "/p", DATA_LAST_PIP_SPEC: DEFAULT_PIP_SPEC},
)
proc = AsyncMock()
install_pkg = MagicMock(return_value=True)
monkeypatch.setattr(es, "async_process_requirements", proc)
monkeypatch.setattr(es, "_force_install_package", install_pkg)
monkeypatch.setattr(es, "pip_kwargs", lambda cfg: {})
monkeypatch.setattr(es, "_installed_ha_mcp_version", lambda: "7.12.1")
monkeypatch.setattr(es, "_dist_installed", lambda name: False)
monkeypatch.setattr(es, "_uninstall_distribution", MagicMock())
await mgr._async_ensure_package()
proc.assert_not_awaited()
install_pkg.assert_called_once()
assert install_pkg.call_args.args[0] == DEFAULT_PIP_SPEC
assert install_pkg.call_args.kwargs["channel_dist"] == "ha-mcp"
async def test_force_install_when_spec_changed(self, tmp_path, monkeypatch):
# Configured spec differs from the last-installed one ⇒ force a real
# reinstall (upgrade=True), not the fast path. Both specs are index
# pins on the SAME distribution, so no replaced-source uninstall: the
# index's version resolution is faithful for a repin, and the version
# change makes the install real by itself.
mgr, _hass, entry = _manager(
tmp_path,
data={DATA_SECRET_PATH: "/p", DATA_LAST_PIP_SPEC: "ha-mcp==7.11.0"},
options={OPT_PIP_SPEC: "ha-mcp==7.12.1"},
)
proc = AsyncMock()
install_pkg = MagicMock(return_value=True)
uninstall = MagicMock(return_value=True)
monkeypatch.setattr(es, "async_process_requirements", proc)
monkeypatch.setattr(es, "_force_install_package", install_pkg)
monkeypatch.setattr(es, "pip_kwargs", lambda cfg: {})
monkeypatch.setattr(es, "_installed_ha_mcp_version", lambda: "7.12.1")
monkeypatch.setattr(
es, "_dist_installed", lambda name: name == DIST_NAME_STABLE
)
monkeypatch.setattr(es, "_uninstall_distribution", uninstall)
await mgr._async_ensure_package()
proc.assert_not_awaited()
uninstall.assert_not_called()
install_pkg.assert_called_once()
assert install_pkg.call_args.args[0] == "ha-mcp==7.12.1"
assert install_pkg.call_args.kwargs["channel_dist"] == "ha-mcp"
# The just-installed spec is persisted so the next start takes the fast path.
assert entry.data[DATA_LAST_PIP_SPEC] == "ha-mcp==7.12.1"
async def test_auto_update_toggle_repin_does_not_uninstall(
self, tmp_path, monkeypatch
):
# Turning auto-update off rewrites the stored bare channel spec to a
# pin on the installed version. That is a repin on the same index
# distribution, not a source change — uninstalling the healthy
# install for it would only open an offline-breakage window (review
# finding on #1923).
install_pkg = MagicMock(return_value=True)
uninstall = MagicMock()
mgr, _hass, _entry = _manager(
tmp_path,
options={OPT_AUTO_UPDATE: False},
data={DATA_SECRET_PATH: "/p", DATA_LAST_PIP_SPEC: DEFAULT_PIP_SPEC},
)
monkeypatch.setattr(es, "_force_install_package", install_pkg)
monkeypatch.setattr(es, "pip_kwargs", lambda cfg: {})
monkeypatch.setattr(es, "_installed_ha_mcp_version", lambda: "7.13.0")
monkeypatch.setattr(es, "_installed_dist_version", lambda dist: "7.13.0")
monkeypatch.setattr(
es, "_dist_installed", lambda name: name == DIST_NAME_STABLE
)
monkeypatch.setattr(es, "_uninstall_distribution", uninstall)
await mgr._async_ensure_package()
uninstall.assert_not_called()
install_pkg.assert_called_once()
assert install_pkg.call_args.args[0] == f"{DIST_NAME_STABLE}==7.13.0"
async def test_cleared_override_uninstalls_replaced_source(
self, tmp_path, monkeypatch
):
# Issue #1914: a PR tarball installs with the same base version as the
# channel release it branched from, so after clearing the override the
# unpinned channel spec resolves to the version already on disk and
# pip's upgrade=True no-ops — the PR code keeps running behind an entry
# that reports a clean channel install. The replaced-source uninstall
# must run first so the reinstall is real.
tarball = (
"https://github.com/homeassistant-ai/ha-mcp/archive/refs/pull/"
"1234/head.tar.gz"
)
calls: list[str] = []
install_pkg = MagicMock(side_effect=lambda *a, **k: calls.append("i") or True)
uninstall = MagicMock(side_effect=lambda *a, **k: calls.append("u") or True)
mgr, _hass, entry = _manager(
tmp_path,
data={DATA_SECRET_PATH: "/p", DATA_LAST_PIP_SPEC: tarball},
)
monkeypatch.setattr(es, "_force_install_package", install_pkg)
monkeypatch.setattr(es, "pip_kwargs", lambda cfg: {})
monkeypatch.setattr(es, "_installed_ha_mcp_version", lambda: "7.13.0")
# The replaced-source check reads the version of the dist it is about
# to replace, so that is the lookup a version-equality case must stub.
monkeypatch.setattr(es, "_installed_dist_version", lambda dist: "7.13.0")
monkeypatch.setattr(
es, "_dist_installed", lambda name: name == DIST_NAME_STABLE
)
monkeypatch.setattr(es, "_uninstall_distribution", uninstall)
await mgr._async_ensure_package()
uninstall.assert_called_once_with(DIST_NAME_STABLE)
install_pkg.assert_called_once()
assert install_pkg.call_args.args[0] == DIST_NAME_STABLE
assert install_pkg.call_args.kwargs["channel_dist"] == "ha-mcp"
assert calls == ["u", "i"] # uninstall strictly before the install
assert entry.data[DATA_LAST_PIP_SPEC] == DIST_NAME_STABLE
async def test_url_to_url_change_keeps_the_working_build_installed(
self, tmp_path, monkeypatch
):
"""Switching between two named URLs must NOT uninstall first.
A URL spec is reinstalled outright (``--reinstall-package``), so the
install cannot be skipped as "already satisfied" and the #1914
uninstall has nothing to unblock. Removing first would delete the
working build BEFORE the new URL is fetched — a bad path or a
network blip then leaves no server installed at all, and it reopens
the uninstall-then-extract window on our own package. A BARE url is
already declined by _replaced_dist_name(); a NAMED one parses as a
requirement and used to fall through to the removal.
"""
old_url = "ha-mcp @ file:///config/ha_mcp-8.0.0-py3-none-any.whl"
new_url = "ha-mcp @ file:///config/ha_mcp-8.1.0-py3-none-any.whl"
install_pkg = MagicMock(return_value=True)
uninstall = MagicMock(return_value=True)
mgr, _hass, _entry = _manager(
tmp_path,
options={OPT_PIP_SPEC: new_url},
data={DATA_SECRET_PATH: "/p", DATA_LAST_PIP_SPEC: old_url},
)
monkeypatch.setattr(es, "_force_install_package", install_pkg)
monkeypatch.setattr(es, "pip_kwargs", lambda cfg: {})
monkeypatch.setattr(es, "_installed_ha_mcp_version", lambda: "8.0.0")
monkeypatch.setattr(es, "_dist_installed", lambda name: True)
monkeypatch.setattr(es, "_uninstall_distribution", uninstall)
await mgr._async_ensure_package()
uninstall.assert_not_called()
install_pkg.assert_called_once()
assert install_pkg.call_args.args[0] == new_url
async def test_pin_compares_against_the_replaced_dist_not_the_generic_version(
self, tmp_path, monkeypatch
):
"""The pin must be compared with the dist actually being replaced.
``installed_version`` comes from whichever dist provides ``ha_mcp``
and is read BEFORE _async_remove_conflicting_dist() runs, so on a
cross-channel switch it can name the other channel's version. Here
the generic lookup reports 8.0.0 while the target ``ha-mcp`` is
already at 8.1.0: comparing against the generic value says "the pin
moved", skips this uninstall, and the install then no-ops as
already satisfied — leaving the tarball's code running (#1914).
"""
tarball = (
"https://github.com/homeassistant-ai/ha-mcp/archive/refs/pull/"
"1234/head.tar.gz"
)
install_pkg = MagicMock(return_value=True)
uninstall = MagicMock(return_value=True)
mgr, _hass, _entry = _manager(
tmp_path,
options={OPT_PIP_SPEC: "ha-mcp==8.1.0"},
data={DATA_SECRET_PATH: "/p", DATA_LAST_PIP_SPEC: tarball},
)
monkeypatch.setattr(es, "_force_install_package", install_pkg)
monkeypatch.setattr(es, "pip_kwargs", lambda cfg: {})
monkeypatch.setattr(es, "_installed_ha_mcp_version", lambda *a: "8.0.0")
monkeypatch.setattr(es, "_installed_dist_version", lambda dist: "8.1.0")
monkeypatch.setattr(es, "_dist_installed", lambda name: True)
monkeypatch.setattr(es, "_uninstall_distribution", uninstall)
await mgr._async_ensure_package()
uninstall.assert_called_once_with(DIST_NAME_STABLE)
async def test_pin_matching_installed_version_still_reinstalls(
self, tmp_path, monkeypatch
):
# The manual-edit variant of #1914: after a tarball install, a user who
# pins the exact version already on disk (to force a "clean" build)
# must still get a real reinstall — the pin's version equals the
# installed one, so only the replaced-source uninstall makes pip act.
tarball = (
"https://github.com/homeassistant-ai/ha-mcp/archive/refs/heads/"
"some-branch.tar.gz"
)
install_pkg = MagicMock(return_value=True)
uninstall = MagicMock(return_value=True)
mgr, _hass, entry = _manager(
tmp_path,
options={OPT_PIP_SPEC: "ha-mcp==7.13.0"},
data={DATA_SECRET_PATH: "/p", DATA_LAST_PIP_SPEC: tarball},
)
monkeypatch.setattr(es, "_force_install_package", install_pkg)
monkeypatch.setattr(es, "pip_kwargs", lambda cfg: {})
monkeypatch.setattr(es, "_installed_ha_mcp_version", lambda: "7.13.0")
# The replaced-source check reads the version of the dist it is about
# to replace, so that is the lookup a version-equality case must stub.
monkeypatch.setattr(es, "_installed_dist_version", lambda dist: "7.13.0")
monkeypatch.setattr(
es, "_dist_installed", lambda name: name == DIST_NAME_STABLE
)
monkeypatch.setattr(es, "_uninstall_distribution", uninstall)
await mgr._async_ensure_package()
uninstall.assert_called_once_with(DIST_NAME_STABLE)
install_pkg.assert_called_once()
assert install_pkg.call_args.args[0] == "ha-mcp==7.13.0"
assert entry.data[DATA_LAST_PIP_SPEC] == "ha-mcp==7.13.0"
async def test_failed_replaced_source_uninstall_raises(self, tmp_path, monkeypatch):
# If the replaced-source uninstall fails and the distribution is still
# present, proceeding would no-op the "forced" install AND persist the
# new spec - reproducing #1914 and then masking it as "unchanged" on
# every later reload. It must raise instead, keeping the stored spec on
# the old value so the next reload retries the source change.
tarball = (
"https://github.com/homeassistant-ai/ha-mcp/archive/refs/pull/"
"1234/head.tar.gz"
)
install_pkg = MagicMock(return_value=True)
uninstall = MagicMock(return_value=False)
mgr, _hass, entry = _manager(
tmp_path,
data={DATA_SECRET_PATH: "/p", DATA_LAST_PIP_SPEC: tarball},
)
monkeypatch.setattr(es, "_force_install_package", install_pkg)
monkeypatch.setattr(es, "pip_kwargs", lambda cfg: {})
monkeypatch.setattr(es, "_installed_ha_mcp_version", lambda: "7.13.0")
monkeypatch.setattr(
es, "_dist_installed", lambda name: name == DIST_NAME_STABLE
)
monkeypatch.setattr(es, "_uninstall_distribution", uninstall)
with pytest.raises(es.EmbeddedServerError) as exc:
await mgr._async_ensure_package()
assert exc.value.kind == "package"
install_pkg.assert_not_called()
assert entry.data[DATA_LAST_PIP_SPEC] == tarball
async def test_failed_uninstall_with_dist_gone_still_installs(
self, tmp_path, monkeypatch
):
# A False from the uninstall subprocess is not proof the distribution
# survived (e.g. a timeout after the files were removed). When the
# re-check shows it gone, the reinstall proceeds normally.
tarball = (
"https://github.com/homeassistant-ai/ha-mcp/archive/refs/pull/"
"1234/head.tar.gz"
)
install_pkg = MagicMock(return_value=True)
uninstall = MagicMock(return_value=False)
mgr, _hass, entry = _manager(
tmp_path,
data={DATA_SECRET_PATH: "/p", DATA_LAST_PIP_SPEC: tarball},
)
monkeypatch.setattr(es, "_force_install_package", install_pkg)
monkeypatch.setattr(es, "pip_kwargs", lambda cfg: {})
monkeypatch.setattr(es, "_installed_ha_mcp_version", lambda: "7.13.0")
# In call order: conflicting-dist check (ha-mcp-dev absent), then the
# replaced-source pre-check (ha-mcp present), then the post-failure
# re-check (ha-mcp gone).
monkeypatch.setattr(
es, "_dist_installed", MagicMock(side_effect=[False, True, False])
)
monkeypatch.setattr(es, "_uninstall_distribution", uninstall)
await mgr._async_ensure_package()
install_pkg.assert_called_once()
assert entry.data[DATA_LAST_PIP_SPEC] == DIST_NAME_STABLE
async def test_dev_channel_override_pin_uninstalls_actual_dist(
self, tmp_path, monkeypatch
):
# Dev channel + override: a repo tarball occupies the STABLE
# distribution name regardless of the selected channel, so re-pointing
# the override to a pin must uninstall the dist the pin names
# (ha-mcp), not the channel's (ha-mcp-dev) — otherwise the pin looks
# already satisfied and the old override code keeps running (#1914 on
# the dev channel).
tarball = (
"https://github.com/homeassistant-ai/ha-mcp/archive/refs/pull/"
"1234/head.tar.gz"
)
install_pkg = MagicMock(return_value=True)
uninstall = MagicMock(return_value=True)
mgr, _hass, _entry = _manager(
tmp_path,
options={OPT_CHANNEL: CHANNEL_DEV, OPT_PIP_SPEC: "ha-mcp==7.13.0"},
data={DATA_SECRET_PATH: "/p", DATA_LAST_PIP_SPEC: tarball},
)
monkeypatch.setattr(es, "_force_install_package", install_pkg)
monkeypatch.setattr(es, "pip_kwargs", lambda cfg: {})
monkeypatch.setattr(es, "_installed_ha_mcp_version", lambda: "7.13.0")
# The replaced-source check reads the version of the dist it is about
# to replace, so that is the lookup a version-equality case must stub.
monkeypatch.setattr(es, "_installed_dist_version", lambda dist: "7.13.0")
monkeypatch.setattr(
es, "_dist_installed", lambda name: name == DIST_NAME_STABLE
)
monkeypatch.setattr(es, "_uninstall_distribution", uninstall)
await mgr._async_ensure_package()
uninstall.assert_called_once_with(DIST_NAME_STABLE)
install_pkg.assert_called_once()
assert install_pkg.call_args.args[0] == "ha-mcp==7.13.0"
async def test_url_override_change_skips_uninstall(self, tmp_path, monkeypatch):
# Re-pointing to a direct-URL spec skips the pre-uninstall: the
# installer re-fetches and rebuilds URL requirements under
# upgrade=True regardless of the installed version, so the install is
# already real — and skipping avoids a needless remove/install gap.
old_tarball = (
"https://github.com/homeassistant-ai/ha-mcp/archive/refs/pull/"
"1111/head.tar.gz"
)
new_tarball = (
"https://github.com/homeassistant-ai/ha-mcp/archive/refs/pull/"
"2222/head.tar.gz"
)
install_pkg = MagicMock(return_value=True)
uninstall = MagicMock(return_value=True)
mgr, _hass, _entry = _manager(
tmp_path,
options={OPT_PIP_SPEC: new_tarball},
data={DATA_SECRET_PATH: "/p", DATA_LAST_PIP_SPEC: old_tarball},
)
monkeypatch.setattr(es, "_force_install_package", install_pkg)
monkeypatch.setattr(es, "pip_kwargs", lambda cfg: {})
monkeypatch.setattr(es, "_installed_ha_mcp_version", lambda: "7.13.0")
monkeypatch.setattr(es, "_dist_installed", lambda name: True)
monkeypatch.setattr(es, "_uninstall_distribution", uninstall)
await mgr._async_ensure_package()
uninstall.assert_not_called()
install_pkg.assert_called_once()
assert install_pkg.call_args.args[0] == new_tarball
async def test_replaced_source_skips_when_nothing_installed(
self, tmp_path, monkeypatch
):
# Stored spec present but the package is gone (externally wiped):
# there is nothing to displace, so no uninstall — the force install
# alone is already real.
install_pkg = MagicMock(return_value=True)
uninstall = MagicMock()
mgr, _hass, _entry = _manager(
tmp_path,
data={DATA_SECRET_PATH: "/p", DATA_LAST_PIP_SPEC: "ha-mcp==7.11.0"},
)
monkeypatch.setattr(es, "_force_install_package", install_pkg)
monkeypatch.setattr(es, "pip_kwargs", lambda cfg: {})
monkeypatch.setattr(
es, "_installed_ha_mcp_version", MagicMock(side_effect=[None, "7.13.0"])
)
monkeypatch.setattr(es, "_dist_installed", lambda name: False)