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
4 changes: 4 additions & 0 deletions fetch_cord/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 ===")
Expand Down
8 changes: 8 additions & 0 deletions fetch_cord/args.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
28 changes: 21 additions & 7 deletions fetch_cord/fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
102 changes: 102 additions & 0 deletions fetch_cord/report.py
Original file line number Diff line number Diff line change
@@ -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 = [
"<!-- Paste this into a FetchCord issue -->",
"",
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
156 changes: 156 additions & 0 deletions tests/test_report.py
Original file line number Diff line number Diff line change
@@ -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("<!-- Paste this into a FetchCord issue -->"))
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)
Loading