command_watcher 0.6.0__tar.gz → 0.7.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: command_watcher
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: Module to watch the execution of shell scripts. Both streams (`stdout` and `stderr`) are captured.
5
5
  Author: Josef Friedrich
6
6
  Author-email: Josef Friedrich <josef@friedrich.rocks>
@@ -37,6 +37,10 @@ command_watcher
37
37
  Module to watch the execution of shell scripts. Both streams (`stdout`
38
38
  and `stderr`) are captured.
39
39
 
40
+ The watcher's loggers print each message once and do not propagate it to
41
+ Python's root logger. Command output is also stored on the command executor
42
+ and, unless ``log=False`` is passed to ``watch.run()``, on the watcher.
43
+
40
44
  .. code:: python
41
45
 
42
46
  watch = Watch()
@@ -12,6 +12,10 @@ command_watcher
12
12
  Module to watch the execution of shell scripts. Both streams (`stdout`
13
13
  and `stderr`) are captured.
14
14
 
15
+ The watcher's loggers print each message once and do not propagate it to
16
+ Python's root logger. Command output is also stored on the command executor
17
+ and, unless ``log=False`` is passed to ``watch.run()``, on the watcher.
18
+
15
19
  .. code:: python
16
20
 
17
21
  watch = Watch()
@@ -0,0 +1,51 @@
1
+ [project]
2
+ name = "command_watcher"
3
+ version = "0.7.0"
4
+ description = "Module to watch the execution of shell scripts. Both streams (`stdout` and `stderr`) are captured."
5
+ requires-python = ">=3.12,<4"
6
+ readme = "README.rst"
7
+ license = "MIT"
8
+ classifiers = [
9
+ "Development Status :: 5 - Production/Stable",
10
+ "License :: OSI Approved :: MIT License",
11
+ "Programming Language :: Python :: 3 :: Only",
12
+ "Programming Language :: Python :: 3",
13
+ "Programming Language :: Python :: 3.12",
14
+ "Programming Language :: Python :: 3.13",
15
+ "Programming Language :: Python :: 3.14",
16
+ ]
17
+ dependencies = [
18
+ "pretiac>=0.6.0",
19
+ "pydantic>=2.12.5",
20
+ "pyyaml>=6.0.3",
21
+ "termcolor>=3.3.0",
22
+ "types-pyyaml>=6.0.12.20250915",
23
+ "types-requests>=2.32.4.20260107",
24
+ "types-termcolor>=1.1.6.2",
25
+ ]
26
+
27
+ [[project.authors]]
28
+ name = "Josef Friedrich"
29
+ email = "josef@friedrich.rocks"
30
+
31
+ [project.urls]
32
+ Repository = "https://github.com/Josef-Friedrich/command-watcher"
33
+
34
+ [dependency-groups]
35
+ dev = [
36
+ "mypy>=1.0",
37
+ "pytest>=9.0.2",
38
+ "stdout-stderr-capturing>=0.2.1",
39
+ ]
40
+
41
+ [tool.uv.sources.pretiac]
42
+ git = "https://github.com/Josef-Friedrich/PREtty-Typed-Icinga2-Api-Client_py.git"
43
+
44
+ [tool.pytest.ini_options]
45
+ verbosity_assertions = 2
46
+ truncation_limit_lines = 999999
47
+ truncation_limit_chars = 999999
48
+
49
+ [build-system]
50
+ requires = ["uv_build>=0.13.7,<0.14.0"]
51
+ build-backend = "uv_build"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "command_watcher"
3
- version = "0.6.0"
3
+ version = "0.7.0"
4
4
  description = "Module to watch the execution of shell scripts. Both streams (`stdout` and `stderr`) are captured."
5
5
  authors = [{ name = "Josef Friedrich", email = "josef@friedrich.rocks" }]
6
6
  requires-python = ">=3.12,<4"
@@ -30,6 +30,7 @@ Repository = "https://github.com/Josef-Friedrich/command-watcher"
30
30
 
31
31
  [dependency-groups]
32
32
  dev = [
33
+ "mypy>=1.0",
33
34
  "pytest>=9.0.2",
34
35
  "stdout-stderr-capturing>=0.2.1",
35
36
  ]
@@ -40,7 +41,7 @@ dev = [
40
41
  pretiac = { git = "https://github.com/Josef-Friedrich/PREtty-Typed-Icinga2-Api-Client_py.git" }
41
42
 
42
43
  [build-system]
43
- requires = ["uv_build>=0.11.1,<0.12.0"]
44
+ requires = ["uv_build>=0.13.7,<0.14.0"]
44
45
  build-backend = "uv_build"
45
46
 
46
47
  [tool.pytest.ini_options]
@@ -17,14 +17,10 @@ from typing import (
17
17
  IO,
18
18
  Any,
19
19
  Literal,
20
- Optional,
21
- Tuple,
22
20
  TypedDict,
23
- Union,
21
+ Unpack,
24
22
  )
25
23
 
26
- from typing_extensions import Unpack
27
-
28
24
  from command_watcher.channels.base_channel import BaseChannel
29
25
  from command_watcher.channels.beep import BeepChannel
30
26
  from command_watcher.channels.email import EmailChannel
@@ -64,10 +60,10 @@ class Timer:
64
60
  """Measure the execution time of a command run."""
65
61
 
66
62
  stop: float
67
- """"The time when the timer stops. (UNIX timestamp)"""
63
+ """The time when the timer stops. (UNIX timestamp)"""
68
64
 
69
65
  start: float
70
- """"The start time. (UNIX timestamp)"""
66
+ """The start time. (UNIX timestamp)"""
71
67
 
72
68
  interval: float
73
69
  """The time interval between start and stop."""
@@ -84,15 +80,15 @@ class Timer:
84
80
  :return: A formatted string displaying the result."""
85
81
  self.stop = time.time()
86
82
  self.interval = self.stop - self.start
87
- return "{:.3f}s".format(self.interval)
83
+ return f"{self.interval:.3f}s"
88
84
 
89
85
 
90
86
  # Main code ###################################################################
91
87
 
92
- Arg = Union[str, Path]
88
+ Arg = str | Path
93
89
  """Command line argument"""
94
90
 
95
- Args = Union[Arg, list[Arg], tuple[Arg]]
91
+ Args = Arg | list[Arg] | tuple[Arg]
96
92
  """Command line arguments"""
97
93
 
98
94
 
@@ -123,7 +119,7 @@ class CommandExecutor:
123
119
  args: Args
124
120
  """Process arguments in various types."""
125
121
 
126
- _queue: "queue.Queue[Optional[Tuple[bytes, Stream]]]"
122
+ _queue: queue.Queue[tuple[bytes, Stream] | None]
127
123
 
128
124
  log: ExtendedLogger
129
125
  """A ready to go and configured logger."""
@@ -135,7 +131,7 @@ class CommandExecutor:
135
131
  def __init__(
136
132
  self,
137
133
  args: Args,
138
- master_logger: Optional[ExtendedLogger] = None,
134
+ master_logger: ExtendedLogger | None = None,
139
135
  **kwargs: Unpack[ProcessArgs],
140
136
  ) -> None:
141
137
  # self.args: typing.Union[str, list, tuple] = args
@@ -217,12 +213,10 @@ class CommandExecutor:
217
213
  with pipe:
218
214
  for line in iter(pipe.readline, b""):
219
215
  self._queue.put((line, stream))
220
- except Exception:
221
- pass
222
216
  finally:
223
217
  self._queue.put(None)
224
218
 
225
- def _start_thread(self, pipe: Optional[IO[bytes]], stream: Stream) -> None:
219
+ def _start_thread(self, pipe: IO[bytes] | None, stream: Stream) -> None:
226
220
  """
227
221
  :param object pipe: ``process.stdout`` or ``process.stdout``
228
222
  """
@@ -249,7 +243,7 @@ class Watch:
249
243
  _service_name: str
250
244
  """A name of the watched service."""
251
245
 
252
- _service_display_name: Optional[str]
246
+ _service_display_name: str | None
253
247
  """A human readable form of the *service name*."""
254
248
 
255
249
  log: ExtendedLogger
@@ -262,7 +256,7 @@ class Watch:
262
256
  :py:class:`Process`. Everytime you use the method
263
257
  `run()` the process object is appened in the list."""
264
258
 
265
- _config: Optional[Config]
259
+ _config: Config | None
266
260
 
267
261
  _raise_exceptions: bool
268
262
  """Raise exceptions"""
@@ -271,12 +265,12 @@ class Watch:
271
265
 
272
266
  def __init__(
273
267
  self,
274
- config_file: Optional[Union[str, Path]] = None,
268
+ config_file: str | Path | None = None,
275
269
  service_name: str = "command_watcher",
276
- service_display_name: Optional[str] = None,
270
+ service_display_name: str | None = None,
277
271
  raise_exceptions: bool = True,
278
- config: Optional[Config] = None,
279
- report_channels: Optional[list[BaseChannel]] = None,
272
+ config: Config | None = None,
273
+ report_channels: list[BaseChannel] | None = None,
280
274
  ) -> None:
281
275
  self._hostname = HOSTNAME
282
276
 
@@ -330,7 +324,7 @@ class Watch:
330
324
  self.log.debug(beep_reporter)
331
325
 
332
326
  else:
333
- reporter.channels = []
327
+ reporter.channels = list(report_channels)
334
328
 
335
329
  self.processes = []
336
330
 
@@ -352,7 +346,7 @@ class Watch:
352
346
  self,
353
347
  args: Args,
354
348
  log: bool = True,
355
- ignore_exceptions: list[int] = [],
349
+ ignore_exceptions: list[int] | None = None,
356
350
  **kwargs: Unpack[ProcessArgs],
357
351
  ) -> CommandExecutor:
358
352
  """
@@ -366,6 +360,8 @@ class Watch:
366
360
  :param ignore_exceptions: A list of none-zero exit codes, which is
367
361
  ignored by this method.
368
362
  """
363
+ if ignore_exceptions is None:
364
+ ignore_exceptions = []
369
365
  if log:
370
366
  master_logger = self.log
371
367
  else:
@@ -406,6 +402,5 @@ class Watch:
406
402
  if "performance_data" not in data_dict:
407
403
  data_dict["performance_data"] = {}
408
404
  data_dict["performance_data"]["execution_time"] = timer_result
409
- if "status" in data_dict:
410
- del data_dict["status"]
405
+ data_dict.pop("status", None)
411
406
  return self.report(status=status, **data_dict)
@@ -1,6 +1,5 @@
1
1
  import shutil
2
2
  import subprocess
3
- from typing import Union
4
3
 
5
4
  from command_watcher.channels.base_channel import BaseChannel
6
5
  from command_watcher.message import Message
@@ -9,7 +8,7 @@ from command_watcher.message import Message
9
8
  class BeepChannel(BaseChannel):
10
9
  """Send beep sounds."""
11
10
 
12
- cmd: Union[str, None]
11
+ cmd: str | None
13
12
 
14
13
  def __init__(self) -> None:
15
14
  self.cmd = shutil.which("beep")
@@ -29,7 +28,9 @@ class BeepChannel(BaseChannel):
29
28
  :param length: Length in milliseconds.
30
29
  """
31
30
  # TODO: Use self.cmd -> Fix tests
32
- subprocess.run(["beep", "-f", str(float(frequency)), "-l", str(float(length))])
31
+ subprocess.run(
32
+ ["beep", "-f", str(float(frequency)), "-l", str(float(length))], check=False
33
+ )
33
34
 
34
35
  def report(self, message: Message) -> None:
35
36
  """Send a beep sounds.
@@ -1,5 +1,3 @@
1
- from typing import Optional
2
-
3
1
  from pretiac.client import Client
4
2
  from pretiac.config import Config
5
3
 
@@ -11,22 +9,22 @@ from command_watcher.utils import HOSTNAME
11
9
  class IcingaChannel(BaseChannel):
12
10
  service_name: str
13
11
 
14
- service_display_name: Optional[str]
12
+ service_display_name: str | None
15
13
 
16
14
  __client: Client
17
15
 
18
- api_endpoint_host: Optional[str]
19
- api_endpoint_port: Optional[int]
20
- http_basic_username: Optional[str]
21
- client_certificate: Optional[str]
22
- client_private_key: Optional[str]
23
- ca_certificate: Optional[str]
16
+ api_endpoint_host: str | None
17
+ api_endpoint_port: int | None
18
+ http_basic_username: str | None
19
+ client_certificate: str | None
20
+ client_private_key: str | None
21
+ ca_certificate: str | None
24
22
 
25
23
  def __init__(
26
24
  self,
27
25
  service_name: str,
28
26
  config: Config,
29
- service_display_name: Optional[str] = None,
27
+ service_display_name: str | None = None,
30
28
  ) -> None:
31
29
  self.service_name = service_name
32
30
  self.service_display_name = service_display_name
@@ -1,5 +1,4 @@
1
1
  from pathlib import Path
2
- from typing import Optional, Union
3
2
 
4
3
  import yaml
5
4
  from pretiac.config import Config as IcingaConfig
@@ -36,17 +35,17 @@ class BeepConfig:
36
35
 
37
36
  @dataclass
38
37
  class Config:
39
- email: Optional[EmailConfig] = None
38
+ email: EmailConfig | None = None
40
39
 
41
- icinga: Optional[IcingaConfig] = None
40
+ icinga: IcingaConfig | None = None
42
41
 
43
- beep: Optional[BeepConfig] = None
42
+ beep: BeepConfig | None = None
44
43
 
45
44
 
46
- _config: Optional[Config] = None
45
+ _config: Config | None = None
47
46
 
48
47
 
49
- def load_config(config_file: Optional[Union[str, Path]] = None) -> Config:
48
+ def load_config(config_file: str | Path | None = None) -> Config:
50
49
  if config_file is None:
51
50
  config_file = "/etc/command-watcher.yml"
52
51
  global _config
@@ -3,7 +3,7 @@ import sys
3
3
  import time
4
4
  import uuid
5
5
  from logging.handlers import BufferingHandler
6
- from typing import Any, Optional, cast
6
+ from typing import Any, cast
7
7
 
8
8
  import termcolor
9
9
 
@@ -29,9 +29,9 @@ DATEFMT = "%Y%m%d_%H%M%S"
29
29
  class LoggingHandler(BufferingHandler):
30
30
  """Store of all logging records in the memory. Print all records on emit."""
31
31
 
32
- _master_logger: Optional[logging.Logger]
32
+ _master_logger: logging.Logger | None
33
33
 
34
- def __init__(self, master_logger: Optional[logging.Logger] = None):
34
+ def __init__(self, master_logger: logging.Logger | None = None):
35
35
  BufferingHandler.__init__(self, capacity=1000000)
36
36
  self._master_logger = master_logger
37
37
 
@@ -84,15 +84,12 @@ class LoggingHandler(BufferingHandler):
84
84
  else:
85
85
  stream = sys.stdout
86
86
 
87
- created = "{}_{:03d}".format(
88
- time.strftime(DATEFMT, time.localtime(record.created)),
89
- int(record.msecs),
90
- )
87
+ created = f"{time.strftime(DATEFMT, time.localtime(record.created))}_{int(record.msecs):03d}"
91
88
 
92
89
  print(
93
90
  "{} {} {}".format(
94
91
  created,
95
- termcolor.colored(" {:<8} ".format(level), color, attrs=reverse),
92
+ termcolor.colored(f" {level:<8} ", color, attrs=reverse),
96
93
  termcolor.colored(record.msg, color, attrs=normal),
97
94
  ),
98
95
  file=stream,
@@ -161,7 +158,7 @@ logging.Logger.stderr = _log_stderr # type: ignore
161
158
 
162
159
 
163
160
  def setup_logging(
164
- master_logger: Optional[logging.Logger] = None,
161
+ master_logger: logging.Logger | None = None,
165
162
  ) -> tuple[ExtendedLogger, LoggingHandler]:
166
163
  """Setup a fresh logger for each watch action.
167
164
 
@@ -178,4 +175,5 @@ def setup_logging(
178
175
  # the root logger is created with level WARNING.
179
176
  logger.setLevel(1)
180
177
  logger.addHandler(handler)
178
+ logger.propagate = False
181
179
  return (cast(ExtendedLogger, logger), handler)
@@ -2,9 +2,7 @@ import os
2
2
  import pwd
3
3
  import socket
4
4
  import textwrap
5
- from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, TypedDict
6
-
7
- from typing_extensions import Unpack
5
+ from typing import TYPE_CHECKING, Any, Literal, TypedDict, Unpack
8
6
 
9
7
  if TYPE_CHECKING:
10
8
  from . import CommandExecutor
@@ -25,7 +23,7 @@ class MinimalMessageParams(TypedDict, total=False):
25
23
  body: str
26
24
  """ A longer report text."""
27
25
 
28
- performance_data: Dict[str, Any]
26
+ performance_data: dict[str, Any]
29
27
  """ A dictionary like
30
28
  `{'perf_1': 1, 'perf_2': 'test'}`"""
31
29
 
@@ -44,14 +42,16 @@ class MessageParams(MinimalMessageParams, total=False):
44
42
  log_records: str
45
43
  """Log records separated by new lines"""
46
44
 
47
- processes: List["CommandExecutor"]
45
+ processes: list["CommandExecutor"]
48
46
 
49
47
 
50
48
  class BaseClass:
51
- def _obj_to_str(self, attributes: List[str] = []) -> str:
49
+ def _obj_to_str(self, attributes: list[str] | None = None) -> str:
50
+ if attributes is None:
51
+ attributes = []
52
52
  if not attributes:
53
53
  attributes = dir(self)
54
- output: List[str] = []
54
+ output: list[str] = []
55
55
  for attribute in attributes:
56
56
  if not attribute.startswith("_") and not callable(getattr(self, attribute)):
57
57
  value = getattr(self, attribute)
@@ -109,11 +109,11 @@ class Message(BaseClass):
109
109
  """
110
110
  performance_data = self._data.get("performance_data")
111
111
  if performance_data:
112
- pairs: List[str] = []
112
+ pairs: list[str] = []
113
113
  key: str
114
114
  value: Any
115
115
  for key, value in performance_data.items():
116
- pairs.append("{!s}={!s}".format(key, value))
116
+ pairs.append(f"{key!s}={value!s}")
117
117
  return " ".join(pairs)
118
118
  return ""
119
119
 
@@ -135,7 +135,7 @@ class Message(BaseClass):
135
135
  output.append(self.service_name.upper())
136
136
  output.append(self.status_text)
137
137
  if self.custom_message:
138
- output.append("- {}".format(self.custom_message))
138
+ output.append(f"- {self.custom_message}")
139
139
  return " ".join(output)
140
140
 
141
141
  @property
@@ -146,13 +146,13 @@ class Message(BaseClass):
146
146
  output.append(self.service_name.upper())
147
147
  output.append(self.status_text)
148
148
  if self.custom_message:
149
- output.append("- {}".format(self.custom_message))
149
+ output.append(f"- {self.custom_message}")
150
150
  return " ".join(output)
151
151
 
152
152
  @property
153
153
  def message_monitoring(self) -> str:
154
154
  """plugin_output + performance_data"""
155
- output: List[str] = []
155
+ output: list[str] = []
156
156
  output.append(self.plugin_output)
157
157
  if self.performance_data:
158
158
  output.append("|")
@@ -162,7 +162,7 @@ class Message(BaseClass):
162
162
  @property
163
163
  def body(self) -> str:
164
164
  """Text body for the e-mail message."""
165
- output: List[str] = []
165
+ output: list[str] = []
166
166
  output.append(f"Host: {HOSTNAME}")
167
167
  output.append(f"User: {USERNAME}")
168
168
  output.append(f"Service name: {self.service_name}")
@@ -185,8 +185,8 @@ class Message(BaseClass):
185
185
  return "\n".join(output)
186
186
 
187
187
  @property
188
- def processes(self) -> Optional[str]:
189
- output: List[str] = []
188
+ def processes(self) -> str | None:
189
+ output: list[str] = []
190
190
  processes = self._data.get("processes")
191
191
  if processes:
192
192
  for process in processes:
@@ -197,4 +197,4 @@ class Message(BaseClass):
197
197
 
198
198
  @property
199
199
  def user(self) -> str:
200
- return "[user:{}]".format(USERNAME)
200
+ return f"[user:{USERNAME}]"
@@ -1,6 +1,4 @@
1
- from typing import List, Literal
2
-
3
- from typing_extensions import Unpack
1
+ from typing import Literal, Unpack
4
2
 
5
3
  from command_watcher.channels.base_channel import BaseChannel
6
4
  from command_watcher.message import Message, MessageParams
@@ -11,7 +9,7 @@ Status = Literal[0, 1, 2, 3]
11
9
  class Reporter:
12
10
  """Collect all channels."""
13
11
 
14
- channels: List[BaseChannel]
12
+ channels: list[BaseChannel]
15
13
 
16
14
  def __init__(self) -> None:
17
15
  self.channels = []