procwatch-cli 0.1.1__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.
procwatch/__init__.py ADDED
File without changes
procwatch/cli.py ADDED
@@ -0,0 +1,376 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import argparse
4
+ import json
5
+ import logging
6
+ import os
7
+ import shlex
8
+ import subprocess
9
+ import sys
10
+ import time
11
+ from typing import Any, List, Optional
12
+
13
+ import psutil
14
+
15
+ # ----------------------------
16
+ # Presets (Variables)
17
+ # ----------------------------
18
+
19
+ PID = "pid"
20
+ NAME = "name"
21
+ USERNAME = "username"
22
+ STATUS = "status"
23
+ CMDLINE = "cmdline"
24
+ PROCESS_FIELDS = [PID, NAME, USERNAME, STATUS, CMDLINE]
25
+ assert all(f in psutil.Process().as_dict().keys() for f in PROCESS_FIELDS)
26
+ CONNECTIONS = "connections"
27
+
28
+
29
+ NO_MATCH_FOUND = "No matching process found."
30
+
31
+
32
+ # ----------------------------
33
+ # Logging
34
+ # ----------------------------
35
+
36
+
37
+ class JsonFormatter(logging.Formatter):
38
+ def format(self, record):
39
+ log_record = {
40
+ "time": self.formatTime(record),
41
+ "level": record.levelname,
42
+ "message": record.getMessage(),
43
+ }
44
+ return json.dumps(log_record)
45
+
46
+
47
+ def log_config(logfile: str | None = None, json_out: bool = False):
48
+ formatter = (
49
+ JsonFormatter()
50
+ if json_out
51
+ else logging.Formatter("%(asctime)s | %(levelname)-8s | %(message)s")
52
+ )
53
+ logger = logging.getLogger()
54
+ logger.setLevel(logging.INFO)
55
+
56
+ # stdout handler
57
+ stream_handler = logging.StreamHandler(sys.stdout)
58
+ stream_handler.setFormatter(formatter)
59
+ logger.addHandler(stream_handler)
60
+
61
+ # file handler
62
+
63
+ if logfile:
64
+ try:
65
+ file_handler = logging.FileHandler(logfile)
66
+ file_handler.setFormatter(formatter)
67
+ logger.addHandler(file_handler)
68
+ except (PermissionError, FileNotFoundError) as e:
69
+ logger.error(f"Log File Error: {e}")
70
+
71
+ return logger
72
+
73
+
74
+ # ----------------------------
75
+ # CLI
76
+ # ----------------------------
77
+
78
+
79
+ def valid_port(value: str) -> int:
80
+ port = int(value)
81
+ if not 1 <= port <= 65535:
82
+ raise argparse.ArgumentTypeError("port must be between 1 and 65535")
83
+ return port
84
+
85
+
86
+ def valid_pid(value: str) -> int:
87
+ pid = int(value)
88
+ if pid <= 0:
89
+ raise argparse.ArgumentTypeError("PID must be > 0")
90
+ return pid
91
+
92
+
93
+ def parse_args(args: list[str] | None = None) -> argparse.Namespace:
94
+ parser = argparse.ArgumentParser(description="Process watchdog")
95
+
96
+ group = parser.add_mutually_exclusive_group(required=True)
97
+ group.add_argument("--pid", type=valid_pid, help="Monitor specific PID, (PID > 0)")
98
+ group.add_argument(
99
+ "--port", type=valid_port, help="Monitor service by port, (1 <= port <= 65535)"
100
+ )
101
+ group.add_argument(
102
+ "--name", type=str.lower, help="Monitor process by name, (substring match)"
103
+ )
104
+
105
+ parser.add_argument("--first", action="store_true", help="Match first process only")
106
+ parser.add_argument("--print", action="store_true", help="Print matching processes")
107
+
108
+ parser.add_argument(
109
+ "--interval",
110
+ type=int,
111
+ help="Monitor continuously every N seconds",
112
+ )
113
+
114
+ parser.add_argument(
115
+ "--restart-cmd",
116
+ type=str,
117
+ help="Command to restart the service",
118
+ )
119
+
120
+ parser.add_argument("--log-file", type=str, help="Output log to log-file")
121
+ parser.add_argument("--json", action="store_true", help="Output in json")
122
+ parser.add_argument("--pretty", action="store_true", help="Output in pretty json")
123
+
124
+ args_parsed = parser.parse_args(args)
125
+
126
+ if args_parsed.pretty and not args_parsed.json:
127
+ parser.error("--pretty requires --json")
128
+
129
+ if args_parsed.first and not (args_parsed.name or args_parsed.port):
130
+ parser.error("--first requires --name or --port")
131
+
132
+ return args_parsed
133
+
134
+
135
+ # ----------------------------
136
+ # Process Filters
137
+ # ----------------------------
138
+
139
+
140
+ def get_self_and_parent_pids() -> set[int]:
141
+ pids = set()
142
+ p: psutil.Process | None = psutil.Process(os.getpid())
143
+
144
+ while p:
145
+ pids.add(p.pid)
146
+ p = p.parent()
147
+
148
+ return pids
149
+
150
+
151
+ def filter_by_pid(pid: int) -> Optional[psutil.Process]:
152
+ try:
153
+ return psutil.Process(pid)
154
+ except psutil.Error:
155
+ return None
156
+ except (ValueError, TypeError) as e:
157
+ print(f"Error pid filter: {e}")
158
+ raise SystemExit(1)
159
+
160
+
161
+ def filter_by_name(name: str, first: bool = False) -> List[psutil.Process]:
162
+ results = []
163
+ ignore_pids = get_self_and_parent_pids()
164
+
165
+ for proc in psutil.process_iter([NAME, CMDLINE]):
166
+ try:
167
+ if proc.pid in ignore_pids:
168
+ continue
169
+
170
+ proc_name = (proc.info[NAME] or "").lower()
171
+ cmd = " ".join(proc.info[CMDLINE] or []).lower()
172
+
173
+ # exact match of name means no more search
174
+ if name == cmd:
175
+ return [proc]
176
+
177
+ # part of name match means more search
178
+ if name in proc_name or name in cmd:
179
+ results.append(proc)
180
+
181
+ if first:
182
+ break
183
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
184
+ continue
185
+
186
+ return results
187
+
188
+
189
+ def filter_by_port(port: int, first: bool = False) -> List[psutil.Process]:
190
+ results: set[psutil.Process] = set()
191
+ ignore_pids = get_self_and_parent_pids()
192
+
193
+ try:
194
+ for conn in psutil.net_connections(kind="inet"):
195
+ # skip if no address or wrong port
196
+ if not conn.laddr or conn.laddr.port != port:
197
+ continue
198
+
199
+ pid = conn.pid
200
+
201
+ # warn if port matches but could not retrieve pid
202
+ if conn.laddr.port == port and pid is None:
203
+ logging.error(
204
+ f"PIDERROR: Port Found - '{conn.laddr}' but PID is {pid}. Sign of PermissionError."
205
+ )
206
+ continue
207
+
208
+ # skip kernel sockets or our own process tree
209
+ if pid is None or pid in ignore_pids:
210
+ continue
211
+
212
+ try:
213
+ proc = psutil.Process(pid)
214
+ results.add(proc)
215
+
216
+ if first:
217
+ break
218
+
219
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
220
+ continue
221
+
222
+ except psutil.Error:
223
+ pass
224
+
225
+ return list(results)
226
+
227
+
228
+ # ----------------------------
229
+ # Restart Logic
230
+ # ----------------------------
231
+
232
+
233
+ def restart_service(command: str, timeout: int = 10) -> bool:
234
+ try:
235
+ logging.warning(f"Restarting service using: {command}")
236
+
237
+ result = subprocess.run(
238
+ shlex.split(command),
239
+ capture_output=True,
240
+ text=True,
241
+ timeout=timeout,
242
+ )
243
+
244
+ if result.returncode == 0:
245
+ logging.info("Restart successful")
246
+ return True
247
+
248
+ logging.error(result.stderr.strip())
249
+ return False
250
+
251
+ except subprocess.TimeoutExpired:
252
+ logging.error("Restart command timed out")
253
+ return False
254
+
255
+ except Exception as e:
256
+ logging.error(f"Restart failed: {e}")
257
+ return False
258
+
259
+
260
+ # ----------------------------
261
+ # Output
262
+ # ----------------------------
263
+
264
+
265
+ def output_processes(
266
+ processes: List[psutil.Process],
267
+ json_out: bool = False,
268
+ pretty: bool = True,
269
+ conn_port: int | None = None,
270
+ ):
271
+ if not processes:
272
+ logging.error(f"OUTPUT: {NO_MATCH_FOUND}")
273
+ return
274
+
275
+ data: list[dict[str, Any]] = []
276
+
277
+ for p in processes:
278
+ try:
279
+ # 1. Get the standard fields first
280
+ proc_dict = p.as_dict(PROCESS_FIELDS)
281
+
282
+ # 2. Try to get network info (the high-privilege part)
283
+ try:
284
+ conns = [
285
+ conn.laddr
286
+ for conn in p.net_connections(kind="inet")
287
+ if conn.laddr
288
+ and (conn_port is None or conn.laddr.port == conn_port)
289
+ ]
290
+ proc_dict[CONNECTIONS] = conns
291
+ except psutil.AccessDenied as e:
292
+ logging.error(
293
+ f"OUTPUT: Access Denied for PID {p.pid}: {e}. "
294
+ "Running with root privileges might help."
295
+ )
296
+ proc_dict[CONNECTIONS] = None
297
+
298
+ data.append(proc_dict)
299
+
300
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
301
+ # If the process disappears or we can't even get basic info, skip it
302
+ continue
303
+ except psutil.Error:
304
+ continue
305
+
306
+ # Output section
307
+ if json_out:
308
+ indent = 4 if pretty else None
309
+ print(json.dumps(data, indent=indent))
310
+ else:
311
+ # Renamed variable to 'item' to avoid Mypy type conflict with 'p'
312
+ for item in data:
313
+ print(item)
314
+
315
+
316
+ # ----------------------------
317
+ # Monitor Loop
318
+ # ----------------------------
319
+
320
+
321
+ def check_process(args: argparse.Namespace) -> List[psutil.Process]:
322
+
323
+ if args.pid:
324
+ proc = filter_by_pid(args.pid)
325
+ return [proc] if proc else []
326
+
327
+ if args.name:
328
+ return filter_by_name(args.name, args.first)
329
+
330
+ if args.port:
331
+ return filter_by_port(args.port, args.first)
332
+
333
+ return []
334
+
335
+
336
+ def run_watchdog(args: argparse.Namespace):
337
+
338
+ while True:
339
+ processes = check_process(args)
340
+
341
+ if processes:
342
+ logging.info("Service healthy. No restart required.")
343
+ else:
344
+ logging.warning("Service not running")
345
+
346
+ if args.restart_cmd:
347
+ restart_service(args.restart_cmd)
348
+ if args.print:
349
+ output_processes(processes, args.json, args.pretty, args.port)
350
+
351
+ if not args.interval:
352
+ break
353
+
354
+ time.sleep(args.interval)
355
+
356
+
357
+ # ----------------------------
358
+ # Main
359
+ # ----------------------------
360
+
361
+
362
+ def main():
363
+ args = parse_args()
364
+ log_file = args.log_file
365
+
366
+ try:
367
+ log_config(log_file, args.json)
368
+ run_watchdog(args)
369
+ except KeyboardInterrupt:
370
+ logging.info("Watchdog stopped")
371
+ except Exception:
372
+ return
373
+
374
+
375
+ if __name__ == "__main__":
376
+ main()
@@ -0,0 +1,39 @@
1
+ Metadata-Version: 2.4
2
+ Name: procwatch-cli
3
+ Version: 0.1.1
4
+ Summary: Process watchdog CLI for monitoring and restarting services
5
+ Author: Your Name
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: psutil>=5.9
10
+ Provides-Extra: dev
11
+ Requires-Dist: types-psutil; extra == "dev"
12
+ Dynamic: license-file
13
+
14
+ # 🔹 Task 3: Service Monitor & Auto-Restart
15
+
16
+ ## Goal:
17
+ Monitor if a process is running. If not, restart it.
18
+
19
+ ## Example:
20
+ `procwatch --pid 34349`
21
+ <!-- `procwatch --pidfile app.pid` -->
22
+ `procwatch --match "python server.py"`
23
+ `procwatch --port 8000`
24
+
25
+ ## Requirements:
26
+ - Check every N seconds
27
+ - Log events
28
+ - Handle failures
29
+ - Timeout protection
30
+
31
+ ## Multiple cli
32
+ `procwatch --match "python -m http.server 8000" --restart "python -m http.server 8000"`
33
+ `procwatch --port 8000 --restart "/usr/bin/startmyapp" --interval 4`
34
+
35
+ ## Skills Learned:
36
+ - Loop automation
37
+ - Subprocess execution
38
+ - Timeouts
39
+ - Basic watchdog design
@@ -0,0 +1,8 @@
1
+ procwatch/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ procwatch/cli.py,sha256=u_AiNASuUeIe8PLpoX33GI8kTTaNUpk3W9KYvQ9V4H8,9725
3
+ procwatch_cli-0.1.1.dist-info/licenses/LICENSE,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ procwatch_cli-0.1.1.dist-info/METADATA,sha256=chI5TXZHA2xF5AVqgJfmRrZ-iCgXZgKP9ecL0k4yK8g,963
5
+ procwatch_cli-0.1.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
6
+ procwatch_cli-0.1.1.dist-info/entry_points.txt,sha256=2zgMkPdC4Y4XjZyro6PR29u7iNbo7fldP8V7lGKHrHg,49
7
+ procwatch_cli-0.1.1.dist-info/top_level.txt,sha256=PcGcas9WIR248safqQbbB5UWSu60PoyZtiYVDQ-MhZc,10
8
+ procwatch_cli-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ procwatch = procwatch.cli:main
File without changes
@@ -0,0 +1 @@
1
+ procwatch