Repository: jamesoff/simplemonitor

Issue:
Support a config ".d" directory and load all the files in it
Useful for having tools like Ansible drop monitor configuration for things they manage without having to modify the main config files.

Comments:

Patch:
diff --git a/docs/configuration.rst b/docs/configuration.rst
index d89a034..0aa8e3e 100644
--- a/docs/configuration.rst
+++ b/docs/configuration.rst
@@ -60,6 +60,22 @@ This file must contain a ``[monitor]`` section, which must contain at least the
     the filename to load the monitors themselves from. Relative to the cwd, not
     the path of this configuration file.
 
+    If you want to use only ``monitors_dir``, set this to nothing (``monitors=``).
+
+
+
+.. confval:: monitors_dir
+
+    :type: string
+    :required: false
+
+    a directory to scan for ``*.ini`` files which are merged with the main
+    monitors config file. Files are loaded in lexicographic order, and if a
+    monitor name is reused, the last definition wins. Relative to the cwd, not
+    the path of this configuration file.
+
+    The main ``monitors`` file is always loaded first.
+
 .. confval:: pidfile
 
     :type: string
diff --git a/simplemonitor/simplemonitor.py b/simplemonitor/simplemonitor.py
index 412bfc5..3c87dde 100644
--- a/simplemonitor/simplemonitor.py
+++ b/simplemonitor/simplemonitor.py
@@ -10,7 +10,7 @@ import sys
 import time
 from pathlib import Path
 from socket import gethostname
-from typing import Any, Dict, List, Optional, Tuple, Union, cast
+from typing import Any, Dict, List, Optional, Sequence, Tuple, Union, cast
 
 from .Alerters.alerter import Alerter
 from .Alerters.alerter import all_types as all_alerter_types
@@ -41,7 +41,7 @@ class SimpleMonitor:
         max_loops: int = -1,
         heartbeat: bool = True,
         one_shot: bool = False,
-        max_workers: Optional[int] = None
+        max_workers: Optional[int] = None,
     ) -> None:
         """Main class turn on."""
         if isinstance(config_file, str):
@@ -110,11 +110,23 @@ class SimpleMonitor:
         else:
             self._network = False
 
-        monitors_file = Path(config.get("monitor", "monitors", fallback="monitors.ini"))
-        self._load_monitors(monitors_file)
+        monitors_files = [
+            Path(config.get("monitor", "monitors", fallback="monitors.ini"))
+        ]
+        monitors_dir = config.get("monitor", "monitors_dir", fallback=None)
+        if monitors_files[0] == Path("."):
+            module_logger.debug("No main monitors.ini file specified")
+            monitors_files.pop(0)
+        elif not monitors_files[0].exists():
+            raise RuntimeError(
+                f"Monitors configuration file '{monitors_files[0]}' does not exist"
+            )
+        if monitors_dir:
+            monitors_files.extend(list(sorted(Path(monitors_dir).glob("*.ini"))))
+        self._load_monitors(monitors_files)
         count = self.count_monitors()
         if count == 0:
-            module_logger.critical("No monitors loaded :(")
+            module_logger.critical("No monitors loaded")
         self._load_loggers(config)
         self._load_alerters(config)
         if not self._verify_dependencies():
@@ -140,19 +152,15 @@ class SimpleMonitor:
             )
             self._remote_listening_thread.start()
 
-    def _load_monitors(self, filename: Union[Path, str]) -> None:
+    def _load_monitors(self, filenames: Sequence[Union[Path, str]]) -> None:
         """Load all the monitors from the config file."""
-        if isinstance(filename, str):
-            filename = Path(filename)
-        elif not isinstance(filename, Path):
-            raise ValueError("filename must be str or Path")
-        if not filename.exists():
-            raise RuntimeError(
-                "Monitors config file {} does not exist".format(filename)
-            )
-        module_logger.info("Loading monitor config from %s", filename)
+
         config = EnvironmentAwareConfigParser()
-        config.read(filename)
+        config.read(filenames)
+        module_logger.info(
+            "Loaded monitor config from: %s",
+            ", ".join(map(str, filenames)),
+        )
         monitors = config.sections()
         if "defaults" in monitors:
             default_config = get_config_dict(config, "defaults")
diff --git a/simplemonitor/util/__init__.py b/simplemonitor/util/__init__.py
index 10d89be..7882c84 100644
--- a/simplemonitor/util/__init__.py
+++ b/simplemonitor/util/__init__.py
@@ -124,10 +124,10 @@ def get_config_option(
 
     value = config_options.get(key, default)
     if required and value is None:
-        raise ValueError("config option {0} is missing and is required".format(key))
+        raise ValueError(f"config option {key} is missing and is required")
     if isinstance(value, str) and required_type:
         if required_type == "str" and value == "" and not allow_empty:
-            raise ValueError("config option {0} cannot be empty".format(key))
+            raise ValueError(f"config option {key} cannot be empty")
         if required_type in ["int", "float"]:
             try:
                 if required_type == "int":
@@ -136,37 +136,37 @@ def get_config_option(
                     value = float(value)
             except TypeError as error:
                 raise TypeError(
-                    "config option {0} needs to be an {1}".format(key, required_type)
+                    f"config option {key} needs to be an {required_type}"
                 ) from error
             if minimum is not None and value < minimum:
-                raise ValueError(
-                    "config option {0} needs to be >= {1}".format(key, minimum)
-                )
+                raise ValueError(f"config option {key} needs to be >= {minimum}")
             if maximum is not None and value > maximum:
+                raise ValueError(f"config option {key} needs to be <= {minimum}")
+        if required_type == "[int]":
+            if not isinstance(value, str):
                 raise ValueError(
-                    "config option {0} needs to be <= {1}".format(key, maximum)
+                    f"config option {key} needs to be a list of int,int,..."
                 )
-        if required_type == "[int]":
             try:
                 value = [int(x) for x in value.split(",")]
             except ValueError as error:
                 raise ValueError(
-                    "config option {0} needs to be a list of int[int,...]".format(key)
+                    f"config option {key} needs to be a list of int[int,...]"
                 ) from error
         if required_type == "bool":
             value = bool(str(value).lower() in ["1", "true", "yes"])
         if required_type == "[str]":
+            if not isinstance(value, str):
+                raise ValueError(
+                    f"config option {key} needs to be a list of int,int,..."
+                )
             value = [x.strip() for x in value.split(",")]
     if isinstance(value, list) and allowed_values:
-        if not all([x in allowed_values for x in value]):
-            raise ValueError(
-                "config option {0} needs to be one of {1}".format(key, allowed_values)
-            )
+        if not all(x in allowed_values for x in value):
+            raise ValueError(f"config option {key} needs to be one of {allowed_values}")
     else:
         if allowed_values is not None and value not in allowed_values:
-            raise ValueError(
-                "config option {0} needs to be one of {1}".format(key, allowed_values)
-            )
+            raise ValueError(f"config option {key} needs to be one of {allowed_values}")
     return value
 
 


Test patch:
diff --git a/tests/test_monitor.py b/tests/test_monitor.py
index 98bf4f1..a593a92 100644
--- a/tests/test_monitor.py
+++ b/tests/test_monitor.py
@@ -15,7 +15,6 @@ from simplemonitor.util import MonitorState, UpDownTime
 
 
 class TestMonitor(unittest.TestCase):
-
     safe_config = {"partition": "/", "limit": "10G"}
 
     one_KB = 1024
@@ -207,7 +206,7 @@ class TestMonitor(unittest.TestCase):
 
     def test_sighup(self):
         m = SimpleMonitor("tests/monitor-empty.ini")
-        m._load_monitors(Path("tests/monitors-prehup.ini"))
+        m._load_monitors([Path("tests/monitors-prehup.ini")])
         self.assertEqual(m._need_hup, False, "need_hup did not start False")
         m._handle_sighup(None, None)
         self.assertEqual(m._need_hup, True, "need_hup did not get set to True")
@@ -226,7 +225,7 @@ class TestMonitor(unittest.TestCase):
             m.monitors["monitor2"].host, "127.0.0.1", "monitor2 did not load correctly"
         )
 
-        m._load_monitors(Path("tests/monitors-posthup.ini"))
+        m._load_monitors([Path("tests/monitors-posthup.ini")])
         self.assertEqual(
             m.monitors["monitor1"].monitor_type, "null", "monitor1 changed type"
         )