Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions src/winml/modelkit/commands/sys.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down
84 changes: 83 additions & 1 deletion tests/unit/commands/test_sys_device_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Loading