Skip to content

Commit a3ff2dd

Browse files
authored
MAJOR: POSIX compliant, argument check, unit tests
- Updated command execution to be POSIX compliant, no longer requiring quotes around commands. - Added checks for arguments to ensure validity. - Enhanced unit tests to cover new argument handling and POSIX-compliant execution. - Improved error handling for invalid frequency values. - Working test and release cycling - Automatic version bumping
1 parent 07471c0 commit a3ff2dd

File tree

4 files changed

+270
-112
lines changed

4 files changed

+270
-112
lines changed

cli_monitor.py

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from datetime import datetime
55
# A script that runs a command at a specified frequency and optionally triggers another command based on regex matches
66
# ReadMe: https://github.com/Dimos082/cli-monitor/
7-
__version__ = "0.9.0"
7+
__version__ = "0.9.0"
88
# Constants:
99
MIN_FREQUENCY = 0.1 # Minimum allowed frequency (seconds)
1010
MAX_FREQUENCY = 100000 # Maximum allowed frequency (seconds)
@@ -14,16 +14,31 @@ class CLIArgumentParser:
1414
"""Parses command-line arguments."""
1515
@staticmethod
1616
def parse_args():
17-
p = argparse.ArgumentParser(description="CLI monitor with optional regex-based triggers.")
17+
class CustomFormatter(argparse.RawTextHelpFormatter, argparse.ArgumentDefaultsHelpFormatter):
18+
pass
19+
p = argparse.ArgumentParser(
20+
description="CLI monitor with optional regex-based triggers.",
21+
formatter_class=CustomFormatter
22+
)
1823
p.add_argument("-v", "--version", action="version", version=f"CLI Monitor {__version__}")
19-
p.add_argument("--command", required=True, help="Main command to run repeatedly.")
2024
p.add_argument("--output-file", help="Log file path; console only if omitted.")
2125
p.add_argument("--frequency", type=float, default=1.0, help="Seconds between each execution.")
22-
p.add_argument("--max-log-size", type=int, default=1024, help="Max log file size in KB.")
26+
p.add_argument("--max-log-size", type=int, default="1024", help="Max log file size in KB.")
2327
p.add_argument("--timer", type=float, default=0, help="Stop after N seconds (0 => infinite).")
2428
p.add_argument("--regex", help="Regex pattern to watch for in output.")
25-
p.add_argument("--regex-execute", help="Command to run once per iteration if regex matches.")
26-
return p.parse_args()
29+
p.add_argument("--command", nargs="+", metavar=("CMD", "ARGS"), required=True,
30+
help="Main command to run.\nExample: --command ls -la /")
31+
p.add_argument("--regex-execute", metavar=("CMD"), default=[],
32+
help="Command to execute when regex matches.\nExample: --regex-execute echo 'Match found'")
33+
args = p.parse_args()
34+
if args.max_log_size <= 0: # Ensure `--max-log-size` is valid
35+
p.error("--max-log-size must be greater than 0 KB")
36+
if "--max-log-size" in sys.argv and not args.output_file: # Ensure `--max-log-size` is ignored if `--output-file` is not set
37+
print("Warning: --max-log-size is ignored since --output-file is not set.")
38+
if not (MIN_FREQUENCY <= args.frequency <= MAX_FREQUENCY): # Ensure frequency is within the allowed range
39+
p.error(f"Error: Frequency must be between {MIN_FREQUENCY} and {MAX_FREQUENCY}.")
40+
exit(1)
41+
return args
2742

2843
class ErrorHandler:
2944
"""Logs command/script errors and increments exception counts."""
@@ -227,11 +242,8 @@ def _finalize_summary(self):
227242

228243
def main():
229244
cfg = CLIArgumentParser.parse_args()
230-
if not (MIN_FREQUENCY <= cfg.frequency <= MAX_FREQUENCY):
231-
print(f"Error: Frequency must be between {MIN_FREQUENCY} and {MAX_FREQUENCY}.")
232-
sys.exit(1)
233245
controller = CliMonitorController(cfg)
234246
controller.run()
235247

236248
if __name__ == "__main__":
237-
main()
249+
main()

tests/test_cli_monitor_basic.py

Lines changed: 61 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,61 @@
1-
import unittest
2-
import subprocess
3-
4-
class TestCliMonitorBasic(unittest.TestCase):
5-
"""Tests the most basic functionality of cli_monitor.py."""
6-
7-
def test_echo_command(self):
8-
"""Verify 'echo Hello' runs without errors and prints 'Hello'."""
9-
cmd = [
10-
"python", "cli_monitor.py",
11-
"--command", "echo Hello",
12-
"--timer", "2" # short run
13-
]
14-
result = subprocess.run(cmd, capture_output=True, text=True)
15-
self.assertEqual(result.returncode, 0)
16-
self.assertIn("Hello", result.stdout)
17-
18-
def test_invalid_frequency(self):
19-
"""Frequency outside allowed range should cause an error exit."""
20-
cmd = [
21-
"python", "cli_monitor.py",
22-
"--command", "echo Hello",
23-
"--frequency", "999999999" # way too large
24-
]
25-
result = subprocess.run(cmd, capture_output=True, text=True)
26-
# Expecting a non-zero exit code due to frequency validation
27-
self.assertNotEqual(result.returncode, 0)
28-
self.assertIn("Error: Frequency must be between", result.stdout)
29-
30-
if __name__ == "__main__":
31-
unittest.main()
1+
import unittest
2+
import subprocess
3+
import cli_monitor
4+
import platform
5+
6+
class TestCliMonitorBasic(unittest.TestCase):
7+
"""Tests the most basic functionality of cli_monitor.py."""
8+
9+
def test_command_parsing(self):
10+
"""Fixed version correctly passes the command as a list of arguments."""
11+
cmd = ["echo", "hello", "world"]
12+
result = cli_monitor.CommandExecutor.execute_command(cmd)
13+
# The command should be correctly split into arguments and executed as expected
14+
self.assertEqual(result[1].strip(), "hello world", "Fixed version should correctly pass arguments!")
15+
16+
def test_echo_command(self):
17+
"""Verify 'echo Hello' runs without errors and prints 'Hello'."""
18+
cmd = [
19+
"python", "cli_monitor.py",
20+
"--command", "echo", "Hello",
21+
"--timer", "2"
22+
]
23+
result = subprocess.run(cmd, capture_output=True, text=True)
24+
self.assertEqual(result.returncode, 0)
25+
self.assertIn("Hello", result.stdout)
26+
27+
def test_invalid_frequency(self):
28+
"""Frequency outside allowed range should cause an error exit."""
29+
cmd = [
30+
"python", "cli_monitor.py",
31+
"--command", "echo", "Hello",
32+
"--frequency", "999999999" # way too large
33+
]
34+
result = subprocess.run(cmd, capture_output=True, text=True)
35+
self.assertNotEqual(result.returncode, 0) # Expecting a non-zero exit code due to frequency validation
36+
self.assertIn("Frequency must be between", result.stderr) # Check stderr instead of stdout
37+
38+
def test_complex_command(self):
39+
"""Ensure complex commands with multiple arguments work correctly."""
40+
cmd = ["dir"] if platform.system() == "Windows" else ["ls", "-la", "/"]
41+
42+
result = subprocess.run(
43+
["python", "cli_monitor.py", "--timer", "2", "--command"] + cmd,
44+
capture_output=True, text=True
45+
)
46+
self.assertEqual(result.returncode, 0)
47+
self.assertIn("total" if platform.system() != "Windows" else "Directory", result.stdout)
48+
49+
def test_command_with_quotes(self):
50+
"""Ensure commands with quotes are properly handled."""
51+
cmd = [
52+
"python", "cli_monitor.py",
53+
"--command", "echo", "'Hello World'",
54+
"--timer", "2"
55+
]
56+
result = subprocess.run(cmd, capture_output=True, text=True)
57+
self.assertEqual(result.returncode, 0)
58+
self.assertIn("Hello World", result.stdout)
59+
60+
if __name__ == "__main__":
61+
unittest.main()

tests/test_cli_monitor_logging.py

Lines changed: 151 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,151 @@
1-
import unittest
2-
import subprocess
3-
import os
4-
5-
class TestCliMonitorLogging(unittest.TestCase):
6-
"""Tests output-file logging and log pruning."""
7-
8-
def setUp(self):
9-
self.log_file = "test_log_output.txt"
10-
# Remove log file if it exists
11-
if os.path.exists(self.log_file):
12-
os.remove(self.log_file)
13-
14-
def tearDown(self):
15-
# Clean up log file
16-
if os.path.exists(self.log_file):
17-
os.remove(self.log_file)
18-
19-
def test_log_pruning(self):
20-
"""Check that log file pruning occurs once it exceeds max size."""
21-
cmd = [
22-
"python", "cli_monitor.py",
23-
"--command", "echo TestLogLine",
24-
"--output-file", self.log_file,
25-
"--max-log-size", "1", # 1KB to force quick pruning
26-
"--timer", "3" # run a few iterations
27-
]
28-
result = subprocess.run(cmd, capture_output=True, text=True)
29-
self.assertEqual(result.returncode, 0)
30-
# We expect the log file to exist, but be under or ~1KB
31-
self.assertTrue(os.path.exists(self.log_file))
32-
file_size = os.path.getsize(self.log_file)
33-
self.assertLessEqual(file_size, 1024, "Log file should be pruned to <= 1KB")
34-
35-
if __name__ == "__main__":
36-
unittest.main()
1+
import unittest
2+
import os
3+
import subprocess
4+
5+
class TestLogPruning(unittest.TestCase):
6+
"""Tests log pruning logic for different max-log-size values."""
7+
8+
def setUp(self):
9+
"""Create a temporary log file for testing."""
10+
self.log_file = "test_log.txt"
11+
12+
def tearDown(self):
13+
"""Clean up the test log file after each test."""
14+
if os.path.exists(self.log_file):
15+
os.remove(self.log_file)
16+
17+
def test_log_pruning_1KB(self):
18+
"""Ensure pruning works correctly when max-log-size is 1KB."""
19+
cmd = [
20+
"python", "cli_monitor.py",
21+
"--command", "echo", "TestLogLine",
22+
"--output-file", self.log_file,
23+
"--max-log-size", "1", # 1 KB
24+
"--timer", "2",
25+
"--frequency", "0.1"
26+
]
27+
result = subprocess.run(cmd, capture_output=True, text=True)
28+
self.assertEqual(result.returncode, 0)
29+
30+
# Ensure log file is pruned correctly
31+
self.assertTrue(os.path.exists(self.log_file))
32+
file_size = os.path.getsize(self.log_file)
33+
self.assertLessEqual(file_size, 1024, f"Log file exceeded 1KB, actual: {file_size} bytes")
34+
35+
def test_log_pruning_10KB(self):
36+
"""Ensure pruning works correctly when max-log-size is 10KB."""
37+
cmd = [
38+
"python", "cli_monitor.py",
39+
"--command", "echo", "TestLogLine",
40+
"--output-file", self.log_file,
41+
"--max-log-size", "10", # 10 KB
42+
"--timer", "3",
43+
"--frequency", "0.1"
44+
]
45+
result = subprocess.run(cmd, capture_output=True, text=True)
46+
self.assertEqual(result.returncode, 0)
47+
48+
# Ensure log file is pruned correctly
49+
self.assertTrue(os.path.exists(self.log_file))
50+
file_size = os.path.getsize(self.log_file)
51+
self.assertLessEqual(file_size, 10 * 1024, f"Log file exceeded 10KB, actual: {file_size} bytes")
52+
53+
def test_log_pruning_100KB(self):
54+
"""Ensure pruning works correctly when max-log-size is 100KB."""
55+
cmd = [
56+
"python", "cli_monitor.py",
57+
"--command", "echo", "TestLogLine",
58+
"--output-file", self.log_file,
59+
"--max-log-size", "100", # 100 KB
60+
"--timer", "5",
61+
"--frequency", "0.1"
62+
]
63+
result = subprocess.run(cmd, capture_output=True, text=True)
64+
self.assertEqual(result.returncode, 0)
65+
66+
# Ensure log file is pruned correctly
67+
self.assertTrue(os.path.exists(self.log_file))
68+
file_size = os.path.getsize(self.log_file)
69+
self.assertLessEqual(file_size, 100 * 1024, f"Log file exceeded 100KB, actual: {file_size} bytes")
70+
71+
def test_log_pruning_min_size(self):
72+
"""Test with the smallest allowed log size (1KB)."""
73+
cmd = [
74+
"python", "cli_monitor.py",
75+
"--command", "echo", "TestLogLine",
76+
"--output-file", self.log_file,
77+
"--max-log-size", "1", # 1 KB (Minimum)
78+
"--timer", "2"
79+
]
80+
result = subprocess.run(cmd, capture_output=True, text=True)
81+
self.assertEqual(result.returncode, 0)
82+
self.assertTrue(os.path.exists(self.log_file))
83+
file_size = os.path.getsize(self.log_file)
84+
self.assertLessEqual(file_size, 1024, f"Log file exceeded 1KB, actual: {file_size} bytes")
85+
86+
def test_log_pruning_large_size(self):
87+
"""Test with a very large log size (10MB) to check if it still works."""
88+
cmd = [
89+
"python", "cli_monitor.py",
90+
"--command", "echo", "TestLogLine",
91+
"--output-file", self.log_file,
92+
"--max-log-size", "10240", # 10MB
93+
"--timer", "2"
94+
]
95+
result = subprocess.run(cmd, capture_output=True, text=True)
96+
self.assertEqual(result.returncode, 0)
97+
self.assertTrue(os.path.exists(self.log_file))
98+
file_size = os.path.getsize(self.log_file)
99+
self.assertLessEqual(file_size, 10 * 1024 * 1024, f"Log file exceeded 10MB, actual: {file_size} bytes")
100+
101+
def test_negative_log_size(self):
102+
"""Ensure negative log size is rejected."""
103+
cmd = [
104+
"python", "cli_monitor.py",
105+
"--command", "echo", "TestLogLine",
106+
"--output-file", self.log_file,
107+
"--max-log-size", "-5" # Negative values should not be allowed
108+
]
109+
result = subprocess.run(cmd, capture_output=True, text=True)
110+
self.assertNotEqual(result.returncode, 0) # Should fail
111+
self.assertIn("error", result.stderr.lower()) # Expect an error message
112+
113+
def test_zero_log_size(self):
114+
"""Ensure zero log size is rejected."""
115+
cmd = [
116+
"python", "cli_monitor.py",
117+
"--command", "echo", "TestLogLine",
118+
"--output-file", self.log_file,
119+
"--max-log-size", "0" # Zero is invalid
120+
]
121+
result = subprocess.run(cmd, capture_output=True, text=True)
122+
self.assertNotEqual(result.returncode, 0) # Should fail
123+
self.assertIn("error", result.stderr.lower()) # Expect an error message
124+
125+
def test_non_integer_log_size(self):
126+
"""Ensure non-integer values for max-log-size are rejected."""
127+
cmd = [
128+
"python", "cli_monitor.py",
129+
"--command", "echo", "TestLogLine",
130+
"--output-file", self.log_file,
131+
"--max-log-size", "ABC" # Invalid string
132+
]
133+
result = subprocess.run(cmd, capture_output=True, text=True)
134+
self.assertNotEqual(result.returncode, 0) # Should fail
135+
self.assertIn("invalid", result.stderr.lower()) # Expect an error message
136+
137+
def test_max_log_size_without_output_file(self):
138+
"""Ensure using --max-log-size without --output-file prints a warning but does not fail."""
139+
cmd = [
140+
"python", "cli_monitor.py",
141+
"--command", "echo", "TestLogLine",
142+
"--max-log-size", "10", # ✅ Should trigger a warning but not an error
143+
"--timer", "1"
144+
]
145+
result = subprocess.run(cmd, capture_output=True, text=True)
146+
self.assertEqual(result.returncode, 0)
147+
warning_message = "Warning: --max-log-size is ignored since --output-file is not set.".lower()
148+
self.assertTrue( # Check for the warning in both stdout and stderr
149+
warning_message in result.stderr.lower() or warning_message in result.stdout.lower(),
150+
f"Expected warning not found. Output received:\nSTDOUT: {result.stdout}\nSTDERR: {result.stderr}"
151+
)

0 commit comments

Comments
 (0)