Problem
Develop a tool named devcheck with a command named diagnostic that retrieves information about the operating system, kernel version, CPU architecture, available memory, disk space, Python version, applications availability, network connectivity, DNS resolution, and any relevant environment variables.
The command
devcheck diagnostic --apps git,ninja,cmake --json diagnostic.json writes a
report with the following format. The values depend on the machine and network:
{
"system": {
"distribution": "Debian",
"kernel": "6.1.0",
"architecture": "x86_64"
},
"python": {
"python_version": "3.12.3",
"python_path": "/usr/bin/python3"
},
"apps": {
"git": {
"available": true,
"version": "git version 2.43.0"
},
"ninja": {
"available": false,
"version": null
},
"cmake": {
"available": true,
"version": "cmake version 3.25.1"
}
},
"memory": {
"total_bytes": 16642998272,
"available_bytes": 8829411328,
"used_percent": 46.9
},
"disk": {
"path": "/",
"total_bytes": 268435456000,
"available_bytes": 83751862272,
"used_percent": 68.8
},
"network": {
"dns": {
"host": "example.com",
"ok": true
},
"tcp": {
"host": "example.com",
"port": 443,
"ok": true
}
}
}
This follows the package dependency planner. I keep the same package layout and use the standard library for the runtime code.
The implementation covers the distribution, kernel, CPU architecture,
Python interpreter, applications, memory, disk space, and network checks.
It accepts both diagnose and diagnostic as the command name. Environment
variables and the distribution version remain outside this version’s report.
Fundamental logic
The platform module provides the system information. On Linux,
freedesktop_os_release() reads the distribution metadata, while uname()
provides the kernel release and machine architecture. Python’s version comes
from platform.python_version(), and sys.executable identifies the
interpreter running the tool.
For applications, shutil.which finds an executable on PATH. If it finds
one, subprocess.run executes it with --version and captures the output.
The tool keeps availability separate from version detection: an executable
can be present even when its version command fails.
Memory comes from Linux procfs. shutil.disk_usage() provides filesystem
capacity, while the socket module handles hostname resolution and TCP
connections. Each check returns its own result, including errors when a
resource or endpoint is unavailable.
argparse handles the command and options. The diagnostic functions return
dictionaries, so the entry point can print them with pprint or serialize
them with json.
Architecture
The CLI parser, diagnostic functions, and entry point each have their own module. Tests cover the checks and the command’s output behavior separately.
devcheck/
├── src
│ └── devcheck
│ ├── __init__.py
│ ├── __main__.py
│ ├── cli.py
│ └── diagnose.py
├── tests
│ ├── expected
│ │ └── diagnostic.json
│ ├── test_diagnose.py
│ └── test_main.py
├── pyproject.toml
└── README.rst
tests/expected/diagnostic.json holds an example report. A test supplies
controlled system and network results and compares the complete report
against this file.
Metadata
pyproject.toml uses Flit to build the package. The version and description
are dynamic: Flit reads __version__ and the module docstring from
__init__.py. The [project.scripts] entry creates the devcheck command
and connects it to devcheck.__main__:main.
The package requires Python 3.12 or later and declares no runtime dependencies.
Flit is a build dependency. README.rst provides installation, usage, and
testing instructions.
devcheck
========
Inspect a Linux environment and write a diagnostic report. This version
reports the distribution, kernel, CPU architecture, active Python interpreter,
availability and version output of selected applications, memory, disk space,
hostname resolution, and TCP connectivity.
It requires Python 3.12 or later and has no runtime dependencies outside the
standard library. Flit builds the package.
Installation
------------
From this directory, run::
python -m pip install .
Usage
-----
Print a diagnostic as a Python dictionary::
devcheck diagnose --apps git,ninja
Repeat ``--apps`` to add applications and use ``--json`` to write a report::
devcheck diagnose --apps git,ninja --apps cmake --json diagnostic.json
Use ``-`` to write JSON to standard output::
python -m devcheck diagnose --apps git --json -
Without ``--apps``, the report contains an empty ``apps`` dictionary.
The command also accepts ``diagnostic`` as an alias for ``diagnose``.
Application checks search ``PATH`` and run each executable with ``--version``.
A missing executable has ``available`` set to false. A failed, timed-out, or
silent version command leaves ``version`` null. Each process has a five-second
timeout. Successful checks keep the full version output without parsing it.
Memory comes from ``MemTotal`` and ``MemAvailable`` in ``/proc/meminfo``.
Values are converted to bytes, and usage is calculated as
``100 * (total - available) / total``. Disk space covers the filesystem
containing ``/`` and uses ``shutil.disk_usage()``. Disk usage is calculated as
``100 * used / total``; reserved blocks can make used and available space add
up to less than the total. Both percentages are rounded to one decimal place.
Failed resource checks keep their sections with null measurements and an
``error`` message.
Network checks resolve ``example.com`` and attempt a TCP connection on port 443.
DNS and TCP results have separate ``ok`` booleans and include an ``error``
message on failure. The TCP socket uses a five-second timeout. This doesn't
bound DNS lookup time or the total duration of multiple address attempts.
The socket closes after the check. TCP success doesn't verify TLS or HTTP.
A completed report returns status 0, including reports with failed checks.
Invalid arguments exit with status 2. Output I/O errors return status 74 on
Linux and print a message to standard error. Unexpected diagnostic errors
propagate.
Tests
-----
After installation, run from this directory::
python -m unittest discover -s tests
The tests mock system queries, application execution, and socket calls. They
cover memory parsing, disk space, network success and failure, argument handling,
output streams, and file errors. A complete-report test compares controlled
results with ``tests/expected/diagnostic.json``.
Current Scope
-------------
The report uses ``system``, ``python``, ``apps``, ``memory``, ``disk``, and
``network`` sections. It doesn't yet report the distribution version or
environment variables. Memory requires Linux procfs with ``MemTotal`` and
``MemAvailable``. The CLI uses ``/`` and ``example.com:443`` for disk and network
checks; different paths or endpoints require changing ``diagnose_all()``.
``tests/expected/diagnostic.json`` contains an example report with controlled
values. ``data.json`` is an earlier report, not an input file.
Diagnosis finishes before the output file is opened. Writing replaces an
existing file and isn't atomic; an I/O failure can leave a partial report.
[build-system]
build-backend = "flit_core.buildapi"
requires = [ "flit_core>=3.11,<5" ]
[project]
name = "devcheck"
dynamic = [ "version", "description" ]
readme = "README.rst"
requires-python = ">=3.12"
authors = [ { name = "Jorge Martinez" } ]
dependencies = []
[project.scripts]
devcheck = "devcheck.__main__:main"
#[tool.flit.sdist]
#include = [ "tests/" ]
"""Linux environment diagnostic tool."""
__version__ = "1.0.dev0"
"""Current version of the project."""
Implementation
From the code directory, install the package and run a diagnostic:
python -m pip install .
devcheck diagnose --apps git,ninja --apps cmake --json diagnostic.json
You can also run python -m devcheck. Omit --json to print a Python
dictionary, or use --json - to write JSON to standard output:
python -m devcheck diagnose --apps git --json -
"""Entry point of the tool."""
import json
import os
import pprint
import sys
from collections.abc import Sequence
from contextlib import nullcontext
from pathlib import Path
from devcheck.cli import parse_args
from devcheck.diagnose import diagnose_all
def main(argv: Sequence[str] | None = None) -> int:
"""Run the requested command and return its exit status."""
args = parse_args(argv)
apps = [
app.strip() for value in args.apps for app in value.split(",") if app.strip()
]
diagnostic = diagnose_all(apps)
if args.json is None:
pprint.pprint(diagnostic)
else:
try:
output = (
nullcontext(sys.stdout)
if args.json == Path("-")
else args.json.open("w", encoding="utf-8")
)
with output as stream:
json.dump(diagnostic, stream, indent=2)
stream.write("\n")
except OSError as error:
print(f"devcheck: {error}", file=sys.stderr)
return os.EX_IOERR
return os.EX_OK
if __name__ == "__main__":
raise SystemExit(main())
"""Command line interface."""
import argparse
from collections.abc import Sequence
from pathlib import Path
import devcheck
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
"""Parse command line arguments.
Parameters
----------
argv : Sequence[str] | None
Command line arguments. Use sys.argv when None.
Returns
-------
argparse.Namespace
Parsed command line arguments.
"""
parser = argparse.ArgumentParser(
prog="devcheck",
description=devcheck.__doc__,
allow_abbrev=False,
)
subparsers = parser.add_subparsers(
dest="command",
required=True,
title="Commands",
description="Commands for inspecting the environment.",
metavar="COMMAND",
)
diagnose_parser = subparsers.add_parser(
"diagnose",
aliases=["diagnostic"],
help="Diagnose the system",
description="Diagnose the system",
allow_abbrev=False,
)
diagnose_parser.add_argument(
"--apps",
help="Diagnose comma-separated applications. This option can be repeated.",
action="append",
default=[],
)
diagnose_parser.add_argument(
"--json",
help="Write the diagnostic report as JSON. Use - for standard output.",
type=Path,
metavar="FILE",
)
return parser.parse_args(argv)
"""Diagnostic utilities."""
import platform
import shutil
import socket
import subprocess
import sys
from collections.abc import Sequence
from pathlib import Path
from typing import NotRequired, TypedDict
type Diagnostic = dict[str, str]
class AppDiagnostic(TypedDict):
"""Application availability and version output."""
available: bool
version: str | None
class ResourceDiagnostic(TypedDict):
"""Resource capacity and usage, or a query error."""
total_bytes: int | None
available_bytes: int | None
used_percent: float | None
error: NotRequired[str]
class DiskDiagnostic(ResourceDiagnostic):
"""Resource usage for the filesystem containing a path."""
path: str
class DNSDiagnostic(TypedDict):
"""Hostname resolution result."""
host: str
ok: bool
error: NotRequired[str]
class TCPDiagnostic(DNSDiagnostic):
"""TCP connection result."""
port: int
class NetworkDiagnostic(TypedDict):
"""Hostname resolution and TCP connection results."""
dns: DNSDiagnostic
tcp: TCPDiagnostic
class Diagnostics(TypedDict):
"""System, Python, application, resource, and network diagnostics."""
system: Diagnostic
python: Diagnostic
apps: dict[str, AppDiagnostic]
memory: ResourceDiagnostic
disk: DiskDiagnostic
network: NetworkDiagnostic
def diagnose_system() -> Diagnostic:
"""Report the distribution, kernel, and CPU architecture."""
uname = platform.uname()
try:
distribution = platform.freedesktop_os_release().get("NAME", uname.system)
except OSError:
distribution = uname.system
return {
"distribution": distribution,
"kernel": uname.release,
"architecture": uname.machine,
}
def diagnose_python() -> Diagnostic:
"""Report the Python version and executable path."""
return {
"python_version": platform.python_version(),
"python_path": sys.executable,
}
def diagnose_apps(apps: Sequence[str]) -> dict[str, AppDiagnostic]:
"""Check applications on PATH and collect their version output."""
diagnostics: dict[str, AppDiagnostic] = {}
for app in apps:
executable = shutil.which(app)
version = None
if executable is not None:
try:
result = subprocess.run(
[executable, "--version"],
capture_output=True,
text=True,
errors="replace",
timeout=5,
check=False,
)
except (OSError, subprocess.TimeoutExpired):
pass
else:
if result.returncode == 0:
version = result.stdout.strip() or result.stderr.strip() or None
diagnostics[app] = {
"available": executable is not None,
"version": version,
}
return diagnostics
def diagnose_memory() -> ResourceDiagnostic:
"""Read total and available memory from Linux procfs."""
try:
values: dict[str, int] = {}
for line in Path("/proc/meminfo").read_text(encoding="utf-8").splitlines():
name, _, value = line.partition(":")
if name in {"MemTotal", "MemAvailable"}:
amount, unit = value.split()
if unit != "kB":
raise ValueError(f"Unexpected unit for {name}: {unit}")
values[name] = int(amount) * 1024
total = values["MemTotal"]
available = values["MemAvailable"]
if total <= 0 or not 0 <= available <= total:
raise ValueError("Invalid memory capacity or available memory")
except (OSError, ValueError, KeyError) as error:
return {
"total_bytes": None,
"available_bytes": None,
"used_percent": None,
"error": str(error),
}
return {
"total_bytes": total,
"available_bytes": available,
"used_percent": round((total - available) / total * 100, 1),
}
def diagnose_disk(path: str = "/") -> DiskDiagnostic:
"""Report capacity and usage for the filesystem containing path."""
try:
usage = shutil.disk_usage(path)
if usage.total <= 0:
raise ValueError("Disk capacity must be positive")
except (OSError, ValueError) as error:
return {
"path": path,
"total_bytes": None,
"available_bytes": None,
"used_percent": None,
"error": str(error),
}
return {
"path": path,
"total_bytes": usage.total,
"available_bytes": usage.free,
"used_percent": round(usage.used / usage.total * 100, 1),
}
def diagnose_network(host: str = "example.com", port: int = 443) -> NetworkDiagnostic:
"""Check hostname resolution and a TCP connection to the endpoint."""
dns: DNSDiagnostic = {"host": host, "ok": False}
tcp: TCPDiagnostic = {"host": host, "port": port, "ok": False}
try:
addresses = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
if not addresses:
raise OSError("No addresses returned")
except OSError as error:
dns["error"] = str(error)
else:
dns["ok"] = True
try:
with socket.create_connection((host, port), timeout=5):
tcp["ok"] = True
except OSError as error:
tcp["error"] = str(error)
return {"dns": dns, "tcp": tcp}
def diagnose_all(apps: Sequence[str]) -> Diagnostics:
"""Collect system, Python, application, resource, and network diagnostics."""
return {
"system": diagnose_system(),
"python": diagnose_python(),
"apps": diagnose_apps(apps),
"memory": diagnose_memory(),
"disk": diagnose_disk(),
"network": diagnose_network(),
}
Explanation
cli.py requires the diagnose subcommand, with diagnostic as an alias.
It disables abbreviated options and accepts repeated --apps arguments.
In main(), each value is split on commas, stripped of surrounding whitespace,
and filtered to remove empty names. Without --apps, the report has an empty
apps dictionary.
diagnose_system() reads NAME from the distribution’s os-release data.
If the file can’t be read, it falls back to the system name from uname().
The kernel release and architecture come from the same uname() result.
diagnose_python() reports the active interpreter, including its path inside
a virtual environment when one is in use.
diagnose_apps() looks up each application and runs the executable
returned by shutil.which(). It passes arguments as a list without invoking
a shell. Each process has a five-second timeout. On a zero exit status, the
tool keeps stripped standard output, falling back to standard error if
standard output is empty. The value is the full output, such as
git version 2.43.0, rather than a parsed version number.
A missing executable produces available: false and version: null in
JSON. A failed, timed-out, or silent version command also produces a null
version, but availability stays true if the executable was found. These
failures don’t stop checks for the remaining applications. Commands that
don’t support --version need a different version check; this implementation
uses the same argument for every application.
diagnose_memory() reads MemTotal and MemAvailable from
/proc/meminfo. Linux reports these values in units labeled kB;
the function multiplies them by 1024 to produce bytes. MemAvailable
estimates how much memory new applications can use without swapping,
including reclaimable memory. It differs from MemFree. The usage percentage
is 100 * (total - available) / total, rounded to one decimal place.
Missing fields, invalid values, and read errors produce null measurements
with an error message.
diagnose_disk() calls shutil.disk_usage() for /. The report
contains the filesystem’s total capacity, space available to unprivileged
users, and 100 * used / total, rounded to one decimal place. Reserved blocks
can make used space and available space add up to less than the total.
The check covers the filesystem containing /, rather than every mounted
filesystem. A query error or zero capacity produces null measurements with
an error message.
diagnose_network() checks example.com on port 443. It uses
socket.getaddrinfo() for hostname resolution and
socket.create_connection() for TCP connectivity.
The results are independent: a resolved hostname can still have a failed
connection. Each result includes the host, an ok boolean, and an error
message on failure. The TCP result also includes the port.
The connection uses a five-second socket timeout and closes through a with
statement. This timeout doesn’t bound the system resolver’s lookup time;
multiple address attempts can also extend the check. A successful TCP
connection verifies reachability of this endpoint, without checking TLS or
an HTTP response.
The TypedDict classes describe the report’s structure for type checkers.
They don’t validate values at runtime. The type Diagnostic = dict[str, str]
statement requires Python 3.12 or later. diagnose_all() collects all six
sections before the entry point opens an output file.
The --json argument is parsed as a Path. Opening the file after diagnosis
keeps invalid arguments or a failed diagnostic from truncating an existing
report. For --json -, nullcontext lets the same with statement use
standard output without closing it. File output uses UTF-8, two-space
indentation, and a trailing newline.
Both invocation forms call main(). A completed report returns
os.EX_OK (0), even if an application is missing or a resource or network
check fails. An output I/O error writes a message to standard error and returns
os.EX_IOERR (74 on Linux).
Invalid arguments exit with status 2. Unexpected diagnostic errors propagate.
File writes aren’t atomic: a failure during writing can leave a partial report.
Tests
The suite uses unittest and unittest.mock from the standard library.
From the code directory, after installation, run:
python -m unittest discover -s tests
test_diagnose.py replaces system queries and subprocess calls with
controlled results. It checks distribution lookup and fallback, missing
applications, version output from either stream, empty output, nonzero exit
statuses, execution errors, and timeouts. It also checks that diagnose_all()
combines the sections. These tests don’t depend on which applications are
installed on the machine running them.
The memory tests check byte conversion, reclaimable memory, unreadable files,
and malformed data. Disk tests cover reserved space, unavailable filesystems,
and zero capacity. Network tests mock both socket calls to check success,
socket cleanup, DNS errors, connection refusal, and timeouts. A complete-report
test runs the diagnostic functions with controlled inputs and compares their
output against tests/expected/diagnostic.json.
test_main.py checks repeated and comma-separated application arguments,
JSON files, JSON on standard output, and output errors. Temporary directories
keep file checks isolated. Two tests verify that an existing report survives
invalid arguments or a diagnostic failure, and another verifies that JSON
output leaves standard output open.
import json
import socket
import subprocess
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from devcheck.diagnose import (
diagnose_all,
diagnose_apps,
diagnose_disk,
diagnose_memory,
diagnose_network,
diagnose_system,
)
class SystemTests(unittest.TestCase):
def test_distribution_comes_from_os_release(self):
uname = SimpleNamespace(system="Linux", release="6.8.0", machine="x86_64")
with (
patch("devcheck.diagnose.platform.uname", return_value=uname),
patch(
"devcheck.diagnose.platform.freedesktop_os_release",
return_value={"NAME": "Debian"},
),
):
self.assertEqual(
diagnose_system(),
{"distribution": "Debian", "kernel": "6.8.0", "architecture": "x86_64"},
)
def test_missing_os_release_falls_back_to_system_name(self):
uname = SimpleNamespace(system="Linux", release="6.8.0", machine="x86_64")
with (
patch("devcheck.diagnose.platform.uname", return_value=uname),
patch(
"devcheck.diagnose.platform.freedesktop_os_release",
side_effect=OSError,
),
):
self.assertEqual(diagnose_system()["distribution"], "Linux")
class AppTests(unittest.TestCase):
def test_missing_application_has_null_version(self):
with (
patch("devcheck.diagnose.shutil.which", return_value=None) as which,
patch("devcheck.diagnose.subprocess.run") as run,
):
self.assertEqual(
diagnose_apps(["missing"]),
{"missing": {"available": False, "version": None}},
)
which.assert_called_once_with("missing")
run.assert_not_called()
def test_version_output(self):
cases = [
(0, "git version 2.43.0\n", "", "git version 2.43.0"),
(0, "", "git version 2.43.0\n", "git version 2.43.0"),
(0, " \n", "", None),
(1, "partial output", "unsupported option", None),
]
for returncode, stdout, stderr, expected in cases:
with self.subTest(returncode=returncode, stdout=stdout, stderr=stderr):
result = subprocess.CompletedProcess(
["/usr/bin/git", "--version"], returncode, stdout, stderr
)
with (
patch(
"devcheck.diagnose.shutil.which", return_value="/usr/bin/git"
),
patch(
"devcheck.diagnose.subprocess.run", return_value=result
) as run,
):
self.assertEqual(
diagnose_apps(("git",)),
{"git": {"available": True, "version": expected}},
)
run.assert_called_once_with(
["/usr/bin/git", "--version"],
capture_output=True,
text=True,
errors="replace",
timeout=5,
check=False,
)
def test_failed_version_check_does_not_stop_other_applications(self):
failures = [OSError("cannot execute"), subprocess.TimeoutExpired("app", 5)]
for failure in failures:
with self.subTest(failure=failure):
result = subprocess.CompletedProcess(["/bin/working"], 0, "1.0\n", "")
with (
patch(
"devcheck.diagnose.shutil.which",
side_effect=["/bin/failing", "/bin/working"],
),
patch(
"devcheck.diagnose.subprocess.run",
side_effect=[failure, result],
),
):
self.assertEqual(
diagnose_apps(["failing", "working"]),
{
"failing": {"available": True, "version": None},
"working": {"available": True, "version": "1.0"},
},
)
class MemoryTests(unittest.TestCase):
def test_available_memory_includes_reclaimable_memory(self):
meminfo = (
"MemTotal: 1000 kB\nMemFree: 100 kB\nMemAvailable: 750 kB\n"
"HugePages_Total: 0\n"
)
with patch("devcheck.diagnose.Path.read_text", return_value=meminfo):
self.assertEqual(
diagnose_memory(),
{
"total_bytes": 1024000,
"available_bytes": 768000,
"used_percent": 25.0,
},
)
def test_unreadable_meminfo_has_null_measurements(self):
with patch(
"devcheck.diagnose.Path.read_text", side_effect=OSError("unreadable")
):
self.assertEqual(
diagnose_memory(),
{
"total_bytes": None,
"available_bytes": None,
"used_percent": None,
"error": "unreadable",
},
)
def test_invalid_meminfo_reports_an_error(self):
cases = [
"MemTotal: 1000 kB\nMemFree: 250 kB\n",
"MemTotal: invalid kB\nMemAvailable: 250 kB\n",
"MemTotal: 1000 MB\nMemAvailable: 250 kB\n",
"MemTotal: 1000\nMemAvailable: 250 kB\n",
"MemTotal: 0 kB\nMemAvailable: 0 kB\n",
"MemTotal: 1000 kB\nMemAvailable: -1 kB\n",
"MemTotal: 1000 kB\nMemAvailable: 1001 kB\n",
]
for meminfo in cases:
with self.subTest(meminfo=meminfo):
with patch("devcheck.diagnose.Path.read_text", return_value=meminfo):
report = diagnose_memory()
self.assertIsNone(report["total_bytes"])
self.assertIsNone(report["available_bytes"])
self.assertIsNone(report["used_percent"])
self.assertTrue(report["error"])
class DiskTests(unittest.TestCase):
def test_reserved_space_is_not_counted_as_used_space(self):
usage = SimpleNamespace(total=1000, used=600, free=300)
with patch("devcheck.diagnose.shutil.disk_usage", return_value=usage) as query:
self.assertEqual(
diagnose_disk("/data"),
{
"path": "/data",
"total_bytes": 1000,
"available_bytes": 300,
"used_percent": 60.0,
},
)
query.assert_called_once_with("/data")
def test_unavailable_filesystem_has_null_measurements(self):
with patch(
"devcheck.diagnose.shutil.disk_usage", side_effect=OSError("unavailable")
):
self.assertEqual(
diagnose_disk(),
{
"path": "/",
"total_bytes": None,
"available_bytes": None,
"used_percent": None,
"error": "unavailable",
},
)
def test_zero_capacity_reports_an_error(self):
usage = SimpleNamespace(total=0, used=0, free=0)
with patch("devcheck.diagnose.shutil.disk_usage", return_value=usage):
report = diagnose_disk()
self.assertIsNone(report["used_percent"])
self.assertEqual(report["error"], "Disk capacity must be positive")
class NetworkTests(unittest.TestCase):
def test_success_closes_connection(self):
with (
patch("devcheck.diagnose.socket.getaddrinfo", return_value=[object()]) as dns,
patch("devcheck.diagnose.socket.create_connection") as connect,
):
self.assertEqual(
diagnose_network(),
{
"dns": {"host": "example.com", "ok": True},
"tcp": {"host": "example.com", "port": 443, "ok": True},
},
)
dns.assert_called_once_with("example.com", 443, type=socket.SOCK_STREAM)
connect.assert_called_once_with(("example.com", 443), timeout=5)
connect.return_value.__exit__.assert_called_once_with(None, None, None)
def test_connection_failure_does_not_change_dns_result(self):
for failure in (TimeoutError("timed out"), ConnectionRefusedError("refused")):
with self.subTest(failure=failure):
with (
patch(
"devcheck.diagnose.socket.getaddrinfo",
return_value=[object()],
),
patch(
"devcheck.diagnose.socket.create_connection", side_effect=failure
),
):
report = diagnose_network()
self.assertTrue(report["dns"]["ok"])
self.assertFalse(report["tcp"]["ok"])
self.assertEqual(report["tcp"]["error"], str(failure))
def test_dns_failure_does_not_skip_tcp_check(self):
with (
patch(
"devcheck.diagnose.socket.getaddrinfo",
side_effect=socket.gaierror("DNS failed"),
),
patch("devcheck.diagnose.socket.create_connection") as connect,
):
report = diagnose_network("localhost", 8080)
self.assertEqual(
report["dns"], {"host": "localhost", "ok": False, "error": "DNS failed"}
)
self.assertEqual(
report["tcp"], {"host": "localhost", "port": 8080, "ok": True}
)
connect.assert_called_once_with(("localhost", 8080), timeout=5)
def test_failed_queries_return_both_results(self):
for failure in (None, socket.gaierror("DNS failed")):
with self.subTest(failure=failure):
with (
patch(
"devcheck.diagnose.socket.getaddrinfo",
return_value=[],
side_effect=failure,
),
patch(
"devcheck.diagnose.socket.create_connection",
side_effect=OSError("offline"),
),
):
report = diagnose_network()
self.assertFalse(report["dns"]["ok"])
self.assertTrue(report["dns"]["error"])
self.assertEqual(report["tcp"]["error"], "offline")
self.assertFalse(report["tcp"]["ok"])
class DiagnosticsTests(unittest.TestCase):
def test_report_matches_example(self):
expected = json.loads(
(Path(__file__).parent / "expected/diagnostic.json").read_text(
encoding="utf-8"
)
)
uname = SimpleNamespace(system="Linux", release="6.1.0", machine="x86_64")
meminfo = "MemTotal: 16252928 kB\nMemAvailable: 8622472 kB\n"
usage = SimpleNamespace(total=268435456000, used=184683593728, free=83751862272)
with (
patch("devcheck.diagnose.platform.uname", return_value=uname),
patch(
"devcheck.diagnose.platform.freedesktop_os_release",
return_value={"NAME": "Debian"},
),
patch("devcheck.diagnose.platform.python_version", return_value="3.12.3"),
patch("devcheck.diagnose.sys.executable", "/usr/bin/python3"),
patch(
"devcheck.diagnose.shutil.which",
side_effect=["/usr/bin/git", None, "/usr/bin/cmake"],
),
patch(
"devcheck.diagnose.subprocess.run",
side_effect=[
subprocess.CompletedProcess([], 0, "git version 2.43.0\n", ""),
subprocess.CompletedProcess([], 0, "cmake version 3.25.1\n", ""),
],
),
patch("devcheck.diagnose.Path.read_text", return_value=meminfo),
patch("devcheck.diagnose.shutil.disk_usage", return_value=usage),
patch("devcheck.diagnose.socket.getaddrinfo", return_value=[object()]),
patch("devcheck.diagnose.socket.create_connection"),
):
self.assertEqual(diagnose_all(["git", "ninja", "cmake"]), expected)
def test_combines_all_sections(self):
with (
patch("devcheck.diagnose.diagnose_system", return_value={"kernel": "6.8"}),
patch(
"devcheck.diagnose.diagnose_python",
return_value={"python_version": "3.12"},
),
patch("devcheck.diagnose.diagnose_apps", return_value={}) as apps,
patch(
"devcheck.diagnose.diagnose_memory", return_value={"total_bytes": 1024}
),
patch("devcheck.diagnose.diagnose_disk", return_value={"path": "/"}),
patch(
"devcheck.diagnose.diagnose_network",
return_value={"dns": {"ok": False}},
),
):
self.assertEqual(
diagnose_all(["git"]),
{
"system": {"kernel": "6.8"},
"python": {"python_version": "3.12"},
"apps": {},
"memory": {"total_bytes": 1024},
"disk": {"path": "/"},
"network": {"dns": {"ok": False}},
},
)
apps.assert_called_once_with(["git"])
if __name__ == "__main__":
unittest.main()
import io
import json
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from devcheck.__main__ import main
class MainTests(unittest.TestCase):
def test_diagnostic_alias_collects_report(self):
with (
patch("devcheck.__main__.diagnose_all", return_value={}) as diagnose,
patch("devcheck.__main__.pprint.pprint"),
):
self.assertEqual(main(["diagnostic", "--apps", "git"]), os.EX_OK)
diagnose.assert_called_once_with(["git"])
def test_app_arguments_reach_diagnosis(self):
cases = [
([], []),
(["--apps", "git"], ["git"]),
(["--apps", "git,ninja"], ["git", "ninja"]),
(["--apps", "git", "--apps", "ninja,cmake"], ["git", "ninja", "cmake"]),
(["--apps", " git, ,ninja, "], ["git", "ninja"]),
(["--apps", ""], []),
]
for arguments, expected_apps in cases:
with self.subTest(arguments=arguments):
with (
patch(
"devcheck.__main__.diagnose_all", return_value={}
) as diagnose,
patch("devcheck.__main__.pprint.pprint"),
):
self.assertEqual(main(["diagnose", *arguments]), os.EX_OK)
diagnose.assert_called_once_with(expected_apps)
def test_json_file_contains_report(self):
report = {"apps": {"missing": {"available": False, "version": None}}}
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "diagnostic.json"
with (
patch("devcheck.__main__.diagnose_all", return_value=report),
patch("devcheck.__main__.pprint.pprint") as pretty_print,
):
self.assertEqual(main(["diagnose", "--json", str(output)]), os.EX_OK)
self.assertEqual(json.loads(output.read_text(encoding="utf-8")), report)
pretty_print.assert_not_called()
def test_json_stdout_remains_open(self):
output = io.StringIO()
with (
patch("devcheck.__main__.diagnose_all", return_value={"apps": {}}),
patch("devcheck.__main__.sys.stdout", output),
):
self.assertEqual(main(["diagnose", "--json", "-"]), os.EX_OK)
self.assertFalse(output.closed)
self.assertEqual(json.loads(output.getvalue()), {"apps": {}})
def test_output_error_returns_failure_status(self):
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "missing" / "diagnostic.json"
with (
patch("devcheck.__main__.diagnose_all", return_value={}),
patch("sys.stderr", new_callable=io.StringIO) as errors,
):
self.assertEqual(main(["diagnose", "--json", str(output)]), os.EX_IOERR)
self.assertIn("devcheck:", errors.getvalue())
self.assertIn(str(output), errors.getvalue())
self.assertFalse(output.exists())
def test_failed_diagnosis_preserves_existing_output(self):
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "diagnostic.json"
output.write_text("existing report", encoding="utf-8")
with (
patch("devcheck.__main__.diagnose_all", side_effect=RuntimeError),
self.assertRaises(RuntimeError),
):
main(["diagnose", "--json", str(output)])
self.assertEqual(output.read_text(encoding="utf-8"), "existing report")
def test_invalid_arguments_preserve_existing_output(self):
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "diagnostic.json"
output.write_text("existing report", encoding="utf-8")
with (
patch("sys.stderr", new_callable=io.StringIO),
self.assertRaises(SystemExit) as error,
):
main(["diagnose", "--json", str(output), "--unknown"])
self.assertEqual(error.exception.code, 2)
self.assertEqual(output.read_text(encoding="utf-8"), "existing report")
if __name__ == "__main__":
unittest.main()
These 24 tests exercise functions directly, including the diagnostic alias.
They don’t check package installation or launch the CLI in a subprocess.
For a manual check, run
devcheck diagnose --apps git --json - and confirm that it reports your
interpreter, application output, memory, disk space, and network results.
Current limits
Environment reporting needs an explicit choice of variables, such as PATH
and VIRTUAL_ENV, rather than copying every variable into the report.
Distribution version is still missing. Memory collection requires Linux
procfs with MemTotal and MemAvailable; there is no fallback estimate when
either field is absent. The CLI uses / for disk space and example.com:443
for network checks. Different paths or endpoints require changing the calls
in diagnose_all().