fix: clear all mypy errors

Annotate untyped containers and Optional attributes, type the stdlib-shaped
metric dicts in the client plugins, and add types-PyYAML/types-psutil to the
dev extras plus a mypy override for nio/mattermostdriver (no stubs).

Behaviour fixes found on the way: enable_kernel_timestamps() now returns False
when SO_TIMESTAMP is unknown instead of raising TypeError, send_shutdown is
defined at module level so cleanup() cannot hit a NameError, and a failed
subprocess returncode falls back to UNKNOWN.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 05:04:54 -04:00
co-authored by Claude Sonnet 5.5
parent 748349a7b9
commit 445ff6b474
20 changed files with 63 additions and 49 deletions
+3 -2
View File
@@ -2,15 +2,16 @@
import logging import logging
import os import os
from typing import Any, Dict
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
try: try:
import yaml import yaml
except Exception: except Exception:
yaml = None yaml = None # type: ignore[assignment]
CLIENT_DEFAULTS = { CLIENT_DEFAULTS: Dict[str, Any] = {
# Network settings # Network settings
"hb_port": 50003, # Port where hbd servers listen "hb_port": 50003, # Port where hbd servers listen
"interval": 10, # Heartbeat interval in seconds "interval": 10, # Heartbeat interval in seconds
+1
View File
@@ -36,6 +36,7 @@ MAXRECV = 32767
# Global state # Global state
running = True running = True
dorestart = False dorestart = False
send_shutdown = False
shutdown_event: Optional[asyncio.Event] = None shutdown_event: Optional[asyncio.Event] = None
active_tasks: List[asyncio.Task] = [] active_tasks: List[asyncio.Task] = []
+2 -2
View File
@@ -17,7 +17,7 @@ import logging
import sys import sys
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Type from typing import Any, Dict, List, Optional
class Plugin(ABC): class Plugin(ABC):
@@ -256,7 +256,7 @@ class PluginRegistry:
"""Get all enabled plugins.""" """Get all enabled plugins."""
return [p for p in self._plugins.values() if p.enabled] return [p for p in self._plugins.values() if p.enabled]
def get_by_type(self, plugin_type: Type[Plugin]) -> List[Plugin]: def get_by_type(self, plugin_type: type) -> List[Plugin]:
"""Get all plugins of a specific type. """Get all plugins of a specific type.
Args: Args:
+1 -1
View File
@@ -28,7 +28,7 @@ class CPUMonitorPlugin(MonitorPlugin):
def __init__(self, config: Optional[Dict[str, Any]] = None): def __init__(self, config: Optional[Dict[str, Any]] = None):
super().__init__(config) super().__init__(config)
self.psutil = None self.psutil: Any = None
self.per_core = config.get("per_core", False) if config else False self.per_core = config.get("per_core", False) if config else False
self.interval = config.get("interval", 300) if config else 300 self.interval = config.get("interval", 300) if config else 300
+3 -3
View File
@@ -10,7 +10,7 @@ from typing import Dict, Any, Optional
try: try:
import psutil import psutil
except ImportError: except ImportError:
psutil = None psutil = None # type: ignore[assignment]
from hbd.client.plugin import MonitorPlugin from hbd.client.plugin import MonitorPlugin
@@ -58,7 +58,7 @@ class DiskMonitorPlugin(MonitorPlugin):
raise ImportError("psutil library is required for disk_monitor plugin") raise ImportError("psutil library is required for disk_monitor plugin")
# Store previous I/O counters for delta calculation # Store previous I/O counters for delta calculation
self._prev_io = {} self._prev_io: Dict[str, Any] = {}
async def initialize(self): async def initialize(self):
"""Initialize the plugin (check psutil availability).""" """Initialize the plugin (check psutil availability)."""
@@ -114,7 +114,7 @@ class DiskMonitorPlugin(MonitorPlugin):
async def _collect_metrics(self) -> Dict[str, Any]: async def _collect_metrics(self) -> Dict[str, Any]:
"""Collect disk metrics from psutil.""" """Collect disk metrics from psutil."""
metrics = {} metrics: Dict[str, Any] = {}
# Collect partition usage # Collect partition usage
partitions_data = {} partitions_data = {}
+4 -4
View File
@@ -10,7 +10,7 @@ from typing import Dict, Any, Optional
try: try:
import psutil import psutil
except ImportError: except ImportError:
psutil = None psutil = None # type: ignore[assignment]
from hbd.client.plugin import InfoPlugin from hbd.client.plugin import InfoPlugin
@@ -98,8 +98,8 @@ class FilesystemInfoPlugin(InfoPlugin):
async def _collect_info(self) -> Dict[str, Any]: async def _collect_info(self) -> Dict[str, Any]:
"""Collect filesystem information from psutil.""" """Collect filesystem information from psutil."""
info = {} info: Dict[str, Any] = {}
filesystems = [] filesystems: list = []
filesystem_types = set() filesystem_types = set()
# Get mounted disk partitions # Get mounted disk partitions
@@ -112,7 +112,7 @@ class FilesystemInfoPlugin(InfoPlugin):
if partition.fstype in self.exclude_types: if partition.fstype in self.exclude_types:
continue continue
fs_info = { fs_info: Dict[str, Any] = {
'device': partition.device, 'device': partition.device,
'mountpoint': partition.mountpoint, 'mountpoint': partition.mountpoint,
'fstype': partition.fstype, 'fstype': partition.fstype,
+2 -2
View File
@@ -10,7 +10,7 @@ from typing import Dict, Any, Optional
try: try:
import psutil import psutil
except ImportError: except ImportError:
psutil = None psutil = None # type: ignore[assignment]
from hbd.client.plugin import MonitorPlugin from hbd.client.plugin import MonitorPlugin
@@ -116,7 +116,7 @@ class MemoryMonitorPlugin(MonitorPlugin):
async def _collect_metrics(self) -> Dict[str, Any]: async def _collect_metrics(self) -> Dict[str, Any]:
"""Collect memory metrics from psutil.""" """Collect memory metrics from psutil."""
metrics = {} metrics: Dict[str, Any] = {}
# Virtual (physical) memory statistics # Virtual (physical) memory statistics
vmem = psutil.virtual_memory() vmem = psutil.virtual_memory()
+4 -3
View File
@@ -124,7 +124,7 @@ class NagiosRunnerPlugin(MonitorPlugin):
Returns: Returns:
Dictionary with results from all plugins Dictionary with results from all plugins
""" """
results = {} results: Dict[str, Any] = {}
for cmd_config in self.commands: for cmd_config in self.commands:
name = cmd_config.get("name") name = cmd_config.get("name")
@@ -181,7 +181,8 @@ class NagiosRunnerPlugin(MonitorPlugin):
self.logger.error(f"Command timed out: {command}") self.logger.error(f"Command timed out: {command}")
return NAGIOS_UNKNOWN, f"Command timed out after {self.timeout}s", {} return NAGIOS_UNKNOWN, f"Command timed out after {self.timeout}s", {}
status_code = proc.returncode # communicate() has finished, so the process has exited and returncode is set
status_code = proc.returncode if proc.returncode is not None else NAGIOS_UNKNOWN
if status_code < 0: if status_code < 0:
return NAGIOS_UNKNOWN, f"Process killed by signal {-status_code}", {} return NAGIOS_UNKNOWN, f"Process killed by signal {-status_code}", {}
@@ -225,7 +226,7 @@ class NagiosRunnerPlugin(MonitorPlugin):
Returns: Returns:
Dictionary of metric_name: value Dictionary of metric_name: value
""" """
perfdata = {} perfdata: Dict[str, Any] = {}
# Performance data comes after the pipe character # Performance data comes after the pipe character
if '|' not in output: if '|' not in output:
+4 -4
View File
@@ -10,7 +10,7 @@ from typing import Dict, Any, Optional
try: try:
import psutil import psutil
except ImportError: except ImportError:
psutil = None psutil = None # type: ignore[assignment]
from hbd.client.plugin import MonitorPlugin from hbd.client.plugin import MonitorPlugin
from hbd.client.connectivity import get_shared from hbd.client.connectivity import get_shared
@@ -63,7 +63,7 @@ class NetworkMonitorPlugin(MonitorPlugin):
raise ImportError("psutil library is required for network_monitor plugin") raise ImportError("psutil library is required for network_monitor plugin")
# Store previous I/O counters for delta calculation # Store previous I/O counters for delta calculation
self._prev_io = {} self._prev_io: Dict[str, Any] = {}
async def initialize(self): async def initialize(self):
"""Initialize the plugin (check psutil availability).""" """Initialize the plugin (check psutil availability)."""
@@ -124,7 +124,7 @@ class NetworkMonitorPlugin(MonitorPlugin):
async def _collect_metrics(self) -> Dict[str, Any]: async def _collect_metrics(self) -> Dict[str, Any]:
"""Collect network metrics from psutil.""" """Collect network metrics from psutil."""
metrics = {} metrics: Dict[str, Any] = {}
# Collect per-interface I/O counters # Collect per-interface I/O counters
try: try:
@@ -169,7 +169,7 @@ class NetworkMonitorPlugin(MonitorPlugin):
if self.include_connections: if self.include_connections:
try: try:
connections = psutil.net_connections(kind='inet') connections = psutil.net_connections(kind='inet')
conn_stats = {} conn_stats: Dict[str, int] = {}
# Count connections by state # Count connections by state
for conn in connections: for conn in connections:
+1
View File
@@ -67,6 +67,7 @@ class PingMonitorPlugin(MonitorPlugin):
self.timeout = int(cfg.get("timeout", 5)) self.timeout = int(cfg.get("timeout", 5))
# hosts: dict of {hostname: {warning: x, critical: y}} or list of hostnames # hosts: dict of {hostname: {warning: x, critical: y}} or list of hostnames
raw_hosts = cfg.get("hosts", {}) raw_hosts = cfg.get("hosts", {})
self.hosts: Dict[str, Any]
if isinstance(raw_hosts, list): if isinstance(raw_hosts, list):
self.hosts = {h: {} for h in raw_hosts} self.hosts = {h: {} for h in raw_hosts}
else: else:
+1 -1
View File
@@ -92,7 +92,7 @@ def stodict(msg: bytes):
'!' and decodes accordingly. Returns a dict with key 'ID' set to the '!' and decodes accordingly. Returns a dict with key 'ID' set to the
message ID and the parsed key/value pairs. message ID and the parsed key/value pairs.
""" """
d = {} d: Dict[str, Any] = {}
if len(msg) > 0 and chr(msg[0]) == "!": if len(msg) > 0 and chr(msg[0]) == "!":
# message is: b'!ID:' + compressed_payload # message is: b'!ID:' + compressed_payload
# original code used msg[1:4].decode() for ID (3 bytes including colon) # original code used msg[1:4].decode() for ID (3 bytes including colon)
+1 -1
View File
@@ -7,7 +7,7 @@ import os
try: try:
import yaml import yaml
except Exception: except Exception:
yaml = None yaml = None # type: ignore[assignment]
SERVER_DEFAULTS = { SERVER_DEFAULTS = {
# Network settings # Network settings
+1 -1
View File
@@ -1,4 +1,4 @@
msgs = [] # in-memory list of recent messages for new websocket clients; also logged to file via notify.eventlog msgs: list = [] # in-memory list of recent messages for new websocket clients; also logged to file via notify.eventlog
class Data: class Data:
+5 -5
View File
@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from subprocess import Popen, PIPE, STDOUT from subprocess import Popen, PIPE, STDOUT
from typing import Optional from typing import Any, Callable, Optional
import asyncio import asyncio
import logging import logging
@@ -79,8 +79,8 @@ async def dns_update_worker(
hbdclass, hbdclass,
cfg: dict, cfg: dict,
async_queue=None, async_queue=None,
log: Optional[callable] = None, log: Optional[Callable[..., Any]] = None,
pushmsg: Optional[callable] = None, pushmsg: Optional[Callable[..., Any]] = None,
loop: Optional[asyncio.AbstractEventLoop] = None, loop: Optional[asyncio.AbstractEventLoop] = None,
): ):
"""Pure async DNS worker that processes updates from asyncio.Queue. """Pure async DNS worker that processes updates from asyncio.Queue.
@@ -164,7 +164,7 @@ async def dns_update_worker(
def start_dns_worker( def start_dns_worker(
hbdclass, hbdclass,
cfg: dict, cfg: dict,
log: Optional[callable] = None, log: Optional[Callable[..., Any]] = None,
loop: Optional[asyncio.AbstractEventLoop] = None, loop: Optional[asyncio.AbstractEventLoop] = None,
): ):
"""Start the async DNS worker and return the Task. """Start the async DNS worker and return the Task.
@@ -176,7 +176,7 @@ def start_dns_worker(
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
# Create asyncio.Queue and wrap in a bridge for thread-safe puts # Create asyncio.Queue and wrap in a bridge for thread-safe puts
async_q = asyncio.Queue() async_q: asyncio.Queue[Any] = asyncio.Queue()
class _QueueBridge: class _QueueBridge:
"""Thread-safe wrapper around asyncio.Queue for synchronous callers.""" """Thread-safe wrapper around asyncio.Queue for synchronous callers."""
+4 -3
View File
@@ -8,6 +8,7 @@ import time
import json import json
import copy import copy
import queue import queue
from typing import Any, Dict
num = 0 num = 0
@@ -24,7 +25,7 @@ def log(host, m):
class Connection: class Connection:
# map of addrs to names # map of addrs to names
htab = {} htab: Dict[str, Any] = {}
UNKNOWN = "unknown" UNKNOWN = "unknown"
UP = "up" UP = "up"
DOWN = "down" DOWN = "down"
@@ -278,8 +279,8 @@ class Connection:
# #
class Host: class Host:
# Table of Hosts # Table of Hosts
hosts = {} hosts: Dict[str, "Host"] = {}
dnsQ = queue.Queue() dnsQ: "queue.Queue[Any]" = queue.Queue()
def __init__(self, name): def __init__(self, name):
global num global num
+2 -2
View File
@@ -11,7 +11,7 @@ import os
import asyncio import asyncio
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Dict, Any, Optional, Callable, Iterable, List, Tuple, Union from typing import Dict, Any, Optional, Callable, Iterable, List, TextIO, Tuple, Union
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -52,7 +52,7 @@ class MessageJournal:
self.enabled = self.config.get('journal_enabled', True) self.enabled = self.config.get('journal_enabled', True)
# Runtime state # Runtime state
self._file_handle = None self._file_handle: Optional[TextIO] = None
self._current_size = 0 self._current_size = 0
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
self._initialized = False self._initialized = False
+1 -1
View File
@@ -128,7 +128,7 @@ def get_providers(config: dict) -> list[ResolvedProvider]:
Entries with missing required fields or unknown types are skipped with Entries with missing required fields or unknown types are skipped with
a warning log. Order follows config declaration order. a warning log. Order follows config declaration order.
""" """
result = [] result: list = []
oauth_cfg = config.get("oauth", {}) oauth_cfg = config.get("oauth", {})
if not isinstance(oauth_cfg, dict): if not isinstance(oauth_cfg, dict):
return result return result
+9 -9
View File
@@ -53,16 +53,16 @@ class AlertState:
self.metric_path = metric_path self.metric_path = metric_path
self.level = AlertLevel.OK self.level = AlertLevel.OK
self.since = time.time() self.since = time.time()
self.last_value = None self.last_value: Any = None
self.last_check = time.time() self.last_check = time.time()
self.notification_count = 0 self.notification_count = 0
self.last_notification = None self.last_notification: Optional[float] = None
self.threshold_value = None # The threshold value that triggered alert self.threshold_value: Optional[float] = None # The threshold value that triggered alert
self.operator = None # The comparison operator (>, <, >=, etc.) self.operator: Optional[str] = None # The comparison operator (>, <, >=, etc.)
self.hysteresis: Optional[float] = None # Hysteresis fraction used for recovery self.hysteresis: Optional[float] = None # Hysteresis fraction used for recovery
self.formatted_message = None # Formatted display message for UI self.formatted_message: Optional[str] = None # Formatted display message for UI
self.acknowledged = False # Whether alert has been acknowledged self.acknowledged = False # Whether alert has been acknowledged
self.acknowledged_at = None # Timestamp when acknowledged self.acknowledged_at: Optional[float] = None # Timestamp when acknowledged
self.consecutive_count = 0 # Consecutive exceedances while still OK (for count gating) self.consecutive_count = 0 # Consecutive exceedances while still OK (for count gating)
self.pending_since: Optional[float] = None # non-None while waiting out grace period before notifying self.pending_since: Optional[float] = None # non-None while waiting out grace period before notifying
@@ -362,13 +362,13 @@ class ThresholdChecker:
journal: Optional MessageJournal instance for logging threshold events journal: Optional MessageJournal instance for logging threshold events
""" """
# Named threshold configurations (pre-merged: defaults + overrides): {config_name: {metric_path: ThresholdConfig}} # Named threshold configurations (pre-merged: defaults + overrides): {config_name: {metric_path: ThresholdConfig}}
self.threshold_configs = {} self.threshold_configs: Dict[str, Dict[str, ThresholdConfig]] = {}
# Raw overrides only for each named config (no defaults baked in): {config_name: {metric_path: ThresholdConfig}} # Raw overrides only for each named config (no defaults baked in): {config_name: {metric_path: ThresholdConfig}}
self.threshold_raw_configs: Dict[str, Dict[str, ThresholdConfig]] = {} self.threshold_raw_configs: Dict[str, Dict[str, ThresholdConfig]] = {}
# Single threshold set for backward compatibility: {metric_path: ThresholdConfig} # Single threshold set for backward compatibility: {metric_path: ThresholdConfig}
self.thresholds = {} self.thresholds: Dict[str, ThresholdConfig] = {}
# Host to ordered list of config names: {host_name: [config_name, ...]} # Host to ordered list of config names: {host_name: [config_name, ...]}
self.host_config_mapping: Dict[str, List[str]] = {} self.host_config_mapping: Dict[str, List[str]] = {}
@@ -1276,7 +1276,7 @@ class ThresholdChecker:
def _format_display( def _format_display(
self, self,
display_format: str, display_format: Optional[str],
value: Any, value: Any,
threshold_value: Optional[float], threshold_value: Optional[float],
op_symbol: str, op_symbol: str,
+7 -5
View File
@@ -5,6 +5,7 @@ import socket
import struct import struct
import time import time
import zlib import zlib
from typing import Optional
import logging import logging
from platform import system as platform_system from platform import system as platform_system
@@ -25,6 +26,7 @@ _STALE_INTERVAL_MULTIPLIER = 3
# Supported on Linux, FreeBSD, and macOS. The constant is not exposed by # Supported on Linux, FreeBSD, and macOS. The constant is not exposed by
# Python's socket module on all platforms # Python's socket module on all platforms
platform = platform_system() platform = platform_system()
_SO_TIMESTAMP: Optional[int]
if platform == "Darwin": if platform == "Darwin":
_SO_TIMESTAMP = 1024 # SO_TIMESTAMP on macOS (not in Python's socket module) _SO_TIMESTAMP = 1024 # SO_TIMESTAMP on macOS (not in Python's socket module)
elif platform == "Linux": elif platform == "Linux":
@@ -45,6 +47,8 @@ def enable_kernel_timestamps(sock) -> bool:
Returns True if the kernel will supply receive timestamps, False otherwise Returns True if the kernel will supply receive timestamps, False otherwise
(unsupported platform, older kernel, or insufficient permissions). (unsupported platform, older kernel, or insufficient permissions).
""" """
if _SO_TIMESTAMP is None:
return False
try: try:
sock.setsockopt(socket.SOL_SOCKET, _SO_TIMESTAMP, 1) sock.setsockopt(socket.SOL_SOCKET, _SO_TIMESTAMP, 1)
return True return True
@@ -430,7 +434,7 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
return return
cfg = ctx.get("config", {}) cfg = ctx.get("config", {})
hbdcls = ctx.get("hbdclass") hbdcls = ctx["hbdclass"]
msg_to_websockets = ctx.get("msg_to_websockets") msg_to_websockets = ctx.get("msg_to_websockets")
DEBUG = ctx.get("DEBUG", 0) DEBUG = ctx.get("DEBUG", 0)
@@ -459,10 +463,8 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
newh = False newh = False
cid = msg.get("id", 0) cid = msg.get("id", 0)
try: rtt_raw = msg.get("rtt")
rtt = float(msg.get("rtt")) rtt = float(rtt_raw) if rtt_raw is not None else None
except TypeError:
rtt = None
if msg.get("ID") == "HTB": if msg.get("ID") == "HTB":
host.doesack = msg.get("acks", -1) host.doesack = msg.get("acks", -1)
+7
View File
@@ -62,6 +62,8 @@ dev = [
"pytest-cov>=4.0", "pytest-cov>=4.0",
"flake8>=5.0", "flake8>=5.0",
"mypy>=1.10", "mypy>=1.10",
"types-PyYAML",
"types-psutil",
"black>=23.0", "black>=23.0",
"isort>=5.0", "isort>=5.0",
"tox>=4.0", "tox>=4.0",
@@ -92,5 +94,10 @@ max-line-length = 111
[tool.pylint.format] [tool.pylint.format]
max-line-length = 111 max-line-length = 111
[[tool.mypy.overrides]]
# optional notification libraries that ship neither stubs nor py.typed
module = ["nio", "nio.*", "mattermostdriver", "mattermostdriver.*"]
ignore_missing_imports = true
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]