mplugin 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mplugin/__init__.py ADDED
@@ -0,0 +1,2315 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import collections
5
+ import functools
6
+ import importlib
7
+ import io
8
+ import json
9
+ import logging
10
+ import numbers
11
+ import os
12
+ import re
13
+ import sys
14
+ import traceback
15
+ import typing
16
+ from collections import UserDict
17
+ from importlib import metadata
18
+ from logging import StreamHandler
19
+ from tempfile import TemporaryFile
20
+ from types import TracebackType
21
+
22
+ import typing_extensions
23
+
24
+ __version__: str = metadata.version("mplugin")
25
+
26
+
27
+ # error.py
28
+
29
+ """Exceptions with special meanings for mplugin."""
30
+
31
+
32
+ class CheckError(RuntimeError):
33
+ """Abort check execution.
34
+
35
+ This exception should be raised if it becomes clear for a plugin
36
+ that it is not able to determine the system status. Raising this
37
+ exception will make the plugin display the exception's argument and
38
+ exit with an UNKNOWN (3) status.
39
+ """
40
+
41
+ pass
42
+
43
+
44
+ class Timeout(RuntimeError):
45
+ """Maximum check run time exceeded.
46
+
47
+ This exception is raised internally by mplugin if the check's
48
+ run time takes longer than allowed. Check execution is aborted and
49
+ the plugin exits with an UNKNOWN (3) status.
50
+ """
51
+
52
+ pass
53
+
54
+
55
+ # state.py
56
+
57
+ """Classes to represent check outcomes.
58
+
59
+ This module defines :class:`ServiceState` which is the abstract base class
60
+ for check outcomes. The four states defined by the :term:`Monitoring plugin API`
61
+ are represented as singleton subclasses.
62
+
63
+ Note that the *warning* state is defined by the :class:`Warn` class. The
64
+ class has not been named `Warning` to avoid being confused with the
65
+ built-in Python exception of the same name.
66
+ """
67
+
68
+
69
+ def worst(states: list["ServiceState"]) -> "ServiceState":
70
+ """Reduce list of *states* to the most significant state."""
71
+ return functools.reduce(lambda a, b: a if a > b else b, states, ok)
72
+
73
+
74
+ class ServiceState:
75
+ """Abstract base class for all states.
76
+
77
+ Each state has two constant attributes: :attr:`text` is the short
78
+ text representation which is printed for example at the beginning of
79
+ the summary line. :attr:`code` is the corresponding exit code.
80
+ """
81
+
82
+ code: int
83
+ """The Plugin API compliant exit code."""
84
+
85
+ text: str
86
+ """The short text representation which is printed for example at the
87
+ beginning of the summary line."""
88
+
89
+ def __init__(self, code: int, text: str) -> None:
90
+ self.code = code
91
+ self.text = text
92
+
93
+ def __str__(self) -> str:
94
+ """Plugin-API compliant text representation."""
95
+ return self.text
96
+
97
+ def __int__(self) -> int:
98
+ """The Plugin API compliant exit code."""
99
+ return self.code
100
+
101
+ def __gt__(self, other: typing.Any) -> bool:
102
+ return (
103
+ hasattr(other, "code")
104
+ and isinstance(other.code, int)
105
+ and self.code > other.code
106
+ )
107
+
108
+ def __eq__(self, other: typing.Any) -> bool:
109
+ return (
110
+ hasattr(other, "code")
111
+ and isinstance(other.code, int)
112
+ and self.code == other.code
113
+ and hasattr(other, "text")
114
+ and isinstance(other.text, str)
115
+ and self.text == other.text
116
+ )
117
+
118
+ def __hash__(self) -> int:
119
+ return hash((self.code, self.text))
120
+
121
+
122
+ class __Ok(ServiceState):
123
+ def __init__(self) -> None:
124
+ super().__init__(0, "ok")
125
+
126
+
127
+ ok: ServiceState = __Ok()
128
+ """The plugin was able to check the service and it appeared to be functioning
129
+ properly."""
130
+
131
+
132
+ class __Warning(ServiceState):
133
+ def __init__(self) -> None:
134
+ super().__init__(1, "warning")
135
+
136
+
137
+ warning: ServiceState = __Warning()
138
+ """
139
+ The plugin was able to check the service, but it appeared to be above some
140
+ ``warning`` threshold or did not appear to be working properly."""
141
+
142
+
143
+ class __Critical(ServiceState):
144
+ def __init__(self) -> None:
145
+ super().__init__(2, "critical")
146
+
147
+
148
+ critical: ServiceState = __Critical()
149
+ """The plugin detected that either the service was not running or it was above
150
+ some ``critical`` threshold."""
151
+
152
+
153
+ class __Unknown(ServiceState):
154
+ def __init__(self) -> None:
155
+ super().__init__(3, "unknown")
156
+
157
+
158
+ unknown: ServiceState = __Unknown()
159
+ """Invalid command line arguments were supplied to the plugin or low-level
160
+ failures internal to the plugin (such as unable to fork, or open a tcp socket)
161
+ that prevent it from performing the specified operation. Higher-level errors
162
+ (such as name resolution errors, socket timeouts, etc) are outside of the control
163
+ of plugins and should generally NOT be reported as ``unknown`` states.
164
+
165
+ The --help or --version output should also result in ``unknown`` state."""
166
+
167
+
168
+ def state(exit_code: int) -> ServiceState:
169
+ """
170
+ Convert an exit code to a ServiceState.
171
+
172
+ :param exit_code: The exit code to convert. Must be 0, 1, 2, or 3.
173
+
174
+ :return: The corresponding ServiceState (ok, warn, critical, or unknown).
175
+
176
+ :raises CheckError: If exit_code is greater than 3.
177
+ """
178
+ if exit_code == 0:
179
+ return ok
180
+ elif exit_code == 1:
181
+ return warning
182
+ elif exit_code == 2:
183
+ return critical
184
+ elif exit_code == 3:
185
+ return unknown
186
+ raise CheckError(f"Exit code {exit_code} is > 3")
187
+
188
+
189
+ # range.py
190
+
191
+
192
+ RangeSpec = typing.Union[str, int, float, "Range"]
193
+
194
+
195
+ class Range:
196
+ """Represents a threshold range.
197
+
198
+ The general format is ``[@][start:][end]``. ``start:`` may be omitted if
199
+ ``start==0``. ``~:`` means that start is negative infinity. If ``end`` is
200
+ omitted, infinity is assumed. To invert the match condition, prefix
201
+ the range expression with ``@``.
202
+
203
+ See the
204
+ `Monitoring plugin guidelines <https://github.com/monitoring-plugins/monitoring-plugin-guidelines/blob/main/definitions/01.range_expressions.md>`__
205
+ for details.
206
+ """
207
+
208
+ invert: bool
209
+
210
+ start: float
211
+
212
+ end: float
213
+
214
+ def __init__(self, spec: typing.Optional[RangeSpec] = None) -> None:
215
+ """Creates a Range object according to `spec`.
216
+
217
+ :param spec: may be either a string, a float, or another
218
+ Range object.
219
+ """
220
+ spec = spec or ""
221
+ if isinstance(spec, Range):
222
+ self.invert = spec.invert
223
+ self.start = spec.start
224
+ self.end = spec.end
225
+ elif isinstance(spec, int) or isinstance(spec, float):
226
+ self.invert = False
227
+ self.start = 0
228
+ self.end = spec
229
+ else:
230
+ self.start, self.end, self.invert = Range._parse(str(spec))
231
+ Range._verify(self.start, self.end)
232
+
233
+ @classmethod
234
+ def _parse(cls, spec: str) -> tuple[float, float, bool]:
235
+ invert = False
236
+ start: float
237
+ start_str: str
238
+ end: float
239
+ end_str: str
240
+ if spec.startswith("@"):
241
+ invert = True
242
+ spec = spec[1:]
243
+ if ":" in spec:
244
+ start_str, end_str = spec.split(":")
245
+ else:
246
+ start_str, end_str = "", spec
247
+ if start_str == "~":
248
+ start = float("-inf")
249
+ else:
250
+ start = cls._parse_atom(start_str, 0)
251
+ end = cls._parse_atom(end_str, float("inf"))
252
+ return start, end, invert
253
+
254
+ @staticmethod
255
+ def _parse_atom(atom: str, default: float) -> float:
256
+ if atom == "":
257
+ return default
258
+ if "." in atom:
259
+ return float(atom)
260
+ return int(atom)
261
+
262
+ @staticmethod
263
+ def _verify(start: float, end: float) -> None:
264
+ """Throws ValueError if the range is not consistent."""
265
+ if start > end:
266
+ raise ValueError("start %s must not be greater than end %s" % (start, end))
267
+
268
+ def match(self, value: float) -> bool:
269
+ """Decides if `value` is inside/outside the threshold.
270
+
271
+ :returns: `True` if value is inside the bounds for non-inverted
272
+ Ranges.
273
+
274
+ Also available as `in` operator.
275
+ """
276
+ if value < self.start:
277
+ return False ^ self.invert
278
+ if value > self.end:
279
+ return False ^ self.invert
280
+ return True ^ self.invert
281
+
282
+ def __contains__(self, value: float) -> bool:
283
+ return self.match(value)
284
+
285
+ def _format(self, omit_zero_start: bool = True) -> str:
286
+ result: list[str] = []
287
+ if self.invert:
288
+ result.append("@")
289
+ if self.start == float("-inf"):
290
+ result.append("~:")
291
+ elif not omit_zero_start or self.start != 0:
292
+ result.append(("%s:" % self.start))
293
+ if self.end != float("inf"):
294
+ result.append(("%s" % self.end))
295
+ return "".join(result)
296
+
297
+ def __str__(self) -> str:
298
+ """Human-readable range specification."""
299
+ return self._format()
300
+
301
+ def __repr__(self) -> str:
302
+ """Parseable range specification."""
303
+ return "Range(%r)" % str(self)
304
+
305
+ def __eq__(self, value: object) -> bool:
306
+ if not isinstance(value, Range):
307
+ return False
308
+ return (
309
+ self.invert == value.invert
310
+ and self.start == self.start
311
+ and self.end == self.end
312
+ )
313
+
314
+ @property
315
+ def violation(self) -> str:
316
+ """Human-readable description why a value does not match."""
317
+ return "outside range {0}".format(self._format(False))
318
+
319
+
320
+ # multiarg.py
321
+
322
+
323
+ class MultiArg:
324
+ """
325
+ A container class for handling multiple arguments that can be indexed and iterated.
326
+
327
+ This class is designed to be used as a type converter in argparse for arguments
328
+ that accept comma-separated or otherwise delimited values. It provides convenient
329
+ access to individual arguments with optional fill values for missing indices.
330
+
331
+
332
+ .. code-block:: python
333
+
334
+ argp.add_argument(
335
+ "--tw",
336
+ "--ttot-warning",
337
+ metavar="RANGE[,RANGE,...]",
338
+ type=mplugin.MultiArg,
339
+ default="",
340
+ )
341
+
342
+ :param args: The list of parsed argument strings.
343
+ :param fill: An optional default value to return for indices
344
+ beyond the length of the args list. If not provided, the last argument
345
+ is returned instead, or None if the list is empty.
346
+ :param splitchar:
347
+ """
348
+
349
+ args: list[str]
350
+ """The list of parsed argument strings."""
351
+
352
+ fill: typing.Optional[str]
353
+ """An optional default value to return for indices
354
+ beyond the length of the args list. If not provided, the last argument
355
+ is returned instead, or None if the list is empty."""
356
+
357
+ def __init__(
358
+ self,
359
+ args: typing.Union[list[str], str],
360
+ fill: typing.Optional[str] = None,
361
+ splitchar: str = ",",
362
+ ) -> None:
363
+ if isinstance(args, list):
364
+ self.args = args
365
+ else:
366
+ self.args = args.split(splitchar)
367
+ self.fill = fill
368
+
369
+ def __len__(self) -> int:
370
+ return self.args.__len__()
371
+
372
+ def __iter__(self) -> typing.Iterator[str]:
373
+ return self.args.__iter__()
374
+
375
+ def __getitem__(self, key: int) -> typing.Optional[str]:
376
+ try:
377
+ return self.args.__getitem__(key)
378
+ except IndexError:
379
+ pass
380
+ if self.fill is not None:
381
+ return self.fill
382
+ try:
383
+ return self.args.__getitem__(-1)
384
+ except IndexError:
385
+ return None
386
+
387
+
388
+ # platform.py
389
+
390
+
391
+ def _with_timeout(
392
+ time: int, func: typing.Callable[P, R], *args: typing.Any, **kwargs: typing.Any
393
+ ) -> None:
394
+ """Call `func` but terminate after `t` seconds."""
395
+
396
+ if os.name == "posix":
397
+ signal = importlib.import_module("signal")
398
+
399
+ def timeout_handler(signum: int, frame: typing.Any) -> typing.NoReturn:
400
+ raise Timeout("{0}s".format(time))
401
+
402
+ signal.signal(signal.SIGALRM, timeout_handler)
403
+ signal.alarm(time)
404
+ try:
405
+ func(*args, **kwargs)
406
+ finally:
407
+ signal.alarm(0)
408
+
409
+ if os.name == "nt":
410
+ # We use a thread here since NT systems don't have POSIX signals.
411
+ threading = importlib.import_module("threading")
412
+
413
+ func_thread = threading.Thread(target=func, args=args, kwargs=kwargs)
414
+ func_thread.daemon = True # quit interpreter even if still running
415
+ func_thread.start()
416
+ func_thread.join(time)
417
+ if func_thread.is_alive():
418
+ raise Timeout("{0}s".format(time))
419
+
420
+
421
+ def _flock_exclusive(fileobj: io.TextIOWrapper) -> None:
422
+ """Acquire exclusive lock for open file `fileobj`."""
423
+
424
+ if os.name == "posix":
425
+ fcntl = importlib.import_module("fcntl")
426
+ fcntl.flock(fileobj, fcntl.LOCK_EX)
427
+
428
+ if os.name == "nt":
429
+ msvcrt = importlib.import_module("msvcrt")
430
+ msvcrt.locking(fileobj.fileno(), msvcrt.LK_LOCK, 2147483647)
431
+
432
+
433
+ # cookie.py
434
+
435
+ """Persistent dict to remember state between invocations.
436
+ """
437
+
438
+
439
+ class Cookie(UserDict[str, typing.Any]):
440
+ """Creates a persistent dict to keep state.
441
+
442
+ Cookies are used to remember file positions, counters and the like
443
+ between plugin invocations. It is not intended for substantial amounts
444
+ of data. Cookies are serialized into JSON and saved to a state file. We
445
+ prefer a plain text format to allow administrators to inspect and edit
446
+ its content. See :class:`~mplugin.logtail.LogTail` for an
447
+ application of cookies to get only new lines of a continuously growing
448
+ file.
449
+
450
+ Cookies are locked exclusively so that at most one process at a time has
451
+ access to it. Changes to the dict are not reflected in the file until
452
+ :meth:`Cookie.commit` is called. It is recommended to use Cookie as
453
+ context manager to get it opened and committed automatically.
454
+
455
+ After creation, a cookie behaves like a normal dict.
456
+
457
+ :param statefile: file name to save the dict's contents
458
+
459
+ .. note:: If `statefile` is empty or None, the Cookie will be
460
+ oblivous, i.e., it will forget its contents on garbage
461
+ collection. This makes it possible to explicitely throw away
462
+ state between plugin runs (for example by a command line
463
+ argument).
464
+ """
465
+
466
+ path: typing.Optional[str]
467
+
468
+ fobj: typing.Optional[io.TextIOWrapper]
469
+
470
+ def __init__(self, statefile: typing.Optional[str] = None) -> None:
471
+
472
+ super(Cookie, self).__init__()
473
+ self.path = statefile
474
+ self.fobj = None
475
+
476
+ def __enter__(self) -> typing_extensions.Self:
477
+ """Allows Cookie to be used as context manager.
478
+
479
+ Opens the file and passes a dict-like object into the
480
+ subordinate context. See :meth:`open` for details about opening
481
+ semantics. When the context is left in the regular way (no
482
+ exception raised), the cookie is committed to disk.
483
+
484
+ :yields: open cookie
485
+ """
486
+ self.open()
487
+ return self
488
+
489
+ def __exit__(
490
+ self,
491
+ exc_type: typing.Optional[type[BaseException]],
492
+ exc_value: typing.Optional[BaseException],
493
+ traceback: typing.Optional[TracebackType],
494
+ ) -> None:
495
+ if not exc_type:
496
+ self.commit()
497
+ self.close()
498
+
499
+ def open(self) -> typing_extensions.Self:
500
+ """Reads/creates the state file and initializes the dict.
501
+
502
+ If the state file does not exist, it is touched into existence.
503
+ An exclusive lock is acquired to ensure serialized access. If
504
+ :meth:`open` fails to parse file contents, it truncates
505
+ the file before raising an exception. This guarantees that
506
+ plugins will not fail repeatedly when their state files get
507
+ damaged.
508
+
509
+ :returns: Cookie object (self)
510
+ :raises ValueError: if the state file is corrupted or does not
511
+ deserialize into a dict
512
+ """
513
+ self.fobj = self._create_fobj()
514
+ _flock_exclusive(self.fobj)
515
+ if os.fstat(self.fobj.fileno()).st_size:
516
+ try:
517
+ self.data = self._load()
518
+ except ValueError:
519
+ self.fobj.truncate(0)
520
+ raise
521
+ return self
522
+
523
+ def _create_fobj(self) -> io.TextIOWrapper:
524
+ if not self.path:
525
+ return TemporaryFile(
526
+ "w+", encoding="ascii", prefix="oblivious_cookie_", dir=None
527
+ )
528
+ # mode='a+' has problems with mixed R/W operation on Mac OS X
529
+ try:
530
+ return open(self.path, "r+", encoding="ascii")
531
+ except IOError:
532
+ return open(self.path, "w+", encoding="ascii")
533
+
534
+ def _load(self) -> dict[str, typing.Any]:
535
+ if not self.fobj:
536
+ raise RuntimeError("file object is none")
537
+ self.fobj.seek(0)
538
+ data = json.load(self.fobj)
539
+ if not isinstance(data, dict):
540
+ raise ValueError(
541
+ "format error: cookie does not contain dict", self.path, data
542
+ )
543
+ return typing.cast(dict[str, typing.Any], data)
544
+
545
+ def close(self) -> None:
546
+ """Closes a cookie and its underlying state file.
547
+
548
+ This method has no effect if the cookie is already closed.
549
+ Once the cookie is closed, any operation (like :meth:`commit`)
550
+ will raise an exception.
551
+ """
552
+ if not self.fobj:
553
+ return
554
+ self.fobj.close()
555
+ self.fobj = None
556
+
557
+ def commit(self) -> None:
558
+ """Persists the cookie's dict items in the state file.
559
+
560
+ The cookies content is serialized as JSON string and saved to
561
+ the state file. The buffers are flushed to ensure that the new
562
+ content is saved in a durable way.
563
+ """
564
+ if not self.fobj:
565
+ raise IOError("cannot commit closed cookie", self.path)
566
+ self.fobj.seek(0)
567
+ self.fobj.truncate()
568
+ json.dump(self.data, self.fobj)
569
+ self.fobj.write("\n")
570
+ self.fobj.flush()
571
+ os.fsync(self.fobj)
572
+
573
+
574
+ # logtail.py
575
+
576
+
577
+ class LogTail:
578
+ """Access previously unseen parts of a growing file.
579
+
580
+ LogTail builds on :class:`~.cookie.Cookie` to access new lines of a
581
+ continuosly growing log file. It should be used as context manager that
582
+ provides an iterator over new lines to the subordinate context. LogTail
583
+ saves the last file position into the provided cookie object.
584
+ As the path to the log file is saved in the cookie, several LogTail
585
+ instances may share the same cookie.
586
+ """
587
+
588
+ path: str
589
+ cookie: "Cookie"
590
+ logfile: typing.Optional[io.BufferedIOBase] = None
591
+ stat: typing.Optional[os.stat_result]
592
+
593
+ def __init__(self, path: str, cookie: "Cookie") -> None:
594
+ """Creates new LogTail context.
595
+
596
+ :param path: path to the log file that is to be observed
597
+ :param cookie: :class:`~.cookie.Cookie` object to save the last
598
+ file position
599
+ """
600
+ self.path = os.path.abspath(path)
601
+ self.cookie = cookie
602
+ self.logfile = None
603
+ self.stat = None
604
+
605
+ def _seek_if_applicable(self, fileinfo: dict[str, typing.Any]) -> None:
606
+ self.stat = os.stat(self.path)
607
+ if (
608
+ self.stat.st_ino == fileinfo.get("inode", -1)
609
+ and self.stat.st_size >= fileinfo.get("pos", 0)
610
+ and self.logfile is not None
611
+ ):
612
+ self.logfile.seek(fileinfo["pos"])
613
+
614
+ def __enter__(self) -> typing.Generator[bytes, typing.Any, None]:
615
+ """Seeks to the last seen position and reads new lines.
616
+
617
+ The last file position is read from the cookie. If the log file
618
+ has not been changed since the last invocation, LogTail seeks to
619
+ that position and reads new lines. Otherwise, the position saved
620
+ in the cookie is reset and LogTail reads from the beginning.
621
+ After leaving the subordinate context, the new position is saved
622
+ in the cookie and the cookie is closed.
623
+
624
+ :yields: new lines as bytes strings
625
+ """
626
+ self.logfile = open(self.path, "rb")
627
+ self.cookie.open()
628
+ self._seek_if_applicable(self.cookie.get(self.path, {}))
629
+ line = self.logfile.readline()
630
+ while len(line):
631
+ yield line
632
+ line = self.logfile.readline()
633
+
634
+ def __exit__(
635
+ self,
636
+ exc_type: typing.Optional[type[BaseException]],
637
+ exc_value: typing.Optional[BaseException],
638
+ traceback: typing.Optional[TracebackType],
639
+ ) -> None:
640
+ if not exc_type and self.stat is not None and self.logfile is not None:
641
+ self.cookie[self.path] = dict(
642
+ inode=self.stat.st_ino, pos=self.logfile.tell()
643
+ )
644
+ self.cookie.commit()
645
+ self.cookie.close()
646
+ if self.logfile is not None:
647
+ self.logfile.close()
648
+
649
+
650
+ # output.py
651
+
652
+
653
+ def _filter_output(output: str, filtered: str) -> str:
654
+ """Filters out characters from output"""
655
+ for char in filtered:
656
+ output = output.replace(char, "")
657
+ return output
658
+
659
+
660
+ class _Output:
661
+ ILLEGAL = "|"
662
+
663
+ logchan: StreamHandler[io.StringIO]
664
+ verbose: int
665
+ status: str
666
+ out: list[str]
667
+ warnings: list[str]
668
+ longperfdata: list[str]
669
+
670
+ def __init__(self, logchan: StreamHandler[io.StringIO], verbose: int = 0) -> None:
671
+ self.logchan = logchan
672
+ self.verbose = verbose
673
+ self.status = ""
674
+ self.out = []
675
+ self.warnings = []
676
+ self.longperfdata = []
677
+
678
+ def add(self, check: "Check") -> None:
679
+ self.status = self.format_status(check)
680
+ if self.verbose == 0:
681
+ perfdata = self.format_perfdata(check)
682
+ if perfdata:
683
+ self.status += " " + perfdata
684
+ else:
685
+ self.add_longoutput(check.verbose_str)
686
+ self.longperfdata.append(self.format_perfdata(check, 79))
687
+
688
+ def format_status(self, check: "Check"):
689
+ if check.name:
690
+ name_prefix = check.name.upper() + " "
691
+ else:
692
+ name_prefix = ""
693
+ summary_str = check.summary_str.strip()
694
+ return self._screen_chars(
695
+ "{0}{1}{2}".format(
696
+ name_prefix,
697
+ str(check.state).upper(),
698
+ " - " + summary_str if summary_str else "",
699
+ ),
700
+ "status line",
701
+ )
702
+
703
+ # Needs refactoring, but won't remove now because it's probably API-breaking
704
+ # pylint: disable-next=unused-argument
705
+ def format_perfdata(self, check: "Check", linebreak: typing.Any = None) -> str:
706
+ if not check.perfdata:
707
+ return ""
708
+ out = " ".join(check.perfdata)
709
+ return "| " + self._screen_chars(out, "perfdata")
710
+
711
+ def add_longoutput(self, text: str | list[str] | tuple[str, ...]) -> None:
712
+ if isinstance(text, (list, tuple)):
713
+ for line in text:
714
+ self.add_longoutput(line)
715
+ else:
716
+ self.out.append(self._screen_chars(text, "long output"))
717
+
718
+ def __str__(self):
719
+ output = [
720
+ elem
721
+ for elem in [self.status]
722
+ + self.out
723
+ + [self._screen_chars(self.logchan.stream.getvalue(), "logging output")]
724
+ + self.warnings
725
+ + self.longperfdata
726
+ if elem
727
+ ]
728
+ return "\n".join(output) + "\n"
729
+
730
+ def _screen_chars(self, text: str, where: str) -> str:
731
+ text = text.rstrip("\n")
732
+ screened = _filter_output(text, self.ILLEGAL)
733
+ if screened != text:
734
+ self.warnings.append(
735
+ self._illegal_chars_warning(where, set(text) - set(screened))
736
+ )
737
+ return screened
738
+
739
+ @staticmethod
740
+ def _illegal_chars_warning(where: str, removed_chars: set[str]) -> str:
741
+ hex_chars = ", ".join("0x{0:x}".format(ord(c)) for c in removed_chars)
742
+ return "warning: removed illegal characters ({0}) from {1}".format(
743
+ hex_chars, where
744
+ )
745
+
746
+
747
+ # performance.py
748
+
749
+
750
+ def _quote(label: str) -> str:
751
+ if re.match(r"^\w+$", label):
752
+ return label
753
+ return f"'{label}'"
754
+
755
+
756
+ class Performance:
757
+ """
758
+ Performance data (perfdata) representation.
759
+
760
+ :term:`Performance data` are created during metric evaluation in a context
761
+ and are written into the *perfdata* section of the plugin's output.
762
+ :class:`Performance` allows the creation of value objects that are passed
763
+ between other mplugin objects.
764
+
765
+ For sake of consistency, performance data should represent their values in
766
+ their respective base unit, so ``Performance('size', 10000, 'B')`` is better
767
+ than ``Performance('size', 10, 'kB')``.
768
+
769
+ See the
770
+ `Monitoring plugin guidelines <https://github.com/monitoring-plugins/monitoring-plugin-guidelines/blob/main/monitoring_plugins_interface/03.Output.md#performance-data>`__
771
+ for details.
772
+ """
773
+
774
+ label: str
775
+ """short identifier, results in graph titles for example (20 chars or less recommended)"""
776
+
777
+ value: typing.Any
778
+ """measured value (usually an int, float, or bool)"""
779
+
780
+ uom: typing.Optional[str]
781
+ """unit of measure -- use base units whereever possible"""
782
+
783
+ warn: typing.Optional["RangeSpec"]
784
+ """warning range"""
785
+
786
+ crit: typing.Optional["RangeSpec"]
787
+ """critical range"""
788
+
789
+ min: typing.Optional[float]
790
+ """known value minimum (None for no minimum)"""
791
+
792
+ max: typing.Optional[float]
793
+ """known value maximum (None for no maximum)"""
794
+
795
+ # Changing these now would be API-breaking, so we'll ignore these
796
+ # shadowed built-ins and the long list of arguments
797
+ # pylint: disable-next=redefined-builtin,too-many-arguments
798
+ def __init__(
799
+ self,
800
+ label: str,
801
+ value: typing.Any,
802
+ uom: typing.Optional[str] = None,
803
+ warn: typing.Optional["RangeSpec"] = None,
804
+ crit: typing.Optional["RangeSpec"] = None,
805
+ min: typing.Optional[float] = None,
806
+ max: typing.Optional[float] = None,
807
+ ) -> None:
808
+ """Create new performance data object.
809
+
810
+ :param label: short identifier, results in graph
811
+ titles for example (20 chars or less recommended)
812
+ :param value: measured value (usually an int, float, or bool)
813
+ :param uom: unit of measure -- use base units whereever possible
814
+ :param warn: warning range
815
+ :param crit: critical range
816
+ :param min: known value minimum (None for no minimum)
817
+ :param max: known value maximum (None for no maximum)
818
+ """
819
+ if "'" in label or "=" in label:
820
+ raise RuntimeError("label contains illegal characters", label)
821
+ self.label = label
822
+ self.value = value
823
+ self.uom = uom
824
+ self.warn = warn
825
+ self.crit = crit
826
+ self.min = min
827
+ self.max = max
828
+
829
+ def __str__(self) -> str:
830
+ """String representation conforming to the plugin API.
831
+
832
+ Labels containing spaces or special characters will be quoted.
833
+ """
834
+
835
+ performance: str = f"{_quote(self.label)}={self.value}"
836
+
837
+ if self.uom is not None:
838
+ performance += self.uom
839
+
840
+ out: list[str] = [performance]
841
+
842
+ if self.warn is not None and self.warn != "" and self.warn != Range(""):
843
+ out.append(str(self.warn))
844
+
845
+ if self.crit is not None and self.crit != "" and self.crit != Range(""):
846
+ out.append(str(self.crit))
847
+
848
+ if self.min is not None:
849
+ out.append(str(self.min))
850
+
851
+ if self.max is not None:
852
+ out.append(str(self.max))
853
+
854
+ return ";".join(out)
855
+
856
+
857
+ # runtime.py
858
+
859
+ """Functions and classes to interface with the system.
860
+
861
+ This module contains the :class:`Runtime` class that handles exceptions,
862
+ timeouts and logging. Plugin authors should not use Runtime directly,
863
+ but decorate the plugin's main function with :func:`~.runtime.guarded`.
864
+ """
865
+
866
+ P = typing.ParamSpec("P")
867
+ R = typing.TypeVar("R")
868
+
869
+
870
+ def guarded(
871
+ original_function: typing.Any = None, verbose: typing.Any = None
872
+ ) -> typing.Any:
873
+ """Runs a function mplugin's Runtime environment.
874
+
875
+ `guarded` makes the decorated function behave correctly with respect
876
+ to the monitoring plugin API if it aborts with an uncaught exception or
877
+ a timeout. It exits with an *unknown* exit code and prints a
878
+ traceback in a format acceptable by monitoring solution.
879
+
880
+ This function should be used as a decorator for the script's `main`
881
+ function.
882
+
883
+ :param verbose: Optional keyword parameter to control verbosity
884
+ level during early execution (before
885
+ :meth:`~mplugin.Check.main` has been called). For example,
886
+ use `@guarded(verbose=0)` to turn tracebacks in that phase off.
887
+ """
888
+
889
+ def _decorate(func: typing.Callable[P, R]):
890
+ @functools.wraps(func)
891
+ # This inconsistent-return-statements error can be fixed by adding a
892
+ # typing.NoReturn type hint to Runtime._handle_exception(), but we can't do
893
+ # that as long as we're maintaining py27 compatability.
894
+ # pylint: disable-next=inconsistent-return-statements
895
+ def wrapper(*args: typing.Any, **kwds: typing.Any):
896
+ runtime = _Runtime()
897
+ if verbose is not None:
898
+ runtime.verbose = verbose
899
+ try:
900
+ return func(*args, **kwds)
901
+ except Timeout as exc:
902
+ runtime._handle_exception( # type: ignore
903
+ "Timeout: check execution aborted after {0}".format(exc)
904
+ )
905
+ except Exception:
906
+ runtime._handle_exception() # type: ignore
907
+
908
+ return wrapper
909
+
910
+ if original_function is not None:
911
+ assert callable(original_function), (
912
+ 'Function {!r} not callable. Forgot to add "verbose=" keyword?'.format(
913
+ original_function
914
+ )
915
+ )
916
+ return _decorate(original_function)
917
+ return _decorate # type: ignore
918
+
919
+
920
+ class _AnsiColorFormatter(logging.Formatter):
921
+ """https://medium.com/@kamilmatejuk/inside-python-colorful-logging-ad3a74442cc6"""
922
+
923
+ def format(self, record: logging.LogRecord) -> str:
924
+ no_style = "\033[0m"
925
+ bold = "\033[91m"
926
+ grey = "\033[90m"
927
+ yellow = "\033[93m"
928
+ red = "\033[31m"
929
+ red_light = "\033[91m"
930
+ blue = "\033[34m"
931
+ start_style = {
932
+ "DEBUG": grey,
933
+ "INFO": blue,
934
+ "WARNING": yellow,
935
+ "ERROR": red,
936
+ "CRITICAL": red_light + bold,
937
+ }.get(record.levelname, no_style)
938
+ end_style = no_style
939
+ return f"{start_style}{super().format(record)}{end_style}"
940
+
941
+
942
+ class _Runtime:
943
+ instance: typing.Optional[typing_extensions.Self] = None # type: ignore
944
+ check: typing.Optional["Check"] = None
945
+ _verbose = 1
946
+ _colorize: bool = False
947
+ """Use ANSI colors to colorize the logging output"""
948
+ timeout: typing.Optional[int] = None
949
+ logchan: logging.StreamHandler[io.StringIO]
950
+ output: _Output
951
+ stdout: typing.Optional[io.StringIO] = None
952
+ exitcode: int = 70 # EX_SOFTWARE
953
+
954
+ def __new__(cls) -> typing_extensions.Self:
955
+ if not cls.instance:
956
+ cls.instance: typing_extensions.Self = super(_Runtime, cls).__new__(cls)
957
+ return cls.instance
958
+
959
+ def __init__(self) -> None:
960
+ rootlogger = logging.getLogger("mplugin")
961
+ rootlogger.setLevel(logging.DEBUG)
962
+ self.logchan = logging.StreamHandler(io.StringIO())
963
+ self.logchan.setFormatter(logging.Formatter("%(message)s"))
964
+ rootlogger.addHandler(self.logchan)
965
+ self.output = _Output(self.logchan)
966
+
967
+ def _handle_exception(
968
+ self, statusline: typing.Optional[str] = None
969
+ ) -> typing.NoReturn:
970
+ exc_type, value = sys.exc_info()[0:2]
971
+ name = self.check.name.upper() + " " if self.check else ""
972
+ self.output.status = "{0}UNKNOWN: {1}".format(
973
+ name,
974
+ statusline or traceback.format_exception_only(exc_type, value)[0].strip(),
975
+ )
976
+ if self.verbose > 0:
977
+ self.output.add_longoutput(traceback.format_exc())
978
+ print("{0}".format(self.output), end="", file=self.stdout)
979
+ self.exitcode = 3
980
+ self.sysexit()
981
+
982
+ @property
983
+ def verbose(self) -> int:
984
+ return self._verbose
985
+
986
+ @verbose.setter
987
+ def verbose(self, verbose: typing.Any) -> None:
988
+ if isinstance(verbose, int):
989
+ self._verbose = verbose
990
+ elif isinstance(verbose, float):
991
+ self._verbose = int(verbose)
992
+ else:
993
+ self._verbose = len(verbose or [])
994
+ if self._verbose >= 3:
995
+ self.logchan.setLevel(logging.DEBUG)
996
+ self._verbose = 3
997
+ elif self._verbose == 2:
998
+ self.logchan.setLevel(logging.INFO)
999
+ else:
1000
+ self.logchan.setLevel(logging.WARNING)
1001
+ self.output.verbose = self._verbose
1002
+
1003
+ @property
1004
+ def colorize(self) -> int:
1005
+ return self._colorize
1006
+
1007
+ @colorize.setter
1008
+ def colorize(self, colorize: bool) -> None:
1009
+ self._colorize = colorize
1010
+ if colorize:
1011
+ self.logchan.setFormatter(_AnsiColorFormatter("%(message)s"))
1012
+ else:
1013
+ self.logchan.setFormatter(logging.Formatter("%(message)s"))
1014
+
1015
+ def run(self, check: "Check") -> None:
1016
+ check()
1017
+ self.output.add(check)
1018
+ self.exitcode = check.exitcode
1019
+
1020
+ def execute(
1021
+ self,
1022
+ check: "Check",
1023
+ verbose: typing.Any = None,
1024
+ timeout: typing.Any = None,
1025
+ colorize: bool = False,
1026
+ ) -> typing.NoReturn:
1027
+ self.check = check
1028
+ if verbose is not None:
1029
+ self.verbose = verbose
1030
+ if timeout is not None:
1031
+ self.timeout = int(timeout)
1032
+ if colorize:
1033
+ self.colorize = True
1034
+ if self.timeout:
1035
+ _with_timeout(self.timeout, self.run, check)
1036
+ else:
1037
+ self.run(check)
1038
+ print("{0}".format(self.output), end="", file=self.stdout)
1039
+ self.sysexit()
1040
+
1041
+ def sysexit(self) -> typing.NoReturn:
1042
+ sys.exit(self.exitcode)
1043
+
1044
+
1045
+ # metric.py
1046
+
1047
+ """Structured representation for data points.
1048
+ """
1049
+
1050
+
1051
+ class _MetricKwargs(typing.TypedDict, total=False):
1052
+ name: str
1053
+ value: typing.Any
1054
+ uom: str
1055
+ min: float
1056
+ max: float
1057
+ context: str
1058
+ contextobj: "Context"
1059
+ resource: "Resource"
1060
+
1061
+
1062
+ class Metric:
1063
+ """Single measured value.
1064
+
1065
+ This module contains the :class:`Metric` class whose instances are
1066
+ passed as value objects between most of mplugin's core classes.
1067
+ Typically, :class:`~.resource.Resource` objects emit a list of metrics
1068
+ as result of their :meth:`~.resource.Resource.probe` methods.
1069
+
1070
+ The value should be expressed in terms of base units, so
1071
+ Metric('swap', 10240, 'B') is better than Metric('swap', 10, 'kiB').
1072
+ """
1073
+
1074
+ name: str
1075
+ value: typing.Any
1076
+ uom: typing.Optional[str] = None
1077
+ min: typing.Optional[float] = None
1078
+ max: typing.Optional[float] = None
1079
+ context: str
1080
+ contextobj: typing.Optional["Context"] = None
1081
+ resource: typing.Optional["Resource"] = None
1082
+
1083
+ # Changing these now would be API-breaking, so we'll ignore these
1084
+ # shadowed built-ins
1085
+ # pylint: disable-next=redefined-builtin
1086
+ def __init__(
1087
+ self,
1088
+ name: str,
1089
+ value: typing.Any,
1090
+ uom: typing.Optional[str] = None,
1091
+ min: typing.Optional[float] = None,
1092
+ max: typing.Optional[float] = None,
1093
+ context: typing.Optional[str] = None,
1094
+ contextobj: typing.Optional["Context"] = None,
1095
+ resource: typing.Optional["Resource"] = None,
1096
+ ) -> None:
1097
+ """Creates new Metric instance.
1098
+
1099
+ :param name: short internal identifier for the value -- appears
1100
+ also in the performance data
1101
+ :param value: data point, usually has a boolen or numeric type,
1102
+ but other types are also possible
1103
+ :param uom: :term:`unit of measure`, preferrably as ISO
1104
+ abbreviation like "s"
1105
+ :param min: minimum value or None if there is no known minimum
1106
+ :param max: maximum value or None if there is no known maximum
1107
+ :param context: name of the associated context (defaults to the
1108
+ metric's name if left out)
1109
+ :param contextobj: reference to the associated context object
1110
+ (set automatically by :class:`~mplugin.check.Check`)
1111
+ :param resource: reference to the originating
1112
+ :class:`~mplugin.Resource` (set automatically
1113
+ by :class:`~mplugin.check.Check`)
1114
+ """
1115
+ self.name = name
1116
+ self.value = value
1117
+ self.uom = uom
1118
+ self.min = min
1119
+ self.max = max
1120
+ if context is not None:
1121
+ self.context = context
1122
+ else:
1123
+ self.context = name
1124
+ self.contextobj = contextobj
1125
+ self.resource = resource
1126
+
1127
+ def __str__(self) -> str:
1128
+ """Same as :attr:`valueunit`."""
1129
+ return self.valueunit
1130
+
1131
+ def replace(
1132
+ self, **attr: typing_extensions.Unpack[_MetricKwargs]
1133
+ ) -> typing_extensions.Self:
1134
+ """Creates new instance with updated attributes."""
1135
+ for key, value in attr.items():
1136
+ setattr(self, key, value)
1137
+ return self
1138
+
1139
+ @property
1140
+ def description(self) -> typing.Optional[str]:
1141
+ """Human-readable, detailed string representation.
1142
+
1143
+ Delegates to the :class:`~.context.Context` to format the value.
1144
+
1145
+ :returns: :meth:`~.context.Context.describe` output or
1146
+ :attr:`valueunit` if no context has been associated yet
1147
+ """
1148
+ if self.contextobj:
1149
+ return self.contextobj.describe(self)
1150
+ return str(self)
1151
+
1152
+ @property
1153
+ def valueunit(self) -> str:
1154
+ """Compact string representation.
1155
+
1156
+ This is just the value and the unit. If the value is a real
1157
+ number, express the value with a limited number of digits to
1158
+ improve readability.
1159
+ """
1160
+ return "%s%s" % (self._human_readable_value, self.uom or "")
1161
+
1162
+ @property
1163
+ def _human_readable_value(self) -> str:
1164
+ """Limit number of digits for floats."""
1165
+ if isinstance(self.value, numbers.Real) and not isinstance(
1166
+ self.value, numbers.Integral
1167
+ ):
1168
+ return "%.4g" % self.value
1169
+ return str(self.value)
1170
+
1171
+ def evaluate(self) -> typing.Union["Result", "ServiceState"]:
1172
+ """Evaluates this instance according to the context.
1173
+
1174
+ :return: :class:`~mplugin.Result` object
1175
+ :raise RuntimeError: if no context has been associated yet
1176
+ """
1177
+ if not self.contextobj:
1178
+ raise RuntimeError("no context set for metric", self.name)
1179
+ if not self.resource:
1180
+ raise RuntimeError("no resource set for metric", self.name)
1181
+ return self.contextobj.evaluate(self, self.resource)
1182
+
1183
+ def performance(self) -> typing.Optional[Performance]:
1184
+ """Generates performance data according to the context.
1185
+
1186
+ :return: :class:`~mplugin.performance.Performance` object
1187
+ :raise RuntimeError: if no context has been associated yet
1188
+ """
1189
+ if not self.contextobj:
1190
+ raise RuntimeError("no context set for metric", self.name)
1191
+ if not self.resource:
1192
+ raise RuntimeError("no resource set for metric", self.name)
1193
+ return self.contextobj.performance(self, self.resource)
1194
+
1195
+
1196
+ # resource.py
1197
+
1198
+ """Domain model for data :term:`acquisition`.
1199
+ """
1200
+
1201
+
1202
+ class Resource:
1203
+ """Abstract base class for custom domain models.
1204
+
1205
+ :class:`Resource` is the base class for the plugin's :term:`domain
1206
+ model`. It shoul model the relevant details of reality that a plugin is
1207
+ supposed to check. The :class:`~.check.Check` controller calls
1208
+ :meth:`Resource.probe` on all passed resource objects to acquire data.
1209
+
1210
+ Plugin authors should subclass :class:`Resource` and write
1211
+ whatever methods are needed to get the interesting bits of information.
1212
+ The most important resource subclass should be named after the plugin
1213
+ itself.
1214
+
1215
+ Subclasses may add arguments to the constructor to parametrize
1216
+ information retrieval.
1217
+ """
1218
+
1219
+ @property
1220
+ def name(self) -> str:
1221
+ return self.__class__.__name__
1222
+
1223
+ # This could be corrected by re-implementing this class as a proper ABC.
1224
+ # See issue #42
1225
+ # pylint: disable=no-self-use
1226
+ def probe(
1227
+ self,
1228
+ ) -> typing.Union[list["Metric"], "Metric", typing.Generator["Metric", None, None]]:
1229
+ """Query system state and return metrics.
1230
+
1231
+ This is the only method called by the check controller.
1232
+ It should trigger all necessary actions and create metrics.
1233
+
1234
+ A plugin can perform several measurements at once.
1235
+
1236
+ .. code-block:: Python
1237
+
1238
+ def probe(self):
1239
+ self.users = self.list_users()
1240
+ self.unique_users = set(self.users)
1241
+ return [
1242
+ Metric("total", len(self.users), min=0, context="users"),
1243
+ Metric("unique", len(self.unique_users), min=0, context="users"),
1244
+ ]
1245
+
1246
+ Alternatively, the probe() method can act as generator and yield metrics:
1247
+
1248
+ .. code-block:: Python
1249
+
1250
+ def probe(self):
1251
+ self.users = self.list_users()
1252
+ self.unique_users = set(self.users)
1253
+ yield Metric('total', len(self.users), min=0,
1254
+ context='users')
1255
+ yield Metric('unique', len(self.unique_users), min=0,
1256
+ context='users')]
1257
+
1258
+ :return: list of :class:`~mplugin.Metric` objects,
1259
+ or generator that emits :class:`~mplugin.Metric`
1260
+ objects, or single :class:`~mplugin.Metric`
1261
+ object
1262
+ """
1263
+ return []
1264
+
1265
+
1266
+ # result.py
1267
+
1268
+ """Outcomes from evaluating metrics in contexts.
1269
+
1270
+ The :class:`Result` class is the base class for all evaluation results.
1271
+ The :class:`Results` class (plural form) provides a result container with
1272
+ access functions and iterators.
1273
+
1274
+ Plugin authors may create their own :class:`Result` subclass to
1275
+ accomodate for special needs.
1276
+ """
1277
+
1278
+
1279
+ class Result:
1280
+ """Evaluation outcome consisting of state and explanation.
1281
+
1282
+ A Result object is typically emitted by a
1283
+ :class:`~mplugin.Context` object and represents the
1284
+ outcome of an evaluation. It contains a
1285
+ :class:`~mplugin.state.ServiceState` as well as an explanation.
1286
+ Plugin authors may subclass Result to implement specific features.
1287
+ """
1288
+
1289
+ state: "ServiceState"
1290
+
1291
+ hint: typing.Optional[str]
1292
+
1293
+ metric: typing.Optional["Metric"]
1294
+
1295
+ def __init__(
1296
+ self,
1297
+ state: "ServiceState",
1298
+ hint: typing.Optional[str] = None,
1299
+ metric: typing.Optional["Metric"] = None,
1300
+ ) -> None:
1301
+ self.state = state
1302
+ self.hint = hint
1303
+ self.metric = metric
1304
+
1305
+ def __str__(self) -> str:
1306
+ """Textual result explanation.
1307
+
1308
+ The result explanation is taken from :attr:`metric.description`
1309
+ (if a metric has been passed to the constructur), followed
1310
+ optionally by the value of :attr:`hint`. This method's output
1311
+ should consist only of a text for the reason but not for the
1312
+ result's state. The latter is rendered independently.
1313
+
1314
+ :returns: result explanation or empty string
1315
+ """
1316
+ if self.metric and self.metric.description:
1317
+ desc = self.metric.description
1318
+ else:
1319
+ desc = None
1320
+
1321
+ if self.hint and desc:
1322
+ return "{0} ({1})".format(desc, self.hint)
1323
+ if self.hint:
1324
+ return self.hint
1325
+ if desc:
1326
+ return desc
1327
+ return ""
1328
+
1329
+ @property
1330
+ def resource(self) -> typing.Optional["Resource"]:
1331
+ """Reference to the resource used to generate this result."""
1332
+ if not self.metric:
1333
+ return None
1334
+ return self.metric.resource
1335
+
1336
+ @property
1337
+ def context(self) -> typing.Optional["Context"]:
1338
+ """Reference to the metric used to generate this result."""
1339
+ if not self.metric:
1340
+ return None
1341
+ return self.metric.contextobj
1342
+
1343
+ def __eq__(self, value: object) -> bool:
1344
+ if not isinstance(value, Result):
1345
+ return False
1346
+ return (
1347
+ self.state == value.state
1348
+ and self.hint == value.hint
1349
+ and self.metric == value.metric
1350
+ )
1351
+
1352
+
1353
+ class Results:
1354
+ """Container for result sets.
1355
+
1356
+ Basically, this class manages a set of results and provides
1357
+ convenient access methods by index, name, or result state. It is
1358
+ meant to make queries in :class:`~.summary.Summary`
1359
+ implementations compact and readable.
1360
+
1361
+ The constructor accepts an arbitrary number of result objects and
1362
+ adds them to the container.
1363
+ """
1364
+
1365
+ results: list[Result]
1366
+ by_state: dict["ServiceState", list[Result]]
1367
+ by_name: dict[str, Result]
1368
+
1369
+ def __init__(self, *results: Result) -> None:
1370
+ self.results = []
1371
+ self.by_state = collections.defaultdict(list)
1372
+ self.by_name = {}
1373
+ if results:
1374
+ self.add(*results)
1375
+
1376
+ def add(self, *results: Result) -> typing_extensions.Self:
1377
+ """Adds more results to the container.
1378
+
1379
+ Besides passing :class:`Result` objects in the constructor,
1380
+ additional results may be added after creating the container.
1381
+
1382
+ :raises ValueError: if `result` is not a :class:`Result` object
1383
+ """
1384
+ for result in results:
1385
+ if not isinstance(result, Result): # type: ignore
1386
+ raise ValueError(
1387
+ "trying to add non-Result to Results container", result
1388
+ )
1389
+ self.results.append(result)
1390
+ self.by_state[result.state].append(result)
1391
+ try:
1392
+ self.by_name[result.metric.name] = result # type: ignore
1393
+ except AttributeError:
1394
+ pass
1395
+ return self
1396
+
1397
+ def __iter__(self) -> typing.Generator[Result, typing.Any, None]:
1398
+ """Iterates over all results.
1399
+
1400
+ The iterator is sorted in order of decreasing state
1401
+ significance (unknown > critical > warning > ok).
1402
+
1403
+ :returns: result object iterator
1404
+ """
1405
+ for state in reversed(sorted(self.by_state)):
1406
+ for result in self.by_state[state]:
1407
+ yield result
1408
+
1409
+ def __len__(self) -> int:
1410
+ """Number of results in this container."""
1411
+ return len(self.results)
1412
+
1413
+ def __getitem__(self, item: typing.Union[int, str]) -> Result:
1414
+ """Access result by index or name.
1415
+
1416
+ If *item* is an integer, the itemth element in the
1417
+ container is returned. If *item* is a string, it is used to
1418
+ look up a result with the given name.
1419
+
1420
+ :returns: :class:`Result` object
1421
+ :raises KeyError: if no matching result is found
1422
+ """
1423
+ if isinstance(item, int):
1424
+ return self.results[item]
1425
+ return self.by_name[item]
1426
+
1427
+ def __contains__(self, name: str) -> bool:
1428
+ """Tests if a result with given name is present.
1429
+
1430
+ :returns: boolean
1431
+ """
1432
+ return name in self.by_name
1433
+
1434
+ @property
1435
+ def most_significant_state(self) -> "ServiceState":
1436
+ """The "worst" state found in all results.
1437
+
1438
+ :returns: :obj:`~mplugin.state.ServiceState` object
1439
+ :raises ValueError: if no results are present
1440
+ """
1441
+ return max(self.by_state.keys())
1442
+
1443
+ @property
1444
+ def most_significant(self) -> list[Result]:
1445
+ """Returns list of results with most significant state.
1446
+
1447
+ From all results present, a subset with the "worst" state is
1448
+ selected.
1449
+
1450
+ :returns: list of :class:`Result` objects or empty list if no
1451
+ results are present
1452
+ """
1453
+ try:
1454
+ return self.by_state[self.most_significant_state]
1455
+ except ValueError:
1456
+ return []
1457
+
1458
+ @property
1459
+ def first_significant(self) -> Result:
1460
+ """Selects one of the results with most significant state.
1461
+
1462
+ :returns: :class:`Result` object
1463
+ :raises IndexError: if no results are present
1464
+ """
1465
+ return self.most_significant[0]
1466
+
1467
+
1468
+ # summary.py
1469
+
1470
+ """Create status line from results.
1471
+
1472
+ This module contains the :class:`Summary` class which serves as base
1473
+ class to get a status line from the check's :class:`~.result.Results`. A
1474
+ Summary object is used by :class:`~.check.Check` to obtain a suitable data
1475
+ :term:`presentation` depending on the check's overall state.
1476
+
1477
+ Plugin authors may either stick to the default implementation or subclass it
1478
+ to adapt it to the check's domain. The status line is probably the most
1479
+ important piece of text returned from a check: It must lead directly to the
1480
+ problem in the most concise way. So while the default implementation is quite
1481
+ usable, plugin authors should consider subclassing to provide a specific
1482
+ implementation that gets the output to the point.
1483
+ """
1484
+
1485
+
1486
+ class Summary:
1487
+ """Creates a summary formatter object.
1488
+
1489
+ This base class takes no parameters in its constructor, but subclasses may
1490
+ provide more elaborate constructors that accept parameters to influence
1491
+ output creation.
1492
+ """
1493
+
1494
+ # It might be possible to re-implement this as a @staticmethod,
1495
+ # but this might be an API-breaking change, so it should probably stay in
1496
+ # place until a 2.x rewrite. If this can't be a @staticmethod, then it
1497
+ # should probably be an @abstractmethod.
1498
+ # See issue #44
1499
+ # pylint: disable-next=no-self-use
1500
+ def ok(self, results: "Results") -> str:
1501
+ """Formats status line when overall state is ok.
1502
+
1503
+ The default implementation returns a string representation of
1504
+ the first result.
1505
+
1506
+ :param results: :class:`~mplugin.Results` container
1507
+ :returns: status line
1508
+ """
1509
+ return "{0}".format(results[0])
1510
+
1511
+ # It might be possible to re-implement this as a @staticmethod,
1512
+ # but this might be an API-breaking change, so it should probably stay in
1513
+ # place until a 2.x rewrite. If this can't be a @staticmethod, then it
1514
+ # should probably be an @abstractmethod.
1515
+ # See issue #44
1516
+ # pylint: disable-next=no-self-use
1517
+ def problem(self, results: "Results") -> str:
1518
+ """Formats status line when overall state is not ok.
1519
+
1520
+ The default implementation returns a string representation of te
1521
+ first significant result, i.e. the result with the "worst"
1522
+ state.
1523
+
1524
+ :param results: :class:`~.result.Results` container
1525
+ :returns: status line
1526
+ """
1527
+ return "{0}".format(results.first_significant)
1528
+
1529
+ # It might be possible to re-implement this as a @staticmethod,
1530
+ # but this might be an API-breaking change, so it should probably stay in
1531
+ # place until a 2.x rewrite. If this can't be a @staticmethod, then it
1532
+ # should probably be an @abstractmethod.
1533
+ # See issue #44
1534
+ # pylint: disable-next=no-self-use
1535
+ def verbose(self, results: "Results") -> list[str]:
1536
+ """Provides extra lines if verbose plugin execution is requested.
1537
+
1538
+ The default implementation returns a list of all resources that are in
1539
+ a non-ok state.
1540
+
1541
+ :param results: :class:`~.result.Results` container
1542
+ :returns: list of strings
1543
+ """
1544
+ msgs: list[str] = []
1545
+ for result in results:
1546
+ if result.state == ok:
1547
+ continue
1548
+ msgs.append("{0}: {1}".format(result.state, result))
1549
+ return msgs
1550
+
1551
+ # It might be possible to re-implement this as a @staticmethod,
1552
+ # but this might be an API-breaking change, so it should probably stay in
1553
+ # place until a 2.x rewrite. If this can't be a @staticmethod, then it
1554
+ # should probably be an @abstractmethod.
1555
+ # See issue #44
1556
+ # pylint: disable-next=no-self-use
1557
+ def empty(self) -> typing.Literal["no check results"]:
1558
+ """Formats status line when the result set is empty.
1559
+
1560
+ :returns: status line
1561
+ """
1562
+ return "no check results"
1563
+
1564
+
1565
+ # context.py
1566
+
1567
+ """Metadata about metrics to perform data :term:`evaluation`.
1568
+
1569
+ This module contains the :class:`Context` class, which is the base for
1570
+ all contexts. :class:`ScalarContext` is an important specialization to
1571
+ cover numeric contexts with warning and critical thresholds. The
1572
+ :class:`~.check.Check` controller selects a context for each
1573
+ :class:`~.metric.Metric` by matching the metric's `context` attribute with the
1574
+ context's `name`. The same context may be used for several metrics.
1575
+
1576
+ Plugin authors may just use to :class:`ScalarContext` in the majority of cases.
1577
+ Sometimes is better to subclass :class:`Context` instead to implement custom
1578
+ evaluation or performance data logic.
1579
+ """
1580
+
1581
+
1582
+ FmtMetric = str | typing.Callable[["Metric", "Context"], str]
1583
+
1584
+
1585
+ class Context:
1586
+ """Creates generic context identified by `name`.
1587
+
1588
+ Generic contexts just format associated metrics and evaluate
1589
+ always to :obj:`~mplugin.ok`. Metric formatting is
1590
+ controlled with the :attr:`fmt_metric` attribute. It can either
1591
+ be a string or a callable. See the :meth:`describe` method for
1592
+ how formatting is done.
1593
+
1594
+ :param name: A context name that is matched by the context
1595
+ attribute of :class:`~mplugin.Metric`
1596
+ :param fmt_metric: string or callable to convert
1597
+ context and associated metric to a human readable string
1598
+ """
1599
+
1600
+ name: str
1601
+ fmt_metric: typing.Optional[FmtMetric]
1602
+
1603
+ def __init__(
1604
+ self,
1605
+ name: str,
1606
+ fmt_metric: typing.Optional[FmtMetric] = None,
1607
+ ) -> None:
1608
+
1609
+ self.name = name
1610
+ self.fmt_metric = fmt_metric
1611
+
1612
+ def evaluate(
1613
+ self, metric: "Metric", resource: "Resource"
1614
+ ) -> typing.Union[Result, ServiceState]:
1615
+ """Determines state of a given metric.
1616
+
1617
+ This base implementation returns :class:`~mplugin.ok`
1618
+ in all cases. Plugin authors may override this method in
1619
+ subclasses to specialize behaviour.
1620
+
1621
+ :param metric: associated metric that is to be evaluated
1622
+ :param resource: resource that produced the associated metric
1623
+ (may optionally be consulted)
1624
+ :returns: :class:`~.result.Result` or
1625
+ :class:`~.state.ServiceState` object
1626
+ """
1627
+ return self.result(ok, metric=metric)
1628
+
1629
+ def result(
1630
+ self,
1631
+ state: ServiceState,
1632
+ hint: typing.Optional[str] = None,
1633
+ metric: typing.Optional["Metric"] = None,
1634
+ ) -> Result:
1635
+ """
1636
+ Create a Result object with the given state, hint, and metric.
1637
+
1638
+ :param state: The service state for the result.
1639
+ :param hint: An optional hint message providing additional context.
1640
+ :param metric: An optional Metric object associated with the result.
1641
+
1642
+ :return: A Result object containing the provided state, hint, and metric.
1643
+ """
1644
+ return Result(state=state, hint=hint, metric=metric)
1645
+
1646
+ def ok(
1647
+ self,
1648
+ hint: typing.Optional[str] = None,
1649
+ metric: typing.Optional["Metric"] = None,
1650
+ ) -> Result:
1651
+ """
1652
+ Create a successful Result.
1653
+
1654
+ :param hint: Optional hint message providing additional context about the successful operation.
1655
+ :param metric: Optional Metric object associated with this result.
1656
+
1657
+ :return: A Result object representing a successful operation.
1658
+ """
1659
+ return self.result(ok, hint=hint, metric=metric)
1660
+
1661
+ def warning(
1662
+ self,
1663
+ hint: typing.Optional[str] = None,
1664
+ metric: typing.Optional["Metric"] = None,
1665
+ ) -> Result:
1666
+ """
1667
+ Create a warning result.
1668
+
1669
+ :param hint: Optional hint message to provide additional context for the warning.
1670
+ :param metric: Optional metric associated with the warning.
1671
+
1672
+ :return: A Result object representing a warning.
1673
+ """
1674
+ return self.result(warning, hint=hint, metric=metric)
1675
+
1676
+ def critical(
1677
+ self,
1678
+ hint: typing.Optional[str] = None,
1679
+ metric: typing.Optional["Metric"] = None,
1680
+ ) -> Result:
1681
+ """
1682
+ Create a critical result.
1683
+
1684
+ :param hint: Optional hint message providing additional context about the critical result.
1685
+ :param metric: Optional metric object associated with this critical result.
1686
+
1687
+ :return: A Result object representing a critical state.
1688
+ """
1689
+ return self.result(critical, hint=hint, metric=metric)
1690
+
1691
+ def unknown(
1692
+ self,
1693
+ hint: typing.Optional[str] = None,
1694
+ metric: typing.Optional["Metric"] = None,
1695
+ ) -> Result:
1696
+ """
1697
+ Create a Result object with an unknown status.
1698
+
1699
+ :param hint: Optional hint message providing additional context about why the result is unknown
1700
+ :param metric: Optional Metric object associated with this result
1701
+
1702
+ :return: A Result object with unknown status
1703
+ """
1704
+ return self.result(unknown, hint=hint, metric=metric)
1705
+
1706
+ # This could be corrected by re-implementing this class as a proper ABC.
1707
+ # See issue #43
1708
+ # pylint: disable-next=no-self-use
1709
+ def performance(
1710
+ self, metric: "Metric", resource: "Resource"
1711
+ ) -> typing.Optional[Performance]:
1712
+ """Derives performance data from a given metric.
1713
+
1714
+ This base implementation just returns none. Plugin authors may
1715
+ override this method in subclass to specialize behaviour.
1716
+
1717
+ .. code-block:: python
1718
+
1719
+ def performance(self, metric: Metric, resource: Resource) -> Performance:
1720
+ return Performance(label=metric.name, value=metric.value)
1721
+
1722
+ .. code-block:: python
1723
+
1724
+ def performance(
1725
+ self, metric: Metric, resource: Resource
1726
+ ) -> Performance | None:
1727
+ if not opts.performance_data:
1728
+ return None
1729
+ return Performance(
1730
+ metric.name,
1731
+ metric.value,
1732
+ metric.uom,
1733
+ self.warning,
1734
+ self.critical,
1735
+ metric.min,
1736
+ metric.max,
1737
+ )
1738
+
1739
+ :param metric: associated metric from which performance data are
1740
+ derived
1741
+ :param resource: resource that produced the associated metric
1742
+ (may optionally be consulted)
1743
+ :returns: :class:`~.performance.Performance` object or `None`
1744
+ """
1745
+ return None
1746
+
1747
+ def describe(self, metric: "Metric") -> typing.Optional[str]:
1748
+ """Provides human-readable metric description.
1749
+
1750
+ Formats the metric according to the :attr:`fmt_metric`
1751
+ attribute. If :attr:`fmt_metric` is a string, it is evaluated as
1752
+ format string with all metric attributes in the root namespace.
1753
+ If :attr:`fmt_metric` is callable, it is called with the metric
1754
+ and this context as arguments. If :attr:`fmt_metric` is not set,
1755
+ this default implementation does not return a description.
1756
+
1757
+ Plugin authors may override this method in subclasses to control
1758
+ text output more tightly.
1759
+
1760
+ :param metric: associated metric
1761
+ :returns: description string or None
1762
+ """
1763
+ if not self.fmt_metric:
1764
+ return None
1765
+
1766
+ if isinstance(self.fmt_metric, str):
1767
+ return self.fmt_metric.format(
1768
+ name=metric.name,
1769
+ value=metric.value,
1770
+ uom=metric.uom,
1771
+ valueunit=metric.valueunit,
1772
+ min=metric.min,
1773
+ max=metric.max,
1774
+ )
1775
+
1776
+ return self.fmt_metric(metric, self)
1777
+
1778
+
1779
+ class ScalarContext(Context):
1780
+ warn_range: Range
1781
+
1782
+ critical_range: Range
1783
+
1784
+ def __init__(
1785
+ self,
1786
+ name: str,
1787
+ warning: typing.Optional[RangeSpec] = None,
1788
+ critical: typing.Optional[RangeSpec] = None,
1789
+ fmt_metric: FmtMetric = "{name} is {valueunit}",
1790
+ ) -> None:
1791
+ """Ready-to-use :class:`Context` subclass for scalar values.
1792
+
1793
+ ScalarContext models the common case where a single scalar is to
1794
+ be evaluated against a pair of warning and critical thresholds.
1795
+
1796
+ :attr:`name` and :attr:`fmt_metric`,
1797
+ are described in the :class:`Context` base class.
1798
+
1799
+ :param warning: Warning threshold as
1800
+ :class:`~mplugin.Range` object or range string.
1801
+ :param critical: Critical threshold as
1802
+ :class:`~mplugin.Range` object or range string.
1803
+ """
1804
+ super(ScalarContext, self).__init__(name, fmt_metric)
1805
+ self.warn_range = Range(warning)
1806
+ self.critical_range = Range(critical)
1807
+
1808
+ def evaluate(self, metric: "Metric", resource: "Resource") -> Result:
1809
+ """Compares metric with ranges and determines result state.
1810
+
1811
+ The metric's value is compared to the instance's :attr:`warning`
1812
+ and :attr:`critical` ranges, yielding an appropropiate state
1813
+ depending on how the metric fits in the ranges. Plugin authors
1814
+ may override this method in subclasses to provide custom
1815
+ evaluation logic.
1816
+
1817
+ :param metric: metric that is to be evaluated
1818
+ :param resource: not used
1819
+ :returns: :class:`~mplugin.Result` object
1820
+ """
1821
+ if not self.critical_range.match(metric.value):
1822
+ return self.critical(self.critical_range.violation, metric)
1823
+ if not self.warn_range.match(metric.value):
1824
+ return self.warning(self.warn_range.violation, metric)
1825
+ return self.ok(None, metric)
1826
+
1827
+ def performance(self, metric: "Metric", resource: "Resource") -> Performance:
1828
+ """Derives performance data.
1829
+
1830
+ The metric's attributes are combined with the local
1831
+ :attr:`warning` and :attr:`critical` ranges to get a
1832
+ fully populated :class:`~mplugin.performance.Performance`
1833
+ object.
1834
+
1835
+ :param metric: metric from which performance data are derived
1836
+ :param resource: not used
1837
+ :returns: :class:`~mplugin.performance.Performance` object
1838
+ """
1839
+ return Performance(
1840
+ metric.name,
1841
+ metric.value,
1842
+ metric.uom,
1843
+ self.warn_range,
1844
+ self.critical_range,
1845
+ metric.min,
1846
+ metric.max,
1847
+ )
1848
+
1849
+
1850
+ class _Contexts:
1851
+ """Container for collecting all generated contexts."""
1852
+
1853
+ by_name: dict[str, Context]
1854
+
1855
+ def __init__(self) -> None:
1856
+ self.by_name = dict(
1857
+ default=ScalarContext("default", "", ""), null=Context("null")
1858
+ )
1859
+
1860
+ def add(self, context: Context) -> None:
1861
+ self.by_name[context.name] = context
1862
+
1863
+ def __getitem__(self, context_name: str) -> Context:
1864
+ try:
1865
+ return self.by_name[context_name]
1866
+ except KeyError:
1867
+ raise KeyError(
1868
+ "cannot find context",
1869
+ context_name,
1870
+ "known contexts: {0}".format(", ".join(self.by_name.keys())),
1871
+ )
1872
+
1873
+ def __contains__(self, context_name: str) -> bool:
1874
+ return context_name in self.by_name
1875
+
1876
+ def __iter__(self) -> typing.Iterator[str]:
1877
+ return iter(self.by_name)
1878
+
1879
+
1880
+ # check.py
1881
+
1882
+ """Controller logic for check execution.
1883
+ """
1884
+
1885
+
1886
+ log: logging.Logger = logging.getLogger("mplugin")
1887
+ """
1888
+ **mplugin** integrates with the logging module from Python's standard
1889
+ library. If the main function is decorated with :meth:`guarded` (which is heavily
1890
+ recommended), the logging module gets automatically configured before the
1891
+ execution of the `main()` function starts. Messages logged to the *mplugin*
1892
+ logger (or any sublogger) are processed with mplugin's integrated logging.
1893
+
1894
+ The verbosity level is set in the :meth:`check.main()` invocation depending on
1895
+ the number of ``-v`` flags.
1896
+
1897
+ When called with *verbose=0,* both the summary and the performance data are
1898
+ printed on one line and the warning message is displayed. Messages logged with
1899
+ *warning* or *error* level are always printed.
1900
+ Setting *verbose* to 1 does not change the logging level but enable multi-line
1901
+ output. Additionally, full tracebacks would be printed in the case of an
1902
+ uncaught exception.
1903
+ Verbosity levels of ``2`` and ``3`` enable logging with *info* or *debug* levels.
1904
+ """
1905
+
1906
+
1907
+ class Check:
1908
+ """Controller logic for check execution.
1909
+
1910
+ The class :class:`Check` orchestrates the
1911
+ the various stages of check execution. Interfacing with the
1912
+ outside system is done via a separate :class:`Runtime` object.
1913
+
1914
+ When a check is called (using :meth:`Check.main` or
1915
+ :meth:`Check.__call__`), it probes all resources and evaluates the
1916
+ returned metrics to results and performance data. A typical usage
1917
+ pattern would be to populate a check with domain objects and then
1918
+ delegate control to it.
1919
+ """
1920
+
1921
+ resources: list[Resource]
1922
+ contexts: _Contexts
1923
+ summary: Summary
1924
+ results: Results
1925
+ perfdata: list[str]
1926
+ name: str
1927
+
1928
+ def __init__(
1929
+ self,
1930
+ *objects: Resource | Context | Summary | Results,
1931
+ name: typing.Optional[str] = None,
1932
+ ) -> None:
1933
+ """Creates and configures a check.
1934
+
1935
+ Specialized *objects* representing resources, contexts,
1936
+ summary, or results are passed to the the :meth:`add` method.
1937
+ Alternatively, objects can be added later manually.
1938
+ If no *name* is given, the output prefix is set to the first
1939
+ resource's name. If *name* is None, no prefix is set at all.
1940
+ """
1941
+ self.resources = []
1942
+ self.contexts = _Contexts()
1943
+ self.summary = Summary()
1944
+ self.results = Results()
1945
+ self.perfdata = []
1946
+ if name is not None:
1947
+ self.name = name
1948
+ else:
1949
+ self.name = ""
1950
+ self.add(*objects)
1951
+
1952
+ def add(self, *objects: Resource | Context | Summary | Results):
1953
+ """Adds domain objects to a check.
1954
+
1955
+ :param objects: one or more objects that are descendants from
1956
+ :class:`~mplugin.Resource`,
1957
+ :class:`~mplugin.Context`,
1958
+ :class:`~mplugin.Summary`, or
1959
+ :class:`~mplugin.Results`.
1960
+ """
1961
+ for obj in objects:
1962
+ if isinstance(obj, Resource):
1963
+ self.resources.append(obj)
1964
+ if self.name is None: # type: ignore
1965
+ self.name = ""
1966
+ elif self.name == "":
1967
+ self.name = self.resources[0].name
1968
+ elif isinstance(obj, Context):
1969
+ self.contexts.add(obj)
1970
+ elif isinstance(obj, Summary):
1971
+ self.summary = obj
1972
+ elif isinstance(obj, Results): # type: ignore
1973
+ self.results = obj
1974
+ else:
1975
+ raise TypeError("cannot add type {0} to check".format(type(obj)), obj)
1976
+ return self
1977
+
1978
+ def _evaluate_resource(self, resource: Resource) -> None:
1979
+ metric = None
1980
+ try:
1981
+ metrics = resource.probe()
1982
+ if not metrics:
1983
+ log.warning("resource %s did not produce any metric", resource.name)
1984
+ if isinstance(metrics, Metric):
1985
+ # resource returned a bare metric instead of list/generator
1986
+ metrics = [metrics]
1987
+ for metric in metrics:
1988
+ context = self.contexts[metric.context]
1989
+ metric = metric.replace(contextobj=context, resource=resource)
1990
+ result = metric.evaluate()
1991
+ if isinstance(result, Result):
1992
+ self.results.add(result)
1993
+ elif isinstance(result, ServiceState): # type: ignore
1994
+ self.results.add(Result(result, metric=metric))
1995
+ else:
1996
+ raise ValueError(
1997
+ "evaluate() returned neither Result nor ServiceState object",
1998
+ metric.name,
1999
+ result,
2000
+ )
2001
+ self.perfdata.append(str(metric.performance() or ""))
2002
+ except CheckError as e:
2003
+ self.results.add(Result(unknown, str(e), metric))
2004
+
2005
+ def __call__(self) -> None:
2006
+ """Actually run the check.
2007
+
2008
+ After a check has been called, the :attr:`results` and
2009
+ :attr:`perfdata` attributes are populated with the outcomes. In
2010
+ most cases, you should not use __call__ directly but invoke
2011
+ :meth:`main`, which delegates check execution to the
2012
+ :class:`Runtime` environment.
2013
+ """
2014
+ for resource in self.resources:
2015
+ self._evaluate_resource(resource)
2016
+ self.perfdata = sorted([p for p in self.perfdata if p])
2017
+
2018
+ def main(
2019
+ self,
2020
+ verbose: typing.Any = None,
2021
+ timeout: typing.Any = None,
2022
+ colorize: bool = False,
2023
+ ) -> typing.NoReturn:
2024
+ """All-in-one control delegation to the runtime environment.
2025
+
2026
+ Get a :class:`~mplugin.runtime.Runtime` instance and
2027
+ perform all phases: run the check (via :meth:`__call__`), print
2028
+ results and exit the program with an appropriate status code.
2029
+
2030
+ :param verbose: output verbosity level between 0 and 3
2031
+ :param timeout: abort check execution with a :exc:`Timeout`
2032
+ exception after so many seconds (use 0 for no timeout)
2033
+ :param colorize: Use ANSI colors to colorize the logging output
2034
+ """
2035
+ runtime = _Runtime()
2036
+ runtime.execute(self, verbose=verbose, timeout=timeout, colorize=colorize)
2037
+
2038
+ @property
2039
+ def state(self) -> ServiceState:
2040
+ """Overall check state.
2041
+
2042
+ The most significant (=worst) state seen in :attr:`results` to
2043
+ far. :obj:`~mplugin.unknown` if no results have been
2044
+ collected yet. Corresponds with :attr:`exitcode`. Read-only
2045
+ property.
2046
+ """
2047
+ try:
2048
+ return self.results.most_significant_state
2049
+ except ValueError:
2050
+ return unknown
2051
+
2052
+ @property
2053
+ def summary_str(self) -> str:
2054
+ """Status line summary string.
2055
+
2056
+ The first line of output that summarizes that situation as
2057
+ perceived by the check. The string is usually queried from a
2058
+ :class:`Summary` object. Read-only property.
2059
+ """
2060
+ if not self.results:
2061
+ return self.summary.empty() or ""
2062
+
2063
+ if self.state == ok:
2064
+ return self.summary.ok(self.results) or ""
2065
+
2066
+ return self.summary.problem(self.results) or ""
2067
+
2068
+ @property
2069
+ def verbose_str(self):
2070
+ """Additional lines of output.
2071
+
2072
+ Long text output if check runs in verbose mode. Also queried
2073
+ from :class:`~mplugin.Summary`. Read-only property.
2074
+ """
2075
+ return self.summary.verbose(self.results) or ""
2076
+
2077
+ @property
2078
+ def exitcode(self) -> int:
2079
+ """Overall check exit code according to the monitoring API.
2080
+
2081
+ Corresponds with :attr:`state`. Read-only property.
2082
+ """
2083
+ try:
2084
+ return int(self.results.most_significant_state)
2085
+ except ValueError:
2086
+ return 3
2087
+
2088
+
2089
+ # cli.py (argparse)
2090
+
2091
+
2092
+ class __CustomArgumentParser(argparse.ArgumentParser):
2093
+ """
2094
+ Override the exit method for the options ``--help``, ``-h`` and ``--version``,
2095
+ ``-V`` with ``Unknown`` (exit code 3), according to the
2096
+ `Monitoring Plugin Guidelines
2097
+ <https://github.com/monitoring-plugins/monitoring-plugin-guidelines/blob/main/monitoring_plugins_interface/02.Input.md>`__.
2098
+ """
2099
+
2100
+ def exit(
2101
+ self, status: int = 3, message: typing.Optional[str] = None
2102
+ ) -> typing.NoReturn:
2103
+ if message:
2104
+ self._print_message(message, sys.stderr)
2105
+ sys.exit(status)
2106
+
2107
+
2108
+ def setup_argparser(
2109
+ name: typing.Optional[str],
2110
+ version: typing.Optional[str] = None,
2111
+ license: typing.Optional[str] = None,
2112
+ repository: typing.Optional[str] = None,
2113
+ copyright: typing.Optional[str] = None,
2114
+ description: typing.Optional[str] = None,
2115
+ epilog: typing.Optional[str] = None,
2116
+ verbose: bool = False,
2117
+ ) -> argparse.ArgumentParser:
2118
+ """
2119
+ Set up and configure an argument parser for a monitoring plugin
2120
+ according the
2121
+ `Monitoring Plugin Guidelines
2122
+ <https://github.com/monitoring-plugins/monitoring-plugin-guidelines/blob/main/monitoring_plugins_interface/02.Input.md>`__.
2123
+
2124
+ This function creates a customized ArgumentParser instance with metadata
2125
+ and formatting suitable for monitoring plugins. It automatically prefixes
2126
+ the plugin name with ``check_`` if not already present.
2127
+
2128
+ :param name: The name of the plugin. If provided and doesn't start with
2129
+ ``check``, it will be prefixed with ``check_``.
2130
+ :param version: The version number of the plugin. If provided, it will be
2131
+ included in the parser description. In addition, an option ``-V``,
2132
+ ``--version`` is provided, which outputs the version number.
2133
+ :param license: The license type of the plugin. If provided, it will be
2134
+ included in the parser description.
2135
+ :param repository: The repository URL of the plugin. If provided, it will
2136
+ be included in the parser description.
2137
+ :param copyright: The copyright information for the plugin. If provided,
2138
+ it will be included in the parser description.
2139
+ :param description: A detailed description of the plugin's functionality.
2140
+ If provided, it will be appended to the parser description after a
2141
+ blank line.
2142
+ :param epilog: Additional information to display after the help message.
2143
+ :param verbose: Provide a ``-v``, ``--verbose`` option. The option can be
2144
+ specified multiple times, e. g. ``-vvv``
2145
+
2146
+ :returns: A configured ArgumentParser instance with RawDescriptionHelpFormatter,
2147
+ 80 character width, and metadata assembled from the provided parameters.
2148
+ """
2149
+ description_lines: list[str] = []
2150
+
2151
+ if name is not None and not name.startswith("check"):
2152
+ name = f"check_{name}"
2153
+
2154
+ if version is not None:
2155
+ description_lines.append(f"version {version}")
2156
+
2157
+ if license is not None:
2158
+ description_lines.append(f"Licensed under the {license}.")
2159
+
2160
+ if repository is not None:
2161
+ description_lines.append(f"Repository: {repository}.")
2162
+
2163
+ if copyright is not None:
2164
+ description_lines.append(copyright)
2165
+
2166
+ if description is not None:
2167
+ description_lines.append("")
2168
+ description_lines.append(description)
2169
+
2170
+ parser: argparse.ArgumentParser = __CustomArgumentParser(
2171
+ prog=name,
2172
+ formatter_class=lambda prog: argparse.RawDescriptionHelpFormatter(
2173
+ prog, width=80
2174
+ ),
2175
+ description="\n".join(description_lines),
2176
+ epilog=epilog,
2177
+ )
2178
+
2179
+ if version is not None:
2180
+ parser.add_argument(
2181
+ "-V",
2182
+ "--version",
2183
+ action="version",
2184
+ version=f"%(prog)s {version}",
2185
+ )
2186
+
2187
+ if verbose:
2188
+ # https://github.com/monitoring-plugins/monitoring-plugin-guidelines/blob/main/monitoring_plugins_interface/02.Input.md
2189
+ parser.add_argument(
2190
+ "-v",
2191
+ "--verbose",
2192
+ action="count",
2193
+ default=0,
2194
+ help="Increase the output verbosity.",
2195
+ )
2196
+
2197
+ return parser
2198
+
2199
+
2200
+ def timespan(spec: typing.Union[str, int, float]) -> float:
2201
+ """Convert a timespan format string to seconds.
2202
+ If no time unit is specified, generally seconds are assumed.
2203
+
2204
+ The following time units are understood:
2205
+
2206
+ - ``years``, ``year``, ``y`` (defined as ``365.25`` days)
2207
+ - ``months``, ``month``, ``M`` (defined as ``30.44`` days)
2208
+ - ``weeks``, ``week``, ``w``
2209
+ - ``days``, ``day``, ``d``
2210
+ - ``hours``, ``hour``, ``hr``, ``h``
2211
+ - ``minutes``, ``minute``, ``min``, ``m``
2212
+ - ``seconds``, ``second``, ``sec``, ``s``
2213
+ - ``milliseconds``, ``millisecond``, ``msec``, ``ms``
2214
+ - ``microseconds``, ``microsecond``, ``usec``, ``μs``, ``μ``, ``us``
2215
+
2216
+ This function can be used as type in the
2217
+ :py:meth:`argparse.ArgumentParser.add_argument` method.
2218
+
2219
+ .. code-block:: python
2220
+
2221
+ parser.add_argument(
2222
+ "-c",
2223
+ "--critical",
2224
+ default=5356800,
2225
+ help="Interval in seconds for critical state.",
2226
+ type=timespan,
2227
+ )
2228
+
2229
+ :param timespan: The specification of the timespan as a string, for example
2230
+ ``2.345s``, ``3min 45.234s``, ``34min``, ``2 months 8 days`` or as a
2231
+ number.
2232
+
2233
+ :return: The timespan in seconds
2234
+ """
2235
+
2236
+ # A int or a float encoded as string without an extension
2237
+ try:
2238
+ spec = float(spec)
2239
+ except ValueError:
2240
+ pass
2241
+
2242
+ if isinstance(spec, int) or isinstance(spec, float):
2243
+ return spec
2244
+
2245
+ # Remove all whitespaces
2246
+ spec = re.sub(r"\s+", "", spec)
2247
+
2248
+ replacements: list[tuple[list[str], str]] = [
2249
+ (["years", "year"], "y"),
2250
+ (["months", "month"], "M"),
2251
+ (["weeks", "week"], "w"),
2252
+ (["days", "day"], "d"),
2253
+ (["hours", "hour", "hr"], "h"),
2254
+ (["minutes", "minute", "min"], "m"),
2255
+ (["seconds", "second", "sec"], "s"),
2256
+ (["milliseconds", "millisecond", "msec"], "ms"),
2257
+ (["microseconds", "microsecond", "usec", "μs", "μ"], "us"),
2258
+ ]
2259
+
2260
+ for replacement in replacements:
2261
+ for r in replacement[0]:
2262
+ spec = spec.replace(r, replacement[1])
2263
+
2264
+ # Add a whitespace after the units
2265
+ spec = re.sub(r"([a-zA-Z]+)", r"\1 ", spec)
2266
+
2267
+ seconds: dict[str, float] = {
2268
+ "y": 31557600, # 365.25 days
2269
+ "M": 2630016, # 30.44 days
2270
+ "w": 604800, # 7 * 24 * 60 * 60
2271
+ "d": 86400, # 24 * 60 * 60
2272
+ "h": 3600, # 60 * 60
2273
+ "m": 60,
2274
+ "s": 1,
2275
+ "ms": 0.001,
2276
+ "us": 0.000001,
2277
+ }
2278
+ result: float = 0
2279
+ # Split on the whitespaces
2280
+ for span in spec.split():
2281
+ match = re.search(r"([\d\.]+)([a-zA-Z]+)", span)
2282
+ if match:
2283
+ value = match.group(1)
2284
+ unit = match.group(2)
2285
+ result += float(value) * seconds[unit]
2286
+ return result
2287
+
2288
+
2289
+ TIMESPAN_FORMAT_HELP = """
2290
+ Timespan format
2291
+ ---------------
2292
+
2293
+ If no time unit is specified, generally seconds are assumed. The following time
2294
+ units are understood:
2295
+
2296
+ - years, year, y (defined as 365.25 days)
2297
+ - months, month, M (defined as 30.44 days)
2298
+ - weeks, week, w
2299
+ - days, day, d
2300
+ - hours, hour, hr, h
2301
+ - minutes, minute, min, m
2302
+ - seconds, second, sec, s
2303
+ - milliseconds, millisecond, msec, ms
2304
+ - microseconds, microsecond, usec, μs, μ, us
2305
+
2306
+ The following are valid examples of timespan specifications:
2307
+
2308
+ - `1`
2309
+ - `1.23`
2310
+ - `2.345s`
2311
+ - `3min 45.234s`
2312
+ - `34min`
2313
+ - `2 months 8 days`
2314
+ - `1h30m`
2315
+ """