diff --git a/fetch_cord/__main__.py b/fetch_cord/__main__.py index f1b185a..ec41b13 100755 --- a/fetch_cord/__main__.py +++ b/fetch_cord/__main__.py @@ -28,6 +28,7 @@ ) from fetch_cord.presence import ResolvedCycle, resolve_cycle from fetch_cord.processes import PauseWatcher +from fetch_cord.report import report from fetch_cord.resources import systemd_service from fetch_cord.update import update @@ -176,6 +177,9 @@ def main( fetch = Fetch([FastfetchProvider(), CommandProvider(command_map), NativeProvider()]) + if getattr(args, "report_hardware", False): + sys.exit(report(fetch.snapshot(), fetchcord_ids)) + if getattr(args, "dry_run", False): snapshot = fetch.snapshot() print("=== Detected ===") diff --git a/fetch_cord/args.py b/fetch_cord/args.py index 80d2d1a..02f2e8d 100755 --- a/fetch_cord/args.py +++ b/fetch_cord/args.py @@ -100,6 +100,14 @@ def parse_args() -> argparse.Namespace: action="store_true", help="Print what would be sent to Discord, then exit without connecting.", ) + 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 8a5da05..2cd13dd 100644 --- a/fetch_cord/fetch.py +++ b/fetch_cord/fetch.py @@ -29,21 +29,35 @@ def get_infos(name: str) -> dict[str, list[str]]: _WARNED_UNKNOWN: set[str] = set() -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. Deliberately silent: the warning, and + the once-only bookkeeping that goes with it, belong to get_component_id. + """ 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 - if search not in _WARNED_UNKNOWN: - _WARNED_UNKNOWN.add(search) - 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_COMPONENT_ID, False - return UNKNOWN_COMPONENT_ID + +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 and search not in _WARNED_UNKNOWN: + _WARNED_UNKNOWN.add(search) + 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..fd400a8 --- /dev/null +++ b/fetch_cord/report.py @@ -0,0 +1,102 @@ +"""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 used as cycle app_id values are lowercased before lookup in +# resolve_cycle; match that so the report can never disagree with what the +# presence actually did. Icon lookups (gpu/shell/system_type) stay as-is. +LOWERCASED = ("os", "cpu", "motherboard", "terminal") + +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..00d43f0 --- /dev/null +++ b/tests/test_report.py @@ -0,0 +1,156 @@ +"""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) + + @patch("builtins.print") + def test_a_miss_neither_warns_nor_silences_the_loop( + self, mock_print: MagicMock + ) -> None: + """The report reports; warning is get_component_id's job. + + get_component_id warns once per unmatched component and remembers it. + If the report resolved ids through that, a --report-hardware run would + print warnings through the middle of the table and leave the presence + loop silent about the very component the report just flagged. + """ + from fetch_cord import fetch as fetch_module + + fetch_module._WARNED_UNKNOWN.clear() + self.addCleanup(fetch_module._WARNED_UNKNOWN.clear) + + snapshot = {**SNAPSHOT, "motherboard": "Some Unlisted Board Co."} + build(snapshot, IDS) + + warnings = [ + call for call in mock_print.call_args_list if "No match found" in str(call) + ] + self.assertEqual(warnings, []) + self.assertEqual(fetch_module._WARNED_UNKNOWN, set()) + + 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)