From 405b1b09037f2aa381dd106026e04fb2d9c3a38a Mon Sep 17 00:00:00 2001 From: Nick Marcel <17437302+nyannoying1337@users.noreply.github.com> Date: Fri, 11 Sep 2026 02:37:12 +0200 Subject: [PATCH] Add --report-hardware for filing id-table issues Unrecognised hardware is the largest single category in the tracker - "Unknown motherboard" alone accounts for #218, #209, #205, #198, #185, #166 and #151, and there are as many again for distros and terminals. Every one of them needs the same thing from the reporter: the exact string detection produced, so a pattern can be written against it. Right now that takes a round trip to ask for. --report-hardware prints it as a markdown table, ready to paste: | Component | Detected | Matched id | Status | | --- | --- | --- | --- | | OS | Microsoft Windows 10 Pro | 741949889465942099 | ok | | CPU | 13th Gen Intel(R) Core(TM) i5-13600K | 741099939198926920 | ok | | Motherboard | ASUSTeK COMPUTER INC. | 743936082780880928 | ok | | Shell | PowerShell 5.1.19041.7663 | 742887089179197462 | **no match - needs an entry** | | System type | - | - | not detected on this machine | The one supporting change is in fetch.py: get_component_id prints on a miss and returns the fallback, so a caller can't tell a real match from the fallback without reading stdout. Split the lookup into resolve_component_id() which returns (id, matched); get_component_id is now a thin wrapper and behaves exactly as before. The report matches the way the main loop looks things up, lowercasing the same fields, so it can't tell someone their hardware resolved when the presence disagrees. Components with no id table get a row saying so rather than being dropped - a silently missing row is the thing this is meant to prevent. --- fetch_cord/__main__.py | 4 ++ fetch_cord/args.py | 8 +++ fetch_cord/fetch.py | 24 ++++++-- fetch_cord/report.py | 101 +++++++++++++++++++++++++++++++ tests/test_report.py | 131 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 263 insertions(+), 5 deletions(-) create mode 100644 fetch_cord/report.py create mode 100644 tests/test_report.py diff --git a/fetch_cord/__main__.py b/fetch_cord/__main__.py index 0cc2736..d05ae14 100755 --- a/fetch_cord/__main__.py +++ b/fetch_cord/__main__.py @@ -25,6 +25,7 @@ get_component_id, get_infos, ) +from fetch_cord.report import report from fetch_cord.resources import systemd_service from fetch_cord.update import update @@ -123,6 +124,9 @@ def main( fetch = Fetch([FastfetchProvider(), CommandProvider(command_map), NativeProvider()]) + if getattr(args, "report_hardware", False): + sys.exit(report(fetch.snapshot(), fetchcord_ids)) + def signal_handler(signum: int, frame: object) -> None: stop_event.set() for cycle in cycles: diff --git a/fetch_cord/args.py b/fetch_cord/args.py index 22653d8..7119999 100755 --- a/fetch_cord/args.py +++ b/fetch_cord/args.py @@ -67,6 +67,14 @@ def parse_args() -> argparse.Namespace: action="store_true", help="Get files from testing branch instead of master.", ) + parser.add_argument( + "--report-hardware", + action="store_true", + help=( + "Print a markdown report of what was detected and which id-table " + "entries it matched, for pasting into an issue." + ), + ) parser.add_argument("--debug", "-d", action="store_true", help="Enable debugging.") parser.add_argument( "--version", "-v", action="store_true", help="Print FetchCord Version." diff --git a/fetch_cord/fetch.py b/fetch_cord/fetch.py index 1fe0e8a..108814d 100644 --- a/fetch_cord/fetch.py +++ b/fetch_cord/fetch.py @@ -24,19 +24,33 @@ def get_infos(name: str) -> dict[str, list[str]]: return data -def get_component_id(search: str, id_list: dict[str, list[str]]) -> str: +def resolve_component_id( + search: str, id_list: dict[str, list[str]] +) -> tuple[str, bool]: + """Look up a component id, and say whether it actually matched. + + Split out from :func:`get_component_id` so callers that want to report on + a miss - rather than warn about it - can tell a real match from the + fallback without parsing stdout. + """ for id, patterns in id_list.items(): if not isinstance(patterns, list): continue if any(re.search(pattern, search) for pattern in patterns): - return id + return id, True - print(f"Warning: No match found for '{search}'") for id, patterns in id_list.items(): if isinstance(patterns, list) and "unknown" in patterns: - return id + return id, False + + return "unknown", False - return "unknown" + +def get_component_id(search: str, id_list: dict[str, list[str]]) -> str: + component_id, matched = resolve_component_id(search, id_list) + if not matched: + print(f"Warning: No match found for '{search}'") + return component_id def _looks_like_python_process(value: str) -> bool: diff --git a/fetch_cord/report.py b/fetch_cord/report.py new file mode 100644 index 0000000..dcab345 --- /dev/null +++ b/fetch_cord/report.py @@ -0,0 +1,101 @@ +"""Building a hardware report to paste into a GitHub issue. + +Unrecognised hardware is the most common thing people open issues about, and +the id tables are this project's main upkeep. This turns "my motherboard shows +as unknown" into one command whose output already contains everything needed +to add an entry: the raw detected string to write a pattern against, and which +component actually missed. +""" + +import platform + +from fetch_cord import VERSION +from fetch_cord.constants import RESULT_NOT_FOUND +from fetch_cord.fetch import resolve_component_id + +# Display name for each component that has an id table, keyed by the snapshot +# field, which is also how __main__ keys fetchcord_ids. +COMPONENTS: tuple[tuple[str, str], ...] = ( + ("OS", "os"), + ("CPU", "cpu"), + ("GPU", "gpu"), + ("Motherboard", "motherboard"), + ("Terminal", "terminal"), + ("Shell", "shell"), + ("System type", "system_type"), +) + +# Fields the main loop lowercases before looking up; match it so the report +# can never disagree with what the presence actually did. +LOWERCASED = ("os", "cpu") + +MATCHED = "ok" +GENERIC = "**no match - needs an entry**" +NOT_DETECTED = "not detected on this machine" +NO_TABLE = "no id table loaded" + + +def _row(component: str, detected: str, matched: str, status: str) -> str: + return f"| {component} | {detected or '-'} | {matched or '-'} | {status} |" + + +def build( + snapshot: dict[str, str], + fetchcord_ids: dict[str, dict[str, list[str]]], +) -> tuple[str, int]: + """Render the report and return it with the number of unmatched components.""" + rows = [] + missing = 0 + + for title, field in COMPONENTS: + detected = snapshot.get(field, "") + if not detected or detected == RESULT_NOT_FOUND: + rows.append(_row(title, "", "", NOT_DETECTED)) + continue + + ids = fetchcord_ids.get(field) + if not ids: + # Say so rather than dropping the row: a silently missing + # component is exactly what this command exists to surface. + rows.append(_row(title, detected, "", NO_TABLE)) + continue + + search = detected.lower() if field in LOWERCASED else detected + component_id, matched = resolve_component_id(search, ids) + + rows.append( + _row(title, detected, component_id, MATCHED if matched else GENERIC) + ) + if not matched: + missing += 1 + + lines = [ + "", + "", + f"**FetchCord** {VERSION} on **{platform.system()} {platform.release()}**", + "", + "| Component | Detected | Matched id | Status |", + "| --- | --- | --- | --- |", + *rows, + "", + ] + + if missing: + lines.append( + f"{missing} component(s) found no match. The **Detected** column is " + "the raw string a new id-table pattern needs to match." + ) + else: + lines.append("Everything on this machine matched an id-table entry.") + + return "\n".join(lines), missing + + +def report( + snapshot: dict[str, str], + fetchcord_ids: dict[str, dict[str, list[str]]], +) -> int: + """Print the report. Returns a process exit code.""" + text, _ = build(snapshot, fetchcord_ids) + print(text) + return 0 diff --git a/tests/test_report.py b/tests/test_report.py new file mode 100644 index 0000000..c2eb549 --- /dev/null +++ b/tests/test_report.py @@ -0,0 +1,131 @@ +"""Tests for the --report-hardware markdown report. + +The report exists so an "unknown motherboard" issue arrives with the raw +string needed to write an id-table pattern, so these pin down that the raw +string is present and that a miss is called a miss. +""" + +import unittest +from unittest.mock import MagicMock, patch + +from fetch_cord.report import build, report + +IDS: dict[str, dict[str, list[str]]] = { + "os": {"os-id": ["(?i)debian"], "os-generic": ["unknown"]}, + "cpu": {"cpu-id": ["(?i)ryzen"], "cpu-generic": ["unknown"]}, + "gpu": {"gpu-id": ["(?i)nvidia"]}, + "motherboard": {"mb-id": ["(?i)asus"]}, + "terminal": {"term-id": ["(?i)xterm"]}, + "shell": {"shell-id": ["(?i)bash"]}, + "system_type": {"desktop": ["(?i)desktop"]}, +} + +SNAPSHOT = { + "os": "Debian GNU/Linux", + "cpu": "AMD Ryzen 7 5800X", + "gpu": "NVIDIA GeForce RTX 3080", + "motherboard": "ASUSTeK COMPUTER INC.", + "terminal": "xterm", + "shell": "bash", + "system_type": "Desktop", +} + + +class TestBuild(unittest.TestCase): + def test_everything_matching_reports_no_missing(self) -> None: + text, missing = build(SNAPSHOT, IDS) + + self.assertEqual(missing, 0) + self.assertIn("Everything on this machine matched", text) + self.assertNotIn("needs an entry", text) + + def test_a_miss_is_counted_and_flagged(self) -> None: + snapshot = {**SNAPSHOT, "motherboard": "Some Unlisted Board Co."} + + text, missing = build(snapshot, IDS) + + self.assertEqual(missing, 1) + self.assertIn("needs an entry", text) + self.assertIn("1 component(s) found no match", text) + + def test_the_raw_detected_string_is_in_the_table(self) -> None: + """That string is the whole point - it's what a pattern must match.""" + snapshot = {**SNAPSHOT, "motherboard": "Micro-Star International Co., Ltd."} + + text, _ = build(snapshot, IDS) + + self.assertIn("Micro-Star International Co., Ltd.", text) + + def test_a_missing_field_is_not_counted_as_a_miss(self) -> None: + """Absent hardware needs no id-table entry, so it isn't a miss.""" + snapshot = {key: value for key, value in SNAPSHOT.items() if key != "gpu"} + + text, missing = build(snapshot, IDS) + + self.assertEqual(missing, 0) + self.assertIn("not detected on this machine", text) + + def test_the_not_found_sentinel_counts_as_absent(self) -> None: + snapshot = {**SNAPSHOT, "system_type": "Not Found"} + + text, missing = build(snapshot, IDS) + + self.assertEqual(missing, 0) + self.assertIn("not detected on this machine", text) + + def test_every_component_gets_a_row(self) -> None: + """A silently dropped row is exactly what this command must not do.""" + text, _ = build(SNAPSHOT, IDS) + + for title in ( + "OS", + "CPU", + "GPU", + "Motherboard", + "Terminal", + "Shell", + "System type", + ): + with self.subTest(component=title): + self.assertIn(f"| {title} |", text) + + def test_a_missing_id_table_is_reported_not_skipped(self) -> None: + ids = {key: value for key, value in IDS.items() if key != "gpu"} + + text, _ = build(SNAPSHOT, ids) + + self.assertIn("| GPU |", text) + self.assertIn("no id table loaded", text) + + def test_os_and_cpu_are_matched_lowercased(self) -> None: + """Mirrors the main loop, which lowercases before looking these up.""" + ids = {**IDS, "os": {"os-id": ["^debian gnu"]}} + + _, missing = build({**SNAPSHOT, "os": "Debian GNU/Linux"}, ids) + + self.assertEqual(missing, 0) + + def test_output_is_pasteable_markdown(self) -> None: + text, _ = build(SNAPSHOT, IDS) + + self.assertTrue(text.startswith("")) + self.assertIn("| Component | Detected | Matched id | Status |", text) + self.assertIn("| --- | --- | --- | --- |", text) + + +class TestReport(unittest.TestCase): + @patch("builtins.print") + def test_report_prints_and_succeeds(self, mock_print: MagicMock) -> None: + self.assertEqual(report(SNAPSHOT, IDS), 0) + mock_print.assert_called_once() + + @patch("builtins.print") + def test_report_succeeds_even_with_misses(self, mock_print: MagicMock) -> None: + """The report ran fine; unmatched hardware isn't a failure of it.""" + snapshot = {**SNAPSHOT, "motherboard": "Unlisted"} + + self.assertEqual(report(snapshot, IDS), 0) + + +if __name__ == "__main__": + unittest.main(verbosity=2)