diff --git a/fetch_cord/__main__.py b/fetch_cord/__main__.py index f1b185a..a4199b0 100755 --- a/fetch_cord/__main__.py +++ b/fetch_cord/__main__.py @@ -23,6 +23,7 @@ CommandProvider, FastfetchProvider, Fetch, + FieldProvider, NativeProvider, get_infos, ) @@ -164,17 +165,29 @@ def main( pause = PauseWatcher(pause_when) os_type = platform.system() - # Only the commands defined for this OS are run. The structured fastfetch - # provider fills the common fields first; command/native providers only - # fill the gaps (e.g. motherboard/resolution/system_type, or everything on - # Windows where fastfetch may not be installed). + # Only the commands defined for this OS are run. command_map = { component_type: value[os_type] for component_type, value in config["commands"].items() if os_type in value } - fetch = Fetch([FastfetchProvider(), CommandProvider(command_map), NativeProvider()]) + # Native fetchers first: they need no tool installed and cost a few + # milliseconds. fastfetch then covers everything native does not implement + # yet, and the commands cover what neither reaches. + # + # This is only safe because a native fetcher returns None when it is not + # confident rather than guessing - an unreadable registry key, or firmware + # boilerplate like "To Be Filled By O.E.M." - so Fetch.snapshot hands the + # field straight to fastfetch. Native coverage can grow one field at a + # time without any platform ever losing detail it has today. + providers: list[FieldProvider] = [ + NativeProvider(), + FastfetchProvider(), + CommandProvider(command_map), + ] + + fetch = Fetch(providers) if getattr(args, "dry_run", False): snapshot = fetch.snapshot() diff --git a/fetch_cord/fetch.py b/fetch_cord/fetch.py index 8a5da05..45dd668 100644 --- a/fetch_cord/fetch.py +++ b/fetch_cord/fetch.py @@ -126,11 +126,20 @@ def fetch(self, skip: set[str] | None = None) -> dict[str, str]: class NativeProvider: - """Uses the bundled native (Windows) fetchers for fields not otherwise - covered, e.g. the installed-packages count on Windows.""" + """Uses the bundled native (Windows) fetchers, which read the registry and + Win32 directly. Adds no tool dependency and no subprocess, and returns {} + for anything it cannot read so another provider fills the gap.""" name = "native" - NATIVE_COMPONENTS = ("packages",) + NATIVE_COMPONENTS = ( + "cpu", + "host", + "kernel", + "mem", + "motherboard", + "os", + "packages", + ) def __init__(self) -> None: self._native = native_module diff --git a/fetch_cord/native/Windows/__init__.py b/fetch_cord/native/Windows/__init__.py index 3e187ce..174bb18 100644 --- a/fetch_cord/native/Windows/__init__.py +++ b/fetch_cord/native/Windows/__init__.py @@ -1,15 +1,30 @@ import os +from types import ModuleType if os.name == "nt": - from . import packages + from . import cpu, host, kernel, mem, motherboard, os_info, packages - def fetch(component_class: str) -> str | None: - fetch_map = {"packages": packages} + fetch_map: dict[str, ModuleType] = { + "cpu": cpu, + "host": host, + "kernel": kernel, + "mem": mem, + "motherboard": motherboard, + "os": os_info, + "packages": packages, + } - if component_class not in fetch_map: + def fetch(component_class: str) -> str | None: + module = fetch_map.get(component_class) + if module is None: return None - return str(fetch_map[component_class].fetch()) + # A fetcher returns None when the value is not readable on this + # machine; pass that through rather than stringifying it, so the next + # provider fills the gap instead of showing "None". + value = module.fetch() + + return str(value) if value else None else: diff --git a/fetch_cord/native/Windows/cpu.py b/fetch_cord/native/Windows/cpu.py new file mode 100644 index 0000000..0907331 --- /dev/null +++ b/fetch_cord/native/Windows/cpu.py @@ -0,0 +1,12 @@ +"""Processor name, matching Win32_Processor.Name. + +e.g. "Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz" +""" + +from fetch_cord.native.Windows import registry + + +def fetch() -> str | None: + name = registry.read(registry.CPU0, "ProcessorNameString") + + return name.strip() if name else None diff --git a/fetch_cord/native/Windows/host.py b/fetch_cord/native/Windows/host.py new file mode 100644 index 0000000..10bc5f1 --- /dev/null +++ b/fetch_cord/native/Windows/host.py @@ -0,0 +1,10 @@ +"""Motherboard product name, matching Win32_BaseBoard.Product. + +e.g. "PRIME B760-PLUS D4" +""" + +from fetch_cord.native.Windows import motherboard, registry + + +def fetch() -> str | None: + return motherboard.usable(registry.read(registry.BIOS, "BaseBoardProduct")) diff --git a/fetch_cord/native/Windows/kernel.py b/fetch_cord/native/Windows/kernel.py new file mode 100644 index 0000000..5e9e187 --- /dev/null +++ b/fetch_cord/native/Windows/kernel.py @@ -0,0 +1,23 @@ +"""Windows version string, matching Win32_OperatingSystem.Version. + +e.g. "10.0.26100" +""" + +from fetch_cord.native.Windows import registry + + +def fetch() -> str | None: + build = registry.read(registry.CURRENT_VERSION, "CurrentBuildNumber") + if build is None: + return None + + major = registry.read_int(registry.CURRENT_VERSION, "CurrentMajorVersionNumber") + minor = registry.read_int(registry.CURRENT_VERSION, "CurrentMinorVersionNumber") + + # Present since Windows 10; fall back to the values every release carries. + if major is None: + major = 10 + if minor is None: + minor = 0 + + return f"{major}.{minor}.{build}" diff --git a/fetch_cord/native/Windows/mem.py b/fetch_cord/native/Windows/mem.py new file mode 100644 index 0000000..7ab1099 --- /dev/null +++ b/fetch_cord/native/Windows/mem.py @@ -0,0 +1,25 @@ +"""Memory use, matching the format the PowerShell command produces. + +e.g. "12.40 GB / 31.92 GB" + +Used is total minus available, which is how the Linux and macOS commands +define it too, so the value stays comparable across platforms. +""" + +import psutil + +GIB = 1024**3 + + +def fetch() -> str | None: + try: + memory = psutil.virtual_memory() + except Exception: + return None + + if not memory.total: + return None + + used = memory.total - memory.available + + return f"{used / GIB:.2f} GB / {memory.total / GIB:.2f} GB" diff --git a/fetch_cord/native/Windows/motherboard.py b/fetch_cord/native/Windows/motherboard.py new file mode 100644 index 0000000..7803262 --- /dev/null +++ b/fetch_cord/native/Windows/motherboard.py @@ -0,0 +1,42 @@ +"""Motherboard vendor and product, matching the Windows PowerShell command. + +Joins BaseBoardManufacturer and BaseBoardProduct the same way Linux reports +board_vendor + board_name, so the host cycle's app_id/top_line can match +board-specific entries in motherboards.json (e.g. TUF) rather than only the +generic vendor. + +e.g. "ASUSTeK COMPUTER INC. PRIME B760-PLUS D4" +""" + +from fetch_cord.native.Windows import registry + +# Strings firmware ships when the vendor never filled the field in. Returning +# None for these lets another provider supply something better. +PLACEHOLDERS = { + "", + "to be filled by o.e.m.", + "default string", + "system manufacturer", + "not applicable", + "not specified", + "none", + "o.e.m.", + "unknown", +} + + +def usable(value: str | None) -> str | None: + """The value, unless it is firmware boilerplate.""" + if value is None: + return None + + value = value.strip() + + return None if value.lower() in PLACEHOLDERS else value + + +def fetch() -> str | None: + manufacturer = usable(registry.read(registry.BIOS, "BaseBoardManufacturer")) + product = usable(registry.read(registry.BIOS, "BaseBoardProduct")) + parts = [part for part in (manufacturer, product) if part] + return " ".join(parts) or None diff --git a/fetch_cord/native/Windows/os_info.py b/fetch_cord/native/Windows/os_info.py new file mode 100644 index 0000000..9b74599 --- /dev/null +++ b/fetch_cord/native/Windows/os_info.py @@ -0,0 +1,28 @@ +"""Windows edition name, matching Win32_OperatingSystem.Caption. + +The registry's ProductName still reads "Windows 10 Pro" on Windows 11, so the +build number decides the release and the name is corrected to match what WMI +would report. +""" + +from fetch_cord.native.Windows import registry + +# Windows 11 starts here; the registry name was never updated for it. +WINDOWS_11_BUILD = 22000 + + +def fetch() -> str | None: + product = registry.read(registry.CURRENT_VERSION, "ProductName") + if product is None: + return None + + build = registry.read_int(registry.CURRENT_VERSION, "CurrentBuildNumber") + if build is not None and build >= WINDOWS_11_BUILD: + product = product.replace("Windows 10", "Windows 11") + + # WMI's Caption is prefixed; match it so the value is interchangeable with + # the PowerShell command this replaces. + if not product.startswith("Microsoft "): + product = f"Microsoft {product}" + + return product diff --git a/fetch_cord/native/Windows/registry.py b/fetch_cord/native/Windows/registry.py new file mode 100644 index 0000000..af9634b --- /dev/null +++ b/fetch_cord/native/Windows/registry.py @@ -0,0 +1,56 @@ +"""Small helpers for reading the Windows registry. + +Every native Windows fetcher reads from here rather than shelling out, so the +whole native path stays free of subprocesses and of any external tool. +""" + +import contextlib +import sys +from typing import TYPE_CHECKING + +# Importable on any platform so the fetchers - and their tests - can be +# imported off Windows; every read simply reports None there. The TYPE_CHECKING +# arm keeps winreg visible to mypy when it runs on a non-Windows host. +if sys.platform == "win32" or TYPE_CHECKING: + import winreg + +# Where the values the fetchers need live. +CURRENT_VERSION = "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion" +BIOS = "HARDWARE\\DESCRIPTION\\System\\BIOS" +CPU0 = "HARDWARE\\DESCRIPTION\\System\\CentralProcessor\\0" + + +def read(path: str, name: str) -> str | None: + """Read one HKEY_LOCAL_MACHINE value, or None if it is not there. + + A missing key or value is an expected outcome - hardware varies and values + come and go between Windows releases - so it is reported as None and the + next provider fills the gap. + """ + if sys.platform != "win32": + return None + + # Read the 64-bit view explicitly: a 32-bit Python would otherwise be + # redirected to WOW6432Node and see a different (or missing) ProductName. + access = winreg.KEY_READ | winreg.KEY_WOW64_64KEY + + with ( + contextlib.suppress(OSError), + winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, path, 0, access) as key, + ): + value = winreg.QueryValueEx(key, name)[0] + return str(value) if value != "" else None + + return None + + +def read_int(path: str, name: str) -> int | None: + """Read one registry value as an int, or None if it is absent or not numeric.""" + value = read(path, name) + if value is None: + return None + + try: + return int(value) + except ValueError: + return None diff --git a/tests/test_native_windows.py b/tests/test_native_windows.py new file mode 100644 index 0000000..c523d85 --- /dev/null +++ b/tests/test_native_windows.py @@ -0,0 +1,209 @@ +"""Tests for the native Windows fetchers and the NativeProvider wiring. + +The registry is faked at the fetcher boundary so these run on any OS, which +keeps them useful on the Linux CI runner. +""" + +import subprocess +import unittest +from collections.abc import Iterator +from contextlib import contextmanager +from unittest.mock import MagicMock, patch + +from fetch_cord.fetch import NativeProvider + +CURRENT_VERSION = "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion" +BIOS = "HARDWARE\\DESCRIPTION\\System\\BIOS" +CPU0 = "HARDWARE\\DESCRIPTION\\System\\CentralProcessor\\0" + +# Values as the registry actually reports them on a Windows 11 machine. Note +# ProductName still says "Windows 10" - the build number is what distinguishes +# the release. +REGISTRY = { + (CURRENT_VERSION, "ProductName"): "Windows 10 Pro", + (CURRENT_VERSION, "CurrentBuildNumber"): "26100", + (CURRENT_VERSION, "CurrentMajorVersionNumber"): "10", + (CURRENT_VERSION, "CurrentMinorVersionNumber"): "0", + (CPU0, "ProcessorNameString"): "Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz", + (BIOS, "BaseBoardManufacturer"): "ASUSTeK COMPUTER INC.", + (BIOS, "BaseBoardProduct"): "PRIME B760-PLUS D4", +} + + +@contextmanager +def fake_registry( + values: dict[tuple[str, str], str] | None = None, +) -> Iterator[MagicMock]: + """Patch the registry helper with a fixed set of values.""" + table = REGISTRY if values is None else values + + def read(path: str, name: str) -> str | None: + return table.get((path, name)) + + with patch( + "fetch_cord.native.Windows.registry.read", side_effect=read + ) as mock_read: + yield mock_read + + +class TestWindowsFetchers(unittest.TestCase): + """Each fetcher must return the same shape the PowerShell command did.""" + + def test_os_corrects_the_stale_product_name(self) -> None: + from fetch_cord.native.Windows import os_info + + with fake_registry(): + self.assertEqual(os_info.fetch(), "Microsoft Windows 11 Pro") + + def test_os_leaves_windows_10_alone(self) -> None: + from fetch_cord.native.Windows import os_info + + values = dict(REGISTRY) + values[(CURRENT_VERSION, "CurrentBuildNumber")] = "19045" + + with fake_registry(values): + self.assertEqual(os_info.fetch(), "Microsoft Windows 10 Pro") + + def test_kernel_matches_win32_version(self) -> None: + from fetch_cord.native.Windows import kernel + + with fake_registry(): + self.assertEqual(kernel.fetch(), "10.0.26100") + + def test_cpu_matches_win32_name(self) -> None: + from fetch_cord.native.Windows import cpu + + with fake_registry(): + self.assertEqual(cpu.fetch(), "Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz") + + def test_motherboard_and_host(self) -> None: + from fetch_cord.native.Windows import host, motherboard + + with fake_registry(): + # Matches #243's PowerShell: vendor + product for id matching. + self.assertEqual( + motherboard.fetch(), "ASUSTeK COMPUTER INC. PRIME B760-PLUS D4" + ) + self.assertEqual(host.fetch(), "PRIME B760-PLUS D4") + + def test_motherboard_falls_back_to_vendor_when_product_missing(self) -> None: + from fetch_cord.native.Windows import motherboard + + values = dict(REGISTRY) + del values[(BIOS, "BaseBoardProduct")] + + with fake_registry(values): + self.assertEqual(motherboard.fetch(), "ASUSTeK COMPUTER INC.") + + def test_oem_placeholders_are_not_reported(self) -> None: + """Firmware boilerplate should let another provider supply the value.""" + from fetch_cord.native.Windows import motherboard + + values = dict(REGISTRY) + values[(BIOS, "BaseBoardManufacturer")] = "To Be Filled By O.E.M." + values[(BIOS, "BaseBoardProduct")] = "To Be Filled By O.E.M." + + with fake_registry(values): + self.assertIsNone(motherboard.fetch()) + + def test_unreadable_registry_returns_none(self) -> None: + from fetch_cord.native.Windows import cpu, kernel, os_info + + with fake_registry({}): + self.assertIsNone(os_info.fetch()) + self.assertIsNone(kernel.fetch()) + self.assertIsNone(cpu.fetch()) + + def test_mem_matches_the_command_format(self) -> None: + from fetch_cord.native.Windows import mem + + memory = MagicMock(total=32 * 1024**3, available=20 * 1024**3) + with patch( + "fetch_cord.native.Windows.mem.psutil.virtual_memory", return_value=memory + ): + self.assertEqual(mem.fetch(), "12.00 GB / 32.00 GB") + + +class TestNativeProvider(unittest.TestCase): + def test_returns_the_fields_it_can_read(self) -> None: + with patch( + "fetch_cord.fetch.native_module.fetch", + side_effect=lambda component: { + "cpu": "AMD", + "os": "Microsoft Windows 11 Pro", + }.get(component), + ): + fields = NativeProvider().fetch() + + self.assertEqual(fields, {"cpu": "AMD", "os": "Microsoft Windows 11 Pro"}) + + def test_skips_fields_an_earlier_provider_supplied(self) -> None: + """Gap-filling is how Fetch.snapshot composes providers.""" + calls = [] + + def record(component: str) -> str: + calls.append(component) + return "value" + + with patch("fetch_cord.fetch.native_module.fetch", side_effect=record): + NativeProvider().fetch(skip={"cpu", "os"}) + + self.assertNotIn("cpu", calls) + self.assertNotIn("os", calls) + self.assertIn("kernel", calls) + + def test_returns_nothing_off_windows(self) -> None: + """native.fetch returns None on other platforms, so the dict is empty.""" + with patch("fetch_cord.fetch.native_module.fetch", return_value=None): + self.assertEqual(NativeProvider().fetch(), {}) + + def test_collecting_natively_runs_no_subprocess(self) -> None: + """The point of the native path: no tool dependency, no shelling out.""" + with ( + patch("fetch_cord.fetch.native_module.fetch", return_value="value"), + patch.object(subprocess, "run") as run, + patch.object(subprocess, "Popen") as popen, + patch.object(subprocess, "check_output") as check_output, + ): + NativeProvider().fetch() + + run.assert_not_called() + popen.assert_not_called() + check_output.assert_not_called() + + +class TestNativeFirstWithFastfetchFallback(unittest.TestCase): + """Native runs first; fastfetch covers whatever native has not implemented.""" + + def test_native_wins_and_fastfetch_fills_the_gaps(self) -> None: + from fetch_cord.fetch import Fetch + + native = MagicMock() + native.fetch.return_value = {"cpu": "from native"} + fastfetch = MagicMock() + fastfetch.fetch.return_value = { + "cpu": "from fastfetch", + "gpu": "from fastfetch", + } + + data = Fetch([native, fastfetch]).snapshot() + + self.assertEqual(data["cpu"], "from native") + self.assertEqual(data["gpu"], "from fastfetch") + + def test_a_field_native_cannot_read_falls_through(self) -> None: + """The safety property: native omits what it is unsure of, so nothing + is lost by putting it first.""" + from fetch_cord.fetch import Fetch + + fastfetch = MagicMock() + fastfetch.fetch.return_value = {"os": "from fastfetch"} + + with patch("fetch_cord.fetch.native_module.fetch", return_value=None): + data = Fetch([NativeProvider(), fastfetch]).snapshot() + + self.assertEqual(data["os"], "from fastfetch") + + +if __name__ == "__main__": + unittest.main(verbosity=2)