robotframework-pabot 5.1.0__py3-none-any.whl → 5.2.0rc1__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.
@@ -0,0 +1,415 @@
1
+ import os
2
+ import sys
3
+ import time
4
+ import threading
5
+ import subprocess
6
+ import datetime
7
+ import queue
8
+ import locale
9
+ import signal
10
+
11
+ try:
12
+ import psutil
13
+ except ImportError:
14
+ psutil = None
15
+
16
+ from .writer import get_writer, Color
17
+
18
+
19
+ def split_on_first(lst, value):
20
+ for i, x in enumerate(lst):
21
+ if x == value:
22
+ return lst[:i], lst[i+1:]
23
+ return lst, []
24
+
25
+
26
+ class ProcessManager:
27
+ def __init__(self):
28
+ self.processes = []
29
+ self.lock = threading.Lock()
30
+ self.writer = get_writer()
31
+ self.interrupted = False
32
+ # Note: Signal handling is done in pabot.py's main_program() to ensure
33
+ # PabotLib is shut down gracefully before process termination
34
+ # This ProcessManager will check the interrupted flag set by pabot.py's keyboard_interrupt()
35
+
36
+ def set_interrupted(self):
37
+ """Called by pabot.py when CTRL+C is received."""
38
+ self.interrupted = True
39
+
40
+ # -------------------------------
41
+ # OUTPUT STREAM READERS
42
+ # -------------------------------
43
+
44
+ def _enqueue_output(self, pipe, q):
45
+ """
46
+ Reads lines from `pipe` and puts them into queue `q`.
47
+ When pipe is exhausted, pushes `None` sentinel.
48
+ """
49
+ try:
50
+ with pipe:
51
+ for line in iter(pipe.readline, b""):
52
+ q.put(line)
53
+ finally:
54
+ q.put(None) # sentinel → "this stream is finished"
55
+
56
+ def _safe_write_to_stream(self, stream, text):
57
+ """
58
+ Writes text safely to an output stream.
59
+ If encoding errors occur, fall back to bytes/replace.
60
+ """
61
+ try:
62
+ stream.write(text)
63
+ try:
64
+ stream.flush()
65
+ except Exception:
66
+ pass
67
+ return
68
+ except UnicodeEncodeError:
69
+ pass
70
+ except Exception:
71
+ pass
72
+
73
+ enc = getattr(stream, "encoding", None) or locale.getpreferredencoding(False) or "utf-8"
74
+
75
+ try:
76
+ b = text.encode(enc, errors="replace")
77
+ if hasattr(stream, "buffer"):
78
+ try:
79
+ stream.buffer.write(b)
80
+ stream.buffer.write(b"\n")
81
+ stream.buffer.flush()
82
+ return
83
+ except Exception:
84
+ pass
85
+
86
+ safe = b.decode(enc, errors="replace")
87
+ stream.write(safe + "\n")
88
+ stream.flush()
89
+ except Exception:
90
+ try:
91
+ print(text)
92
+ except Exception:
93
+ pass
94
+
95
+ # -------------------------------
96
+ # STREAM OUTPUT MERGER
97
+ # -------------------------------
98
+
99
+ def _stream_output(self, process, stdout=None, stderr=None,
100
+ item_name="process", log_file=None):
101
+
102
+ q_out = queue.Queue()
103
+ q_err = queue.Queue()
104
+
105
+ t_out = None
106
+ t_err = None
107
+
108
+ if process.stdout:
109
+ t_out = threading.Thread(target=self._enqueue_output, args=(process.stdout, q_out))
110
+ t_out.daemon = True
111
+ t_out.start()
112
+
113
+ if process.stderr:
114
+ t_err = threading.Thread(target=self._enqueue_output, args=(process.stderr, q_err))
115
+ t_err.daemon = True
116
+ t_err.start()
117
+
118
+ stdout_done = False
119
+ stderr_done = False
120
+
121
+ log_handle = None
122
+ if log_file:
123
+ os.makedirs(os.path.dirname(log_file), exist_ok=True)
124
+ log_handle = open(log_file, "a", encoding="utf-8")
125
+
126
+ try:
127
+ while True:
128
+ now = datetime.datetime.now()
129
+
130
+ # STDOUT
131
+ if not stdout_done:
132
+ try:
133
+ line = q_out.get(timeout=0.05)
134
+ if line is None:
135
+ stdout_done = True
136
+ else:
137
+ msg = line.decode(errors="replace").rstrip()
138
+ self._safe_write_to_stream(stdout or sys.stdout, msg + "\n")
139
+ if log_handle:
140
+ log_handle.write(f"{now} {msg}\n")
141
+ except queue.Empty:
142
+ pass
143
+
144
+ # STDERR
145
+ if not stderr_done:
146
+ try:
147
+ line = q_err.get_nowait()
148
+ if line is None:
149
+ stderr_done = True
150
+ else:
151
+ msg = line.decode(errors="replace").rstrip()
152
+ self._safe_write_to_stream(stderr or sys.stderr, msg + "\n")
153
+ if log_handle:
154
+ log_handle.write(f"{now} {msg}\n")
155
+ except queue.Empty:
156
+ pass
157
+
158
+ # Terminate when both streams finished
159
+ if stdout_done and stderr_done:
160
+ break
161
+
162
+ finally:
163
+ if t_out:
164
+ t_out.join()
165
+ if t_err:
166
+ t_err.join()
167
+ if log_handle:
168
+ log_handle.close()
169
+
170
+ # -------------------------------
171
+ # PROCESS CREATION
172
+ # -------------------------------
173
+
174
+ def _start_process(self, cmd, env=None):
175
+ if sys.platform == "win32":
176
+ return subprocess.Popen(
177
+ cmd,
178
+ stdout=subprocess.PIPE,
179
+ stderr=subprocess.PIPE,
180
+ env=env,
181
+ shell=False,
182
+ creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,
183
+ )
184
+ else:
185
+ return subprocess.Popen(
186
+ cmd,
187
+ stdout=subprocess.PIPE,
188
+ stderr=subprocess.PIPE,
189
+ env=env,
190
+ shell=False,
191
+ preexec_fn=os.setsid,
192
+ )
193
+
194
+ # -------------------------------
195
+ # PROCESS TREE TERMINATION
196
+ # -------------------------------
197
+
198
+ def _terminate_tree(self, process):
199
+ if process.poll() is not None:
200
+ return
201
+
202
+ self.writer.write(
203
+ f"[ProcessManager] Terminating process tree PID={process.pid}",
204
+ level='debug'
205
+ )
206
+
207
+ # PRIMARY: psutil (best reliability)
208
+ if psutil:
209
+ try:
210
+ parent = psutil.Process(process.pid)
211
+ children = parent.children(recursive=True)
212
+ for c in children:
213
+ try:
214
+ c.terminate()
215
+ except Exception:
216
+ pass
217
+ psutil.wait_procs(children, timeout=5)
218
+
219
+ for c in children:
220
+ if c.is_running():
221
+ try:
222
+ c.kill()
223
+ except Exception:
224
+ pass
225
+
226
+ try:
227
+ parent.terminate()
228
+ except Exception:
229
+ pass
230
+
231
+ try:
232
+ parent.wait(timeout=5)
233
+ except psutil.TimeoutExpired:
234
+ try:
235
+ parent.kill()
236
+ except Exception:
237
+ pass
238
+
239
+ return
240
+ except Exception:
241
+ pass
242
+
243
+ # FALLBACK — Windows
244
+ if sys.platform == "win32":
245
+ subprocess.run(
246
+ ["taskkill", "/PID", str(process.pid), "/T", "/F"],
247
+ stdout=subprocess.DEVNULL,
248
+ stderr=subprocess.DEVNULL,
249
+ )
250
+ return
251
+
252
+ # FALLBACK — Linux / macOS
253
+ try:
254
+ os.killpg(os.getpgid(process.pid), signal.SIGTERM)
255
+ time.sleep(2)
256
+ if process.poll() is None:
257
+ os.killpg(os.getpgid(process.pid), signal.SIGKILL)
258
+ except Exception:
259
+ if process.poll() is None:
260
+ try:
261
+ process.kill()
262
+ except Exception:
263
+ pass
264
+
265
+ try:
266
+ process.wait(timeout=5)
267
+ except Exception:
268
+ pass
269
+
270
+ # -------------------------------
271
+ # PUBLIC API
272
+ # -------------------------------
273
+
274
+ def terminate_all(self):
275
+ with self.lock:
276
+ for p in list(self.processes):
277
+ self._terminate_tree(p)
278
+ self.processes.clear()
279
+
280
+ def run(self, cmd, *, env=None, stdout=None, stderr=None,
281
+ timeout=None, verbose=False, item_name="process",
282
+ log_file=None, pool_id=0, item_index=0):
283
+
284
+ start = time.time()
285
+ process = self._start_process(cmd, env)
286
+
287
+ with self.lock:
288
+ self.processes.append(process)
289
+
290
+ ts = datetime.datetime.now()
291
+
292
+ if verbose:
293
+ self.writer.write(
294
+ f"{ts} [PID:{process.pid}] [{pool_id}] [ID:{item_index}] "
295
+ f"EXECUTING PARALLEL {item_name}:\n{' '.join(cmd)}",
296
+ level='debug'
297
+ )
298
+ else:
299
+ self.writer.write(
300
+ f"{ts} [PID:{process.pid}] [{pool_id}] [ID:{item_index}] EXECUTING {item_name}",
301
+ level='debug'
302
+ )
303
+
304
+ # Start logging thread
305
+ log_thread = threading.Thread(
306
+ target=self._stream_output,
307
+ args=(process, stdout, stderr, item_name, log_file),
308
+ )
309
+ log_thread.daemon = True
310
+ log_thread.start()
311
+
312
+ rc = None
313
+ ping_interval = 50 # 5s
314
+ next_ping = ping_interval
315
+ counter = 0
316
+
317
+ while rc is None:
318
+ rc = process.poll()
319
+
320
+ # INTERRUPT CHECK - terminate process gracefully when CTRL+C is pressed
321
+ if self.interrupted:
322
+ ts = datetime.datetime.now()
323
+ self.writer.write(
324
+ f"{ts} [PID:{process.pid}] [{pool_id}] [ID:{item_index}] "
325
+ f"Process {item_name} interrupted by user (Ctrl+C)",
326
+ color=Color.YELLOW, level='warning'
327
+ )
328
+ self._terminate_tree(process)
329
+ rc = -1
330
+
331
+ # Dryrun process to mark all tests as failed due to user interrupt
332
+ this_dir = os.path.dirname(os.path.abspath(__file__))
333
+ listener_path = os.path.join(this_dir, "listener", "interrupt_listener.py")
334
+ dry_run_env = env.copy() if env else os.environ.copy()
335
+ before, after = split_on_first(cmd, "-A")
336
+ dryrun_cmd = before + ["--dryrun", '--listener', listener_path, '-A'] + after
337
+
338
+ self.writer.write(
339
+ f"{ts} [PID:{process.pid}] [{pool_id}] [ID:{item_index}] "
340
+ f"Starting dry run to mark test as failed due to user interrupt: {' '.join(dryrun_cmd)}",
341
+ level='debug'
342
+ )
343
+ try:
344
+ subprocess.run(
345
+ dryrun_cmd,
346
+ env=dry_run_env,
347
+ stdout=subprocess.PIPE,
348
+ stderr=subprocess.PIPE,
349
+ timeout=3,
350
+ text=True,
351
+ )
352
+ except subprocess.TimeoutExpired as e:
353
+ self.writer.write(f"Dry-run timed out after 3s: {e}", level='debug')
354
+ break
355
+
356
+ # TIMEOUT CHECK
357
+ if timeout and (time.time() - start > timeout):
358
+ ts = datetime.datetime.now()
359
+ self.writer.write(
360
+ f"{ts} [PID:{process.pid}] [{pool_id}] [ID:{item_index}] "
361
+ f"Process {item_name} killed due to exceeding the maximum timeout of {timeout} seconds",
362
+ color=Color.YELLOW, level='warning'
363
+ )
364
+ self._terminate_tree(process)
365
+ rc = -1
366
+
367
+ # Dryrun process to mark all tests as failed due to timeout
368
+ this_dir = os.path.dirname(os.path.abspath(__file__))
369
+ listener_path = os.path.join(this_dir, "listener", "timeout_listener.py")
370
+ dry_run_env = env.copy() if env else os.environ.copy()
371
+ before, after = split_on_first(cmd, "-A")
372
+ dryrun_cmd = before + ["--dryrun", '--listener', listener_path, '-A'] + after
373
+
374
+ self.writer.write(
375
+ f"{ts} [PID:{process.pid}] [{pool_id}] [ID:{item_index}] "
376
+ f"Starting dry run to mark test as failed due to timeout: {' '.join(dryrun_cmd)}",
377
+ level='debug'
378
+ )
379
+ try:
380
+ subprocess.run(
381
+ dryrun_cmd,
382
+ env=dry_run_env,
383
+ stdout=subprocess.PIPE,
384
+ stderr=subprocess.PIPE,
385
+ timeout=3,
386
+ text=True,
387
+ )
388
+ except subprocess.TimeoutExpired as e:
389
+ self.writer.write(f"Dry-run timed out after 3s: {e}", level='debug')
390
+
391
+ break
392
+
393
+ # Progress ping
394
+ if counter == next_ping:
395
+ ts = datetime.datetime.now()
396
+ self.writer.write(
397
+ f"{ts} [PID:{process.pid}] [{pool_id}] [ID:{item_index}] still running "
398
+ f"{item_name} after {(counter * 0.1):.1f}s",
399
+ level='debug'
400
+ )
401
+ ping_interval += 50
402
+ next_ping += ping_interval
403
+
404
+ time.sleep(0.1)
405
+ counter += 1
406
+
407
+ log_thread.join()
408
+
409
+ elapsed = round(time.time() - start, 1)
410
+
411
+ with self.lock:
412
+ if process in self.processes:
413
+ self.processes.remove(process)
414
+
415
+ return process, (rc, elapsed)
pabot/__init__.py CHANGED
@@ -7,4 +7,4 @@ try:
7
7
  except ImportError:
8
8
  pass
9
9
 
10
- __version__ = "5.1.0"
10
+ __version__ = "5.2.0rc1"
pabot/arguments.py CHANGED
@@ -151,6 +151,11 @@ def _parse_artifacts(arg):
151
151
 
152
152
 
153
153
  def _parse_pabot_args(args): # type: (List[str]) -> Tuple[List[str], Dict[str, object]]
154
+ """
155
+ Parse pabot-specific command line arguments.
156
+ Supports new --ordering syntax:
157
+ --ordering <file> [static|dynamic] [skip|run_all]
158
+ """
154
159
  pabot_args = {
155
160
  "command": ["pybot" if ROBOT_VERSION < "3.1" else "robot"],
156
161
  "verbose": False,
@@ -169,8 +174,10 @@ def _parse_pabot_args(args): # type: (List[str]) -> Tuple[List[str], Dict[str,
169
174
  "shardcount": 1,
170
175
  "chunk": False,
171
176
  "no-rebot": False,
177
+ "pabotconsole": "verbose",
172
178
  }
173
- # Explicitly define argument types for validation
179
+
180
+ # Arguments that are flags (boolean)
174
181
  flag_args = {
175
182
  "verbose",
176
183
  "help",
@@ -178,8 +185,10 @@ def _parse_pabot_args(args): # type: (List[str]) -> Tuple[List[str], Dict[str,
178
185
  "pabotlib",
179
186
  "artifactsinsubfolders",
180
187
  "chunk",
181
- "no-rebot"
188
+ "no-rebot",
182
189
  }
190
+
191
+ # Arguments that expect values
183
192
  value_args = {
184
193
  "hive": str,
185
194
  "processes": lambda x: int(x) if x != "all" else None,
@@ -188,17 +197,18 @@ def _parse_pabot_args(args): # type: (List[str]) -> Tuple[List[str], Dict[str,
188
197
  "pabotlibport": int,
189
198
  "pabotprerunmodifier": str,
190
199
  "processtimeout": int,
191
- "ordering": str,
200
+ "ordering": str, # special handling below
192
201
  "suitesfrom": str,
193
202
  "artifacts": _parse_artifacts,
194
203
  "shard": _parse_shard,
204
+ "pabotconsole": str,
195
205
  }
196
206
 
197
207
  argumentfiles = []
198
208
  remaining_args = []
199
209
  i = 0
200
210
 
201
- # Track conflicting options during parsing
211
+ # Track conflicting pabotlib options
202
212
  saw_pabotlib_flag = False
203
213
  saw_no_pabotlib = False
204
214
 
@@ -209,19 +219,20 @@ def _parse_pabot_args(args): # type: (List[str]) -> Tuple[List[str], Dict[str,
209
219
  i += 1
210
220
  continue
211
221
 
212
- arg_name = arg[2:] # Strip '--'
222
+ arg_name = arg[2:] # remove leading '--'
213
223
 
224
+ # Handle mutually exclusive pabotlib flags
214
225
  if arg_name == "no-pabotlib":
215
226
  saw_no_pabotlib = True
216
- pabot_args["pabotlib"] = False # Just set the main flag
217
- args = args[1:]
227
+ pabot_args["pabotlib"] = False
228
+ i += 1
218
229
  continue
219
230
  if arg_name == "pabotlib":
220
231
  saw_pabotlib_flag = True
221
- args = args[1:]
232
+ i += 1
222
233
  continue
223
234
 
224
- # Special case for command
235
+ # Special handling for --command ... --end-command
225
236
  if arg_name == "command":
226
237
  try:
227
238
  end_index = args.index("--end-command", i)
@@ -231,7 +242,7 @@ def _parse_pabot_args(args): # type: (List[str]) -> Tuple[List[str], Dict[str,
231
242
  except ValueError:
232
243
  raise DataError("--command requires matching --end-command")
233
244
 
234
- # Handle flag arguments
245
+ # Handle boolean flags
235
246
  if arg_name in flag_args:
236
247
  pabot_args[arg_name] = True
237
248
  i += 1
@@ -242,23 +253,68 @@ def _parse_pabot_args(args): # type: (List[str]) -> Tuple[List[str], Dict[str,
242
253
  if i + 1 >= len(args):
243
254
  raise DataError(f"--{arg_name} requires a value")
244
255
  try:
245
- value = value_args[arg_name](args[i + 1])
246
- if arg_name == "shard":
247
- pabot_args["shardindex"], pabot_args["shardcount"] = value
248
- elif arg_name == "pabotlibhost":
249
- pabot_args["pabotlib"] = False
250
- pabot_args[arg_name] = value
251
- elif arg_name == "artifacts":
252
- pabot_args["artifacts"] = value[0]
253
- pabot_args["artifactstimestamps"] = value[1]
256
+ # Special parsing for --ordering <file> [mode] [failure_policy]
257
+ if arg_name == "ordering":
258
+ if i + 1 >= len(args):
259
+ raise DataError("--ordering requires at least a file path")
260
+
261
+ ordering_file = args[i + 1]
262
+ mode = "static" # default
263
+ failure_policy = "run_all" # default
264
+
265
+ # optional mode
266
+ if i + 2 < len(args) and args[i + 2] in ("static", "dynamic"):
267
+ mode = args[i + 2]
268
+ i_mode_offset = 1
269
+ else:
270
+ i_mode_offset = 0
271
+
272
+ # optional failure policy, only for dynamic mode
273
+ if mode == "dynamic" and i + 2 + i_mode_offset < len(args) and args[i + 2 + i_mode_offset] in ("skip", "run_all"):
274
+ failure_policy = args[i + 2 + i_mode_offset]
275
+ i_failure_offset = 1
276
+ else:
277
+ i_failure_offset = 0
278
+
279
+ # store
280
+ pabot_args["ordering"] = {
281
+ "file": ordering_file,
282
+ "mode": mode,
283
+ "failure_policy": failure_policy,
284
+ }
285
+
286
+ # move index past ordering args only
287
+ i += 2 + i_mode_offset + i_failure_offset
288
+ continue
289
+ elif arg_name == "pabotconsole":
290
+ console_type = args[i + 1]
291
+ valid_types = ("verbose", "dotted", "quiet", "none")
292
+ if console_type not in valid_types:
293
+ raise DataError(
294
+ f"Invalid value for --pabotconsole: {console_type}. "
295
+ f"Valid values are: {', '.join(valid_types)}"
296
+ )
297
+ pabot_args["pabotconsole"] = console_type
298
+ i += 2
299
+ continue
254
300
  else:
255
- pabot_args[arg_name] = value
256
- i += 2
257
- continue
258
- except (ValueError, TypeError) as e:
301
+ value = value_args[arg_name](args[i + 1])
302
+ if arg_name == "shard":
303
+ pabot_args["shardindex"], pabot_args["shardcount"] = value
304
+ elif arg_name == "pabotlibhost":
305
+ pabot_args["pabotlib"] = False
306
+ pabot_args[arg_name] = value
307
+ elif arg_name == "artifacts":
308
+ pabot_args["artifacts"] = value[0]
309
+ pabot_args["artifactstimestamps"] = value[1]
310
+ else:
311
+ pabot_args[arg_name] = value
312
+ i += 2
313
+ continue
314
+ except (ValueError, TypeError):
259
315
  raise DataError(f"Invalid value for --{arg_name}: {args[i + 1]}")
260
-
261
- # Handle argument files
316
+
317
+ # Handle argumentfiles like --argumentfile1
262
318
  match = ARGSMATCHER.match(arg)
263
319
  if match:
264
320
  if i + 1 >= len(args):
@@ -267,10 +323,11 @@ def _parse_pabot_args(args): # type: (List[str]) -> Tuple[List[str], Dict[str,
267
323
  i += 2
268
324
  continue
269
325
 
270
- # If we get here, it's a non-pabot argument
326
+ # Any other non-pabot argument
271
327
  remaining_args.append(arg)
272
328
  i += 1
273
329
 
330
+ # Check for conflicting pabotlib flags
274
331
  if saw_pabotlib_flag and saw_no_pabotlib:
275
332
  raise DataError("Cannot use both --pabotlib and --no-pabotlib options together")
276
333