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().