1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333
|
from __future__ import annotations
import functools
from typing import TYPE_CHECKING
from typing import Any
from typing import NoReturn
from typing import Optional
from typing import Protocol
from typing import runtime_checkable
if TYPE_CHECKING:
from httpx import ConnectError
from httpx import RequestError
from httpx import Response as HTTPResponse
from pydantic import BaseModel
from pydantic import ValidationError
from zabbix_cli.pyzabbix.types import ParamsType
from zabbix_cli.pyzabbix.types import ZabbixAPIResponse
class ZabbixCLIError(Exception):
"""Base exception class for ZabbixCLI exceptions."""
class ZabbixCLIFileError(ZabbixCLIError, OSError):
"""Errors related to reading/writing files."""
class ZabbixCLIFileNotFoundError(ZabbixCLIError, FileNotFoundError):
"""Errors related to reading/writing files."""
class ConfigError(ZabbixCLIError):
"""Error with configuration file."""
class ConfigExistsError(ConfigError):
"""Configuration file already exists."""
class ConfigOptionNotFound(ConfigError):
"""Configuration option is missing from the loaded config."""
class CommandFileError(ZabbixCLIError):
"""Error running bulk commands from a file."""
class AuthError(ZabbixCLIError):
"""Base class for all authentication errors."""
# NOTE: We might still run into the problem of expired tokens, which won't raise
# this type of error, but instead raise a ZabbixAPIRequestError.
# We should probably handle that in the client and raise the appropriate exception
class SessionFileError(AuthError):
"""Session file error."""
class SessionFileNotFoundError(SessionFileError, FileNotFoundError):
"""Session file does not exist."""
class SessionFilePermissionsError(SessionFileError):
"""Session file has incorrect permissions."""
class AuthTokenFileError(AuthError):
"""Auth token file error."""
class AuthTokenError(AuthError):
"""Auth token (not file) error."""
class PluginError(ZabbixCLIError):
"""Plugin error."""
class PluginConfigError(PluginError, ConfigError):
"""Plugin configuration error."""
class PluginConfigTypeError(PluginConfigError, TypeError):
"""Plugin configuration type error."""
class PluginLoadError(PluginError):
"""Error loading a plugin."""
msg = "Error loading plugin '{plugin_name}'"
def __init__(self, plugin_name: str, plugin_config: BaseModel | None) -> None:
self.plugin_name = plugin_name
self.plugin_config = plugin_config
super().__init__(self.msg.format(plugin_name=plugin_name))
class PluginPostImportError(PluginLoadError):
"""Error running post-import configuration for a plugin."""
msg = "Error running post-import configuration for plugin '{plugin_name}'"
class ZabbixAPIException(ZabbixCLIError):
# Extracted from pyzabbix, hence *Exception suffix instead of *Error
"""Base exception class for Zabbix API exceptions."""
def reason(self) -> str:
return ""
class ZabbixAPIRequestError(ZabbixAPIException):
"""Zabbix API response error."""
def __init__(
self,
*args: Any,
params: Optional[ParamsType] = None,
api_response: Optional[ZabbixAPIResponse] = None,
response: Optional[HTTPResponse] = None,
) -> None:
super().__init__(*args)
self.params = params
self.api_response = api_response
self.response = response
def reason(self) -> str:
if self.api_response and self.api_response.error:
reason = (
f"({self.api_response.error.code}) {self.api_response.error.message}"
)
if self.api_response.error.data:
reason += f" {self.api_response.error.data}"
elif self.response and self.response.text:
reason = self.response.text
else:
reason = str(self)
return reason
class ZabbixAPITokenExpiredError(ZabbixAPIRequestError, AuthError):
"""Zabbix API token expired error."""
class ZabbixAPINotAuthorizedError(ZabbixAPIRequestError):
"""Zabbix API not authorized error."""
class ZabbixAPIResponseParsingError(ZabbixAPIRequestError):
"""Zabbix API request error."""
class ZabbixAPISessionExpired(ZabbixAPIRequestError):
"""Zabbix API session expired."""
class ZabbixAPICallError(ZabbixAPIException):
"""Zabbix API request error."""
def __str__(self) -> str:
msg = super().__str__()
if self.__cause__ and isinstance(self.__cause__, ZabbixAPIRequestError):
msg = f"{msg}: {self.__cause__.reason()}"
return msg
class ZabbixAPILoginError(ZabbixAPICallError, AuthError):
"""Zabbix API login error."""
class ZabbixAPILogoutError(ZabbixAPICallError, AuthError):
"""Zabbix API logout error."""
class ZabbixNotFoundError(ZabbixAPICallError):
"""A Zabbix API resource was not found."""
class Exiter(Protocol):
"""Protocol class for exit function that can be passed to an
exception handler function.
See Also:
--------
[zabbix_cli.exceptions.HandleFunc][]
"""
def __call__(
self,
message: str,
code: int = ...,
exception: Optional[Exception] = ...,
**kwargs: Any,
) -> NoReturn: ...
@runtime_checkable
class HandleFunc(Protocol):
"""Interface for exception handler functions.
They take any exception and an Exiter function as the arguments
and exit with the appropriate message after running any necessary
cleanup and/or logging.
"""
def __call__(self, e: Any) -> NoReturn: ...
def get_cause_args(e: Optional[BaseException]) -> list[str]:
"""Retrieves all args as strings from all exceptions in the cause chain.
Flattens the args into a single list.
"""
args: list[str] = []
while e:
args.extend(get_exc_args(e))
e = e.__cause__
return args
def get_exc_args(e: BaseException) -> list[str]:
"""Returns the error message as a string."""
args: list[str] = [str(arg) for arg in e.args]
if isinstance(e, ZabbixAPIRequestError):
args.append(e.reason())
return args
def handle_notraceback(e: Exception) -> NoReturn:
"""Handles an exception with no traceback in console.
The exception is logged with a traceback in the log file.
"""
get_exit_err()(str(e), exception=e, exc_info=True)
def handle_validation_error(e: ValidationError) -> NoReturn:
"""Handles a Pydantic validation error."""
# TODO: Use some very primitive heuristics to determine whether or not
# the error is from an API response or somewhere else
get_exit_err()(str(e), exception=e, exc_info=True)
def _fmt_request_error(e: RequestError, exc_type: str, reason: str) -> str:
method = e.request.method
url = e.request.url
return f"{exc_type}: {method} {url} - {reason}"
def handle_connect_error(e: ConnectError) -> NoReturn:
"""Handles an httpx ConnectError."""
# Simple heuristic here to determine cause
if e.args and "connection refused" in str(e.args[0]).casefold():
reason = "Connection refused"
else:
reason = str(e)
msg = _fmt_request_error(e, "Connection error", reason)
get_exit_err()(msg, exception=e, exc_info=False)
def handle_zabbix_api_exception(e: ZabbixAPIException) -> NoReturn:
"""Handles a ZabbixAPIException."""
from zabbix_cli.state import get_state
state = get_state()
# If we have a stale auth token, we need to clear it.
if (
state.is_config_loaded
and state.config.app.use_session_file
and any("re-login" in arg for arg in get_cause_args(e))
):
from zabbix_cli.auth import clear_auth_token_file
from zabbix_cli.output.console import error
# Clear token file and from the config object
error("Auth token expired. You must re-authenticate.")
clear_auth_token_file(state.config)
if state.repl: # Hack: run login flow again in REPL
state.login()
# NOTE: ideally we automatically re-run the command here, but that's
# VERY hacky and could lead to unexpected behavior.
raise SystemExit(1) # Exit without a message
else:
# TODO: extract the reason for the error from the exception here
# and add it to the message.
handle_notraceback(e)
def get_exception_handler(type_: type[Exception]) -> Optional[HandleFunc]:
"""Returns the exception handler for the given exception type."""
from httpx import ConnectError
from pydantic import ValidationError
# Defined inline for performance reasons (httpx and pydantic imports)
EXC_HANDLERS: dict[type[Exception], HandleFunc] = {
# ZabbixAPICallError: handle_zabbix_api_call_error, # NOTE: use different strategy for this?
ZabbixAPIException: handle_zabbix_api_exception, # NOTE: use different strategy for this?
ZabbixCLIError: handle_notraceback,
ValidationError: handle_validation_error,
ConnectError: handle_connect_error,
ConfigError: handle_notraceback, # NOTE: can we remove this? subclass of ZabbixCLIError
}
"""Mapping of exception types to exception handling strategies."""
handler = EXC_HANDLERS.get(type_, None)
if handler:
return handler
if type_.__bases__:
for base in type_.__bases__:
handler = get_exception_handler(base)
if handler:
return handler
return None
def handle_exception(e: Exception) -> NoReturn:
"""Handles an exception and exits with the appropriate message."""
handler = get_exception_handler(type(e))
if not handler:
raise e
handler(e)
@functools.lru_cache(maxsize=1)
def get_exit_err() -> Exiter:
"""Cached lazy-import of `zabbix_cli.output.console.exit_err`.
Avoids circular imports. Because we can "exit" multiple times in the
REPL, it's arguably worth caching the import this way.
"""
from zabbix_cli.output.console import exit_err as _exit_err
return _exit_err
|