robotframework-pabot 5.2.0b1__py3-none-any.whl → 5.2.0rc2__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.
pabot/pabot.py CHANGED
@@ -84,7 +84,7 @@ from .execution_items import (
84
84
  create_dependency_tree,
85
85
  )
86
86
  from .result_merger import merge
87
- from .writer import get_writer
87
+ from .writer import get_writer, get_stdout_writer, get_stderr_writer, ThreadSafeWriter, MessageWriter
88
88
 
89
89
  try:
90
90
  import queue # type: ignore
@@ -105,18 +105,26 @@ except ImportError:
105
105
  from typing import Any, Dict, List, Optional, Tuple, Union
106
106
 
107
107
  CTRL_C_PRESSED = False
108
- #MESSAGE_QUEUE = queue.Queue()
109
- EXECUTION_POOL_IDS = [] # type: List[int]
110
- EXECUTION_POOL_ID_LOCK = threading.Lock()
111
- POPEN_LOCK = threading.Lock()
112
108
  _PABOTLIBURI = "127.0.0.1:8270"
113
109
  _PABOTLIBPROCESS = None # type: Optional[subprocess.Popen]
110
+ _PABOTWRITER = None # type: Optional[MessageWriter]
111
+ _PABOTLIBTHREAD = None # type: Optional[threading.Thread]
114
112
  _NUMBER_OF_ITEMS_TO_BE_EXECUTED = 0
115
113
  _ABNORMAL_EXIT_HAPPENED = False
114
+ _PABOTCONSOLE = "verbose" # type: str
115
+ _USE_USER_COMMAND = False
116
116
 
117
117
  _COMPLETED_LOCK = threading.Lock()
118
118
  _NOT_COMPLETED_INDEXES = [] # type: List[int]
119
119
 
120
+ # Thread-local storage for tracking executor number assigned to each thread
121
+ _EXECUTOR_THREAD_LOCAL = threading.local()
122
+ # Next executor number to assign (incremented each time a task is submitted)
123
+ _EXECUTOR_COUNTER = 0
124
+ _EXECUTOR_COUNTER_LOCK = threading.Lock()
125
+ # Maximum number of executors (workers in the thread pool)
126
+ _MAX_EXECUTORS = 1
127
+
120
128
  _ROBOT_EXTENSIONS = [
121
129
  ".html",
122
130
  ".htm",
@@ -205,15 +213,18 @@ def extract_section(lines, start_marker="<!-- START DOCSTRING -->", end_marker="
205
213
  if end_marker in line:
206
214
  break
207
215
  if inside_section:
208
- # Remove Markdown hyperlinks but keep text
209
- line = re.sub(r'\[([^\]]+)\]\(https?://[^\)]+\)', r'\1', line)
210
- # Remove Markdown section links but keep text
211
- line = re.sub(r'\[([^\]]+)\]\(#[^\)]+\)', r'\1', line)
212
- # Remove ** and backticks `
213
- line = re.sub(r'(\*\*|`)', '', line)
214
216
  extracted_lines.append(line)
215
217
 
216
- return "".join(extracted_lines).strip()
218
+ result = "".join(extracted_lines)
219
+
220
+ # Remove Markdown hyperlinks but keep text
221
+ result = re.sub(r'\[([^\]]+)\]\(https?://[^\)]+\)', r'\1', result)
222
+ # Remove Markdown section links but keep text
223
+ result = re.sub(r'\[([^\]]+)\]\(#[^\)]+\)', r'\1', result)
224
+ # Remove ** and backticks `
225
+ result = re.sub(r'(\*\*|`)', '', result)
226
+
227
+ return result.strip()
217
228
 
218
229
 
219
230
  class Color:
@@ -225,6 +236,32 @@ class Color:
225
236
  YELLOW = "\033[93m"
226
237
 
227
238
 
239
+ def _get_next_executor_num():
240
+ """Get the next executor number in round-robin fashion."""
241
+ global _EXECUTOR_COUNTER, _MAX_EXECUTORS
242
+ with _EXECUTOR_COUNTER_LOCK:
243
+ executor_num = _EXECUTOR_COUNTER % _MAX_EXECUTORS
244
+ _EXECUTOR_COUNTER += 1
245
+ return executor_num
246
+
247
+
248
+ def _set_executor_num(executor_num):
249
+ """Set the executor number for the current thread."""
250
+ _EXECUTOR_THREAD_LOCAL.executor_num = executor_num
251
+
252
+
253
+ def _get_executor_num():
254
+ """Get the executor number for the current thread."""
255
+ return getattr(_EXECUTOR_THREAD_LOCAL, 'executor_num', 0)
256
+
257
+
258
+ def _execute_item_with_executor_tracking(item):
259
+ """Wrapper to track executor number and call execute_and_wait_with."""
260
+ executor_num = _get_next_executor_num()
261
+ _set_executor_num(executor_num)
262
+ return execute_and_wait_with(item)
263
+
264
+
228
265
  def execute_and_wait_with(item):
229
266
  # type: ('QueueItem') -> int
230
267
  global CTRL_C_PRESSED, _NUMBER_OF_ITEMS_TO_BE_EXECUTED
@@ -253,7 +290,7 @@ def execute_and_wait_with(item):
253
290
  outs_dir,
254
291
  name,
255
292
  item.verbose,
256
- _make_id(),
293
+ _get_executor_num(),
257
294
  caller_id,
258
295
  item.index,
259
296
  )
@@ -264,7 +301,7 @@ def execute_and_wait_with(item):
264
301
  outs_dir,
265
302
  name,
266
303
  item.verbose,
267
- _make_id(),
304
+ _get_executor_num(),
268
305
  caller_id,
269
306
  item.index,
270
307
  item.execution_item.type != "test",
@@ -272,10 +309,10 @@ def execute_and_wait_with(item):
272
309
  sleep_before_start=item.sleep_before_start
273
310
  )
274
311
  outputxml_preprocessing(
275
- item.options, outs_dir, name, item.verbose, _make_id(), caller_id, item.index
312
+ item.options, outs_dir, name, item.verbose, _get_executor_num(), caller_id, item.index
276
313
  )
277
314
  except:
278
- _write(traceback.format_exc())
315
+ _write(traceback.format_exc(), level="error")
279
316
  return rc
280
317
 
281
318
 
@@ -313,7 +350,7 @@ def _hived_execute(
313
350
  try:
314
351
  make_order(hive, " ".join(cmd), outs_dir)
315
352
  except:
316
- _write(traceback.format_exc())
353
+ _write(traceback.format_exc(), level="error")
317
354
  if plib:
318
355
  _increase_completed(plib, my_index)
319
356
 
@@ -336,45 +373,63 @@ def _try_execute_and_wait(
336
373
  is_ignored = False
337
374
  if _pabotlib_in_use():
338
375
  plib = Remote(_PABOTLIBURI)
376
+
377
+ command_name = _get_command_name(run_cmd[0])
378
+ stdout_path = os.path.join(outs_dir, f"{command_name}_stdout.out")
379
+ stderr_path = os.path.join(outs_dir, f"{command_name}_stderr.out")
380
+
339
381
  try:
340
- with open(os.path.join(outs_dir, run_cmd[-1] + "_stdout.out"), "w") as stdout:
341
- with open(os.path.join(outs_dir, run_cmd[-1] + "_stderr.out"), "w") as stderr:
342
- process, (rc, elapsed) = _run(
343
- run_cmd,
344
- run_options,
345
- stderr,
346
- stdout,
347
- item_name,
348
- verbose,
349
- pool_id,
350
- my_index,
351
- outs_dir,
352
- process_timeout,
353
- sleep_before_start
354
- )
382
+ with open(stdout_path, "w", encoding="utf-8", buffering=1) as stdout, \
383
+ open(stderr_path, "w", encoding="utf-8", buffering=1) as stderr:
384
+
385
+ process, (rc, elapsed) = _run(
386
+ run_cmd,
387
+ run_options,
388
+ stderr,
389
+ stdout,
390
+ item_name,
391
+ verbose,
392
+ pool_id,
393
+ my_index,
394
+ outs_dir,
395
+ process_timeout,
396
+ sleep_before_start
397
+ )
398
+
399
+ # Ensure writing
400
+ stdout.flush()
401
+ stderr.flush()
402
+ os.fsync(stdout.fileno())
403
+ os.fsync(stderr.fileno())
404
+
405
+ if plib:
406
+ _increase_completed(plib, my_index)
407
+ is_ignored = _is_ignored(plib, caller_id)
408
+
409
+ # Thread-safe list append
410
+ _ALL_ELAPSED.append(elapsed)
411
+
412
+ _result_to_stdout(
413
+ elapsed=elapsed,
414
+ is_ignored=is_ignored,
415
+ item_name=item_name,
416
+ my_index=my_index,
417
+ pool_id=pool_id,
418
+ process=process,
419
+ rc=rc,
420
+ stderr=stderr_path,
421
+ stdout=stdout_path,
422
+ verbose=verbose,
423
+ show_stdout_on_failure=show_stdout_on_failure,
424
+ )
425
+
426
+ if is_ignored and os.path.isdir(outs_dir):
427
+ _rmtree_with_path(outs_dir)
428
+ return rc
429
+
355
430
  except:
356
- _write(traceback.format_exc())
357
- if plib:
358
- _increase_completed(plib, my_index)
359
- is_ignored = _is_ignored(plib, caller_id)
360
- # Thread-safe list append
361
- _ALL_ELAPSED.append(elapsed)
362
- _result_to_stdout(
363
- elapsed,
364
- is_ignored,
365
- item_name,
366
- my_index,
367
- pool_id,
368
- process,
369
- rc,
370
- stderr,
371
- stdout,
372
- verbose,
373
- show_stdout_on_failure,
374
- )
375
- if is_ignored and os.path.isdir(outs_dir):
376
- _rmtree_with_path(outs_dir)
377
- return rc
431
+ _write(traceback.format_exc(), level="error")
432
+ return 252
378
433
 
379
434
 
380
435
  def _result_to_stdout(
@@ -396,6 +451,7 @@ def _result_to_stdout(
396
451
  pool_id,
397
452
  my_index,
398
453
  _execution_ignored_message(item_name, stdout, stderr, elapsed, verbose),
454
+ level="info_ignored",
399
455
  )
400
456
  elif rc != 0:
401
457
  _write_with_id(
@@ -406,6 +462,7 @@ def _result_to_stdout(
406
462
  item_name, stdout, stderr, rc, verbose or show_stdout_on_failure
407
463
  ),
408
464
  Color.RED,
465
+ level="info_failed",
409
466
  )
410
467
  else:
411
468
  _write_with_id(
@@ -414,6 +471,7 @@ def _result_to_stdout(
414
471
  my_index,
415
472
  _execution_passed_message(item_name, stdout, stderr, elapsed, verbose),
416
473
  Color.GREEN,
474
+ level="info_passed",
417
475
  )
418
476
 
419
477
 
@@ -489,25 +547,16 @@ def outputxml_preprocessing(options, outs_dir, item_name, verbose, pool_id, call
489
547
  print(sys.exc_info())
490
548
 
491
549
 
492
- def _write_with_id(process, pool_id, item_index, message, color=None, timestamp=None):
550
+ def _write_with_id(process, pool_id, item_index, message, color=None, timestamp=None, level="debug"):
493
551
  timestamp = timestamp or datetime.datetime.now()
494
552
  _write(
495
553
  "%s [PID:%s] [%s] [ID:%s] %s"
496
554
  % (timestamp, process.pid, pool_id, item_index, message),
497
555
  color,
556
+ level=level,
498
557
  )
499
558
 
500
559
 
501
- def _make_id(): # type: () -> int
502
- global EXECUTION_POOL_IDS, EXECUTION_POOL_ID_LOCK
503
- thread_id = threading.current_thread().ident
504
- assert thread_id is not None
505
- with EXECUTION_POOL_ID_LOCK:
506
- if thread_id not in EXECUTION_POOL_IDS:
507
- EXECUTION_POOL_IDS += [thread_id]
508
- return EXECUTION_POOL_IDS.index(thread_id)
509
-
510
-
511
560
  def _increase_completed(plib, my_index):
512
561
  # type: (Remote, int) -> None
513
562
  global _COMPLETED_LOCK, _NOT_COMPLETED_INDEXES
@@ -577,7 +626,7 @@ def _run(
577
626
  _write(f"{timestamp} [{pool_id}] [ID:{item_index}] SLEEPING {sleep_before_start} SECONDS BEFORE STARTING {item_name}")
578
627
  time.sleep(sleep_before_start)
579
628
 
580
- command_name = run_command[-1].replace(" ", "_")
629
+ command_name = _get_command_name(run_command[0])
581
630
  argfile_path = os.path.join(outs_dir, f"{command_name}_argfile.txt")
582
631
  _write_internal_argument_file(run_options, filename=argfile_path)
583
632
 
@@ -608,11 +657,11 @@ def _run(
608
657
 
609
658
  def _read_file(file_handle):
610
659
  try:
611
- with open(file_handle.name, "r") as content_file:
660
+ with open(file_handle, "r") as content_file:
612
661
  content = content_file.read()
613
662
  return content
614
- except:
615
- return "Unable to read file %s" % file_handle
663
+ except Exception as e:
664
+ return "Unable to read file %s, error: %s" % (os.path.abspath(file_handle), e)
616
665
 
617
666
 
618
667
  def _execution_failed_message(suite_name, stdout, stderr, rc, verbose):
@@ -679,7 +728,7 @@ def _options_for_executor(
679
728
  # Prevent multiple appending of PABOTLIBURI variable setting
680
729
  if pabotLibURIVar not in options["variable"]:
681
730
  options["variable"].append(pabotLibURIVar)
682
- pabotExecutionPoolId = "PABOTEXECUTIONPOOLID:%d" % _make_id()
731
+ pabotExecutionPoolId = "PABOTEXECUTIONPOOLID:%d" % _get_executor_num()
683
732
  if pabotExecutionPoolId not in options["variable"]:
684
733
  options["variable"].append(pabotExecutionPoolId)
685
734
  pabotIsLast = "PABOTISLASTEXECUTIONINPOOL:%s" % ("1" if is_last else "0")
@@ -702,7 +751,7 @@ def _options_for_executor(
702
751
  del options["include"]
703
752
  if skip:
704
753
  this_dir = os.path.dirname(os.path.abspath(__file__))
705
- listener_path = os.path.join(this_dir, "skip_listener.py")
754
+ listener_path = os.path.join(this_dir, "listener", "skip_listener.py")
706
755
  options["dryrun"] = True
707
756
  options["listener"].append(listener_path)
708
757
  return _set_terminal_coloring_options(options)
@@ -1206,7 +1255,7 @@ def store_suite_names(hashes, suite_names):
1206
1255
  _write(
1207
1256
  "[ "
1208
1257
  + _wrap_with(Color.YELLOW, "WARNING")
1209
- + " ]: storing .pabotsuitenames failed"
1258
+ + " ]: storing .pabotsuitenames failed", level="warning",
1210
1259
  )
1211
1260
 
1212
1261
 
@@ -1236,6 +1285,7 @@ def generate_suite_names_with_builder(outs_dir, datasources, options):
1236
1285
  if ROBOT_VERSION >= "6.1":
1237
1286
  builder = TestSuiteBuilder(
1238
1287
  included_extensions=settings.extension,
1288
+ included_files=settings.parse_include,
1239
1289
  rpa=settings.rpa,
1240
1290
  lang=opts.get("language"),
1241
1291
  )
@@ -1271,13 +1321,13 @@ def generate_suite_names_with_builder(outs_dir, datasources, options):
1271
1321
  if stdout_value:
1272
1322
  _write(
1273
1323
  "[STDOUT] from suite search:\n" + stdout_value + "[STDOUT] end",
1274
- Color.YELLOW,
1324
+ Color.YELLOW, level="warning",
1275
1325
  )
1276
1326
  stderr_value = opts["stderr"].getvalue()
1277
1327
  if stderr_value:
1278
1328
  _write(
1279
1329
  "[STDERR] from suite search:\n" + stderr_value + "[STDERR] end",
1280
- Color.RED,
1330
+ Color.RED, level="error",
1281
1331
  )
1282
1332
  return list(sorted(set(suite_names)))
1283
1333
 
@@ -1333,7 +1383,7 @@ def _options_for_dryrun(options, outs_dir):
1333
1383
  return _set_terminal_coloring_options(options)
1334
1384
 
1335
1385
 
1336
- def _options_for_rebot(options, start_time_string, end_time_string):
1386
+ def _options_for_rebot(options, start_time_string, end_time_string, num_of_executions=0):
1337
1387
  rebot_options = options.copy()
1338
1388
  rebot_options["starttime"] = start_time_string
1339
1389
  rebot_options["endtime"] = end_time_string
@@ -1342,6 +1392,12 @@ def _options_for_rebot(options, start_time_string, end_time_string):
1342
1392
  rebot_options["test"] = []
1343
1393
  rebot_options["exclude"] = []
1344
1394
  rebot_options["include"] = []
1395
+ rebot_options["metadata"].append(
1396
+ f"Pabot Info:[https://pabot.org/?ref=log|Pabot] result from {num_of_executions} executions."
1397
+ )
1398
+ rebot_options["metadata"].append(
1399
+ f"Pabot Version:{PABOT_VERSION}"
1400
+ )
1345
1401
  if rebot_options.get("runemptysuite"):
1346
1402
  rebot_options["processemptysuite"] = True
1347
1403
  if ROBOT_VERSION >= "2.8":
@@ -1387,9 +1443,11 @@ def _now():
1387
1443
  def _print_elapsed(start, end):
1388
1444
  _write(
1389
1445
  "Total testing: "
1390
- + _time_string(sum(_ALL_ELAPSED))
1391
- + "\nElapsed time: "
1392
- + _time_string(end - start)
1446
+ + _time_string(sum(_ALL_ELAPSED)), level="info"
1447
+ )
1448
+ _write(
1449
+ "Elapsed time: "
1450
+ + _time_string(end - start), level="info"
1393
1451
  )
1394
1452
 
1395
1453
 
@@ -1416,6 +1474,13 @@ def _time_string(elapsed):
1416
1474
  def keyboard_interrupt(*args):
1417
1475
  global CTRL_C_PRESSED
1418
1476
  CTRL_C_PRESSED = True
1477
+ # Notify ProcessManager to interrupt running processes
1478
+ if _PROCESS_MANAGER:
1479
+ _PROCESS_MANAGER.set_interrupted()
1480
+ if _PABOTWRITER:
1481
+ _write("[ INTERRUPT ] Ctrl+C pressed - initiating graceful shutdown...", Color.YELLOW, level="warning")
1482
+ else:
1483
+ print("[ INTERRUPT ] Ctrl+C pressed - initiating graceful shutdown...")
1419
1484
 
1420
1485
 
1421
1486
  def _get_depends(item):
@@ -1423,32 +1488,90 @@ def _get_depends(item):
1423
1488
 
1424
1489
 
1425
1490
  def _dependencies_satisfied(item, completed):
1426
- return all(dep in completed for dep in _get_depends(item))
1491
+ """
1492
+ Check if all dependencies for an item are satisfied (completed).
1493
+ Uses unique names that include argfile_index when applicable.
1494
+ """
1495
+ for dep in _get_depends(item):
1496
+ # Build unique name for dependency with same argfile_index as the item
1497
+ if hasattr(item, 'argfile_index') and item.argfile_index:
1498
+ # Item has an argfile index, so check for dependency with same argfile index
1499
+ dep_unique_name = f"{item.argfile_index}:{dep}"
1500
+ if dep_unique_name not in completed:
1501
+ return False
1502
+ else:
1503
+ # No argfile index (single argumentfile case)
1504
+ if dep not in completed:
1505
+ return False
1506
+
1507
+ return True
1427
1508
 
1428
1509
 
1429
1510
  def _collect_transitive_dependents(failed_name, pending_items):
1430
1511
  """
1431
1512
  Returns all pending items that (directly or indirectly) depend on failed_name.
1513
+ Handles both regular names and unique names (with argfile_index).
1514
+
1515
+ When failed_name is "1:Suite", it means Suite failed in argumentfile 1.
1516
+ We should only skip items in argumentfile 1 that depend on Suite,
1517
+ not items in other argumentfiles.
1432
1518
  """
1433
1519
  to_skip = set()
1434
1520
  queue = [failed_name]
1435
1521
 
1436
- # Build dependency map once
1522
+ # Extract argfile_index from failed_name if it has one
1523
+ if ":" in failed_name:
1524
+ argfile_index, base_name = failed_name.split(":", 1)
1525
+ else:
1526
+ argfile_index = ""
1527
+ base_name = failed_name
1528
+
1529
+ # Build dependency map: item unique name -> set of dependency base names
1437
1530
  depends_map = {
1438
- item.execution_item.name: set(_get_depends(item))
1531
+ _get_unique_execution_name(item): set(_get_depends(item))
1439
1532
  for item in pending_items
1440
1533
  }
1441
1534
 
1442
1535
  while queue:
1443
1536
  current = queue.pop(0)
1537
+
1538
+ # Extract base name from current (e.g., "1:Suite" -> "Suite")
1539
+ if ":" in current:
1540
+ current_argfile, current_base = current.split(":", 1)
1541
+ else:
1542
+ current_argfile = ""
1543
+ current_base = current
1544
+
1444
1545
  for item_name, deps in depends_map.items():
1445
- if current in deps and item_name not in to_skip:
1546
+ # Only skip items from the same argumentfile
1547
+ # Check if item_name corresponds to the same argumentfile
1548
+ if ":" in item_name:
1549
+ item_argfile, _ = item_name.split(":", 1)
1550
+ else:
1551
+ item_argfile = ""
1552
+
1553
+ # Only process if same argumentfile
1554
+ if item_argfile != argfile_index:
1555
+ continue
1556
+
1557
+ # Check if this item depends on the current failed item
1558
+ if current_base in deps and item_name not in to_skip:
1446
1559
  to_skip.add(item_name)
1447
1560
  queue.append(item_name)
1448
1561
 
1449
1562
  return to_skip
1450
1563
 
1451
1564
 
1565
+ def _get_unique_execution_name(item):
1566
+ """
1567
+ Create a unique identifier for an execution item that includes argfile index.
1568
+ This ensures that the same test run with different argumentfiles are treated as distinct items.
1569
+ """
1570
+ if item.argfile_index:
1571
+ return f"{item.argfile_index}:{item.execution_item.name}"
1572
+ return item.execution_item.name
1573
+
1574
+
1452
1575
  def _parallel_execute_dynamic(
1453
1576
  items,
1454
1577
  processes,
@@ -1457,9 +1580,13 @@ def _parallel_execute_dynamic(
1457
1580
  opts_for_run,
1458
1581
  pabot_args,
1459
1582
  ):
1460
- original_signal_handler = signal.signal(signal.SIGINT, keyboard_interrupt)
1583
+ # Signal handler is already set in main_program, no need to set it again
1584
+ # Just use the thread pool without managing signals
1585
+ global _MAX_EXECUTORS, _EXECUTOR_COUNTER
1461
1586
 
1462
1587
  max_processes = processes or len(items)
1588
+ _MAX_EXECUTORS = max_processes
1589
+ _EXECUTOR_COUNTER = 0 # Reset executor counter for each parallel execution batch
1463
1590
  pool = ThreadPool(max_processes)
1464
1591
 
1465
1592
  pending = set(items)
@@ -1475,24 +1602,28 @@ def _parallel_execute_dynamic(
1475
1602
 
1476
1603
  with lock:
1477
1604
  running.pop(it, None)
1478
- completed.add(it.execution_item.name)
1605
+ unique_name = _get_unique_execution_name(it)
1606
+ completed.add(unique_name)
1479
1607
 
1480
1608
  if rc != 0:
1481
- failed.add(it.execution_item.name)
1609
+ failed.add(unique_name)
1482
1610
 
1483
1611
  if failure_policy == "skip":
1484
1612
  to_skip_names = _collect_transitive_dependents(
1485
- it.execution_item.name,
1613
+ unique_name,
1486
1614
  pending,
1487
1615
  )
1488
1616
 
1489
1617
  for other in list(pending):
1490
- if other.execution_item.name in to_skip_names:
1491
- _write(
1492
- f"Skipping '{other.execution_item.name}' because dependency "
1493
- f"'{it.execution_item.name}' failed (transitive).",
1494
- Color.YELLOW,
1495
- )
1618
+ other_unique_name = _get_unique_execution_name(other)
1619
+ if other_unique_name in to_skip_names:
1620
+ # Only log skip once when first marking it as skipped
1621
+ if not other.skip:
1622
+ _write(
1623
+ f"Skipping '{other_unique_name}' because dependency "
1624
+ f"'{unique_name}' failed (transitive).",
1625
+ Color.YELLOW, level="debug"
1626
+ )
1496
1627
  other.skip = True
1497
1628
 
1498
1629
  try:
@@ -1508,7 +1639,7 @@ def _parallel_execute_dynamic(
1508
1639
  pending.remove(item)
1509
1640
 
1510
1641
  result = pool.apply_async(
1511
- execute_and_wait_with,
1642
+ _execute_item_with_executor_tracking,
1512
1643
  (item,),
1513
1644
  callback=lambda rc, it=item: on_complete(it, rc),
1514
1645
  )
@@ -1526,15 +1657,19 @@ def _parallel_execute_dynamic(
1526
1657
 
1527
1658
  finally:
1528
1659
  pool.close()
1529
- signal.signal(signal.SIGINT, original_signal_handler)
1660
+ # Signal handler was set in main_program and will be restored there
1530
1661
 
1531
1662
 
1532
1663
  def _parallel_execute(
1533
1664
  items, processes, datasources, outs_dir, opts_for_run, pabot_args
1534
1665
  ):
1535
- original_signal_handler = signal.signal(signal.SIGINT, keyboard_interrupt)
1536
- pool = ThreadPool(len(items) if processes is None else processes)
1537
- results = [pool.map_async(execute_and_wait_with, items, 1)]
1666
+ # Signal handler is already set in main_program, no need to set it again
1667
+ global _MAX_EXECUTORS, _EXECUTOR_COUNTER
1668
+ max_workers = len(items) if processes is None else processes
1669
+ _MAX_EXECUTORS = max_workers
1670
+ _EXECUTOR_COUNTER = 0 # Reset executor counter for each parallel execution batch
1671
+ pool = ThreadPool(max_workers)
1672
+ results = [pool.map_async(_execute_item_with_executor_tracking, items, 1)]
1538
1673
  delayed_result_append = 0
1539
1674
  new_items = []
1540
1675
  while not all(result.ready() for result in results) or delayed_result_append > 0:
@@ -1554,10 +1689,10 @@ def _parallel_execute(
1554
1689
  delayed_result_append = max(0, delayed_result_append - 1)
1555
1690
  if new_items and delayed_result_append == 0:
1556
1691
  _construct_last_levels([new_items])
1557
- results.append(pool.map_async(execute_and_wait_with, new_items, 1))
1692
+ results.append(pool.map_async(_execute_item_with_executor_tracking, new_items, 1))
1558
1693
  new_items = []
1559
1694
  pool.close()
1560
- signal.signal(signal.SIGINT, original_signal_handler)
1695
+ # Signal handler will be restored in main_program's finally block
1561
1696
 
1562
1697
 
1563
1698
  def _output_dir(options, cleanup=True):
@@ -1622,7 +1757,7 @@ def _copy_output_artifacts(options, timestamp_id=None, file_extensions=None, inc
1622
1757
  return copied_artifacts
1623
1758
 
1624
1759
 
1625
- def _check_pabot_results_for_missing_xml(base_dir, command_name, output_xml_name):
1760
+ def _check_pabot_results_for_missing_xml(base_dir, command_name, output_xml_name='output.xml'):
1626
1761
  """
1627
1762
  Check for missing Robot Framework output XML files in pabot result directories,
1628
1763
  taking into account the optional timestamp added by the -T option.
@@ -1648,14 +1783,18 @@ def _check_pabot_results_for_missing_xml(base_dir, command_name, output_xml_name
1648
1783
  # Check if any file matches the expected XML name or timestamped variant
1649
1784
  has_xml = any(pattern.match(fname) for fname in os.listdir(subdir_path))
1650
1785
  if not has_xml:
1651
- sanitized_cmd = command_name.replace(" ", "_")
1786
+ sanitized_cmd = _get_command_name(command_name)
1652
1787
  missing.append(os.path.join(subdir_path, f"{sanitized_cmd}_stderr.out"))
1653
1788
  break # only check immediate subdirectories
1654
1789
  return missing
1655
1790
 
1656
1791
 
1792
+ def _get_command_name(command_name):
1793
+ global _USE_USER_COMMAND
1794
+ return "user_command" if _USE_USER_COMMAND else command_name
1795
+
1796
+
1657
1797
  def _report_results(outs_dir, pabot_args, options, start_time_string, tests_root_name):
1658
- output_xml_name = options.get("output") or "output.xml"
1659
1798
  if "pythonpath" in options:
1660
1799
  del options["pythonpath"]
1661
1800
  if ROBOT_VERSION < "4.0":
@@ -1673,41 +1812,44 @@ def _report_results(outs_dir, pabot_args, options, start_time_string, tests_root
1673
1812
  missing_outputs = []
1674
1813
  if pabot_args["argumentfiles"]:
1675
1814
  outputs = [] # type: List[str]
1815
+ total_num_of_executions = 0
1676
1816
  for index, _ in pabot_args["argumentfiles"]:
1677
1817
  copied_artifacts = _copy_output_artifacts(
1678
1818
  options, _get_timestamp_id(start_time_string, pabot_args["artifactstimestamps"]), pabot_args["artifacts"], pabot_args["artifactsinsubfolders"], index
1679
1819
  )
1680
- outputs += [
1681
- _merge_one_run(
1682
- os.path.join(outs_dir, index),
1683
- options,
1684
- tests_root_name,
1685
- stats,
1686
- copied_artifacts,
1687
- timestamp_id=_get_timestamp_id(start_time_string, pabot_args["artifactstimestamps"]),
1688
- outputfile=os.path.join("pabot_results", "output%s.xml" % index),
1689
- )
1690
- ]
1691
- missing_outputs.extend(_check_pabot_results_for_missing_xml(os.path.join(outs_dir, index), pabot_args.get('command')[-1], output_xml_name))
1820
+ output, num_of_executions = _merge_one_run(
1821
+ os.path.join(outs_dir, index),
1822
+ options,
1823
+ tests_root_name,
1824
+ stats,
1825
+ copied_artifacts,
1826
+ timestamp_id=_get_timestamp_id(start_time_string, pabot_args["artifactstimestamps"]),
1827
+ outputfile=os.path.join("pabot_results", "output%s.xml" % index),
1828
+ )
1829
+ outputs += [output]
1830
+ total_num_of_executions += num_of_executions
1831
+ missing_outputs.extend(_check_pabot_results_for_missing_xml(os.path.join(outs_dir, index), pabot_args.get('command')))
1692
1832
  if "output" not in options:
1693
1833
  options["output"] = "output.xml"
1694
1834
  _write_stats(stats)
1695
- exit_code = rebot(*outputs, **_options_for_rebot(options, start_time_string, _now()))
1835
+ stdout_writer = get_stdout_writer()
1836
+ stderr_writer = get_stderr_writer(original_stderr_name='Internal Rebot')
1837
+ exit_code = rebot(*outputs, **_options_for_rebot(options, start_time_string, _now(), total_num_of_executions), stdout=stdout_writer, stderr=stderr_writer)
1696
1838
  else:
1697
1839
  exit_code = _report_results_for_one_run(
1698
1840
  outs_dir, pabot_args, options, start_time_string, tests_root_name, stats
1699
1841
  )
1700
- missing_outputs.extend(_check_pabot_results_for_missing_xml(outs_dir, pabot_args.get('command')[-1], output_xml_name))
1842
+ missing_outputs.extend(_check_pabot_results_for_missing_xml(outs_dir, pabot_args.get('command')))
1701
1843
  if missing_outputs:
1702
1844
  _write(("[ " + _wrap_with(Color.YELLOW, 'WARNING') + " ] "
1703
1845
  "One or more subprocesses encountered an error and the "
1704
1846
  "internal .xml files could not be generated. Please check the "
1705
- "following stderr files to identify the cause:"))
1847
+ "following stderr files to identify the cause:"), level="warning")
1706
1848
  for missing in missing_outputs:
1707
- _write(repr(missing))
1849
+ _write(repr(missing), level="warning")
1708
1850
  _write((f"[ " + _wrap_with(Color.RED, 'ERROR') + " ] "
1709
1851
  "The output, log and report files produced by Pabot are "
1710
- "incomplete and do not contain all test cases."))
1852
+ "incomplete and do not contain all test cases."), level="error")
1711
1853
  return exit_code if not missing_outputs else 252
1712
1854
 
1713
1855
 
@@ -1717,18 +1859,18 @@ def _write_stats(stats):
1717
1859
  al = stats["all"]
1718
1860
  _write(
1719
1861
  "%d critical tests, %d passed, %d failed"
1720
- % (crit["total"], crit["passed"], crit["failed"])
1862
+ % (crit["total"], crit["passed"], crit["failed"]), level="info"
1721
1863
  )
1722
1864
  _write(
1723
1865
  "%d tests total, %d passed, %d failed"
1724
- % (al["total"], al["passed"], al["failed"])
1866
+ % (al["total"], al["passed"], al["failed"]), level="info"
1725
1867
  )
1726
1868
  else:
1727
1869
  _write(
1728
1870
  "%d tests, %d passed, %d failed, %d skipped."
1729
- % (stats["total"], stats["passed"], stats["failed"], stats["skipped"])
1871
+ % (stats["total"], stats["passed"], stats["failed"], stats["skipped"]), level="info"
1730
1872
  )
1731
- _write("===================================================")
1873
+ _write("===================================================", level="info")
1732
1874
 
1733
1875
 
1734
1876
  def add_timestamp_to_filename(file_path: str, timestamp: str) -> str:
@@ -1753,7 +1895,7 @@ def _report_results_for_one_run(
1753
1895
  copied_artifacts = _copy_output_artifacts(
1754
1896
  options, _get_timestamp_id(start_time_string, pabot_args["artifactstimestamps"]), pabot_args["artifacts"], pabot_args["artifactsinsubfolders"]
1755
1897
  )
1756
- output_path = _merge_one_run(
1898
+ output_path, num_of_executions = _merge_one_run(
1757
1899
  outs_dir, options, tests_root_name, stats, copied_artifacts, _get_timestamp_id(start_time_string, pabot_args["artifactstimestamps"])
1758
1900
  )
1759
1901
  _write_stats(stats)
@@ -1770,9 +1912,12 @@ def _report_results_for_one_run(
1770
1912
  "output"
1771
1913
  ] = output_path # REBOT will return error 252 if nothing is written
1772
1914
  else:
1773
- _write("Output: %s" % output_path)
1915
+ _write("Output: %s" % output_path, level="info")
1774
1916
  options["output"] = None # Do not write output again with rebot
1775
- return rebot(output_path, **_options_for_rebot(options, start_time_string, ts))
1917
+ stdout_writer = get_stdout_writer()
1918
+ stderr_writer = get_stderr_writer(original_stderr_name="Internal Rebot")
1919
+ exit_code = rebot(output_path, **_options_for_rebot(options, start_time_string, ts, num_of_executions), stdout=stdout_writer, stderr=stderr_writer)
1920
+ return exit_code
1776
1921
 
1777
1922
 
1778
1923
  def _merge_one_run(
@@ -1782,7 +1927,7 @@ def _merge_one_run(
1782
1927
  output_path = os.path.abspath(
1783
1928
  os.path.join(options.get("outputdir", "."), outputfile)
1784
1929
  )
1785
- filename = options.get("output") or "output.xml"
1930
+ filename = "output.xml"
1786
1931
  base_name, ext = os.path.splitext(filename)
1787
1932
  # Glob all candidates
1788
1933
  candidate_files = glob(os.path.join(outs_dir, "**", f"*{base_name}*{ext}"), recursive=True)
@@ -1794,8 +1939,8 @@ def _merge_one_run(
1794
1939
  files = natsorted(files)
1795
1940
 
1796
1941
  if not files:
1797
- _write('WARN: No output files in "%s"' % outs_dir, Color.YELLOW)
1798
- return ""
1942
+ _write('[ WARNING ]: No output files in "%s"' % outs_dir, Color.YELLOW, level="warning")
1943
+ return "", 0
1799
1944
 
1800
1945
  def invalid_xml_callback():
1801
1946
  global _ABNORMAL_EXIT_HAPPENED
@@ -1811,7 +1956,7 @@ def _merge_one_run(
1811
1956
  resu.save(output_path, legacy_output=True)
1812
1957
  else:
1813
1958
  resu.save(output_path)
1814
- return output_path
1959
+ return output_path, len(files)
1815
1960
 
1816
1961
 
1817
1962
  def _update_stats(result, stats):
@@ -1843,9 +1988,9 @@ def _glob_escape(pathname):
1843
1988
  return drive + pathname
1844
1989
 
1845
1990
 
1846
- def _write(message, color=None):
1991
+ def _write(message, color=None, level="debug"):
1847
1992
  writer = get_writer()
1848
- writer.write(message, color=color)
1993
+ writer.write(message, color=color, level=level)
1849
1994
 
1850
1995
 
1851
1996
  def _wrap_with(color, message):
@@ -1877,11 +2022,11 @@ def _get_free_port():
1877
2022
  return s.getsockname()[1]
1878
2023
 
1879
2024
 
1880
- def _start_remote_library(pabot_args): # type: (dict) -> Optional[subprocess.Popen]
2025
+ def _start_remote_library(pabot_args): # type: (dict) -> Optional[Tuple[subprocess.Popen, threading.Thread]]
1881
2026
  global _PABOTLIBURI
1882
2027
  # If pabotlib is not enabled, do nothing
1883
2028
  if not pabot_args.get("pabotlib"):
1884
- return None
2029
+ return None, None
1885
2030
 
1886
2031
  host = pabot_args.get("pabotlibhost", "127.0.0.1")
1887
2032
  port = pabot_args.get("pabotlibport", 8270)
@@ -1891,7 +2036,7 @@ def _start_remote_library(pabot_args): # type: (dict) -> Optional[subprocess.Po
1891
2036
  _write(
1892
2037
  f"Warning: specified pabotlibport {port} is already in use. "
1893
2038
  "A free port will be assigned automatically.",
1894
- Color.YELLOW,
2039
+ Color.YELLOW, level="warning"
1895
2040
  )
1896
2041
  port = _get_free_port()
1897
2042
 
@@ -1905,7 +2050,7 @@ def _start_remote_library(pabot_args): # type: (dict) -> Optional[subprocess.Po
1905
2050
  _write(
1906
2051
  "Warning: specified resource file doesn't exist."
1907
2052
  " Some tests may fail or continue forever.",
1908
- Color.YELLOW,
2053
+ Color.YELLOW, level="warning"
1909
2054
  )
1910
2055
  resourcefile = ""
1911
2056
  cmd = [
@@ -1915,28 +2060,82 @@ def _start_remote_library(pabot_args): # type: (dict) -> Optional[subprocess.Po
1915
2060
  pabot_args["pabotlibhost"],
1916
2061
  str(port),
1917
2062
  ]
1918
- return subprocess.Popen(cmd)
2063
+ # Start PabotLib in isolation so it doesn't receive CTRL+C when the main process is interrupted.
2064
+ # This allows graceful shutdown in finally block.
2065
+ kwargs = {
2066
+ "stdout": subprocess.PIPE,
2067
+ "stderr": subprocess.STDOUT,
2068
+ "text": True,
2069
+ "bufsize": 1,
2070
+ "env": {**os.environ, "PYTHONUNBUFFERED": "1"},
2071
+ }
2072
+ if sys.platform.startswith('win'):
2073
+ # Windows: use CREATE_NEW_PROCESS_GROUP
2074
+ kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
2075
+ else:
2076
+ # Unix/Linux/macOS: use preexec_fn to create new session
2077
+ import os as os_module
2078
+ kwargs["preexec_fn"] = os_module.setsid
2079
+
2080
+ process = subprocess.Popen(cmd, **kwargs)
2081
+
2082
+ def _read_output(proc, writer):
2083
+ try:
2084
+ for line in proc.stdout:
2085
+ if line.strip(): # Skip empty lines
2086
+ try:
2087
+ writer.write(line.rstrip('\n') + '\n', level="info")
2088
+ writer.flush()
2089
+ except (RuntimeError, ValueError):
2090
+ # Writer/stdout already closed during shutdown
2091
+ break
2092
+ finally:
2093
+ try:
2094
+ proc.stdout.close()
2095
+ except Exception:
2096
+ pass
2097
+
2098
+ pabotlib_writer = ThreadSafeWriter(get_writer())
2099
+ thread = threading.Thread(
2100
+ target=_read_output,
2101
+ args=(process, pabotlib_writer),
2102
+ daemon=False, # Non-daemon so output is captured before exit
2103
+ )
2104
+ thread.start()
2105
+
2106
+ return process, thread
1919
2107
 
1920
2108
 
1921
2109
  def _stop_remote_library(process): # type: (subprocess.Popen) -> None
1922
- _write("Stopping PabotLib process")
2110
+ _write("Stopping PabotLib process", level="debug")
1923
2111
  try:
1924
2112
  remoteLib = Remote(_PABOTLIBURI)
1925
2113
  remoteLib.run_keyword("stop_remote_libraries", [], {})
1926
2114
  remoteLib.run_keyword("stop_remote_server", [], {})
1927
2115
  except RuntimeError:
1928
- _write("Could not connect to PabotLib - assuming stopped already")
1929
- return
2116
+ _write("Could not connect to PabotLib - assuming stopped already", level="info")
2117
+
2118
+ # Always wait for graceful shutdown, regardless of remote connection status
1930
2119
  i = 50
1931
2120
  while i > 0 and process.poll() is None:
1932
2121
  time.sleep(0.1)
1933
2122
  i -= 1
1934
- if i == 0:
2123
+
2124
+ # If still running after remote stop attempt, terminate it
2125
+ if process.poll() is None:
1935
2126
  _write(
1936
2127
  "Could not stop PabotLib Process in 5 seconds " "- calling terminate",
1937
- Color.YELLOW,
2128
+ Color.YELLOW, level="warning"
1938
2129
  )
1939
2130
  process.terminate()
2131
+ # Give it a moment to respond to SIGTERM
2132
+ time.sleep(0.5)
2133
+ if process.poll() is None:
2134
+ _write(
2135
+ "PabotLib Process did not respond to terminate - calling kill",
2136
+ Color.RED, level="error"
2137
+ )
2138
+ process.kill()
1940
2139
  else:
1941
2140
  _write("PabotLib process stopped")
1942
2141
 
@@ -1971,6 +2170,7 @@ class QueueItem(object):
1971
2170
  outs_dir.encode("utf-8") if PY2 and is_unicode(outs_dir) else outs_dir
1972
2171
  )
1973
2172
  self.options = options
2173
+ self.options["output"] = "output.xml" # This is hardcoded output.xml inside pabot_results, not the final output
1974
2174
  self.execution_item = (
1975
2175
  execution_item if not hive else HivedItem(execution_item, hive)
1976
2176
  )
@@ -2051,7 +2251,9 @@ def _create_execution_items_for_run(
2051
2251
  return all_items
2052
2252
 
2053
2253
 
2054
- def _create_items(datasources, opts_for_run, outs_dir, pabot_args, suite_group):
2254
+ def _create_items(datasources, opts_for_run, outs_dir, pabot_args, suite_group, argfile=None):
2255
+ # If argfile is provided, use only that one. Otherwise, loop through all argumentfiles.
2256
+ argumentfiles = [argfile] if argfile is not None else (pabot_args["argumentfiles"] or [("", None)])
2055
2257
  return [
2056
2258
  QueueItem(
2057
2259
  datasources,
@@ -2060,13 +2262,13 @@ def _create_items(datasources, opts_for_run, outs_dir, pabot_args, suite_group):
2060
2262
  suite,
2061
2263
  pabot_args["command"],
2062
2264
  pabot_args["verbose"],
2063
- argfile,
2265
+ af,
2064
2266
  pabot_args.get("hive"),
2065
2267
  pabot_args["processes"],
2066
2268
  pabot_args["processtimeout"],
2067
2269
  )
2068
2270
  for suite in suite_group
2069
- for argfile in pabot_args["argumentfiles"] or [("", None)]
2271
+ for af in argumentfiles
2070
2272
  ]
2071
2273
 
2072
2274
 
@@ -2093,31 +2295,20 @@ def _create_execution_items_for_dry_run(
2093
2295
  def _chunk_items(items, chunk_size):
2094
2296
  for i in range(0, len(items), chunk_size):
2095
2297
  chunked_items = items[i : i + chunk_size]
2096
- base_item = chunked_items[0]
2097
- if not base_item:
2298
+ if not chunked_items:
2098
2299
  continue
2300
+ # For TestItem execution items, yield each item separately
2301
+ # For Suite items, combine them into one item
2302
+ base_item = chunked_items[0]
2099
2303
  if isinstance(base_item.execution_item, TestItem):
2100
2304
  for item in chunked_items:
2101
- chunked_item = _queue_item(base_item, item.execution_item)
2102
- yield chunked_item
2305
+ yield item
2103
2306
  else:
2307
+ # For suites, create a combined execution item with all suite execution items
2104
2308
  execution_items = SuiteItems([item.execution_item for item in chunked_items])
2105
- chunked_item = _queue_item(base_item, execution_items)
2106
- yield chunked_item
2107
-
2108
-
2109
- def _queue_item(base_item, execution_items):
2110
- return QueueItem(
2111
- base_item.datasources,
2112
- base_item.outs_dir,
2113
- base_item.options,
2114
- execution_items,
2115
- base_item.command,
2116
- base_item.verbose,
2117
- (base_item.argfile_index, base_item.argfile),
2118
- processes=base_item.processes,
2119
- timeout=base_item.timeout,
2120
- )
2309
+ # Reuse the base item but update its execution_item to the combined one
2310
+ base_item.execution_item = execution_items
2311
+ yield base_item
2121
2312
 
2122
2313
 
2123
2314
  def _find_ending_level(name, group):
@@ -2184,10 +2375,10 @@ def _get_dynamically_created_execution_items(
2184
2375
  new_suites = plib.run_keyword("get_added_suites", [], {})
2185
2376
  except RuntimeError as err:
2186
2377
  _write(
2187
- "[WARN] PabotLib unreachable during post-run phase, "
2378
+ "[ WARNING ] PabotLib unreachable during post-run phase, "
2188
2379
  "assuming no dynamically added suites. "
2189
2380
  "Original error: %s",
2190
- err,
2381
+ err, level="warning"
2191
2382
  )
2192
2383
  new_suites = []
2193
2384
  if len(new_suites) == 0:
@@ -2220,7 +2411,7 @@ def main(args=None):
2220
2411
 
2221
2412
 
2222
2413
  def main_program(args):
2223
- global _PABOTLIBPROCESS
2414
+ global _PABOTLIBPROCESS, _PABOTCONSOLE, _PABOTWRITER, _PABOTLIBTHREAD, _USE_USER_COMMAND
2224
2415
  outs_dir = None
2225
2416
  args = args or sys.argv[1:]
2226
2417
  if len(args) == 0:
@@ -2234,14 +2425,17 @@ def main_program(args):
2234
2425
  start_time = time.time()
2235
2426
  start_time_string = _now()
2236
2427
  # NOTE: timeout option
2428
+ original_signal_handler = signal.default_int_handler # Save default handler in case of early exit
2237
2429
  try:
2238
2430
  options, datasources, pabot_args, opts_for_run = parse_args(args)
2431
+ _USE_USER_COMMAND = pabot_args.get("use_user_command", False)
2432
+ _PABOTCONSOLE = pabot_args.get("pabotconsole", "verbose")
2239
2433
  if pabot_args["help"]:
2240
2434
  help_print = __doc__.replace(
2241
2435
  "PLACEHOLDER_README.MD",
2242
2436
  read_args_from_readme()
2243
2437
  )
2244
- print(help_print.replace("[PABOT_VERSION]", PABOT_VERSION))
2438
+ print(help_print.replace("[PABOT_VERSION]", PABOT_VERSION, 1))
2245
2439
  return 251
2246
2440
  if len(datasources) == 0:
2247
2441
  print("[ " + _wrap_with(Color.RED, "ERROR") + " ]: No datasources given.")
@@ -2250,11 +2444,14 @@ def main_program(args):
2250
2444
  outs_dir = _output_dir(options)
2251
2445
 
2252
2446
  # These ensure MessageWriter and ProcessManager are ready before any parallel execution.
2253
- writer = get_writer(log_dir=outs_dir)
2447
+ _PABOTWRITER = get_writer(log_dir=outs_dir, console_type=_PABOTCONSOLE)
2254
2448
  _ensure_process_manager()
2255
- _write(f"Initialized logging in {outs_dir}")
2449
+ _write(f"Initialized logging in {outs_dir}", level="info")
2256
2450
 
2257
- _PABOTLIBPROCESS = _start_remote_library(pabot_args)
2451
+ _PABOTLIBPROCESS, _PABOTLIBTHREAD = _start_remote_library(pabot_args)
2452
+ # Set up signal handler to keep PabotLib alive during CTRL+C
2453
+ # This ensures graceful shutdown in the finally block
2454
+ original_signal_handler = signal.signal(signal.SIGINT, keyboard_interrupt)
2258
2455
  if _pabotlib_in_use():
2259
2456
  _initialize_queue_index()
2260
2457
 
@@ -2262,19 +2459,23 @@ def main_program(args):
2262
2459
  if pabot_args["verbose"]:
2263
2460
  _write("Suite names resolved in %s seconds" % str(time.time() - start_time))
2264
2461
  if not suite_groups or suite_groups == [[]]:
2265
- _write("No tests to execute")
2462
+ _write("No tests to execute", level="info")
2266
2463
  if not options.get("runemptysuite", False):
2267
2464
  return 252
2268
- execution_items = _create_execution_items(
2465
+
2466
+ # Create execution items for all argumentfiles at once
2467
+ all_execution_items = _create_execution_items(
2269
2468
  suite_groups, datasources, outs_dir, options, opts_for_run, pabot_args
2270
2469
  )
2470
+
2471
+ # Now execute all items from all argumentfiles in parallel
2271
2472
  if pabot_args.get("ordering", {}).get("mode") == "dynamic":
2272
2473
  # flatten stages
2273
- all_items = []
2274
- for stage in execution_items:
2275
- all_items.extend(stage)
2474
+ flattened_items = []
2475
+ for stage in all_execution_items:
2476
+ flattened_items.extend(stage)
2276
2477
  _parallel_execute_dynamic(
2277
- all_items,
2478
+ flattened_items,
2278
2479
  pabot_args["processes"],
2279
2480
  datasources,
2280
2481
  outs_dir,
@@ -2282,8 +2483,8 @@ def main_program(args):
2282
2483
  pabot_args,
2283
2484
  )
2284
2485
  else:
2285
- while execution_items:
2286
- items = execution_items.pop(0)
2486
+ while all_execution_items:
2487
+ items = all_execution_items.pop(0)
2287
2488
  _parallel_execute(
2288
2489
  items,
2289
2490
  pabot_args["processes"],
@@ -2297,8 +2498,8 @@ def main_program(args):
2297
2498
  "All tests were executed, but the --no-rebot argument was given, "
2298
2499
  "so the results were not compiled, and no summary was generated. "
2299
2500
  f"All results have been saved in the {outs_dir} folder."
2300
- ))
2301
- _write("===================================================")
2501
+ ), level="info")
2502
+ _write("===================================================", level="info")
2302
2503
  return 253
2303
2504
  result_code = _report_results(
2304
2505
  outs_dir,
@@ -2307,67 +2508,138 @@ def main_program(args):
2307
2508
  start_time_string,
2308
2509
  _get_suite_root_name(suite_groups),
2309
2510
  )
2511
+ # If CTRL+C was pressed during execution, raise KeyboardInterrupt now.
2512
+ # This can happen without previous errors if test are for example almost ready.
2513
+ if CTRL_C_PRESSED:
2514
+ raise KeyboardInterrupt()
2310
2515
  return result_code if not _ABNORMAL_EXIT_HAPPENED else 252
2311
2516
  except Information as i:
2312
2517
  version_print = __doc__.replace("\nPLACEHOLDER_README.MD\n", "")
2313
2518
  print(version_print.replace("[PABOT_VERSION]", PABOT_VERSION))
2314
- print(i.message)
2519
+ if _PABOTWRITER:
2520
+ _write(i.message, level="info")
2521
+ else:
2522
+ print(i.message)
2315
2523
  return 251
2316
2524
  except DataError as err:
2317
- print(err.message)
2525
+ if _PABOTWRITER:
2526
+ _write(err.message, Color.RED, level="error")
2527
+ else:
2528
+ print(err.message)
2318
2529
  return 252
2319
- except Exception:
2530
+ except (Exception, KeyboardInterrupt):
2320
2531
  if not CTRL_C_PRESSED:
2321
- _write("[ERROR] EXCEPTION RAISED DURING PABOT EXECUTION", Color.RED)
2322
- _write(
2323
- "[ERROR] PLEASE CONSIDER REPORTING THIS ISSUE TO https://github.com/mkorpela/pabot/issues",
2324
- Color.RED,
2325
- )
2326
- _write("Pabot: %s" % PABOT_VERSION)
2327
- _write("Python: %s" % sys.version)
2328
- _write("Robot Framework: %s" % ROBOT_VERSION)
2532
+ if _PABOTWRITER:
2533
+ _write("[ ERROR ] EXCEPTION RAISED DURING PABOT EXECUTION", Color.RED, level="error")
2534
+ _write(
2535
+ "[ ERROR ] PLEASE CONSIDER REPORTING THIS ISSUE TO https://github.com/mkorpela/pabot/issues",
2536
+ Color.RED, level="error"
2537
+ )
2538
+ _write("Pabot: %s" % PABOT_VERSION, level="info")
2539
+ _write("Python: %s" % sys.version, level="info")
2540
+ _write("Robot Framework: %s" % ROBOT_VERSION, level="info")
2541
+ else:
2542
+ print("[ ERROR ] EXCEPTION RAISED DURING PABOT EXECUTION")
2543
+ print("[ ERROR ] PLEASE CONSIDER REPORTING THIS ISSUE TO https://github.com/mkorpela/pabot/issues")
2544
+ print("Pabot: %s" % PABOT_VERSION)
2545
+ print("Python: %s" % sys.version)
2546
+ print("Robot Framework: %s" % ROBOT_VERSION)
2329
2547
  import traceback
2330
2548
  traceback.print_exc()
2331
- sys.exit(255)
2549
+ return 255
2332
2550
  else:
2333
- _write("[ERROR] Execution stopped by user (Ctrl+C)", Color.RED)
2334
- sys.exit(253)
2551
+ if _PABOTWRITER:
2552
+ _write("[ ERROR ] Execution stopped by user (Ctrl+C)", Color.RED, level="error")
2553
+ else:
2554
+ print("[ ERROR ] Execution stopped by user (Ctrl+C)")
2555
+ return 253
2335
2556
  finally:
2336
- # Ensure that writer exists
2337
- writer = None
2557
+ if _PABOTWRITER:
2558
+ _write("Finalizing Pabot execution...", level="debug")
2559
+ else:
2560
+ print("Finalizing Pabot execution...")
2561
+
2562
+ # Restore original signal handler
2338
2563
  try:
2339
- if outs_dir is not None:
2340
- writer = get_writer(log_dir=outs_dir)
2564
+ signal.signal(signal.SIGINT, original_signal_handler)
2341
2565
  except Exception as e:
2342
- print(f"[WARN] Could not initialize writer in finally: {e}")
2343
- # Try to stop remote library
2566
+ if _PABOTWRITER:
2567
+ _write(f"[ WARNING ] Could not restore signal handler: {e}", Color.YELLOW, level="warning")
2568
+ else:
2569
+ print(f"[ WARNING ] Could not restore signal handler: {e}")
2570
+
2571
+ # First: Terminate all test subprocesses gracefully
2572
+ # This must happen BEFORE stopping PabotLib so test processes
2573
+ # can cleanly disconnect from the remote library
2574
+ try:
2575
+ if _PROCESS_MANAGER:
2576
+ _PROCESS_MANAGER.terminate_all()
2577
+ except Exception as e:
2578
+ if _PABOTWRITER:
2579
+ _write(f"[ WARNING ] Could not terminate test subprocesses: {e}", Color.YELLOW, level="warning")
2580
+ else:
2581
+ print(f"[ WARNING ] Could not terminate test subprocesses: {e}")
2582
+
2583
+ # Then: Stop PabotLib after all test processes are gone
2584
+ # This ensures clean shutdown with no orphaned remote connections
2344
2585
  try:
2345
2586
  if _PABOTLIBPROCESS:
2346
2587
  _stop_remote_library(_PABOTLIBPROCESS)
2347
2588
  except Exception as e:
2348
- if writer:
2349
- writer.write(f"[WARN] Failed to stop remote library cleanly: {e}", Color.YELLOW)
2589
+ if _PABOTWRITER:
2590
+ _write(f"[ WARNING ] Failed to stop remote library cleanly: {e}", Color.YELLOW, level="warning")
2350
2591
  else:
2351
- print(f"[WARN] Failed to stop remote library cleanly: {e}")
2352
- # print elapsed time
2592
+ print(f"[ WARNING ] Failed to stop remote library cleanly: {e}")
2593
+
2594
+ # Print elapsed time
2353
2595
  try:
2354
2596
  _print_elapsed(start_time, time.time())
2355
2597
  except Exception as e:
2356
- if writer:
2357
- writer.write(f"[WARN] Failed to print elapsed time: {e}", Color.YELLOW)
2598
+ if _PABOTWRITER:
2599
+ _write(f"[ WARNING ] Failed to print elapsed time: {e}", Color.YELLOW, level="warning")
2600
+ else:
2601
+ print(f"[ WARNING ] Failed to print elapsed time: {e}")
2602
+
2603
+ # Ensure pabotlib output reader thread has finished
2604
+ try:
2605
+ if _PABOTLIBTHREAD:
2606
+ _PABOTLIBTHREAD.join(timeout=5)
2607
+ if _PABOTLIBTHREAD.is_alive():
2608
+ if _PABOTWRITER:
2609
+ _write(
2610
+ "[ WARNING ] PabotLib output thread did not finish before timeout",
2611
+ Color.YELLOW,
2612
+ level="warning"
2613
+ )
2614
+ else:
2615
+ print("[ WARNING ] PabotLib output thread did not finish before timeout")
2616
+ except Exception as e:
2617
+ if _PABOTWRITER:
2618
+ _write(f"[ WARNING ] Could not join pabotlib output thread: {e}", Color.YELLOW, level="warning")
2358
2619
  else:
2359
- print(f"[WARN] Failed to print elapsed time: {e}")
2620
+ print(f"[ WARNING ] Could not join pabotlib output thread: {e}")
2621
+
2360
2622
  # Flush and stop writer
2361
- if writer:
2362
- try:
2363
- writer.flush()
2364
- writer.write("Logs flushed successfully.")
2365
- except Exception as e:
2366
- print(f"[WARN] Could not flush writer: {e}")
2367
- try:
2368
- writer.stop()
2369
- except Exception as e:
2370
- print(f"[WARN] Could not stop writer: {e}")
2623
+ try:
2624
+ if _PABOTWRITER:
2625
+ _PABOTWRITER.write("Logs flushed successfully.", level="debug")
2626
+ _PABOTWRITER.flush()
2627
+ else:
2628
+ writer = get_writer()
2629
+ if writer:
2630
+ writer.flush()
2631
+ except Exception as e:
2632
+ print(f"[ WARNING ] Could not flush writer: {e}")
2633
+
2634
+ try:
2635
+ if _PABOTWRITER:
2636
+ _PABOTWRITER.stop()
2637
+ else:
2638
+ writer = get_writer()
2639
+ if writer:
2640
+ writer.stop()
2641
+ except Exception as e:
2642
+ print(f"[ WARNING ] Could not stop writer: {e}")
2371
2643
 
2372
2644
 
2373
2645
  def _parse_ordering(filename): # type: (str) -> List[ExecutionItem]
@@ -2403,13 +2675,13 @@ def _check_ordering(ordering_file, suite_names): # type: (List[ExecutionItem],
2403
2675
  duplicates.append(f"{item.type.title()} item: '{item.name}'")
2404
2676
  suite_and_test_names.append(item.name)
2405
2677
  if skipped_runnable_items:
2406
- _write("Note: The ordering file contains test or suite items that are not included in the current test run. The following items will be ignored/skipped:")
2678
+ _write("Note: The ordering file contains test or suite items that are not included in the current test run. The following items will be ignored/skipped:", level="info")
2407
2679
  for item in skipped_runnable_items:
2408
- _write(f" - {item}")
2680
+ _write(f" - {item}", level="info")
2409
2681
  if duplicates:
2410
- _write("Note: The ordering file contains duplicate suite or test items. Only the first occurrence is taken into account. These are duplicates:")
2682
+ _write("Note: The ordering file contains duplicate suite or test items. Only the first occurrence is taken into account. These are duplicates:", level="info")
2411
2683
  for item in duplicates:
2412
- _write(f" - {item}")
2684
+ _write(f" - {item}", level="info")
2413
2685
 
2414
2686
 
2415
2687
  def _group_suites(outs_dir, datasources, options, pabot_args):