diff --git a/src/winml/modelkit/commands/sys.py b/src/winml/modelkit/commands/sys.py index 0cae0f4fc..ea05b0b62 100644 --- a/src/winml/modelkit/commands/sys.py +++ b/src/winml/modelkit/commands/sys.py @@ -1123,6 +1123,10 @@ def _gather( :attr:`WinMLEPRegistry._registered` is what keeps enrichment working now that filesystem-backed EPs are registered only in isolated subprocesses (their live handles never exist in the parent). + Because that inventory is the enrichment source, it is gathered + whenever ``devices`` is requested — ``eps`` only decides whether the + ``executionProviders`` section is *emitted*, so ``--list-device`` + reports the same device rows as the default view. When ``tolerant=True``, a per-section failure is logged at WARNING and the section is filled with an empty container so downstream @@ -1132,18 +1136,27 @@ def _gather( info: dict[str, Any] = {} if system: info.update(_gather_system_info(verbose=verbose)) - if eps: + # EP info is gathered whenever devices are requested, even when the caller + # did not ask for the EP section: it is the only carrier of ``device_facts`` + # (Architecture + Driver), so skipping it made ``--list-device`` report + # fewer ``details`` keys than the default view for the same hardware. + ep_info: dict[str, dict[str, Any]] | None = None + if eps or devices: try: - info["executionProviders"] = _gather_ep_info() + ep_info = _gather_ep_info() except Exception as e: - if not tolerant: + # A failure is only fatal when the EP section was explicitly + # requested; for devices-only it just degrades enrichment. + if eps and not tolerant: logger.exception("Failed to detect execution providers") raise click.ClickException(f"Error detecting execution providers: {e}") from e logger.warning("EP detection failed (tolerant): %s", e) - info["executionProviders"] = {} + ep_info = {} + if eps: + info["executionProviders"] = ep_info if devices: try: - info["devices"] = _gather_device_info(info.get("executionProviders")) + info["devices"] = _gather_device_info(ep_info) except Exception as e: if not tolerant: logger.exception("Failed to detect devices") diff --git a/tests/unit/commands/test_sys_device_info.py b/tests/unit/commands/test_sys_device_info.py index 01550b557..c0879eb13 100644 --- a/tests/unit/commands/test_sys_device_info.py +++ b/tests/unit/commands/test_sys_device_info.py @@ -23,7 +23,10 @@ from typing import Any from unittest.mock import MagicMock, patch -from winml.modelkit.commands.sys import _gather_device_info +import click +import pytest + +from winml.modelkit.commands.sys import _gather, _gather_device_info def _fake_ep_info( @@ -188,3 +191,82 @@ def test_device_info_no_ep_info_is_non_fatal(self) -> None: assert len(result) == 1 assert result[0]["details"]["driver"] == "32.0.100" assert "architecture" not in result[0]["details"] + + +class TestGatherDeviceSectionEnrichment: + """``_gather`` must enrich devices even when the EP section isn't emitted. + + ``winml sys --list-device`` asks for devices only, but ``ep_info`` is the + sole carrier of ``device_facts``; gathering it only for ``eps=True`` made + that view drop ``details`` keys the default view shows for the very same + hardware. + """ + + def test_device_only_view_still_gathers_ep_info(self) -> None: + ep_info = _fake_ep_info( + device_type="NPU", + hardware_name="Intel(R) AI Boost", + architecture="4000", + ) + + with ( + patch("winml.modelkit.commands.sys._gather_ep_info", return_value=ep_info) as mock_eps, + patch( + "winml.modelkit.commands.sys._gather_device_info", return_value=[] + ) as mock_devices, + ): + info = _gather(devices=True, tolerant=False) + + mock_eps.assert_called_once_with() + mock_devices.assert_called_once_with(ep_info) + # The EP section is gathered for enrichment only, never emitted. + assert set(info) == {"devices"} + + def test_devices_match_across_default_and_device_only_views(self) -> None: + npu_item = MagicMock(driver_version="32.0.100.4023", manufacturer="Intel") + npu_item.name = "Intel(R) AI Boost" + ep_info = _fake_ep_info( + device_type="NPU", + hardware_name="Intel(R) AI Boost", + architecture="4000", + ) + + with ( + patch("winml.modelkit.commands.sys._gather_ep_info", return_value=ep_info), + patch("winml.modelkit.commands.sys._gather_system_info", return_value={}), + patch("winml.modelkit.sysinfo.NPU.get_all", return_value=[npu_item]), + patch("winml.modelkit.sysinfo.GPU.get_all", return_value=[]), + patch("winml.modelkit.sysinfo.CPU.get_all", return_value=[]), + ): + default = _gather(system=True, devices=True, eps=True, tolerant=True) + device_only = _gather(devices=True, tolerant=False) + + assert default["devices"] == device_only["devices"] + assert device_only["devices"][0]["details"]["architecture"] == "4000" + + def test_ep_failure_is_non_fatal_for_device_only_view(self) -> None: + """Enrichment is best-effort: a broken EP probe must not fail devices.""" + with ( + patch( + "winml.modelkit.commands.sys._gather_ep_info", + side_effect=RuntimeError("boom"), + ), + patch( + "winml.modelkit.commands.sys._gather_device_info", return_value=[] + ) as mock_devices, + ): + info = _gather(devices=True, tolerant=False) + + mock_devices.assert_called_once_with({}) + assert info == {"devices": []} + + def test_ep_failure_still_raises_when_ep_section_requested(self) -> None: + """The strict contract for an explicitly pinned EP section is unchanged.""" + with ( + patch( + "winml.modelkit.commands.sys._gather_ep_info", + side_effect=RuntimeError("boom"), + ), + pytest.raises(click.ClickException, match="Error detecting execution providers"), + ): + _gather(eps=True, tolerant=False)