robotframework-pabot 5.0.0__py3-none-any.whl → 5.2.0b1__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
@@ -48,6 +48,7 @@ from glob import glob
48
48
  from io import BytesIO, StringIO
49
49
  from multiprocessing.pool import ThreadPool
50
50
  from natsort import natsorted
51
+ from pathlib import Path
51
52
 
52
53
  from robot import __version__ as ROBOT_VERSION
53
54
  from robot import rebot
@@ -83,6 +84,7 @@ from .execution_items import (
83
84
  create_dependency_tree,
84
85
  )
85
86
  from .result_merger import merge
87
+ from .writer import get_writer
86
88
 
87
89
  try:
88
90
  import queue # type: ignore
@@ -100,10 +102,10 @@ try:
100
102
  except ImportError:
101
103
  METADATA_AVAILABLE = False
102
104
 
103
- from typing import IO, Any, Dict, List, Optional, Tuple, Union
105
+ from typing import Any, Dict, List, Optional, Tuple, Union
104
106
 
105
107
  CTRL_C_PRESSED = False
106
- MESSAGE_QUEUE = queue.Queue()
108
+ #MESSAGE_QUEUE = queue.Queue()
107
109
  EXECUTION_POOL_IDS = [] # type: List[int]
108
110
  EXECUTION_POOL_ID_LOCK = threading.Lock()
109
111
  POPEN_LOCK = threading.Lock()
@@ -130,6 +132,15 @@ _ALL_ELAPSED = [] # type: List[Union[int, float]]
130
132
  # Python version check for supporting importlib.metadata (requires Python 3.8+)
131
133
  IS_PYTHON_3_8_OR_NEWER = sys.version_info >= (3, 8)
132
134
 
135
+ _PROCESS_MANAGER = None
136
+
137
+ def _ensure_process_manager():
138
+ global _PROCESS_MANAGER
139
+ if _PROCESS_MANAGER is None:
140
+ from pabot.ProcessManager import ProcessManager
141
+ _PROCESS_MANAGER = ProcessManager()
142
+ return _PROCESS_MANAGER
143
+
133
144
 
134
145
  def read_args_from_readme():
135
146
  """Reads a specific section from package METADATA or development README.md if available."""
@@ -194,8 +205,13 @@ def extract_section(lines, start_marker="<!-- START DOCSTRING -->", end_marker="
194
205
  if end_marker in line:
195
206
  break
196
207
  if inside_section:
197
- # Remove Markdown links but keep the text
198
- extracted_lines.append(re.sub(r'\[([^\]]+)\]\(https?://[^\)]+\)', r'\1', line))
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
+ extracted_lines.append(line)
199
215
 
200
216
  return "".join(extracted_lines).strip()
201
217
 
@@ -210,7 +226,7 @@ class Color:
210
226
 
211
227
 
212
228
  def execute_and_wait_with(item):
213
- # type: ('QueueItem') -> None
229
+ # type: ('QueueItem') -> int
214
230
  global CTRL_C_PRESSED, _NUMBER_OF_ITEMS_TO_BE_EXECUTED
215
231
  is_last = _NUMBER_OF_ITEMS_TO_BE_EXECUTED == 1
216
232
  _NUMBER_OF_ITEMS_TO_BE_EXECUTED -= 1
@@ -229,6 +245,7 @@ def execute_and_wait_with(item):
229
245
  run_cmd, run_options = _create_command_for_execution(
230
246
  caller_id, datasources, is_last, item, outs_dir
231
247
  )
248
+ rc = 0
232
249
  if item.hive:
233
250
  _hived_execute(
234
251
  item.hive,
@@ -241,7 +258,7 @@ def execute_and_wait_with(item):
241
258
  item.index,
242
259
  )
243
260
  else:
244
- _try_execute_and_wait(
261
+ rc = _try_execute_and_wait(
245
262
  run_cmd,
246
263
  run_options,
247
264
  outs_dir,
@@ -259,6 +276,7 @@ def execute_and_wait_with(item):
259
276
  )
260
277
  except:
261
278
  _write(traceback.format_exc())
279
+ return rc
262
280
 
263
281
 
264
282
  def _create_command_for_execution(caller_id, datasources, is_last, item, outs_dir):
@@ -276,6 +294,7 @@ def _create_command_for_execution(caller_id, datasources, is_last, item, outs_di
276
294
  item.index,
277
295
  item.last_level,
278
296
  item.processes,
297
+ item.skip,
279
298
  )
280
299
  + datasources
281
300
  )
@@ -312,7 +331,7 @@ def _try_execute_and_wait(
312
331
  process_timeout=None,
313
332
  sleep_before_start=0
314
333
  ):
315
- # type: (List[str], List[str], str, str, bool, int, str, int, bool, Optional[int], int) -> None
334
+ # type: (List[str], List[str], str, str, bool, int, str, int, bool, Optional[int], int) -> int
316
335
  plib = None
317
336
  is_ignored = False
318
337
  if _pabotlib_in_use():
@@ -354,7 +373,8 @@ def _try_execute_and_wait(
354
373
  show_stdout_on_failure,
355
374
  )
356
375
  if is_ignored and os.path.isdir(outs_dir):
357
- shutil.rmtree(outs_dir)
376
+ _rmtree_with_path(outs_dir)
377
+ return rc
358
378
 
359
379
 
360
380
  def _result_to_stdout(
@@ -551,88 +571,39 @@ def _run(
551
571
  process_timeout,
552
572
  sleep_before_start,
553
573
  ):
554
- # type: (List[str], List[str], IO[Any], IO[Any], str, bool, int, int, str, Optional[int], int) -> Tuple[Union[subprocess.Popen[bytes], subprocess.Popen], Tuple[int, float]]
555
574
  timestamp = datetime.datetime.now()
575
+
556
576
  if sleep_before_start > 0:
557
- _write(
558
- "%s [%s] [ID:%s] SLEEPING %s SECONDS BEFORE STARTING %s"
559
- % (timestamp, pool_id, item_index, sleep_before_start, item_name),
560
- )
577
+ _write(f"{timestamp} [{pool_id}] [ID:{item_index}] SLEEPING {sleep_before_start} SECONDS BEFORE STARTING {item_name}")
561
578
  time.sleep(sleep_before_start)
562
- timestamp = datetime.datetime.now()
579
+
563
580
  command_name = run_command[-1].replace(" ", "_")
564
581
  argfile_path = os.path.join(outs_dir, f"{command_name}_argfile.txt")
565
582
  _write_internal_argument_file(run_options, filename=argfile_path)
566
- cmd = ' '.join(run_command + ['-A'] + [argfile_path])
567
- if PY2:
568
- cmd = cmd.decode("utf-8").encode(SYSTEM_ENCODING)
569
- # avoid hitting https://bugs.python.org/issue10394
570
- with POPEN_LOCK:
571
- my_env = os.environ.copy()
572
- syslog_file = my_env.get("ROBOT_SYSLOG_FILE", None)
573
- if syslog_file:
574
- my_env["ROBOT_SYSLOG_FILE"] = os.path.join(
575
- outs_dir, os.path.basename(syslog_file)
576
- )
577
- process = subprocess.Popen(
578
- cmd, shell=True, stderr=stderr, stdout=stdout, env=my_env
579
- )
580
- if verbose:
581
- _write_with_id(
582
- process,
583
- pool_id,
584
- item_index,
585
- "EXECUTING PARALLEL %s with command:\n%s" % (item_name, cmd),
586
- timestamp=timestamp,
587
- )
588
- else:
589
- _write_with_id(
590
- process,
591
- pool_id,
592
- item_index,
593
- "EXECUTING %s" % item_name,
594
- timestamp=timestamp,
595
- )
596
- return process, _wait_for_return_code(
597
- process, item_name, pool_id, item_index, process_timeout
598
- )
599
-
600
-
601
- def _wait_for_return_code(process, item_name, pool_id, item_index, process_timeout):
602
- rc = None
603
- elapsed = 0
604
- ping_time = ping_interval = 150
605
- while rc is None:
606
- rc = process.poll()
607
- time.sleep(0.1)
608
- elapsed += 1
609
-
610
- if process_timeout and elapsed / 10.0 >= process_timeout:
611
- process.terminate()
612
- process.wait()
613
- rc = (
614
- -1
615
- ) # Set a return code indicating that the process was killed due to timeout
616
- _write_with_id(
617
- process,
618
- pool_id,
619
- item_index,
620
- "Process %s killed due to exceeding the maximum timeout of %s seconds"
621
- % (item_name, process_timeout),
622
- )
623
- break
624
583
 
625
- if elapsed == ping_time:
626
- ping_interval += 50
627
- ping_time += ping_interval
628
- _write_with_id(
629
- process,
630
- pool_id,
631
- item_index,
632
- "still running %s after %s seconds" % (item_name, elapsed / 10.0),
633
- )
584
+ cmd = run_command + ['-A', argfile_path]
585
+ my_env = os.environ.copy()
586
+ syslog_file = my_env.get("ROBOT_SYSLOG_FILE", None)
587
+ if syslog_file:
588
+ my_env["ROBOT_SYSLOG_FILE"] = os.path.join(outs_dir, os.path.basename(syslog_file))
589
+
590
+ log_path = os.path.join(outs_dir, f"{command_name}_{item_index}.log")
591
+
592
+ manager = _ensure_process_manager()
593
+ process, (rc, elapsed) = manager.run(
594
+ cmd,
595
+ env=my_env,
596
+ stdout=stdout,
597
+ stderr=stderr,
598
+ timeout=process_timeout,
599
+ verbose=verbose,
600
+ item_name=item_name,
601
+ log_file=log_path,
602
+ pool_id=pool_id,
603
+ item_index=item_index,
604
+ )
634
605
 
635
- return rc, elapsed / 10.0
606
+ return process, (rc, elapsed)
636
607
 
637
608
 
638
609
  def _read_file(file_handle):
@@ -692,6 +663,7 @@ def _options_for_executor(
692
663
  queueIndex,
693
664
  last_level,
694
665
  processes,
666
+ skip,
695
667
  ):
696
668
  options = options.copy()
697
669
  options["log"] = "NONE"
@@ -728,6 +700,11 @@ def _options_for_executor(
728
700
  options["argumentfile"] = argfile
729
701
  if options.get("test", False) and options.get("include", []):
730
702
  del options["include"]
703
+ if skip:
704
+ this_dir = os.path.dirname(os.path.abspath(__file__))
705
+ listener_path = os.path.join(this_dir, "skip_listener.py")
706
+ options["dryrun"] = True
707
+ options["listener"].append(listener_path)
731
708
  return _set_terminal_coloring_options(options)
732
709
 
733
710
 
@@ -750,10 +727,13 @@ def _modify_options_for_argfile_use(argfile, options):
750
727
 
751
728
 
752
729
  def _replace_base_name(new_name, options, key):
753
- if isinstance(options.get(key, None), str):
754
- options[key] = new_name + '.' + options[key].split('.', 1)[1]
730
+ if isinstance(options.get(key), str):
731
+ options[key] = f"{new_name}.{options[key].split('.', 1)[1]}" if '.' in options[key] else new_name
755
732
  elif key in options:
756
- options[key] = [new_name + '.' + s.split('.', 1)[1] for s in options.get(key, [])]
733
+ options[key] = [
734
+ f"{new_name}.{s.split('.', 1)[1]}" if '.' in s else new_name
735
+ for s in options.get(key, [])
736
+ ]
757
737
 
758
738
 
759
739
  def _set_terminal_coloring_options(options):
@@ -1438,6 +1418,117 @@ def keyboard_interrupt(*args):
1438
1418
  CTRL_C_PRESSED = True
1439
1419
 
1440
1420
 
1421
+ def _get_depends(item):
1422
+ return getattr(item.execution_item, "depends", [])
1423
+
1424
+
1425
+ def _dependencies_satisfied(item, completed):
1426
+ return all(dep in completed for dep in _get_depends(item))
1427
+
1428
+
1429
+ def _collect_transitive_dependents(failed_name, pending_items):
1430
+ """
1431
+ Returns all pending items that (directly or indirectly) depend on failed_name.
1432
+ """
1433
+ to_skip = set()
1434
+ queue = [failed_name]
1435
+
1436
+ # Build dependency map once
1437
+ depends_map = {
1438
+ item.execution_item.name: set(_get_depends(item))
1439
+ for item in pending_items
1440
+ }
1441
+
1442
+ while queue:
1443
+ current = queue.pop(0)
1444
+ for item_name, deps in depends_map.items():
1445
+ if current in deps and item_name not in to_skip:
1446
+ to_skip.add(item_name)
1447
+ queue.append(item_name)
1448
+
1449
+ return to_skip
1450
+
1451
+
1452
+ def _parallel_execute_dynamic(
1453
+ items,
1454
+ processes,
1455
+ datasources,
1456
+ outs_dir,
1457
+ opts_for_run,
1458
+ pabot_args,
1459
+ ):
1460
+ original_signal_handler = signal.signal(signal.SIGINT, keyboard_interrupt)
1461
+
1462
+ max_processes = processes or len(items)
1463
+ pool = ThreadPool(max_processes)
1464
+
1465
+ pending = set(items)
1466
+ running = {}
1467
+ completed = set()
1468
+ failed = set()
1469
+
1470
+ failure_policy = pabot_args.get("ordering", {}).get("failure_policy", "run_all")
1471
+ lock = threading.Lock()
1472
+
1473
+ def on_complete(it, rc):
1474
+ nonlocal pending, running, completed, failed
1475
+
1476
+ with lock:
1477
+ running.pop(it, None)
1478
+ completed.add(it.execution_item.name)
1479
+
1480
+ if rc != 0:
1481
+ failed.add(it.execution_item.name)
1482
+
1483
+ if failure_policy == "skip":
1484
+ to_skip_names = _collect_transitive_dependents(
1485
+ it.execution_item.name,
1486
+ pending,
1487
+ )
1488
+
1489
+ 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
+ )
1496
+ other.skip = True
1497
+
1498
+ try:
1499
+ while pending or running:
1500
+ with lock:
1501
+ ready = [
1502
+ item for item in list(pending)
1503
+ if _dependencies_satisfied(item, completed)
1504
+ ]
1505
+
1506
+ while ready and len(running) < max_processes:
1507
+ item = ready.pop(0)
1508
+ pending.remove(item)
1509
+
1510
+ result = pool.apply_async(
1511
+ execute_and_wait_with,
1512
+ (item,),
1513
+ callback=lambda rc, it=item: on_complete(it, rc),
1514
+ )
1515
+ running[item] = result
1516
+
1517
+ dynamic_items = _get_dynamically_created_execution_items(
1518
+ datasources, outs_dir, opts_for_run, pabot_args
1519
+ )
1520
+ if dynamic_items:
1521
+ with lock:
1522
+ for di in dynamic_items:
1523
+ pending.add(di)
1524
+
1525
+ time.sleep(0.1)
1526
+
1527
+ finally:
1528
+ pool.close()
1529
+ signal.signal(signal.SIGINT, original_signal_handler)
1530
+
1531
+
1441
1532
  def _parallel_execute(
1442
1533
  items, processes, datasources, outs_dir, opts_for_run, pabot_args
1443
1534
  ):
@@ -1473,15 +1564,30 @@ def _output_dir(options, cleanup=True):
1473
1564
  outputdir = options.get("outputdir", ".")
1474
1565
  outpath = os.path.join(outputdir, "pabot_results")
1475
1566
  if cleanup and os.path.isdir(outpath):
1476
- shutil.rmtree(outpath)
1567
+ _rmtree_with_path(outpath)
1477
1568
  return outpath
1478
1569
 
1479
1570
 
1480
- def _get_timestamp_id(timestamp_str):
1481
- return datetime.datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S.%f").strftime("%Y%m%d_%H%M%S")
1571
+ def _rmtree_with_path(path):
1572
+ """
1573
+ Remove a directory tree and, if a PermissionError occurs,
1574
+ re-raise it with the absolute path included in the message.
1575
+ """
1576
+ try:
1577
+ shutil.rmtree(path)
1578
+ except PermissionError as e:
1579
+ abs_path = os.path.abspath(path)
1580
+ raise PermissionError(f"Failed to delete path {abs_path}") from e
1581
+
1582
+
1583
+ def _get_timestamp_id(timestamp_str, add_timestamp):
1584
+ # type: (str, bool) -> Optional[str]
1585
+ if add_timestamp:
1586
+ return str(datetime.datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S.%f").strftime("%Y%m%d_%H%M%S"))
1587
+ return None
1482
1588
 
1483
1589
 
1484
- def _copy_output_artifacts(options, timestamp_id, file_extensions=None, include_subfolders=False, index=None):
1590
+ def _copy_output_artifacts(options, timestamp_id=None, file_extensions=None, include_subfolders=False, index=None):
1485
1591
  file_extensions = file_extensions or ["png"]
1486
1592
  pabot_outputdir = _output_dir(options, cleanup=False)
1487
1593
  outputdir = options.get("outputdir", ".")
@@ -1505,9 +1611,9 @@ def _copy_output_artifacts(options, timestamp_id, file_extensions=None, include_
1505
1611
  dst_folder_path = os.path.join(outputdir, subfolder_path)
1506
1612
  if not os.path.isdir(dst_folder_path):
1507
1613
  os.makedirs(dst_folder_path)
1508
- dst_file_name = "-".join([timestamp_id, prefix, file_name])
1509
- if index:
1510
- dst_file_name = "-".join([timestamp_id, index, prefix, file_name])
1614
+ dst_file_name_parts = [timestamp_id, index, prefix, file_name]
1615
+ filtered_name = [str(p) for p in dst_file_name_parts if p is not None]
1616
+ dst_file_name = "-".join(filtered_name)
1511
1617
  shutil.copy2(
1512
1618
  os.path.join(location, file_name),
1513
1619
  os.path.join(dst_folder_path, dst_file_name),
@@ -1517,16 +1623,34 @@ def _copy_output_artifacts(options, timestamp_id, file_extensions=None, include_
1517
1623
 
1518
1624
 
1519
1625
  def _check_pabot_results_for_missing_xml(base_dir, command_name, output_xml_name):
1626
+ """
1627
+ Check for missing Robot Framework output XML files in pabot result directories,
1628
+ taking into account the optional timestamp added by the -T option.
1629
+
1630
+ Args:
1631
+ base_dir: The root directory containing pabot subdirectories
1632
+ command_name: Name of the command that generated the output (used for fallback stderr filename)
1633
+ output_xml_name: Expected XML filename, e.g., 'output.xml'
1634
+
1635
+ Returns:
1636
+ List of paths to stderr output files for directories where the XML is missing.
1637
+ """
1520
1638
  missing = []
1639
+ # Prepare regex to match timestamped filenames like output-YYYYMMDD-hhmmss.xml
1640
+ name_stem = os.path.splitext(output_xml_name)[0]
1641
+ name_suffix = os.path.splitext(output_xml_name)[1]
1642
+ pattern = re.compile(rf"^{re.escape(name_stem)}(-\d{{8}}-\d{{6}})?{re.escape(name_suffix)}$")
1643
+
1521
1644
  for root, dirs, _ in os.walk(base_dir):
1522
1645
  if root == base_dir:
1523
1646
  for subdir in dirs:
1524
1647
  subdir_path = os.path.join(base_dir, subdir)
1525
- has_xml = any(fname.endswith(output_xml_name) for fname in os.listdir(subdir_path))
1648
+ # Check if any file matches the expected XML name or timestamped variant
1649
+ has_xml = any(pattern.match(fname) for fname in os.listdir(subdir_path))
1526
1650
  if not has_xml:
1527
- command_name = command_name.replace(" ", "_")
1528
- missing.append(os.path.join(subdir_path, f'{command_name}_stderr.out'))
1529
- break
1651
+ sanitized_cmd = command_name.replace(" ", "_")
1652
+ missing.append(os.path.join(subdir_path, f"{sanitized_cmd}_stderr.out"))
1653
+ break # only check immediate subdirectories
1530
1654
  return missing
1531
1655
 
1532
1656
 
@@ -1551,7 +1675,7 @@ def _report_results(outs_dir, pabot_args, options, start_time_string, tests_root
1551
1675
  outputs = [] # type: List[str]
1552
1676
  for index, _ in pabot_args["argumentfiles"]:
1553
1677
  copied_artifacts = _copy_output_artifacts(
1554
- options, _get_timestamp_id(start_time_string), pabot_args["artifacts"], pabot_args["artifactsinsubfolders"], index
1678
+ options, _get_timestamp_id(start_time_string, pabot_args["artifactstimestamps"]), pabot_args["artifacts"], pabot_args["artifactsinsubfolders"], index
1555
1679
  )
1556
1680
  outputs += [
1557
1681
  _merge_one_run(
@@ -1560,7 +1684,7 @@ def _report_results(outs_dir, pabot_args, options, start_time_string, tests_root
1560
1684
  tests_root_name,
1561
1685
  stats,
1562
1686
  copied_artifacts,
1563
- timestamp_id=_get_timestamp_id(start_time_string),
1687
+ timestamp_id=_get_timestamp_id(start_time_string, pabot_args["artifactstimestamps"]),
1564
1688
  outputfile=os.path.join("pabot_results", "output%s.xml" % index),
1565
1689
  )
1566
1690
  ]
@@ -1607,16 +1731,35 @@ def _write_stats(stats):
1607
1731
  _write("===================================================")
1608
1732
 
1609
1733
 
1734
+ def add_timestamp_to_filename(file_path: str, timestamp: str) -> str:
1735
+ """
1736
+ Rename the given file by inserting a timestamp before the extension.
1737
+ Format: YYYYMMDD-hhmmss
1738
+ Example: output.xml -> output-20251222-152233.xml
1739
+ """
1740
+ file_path = Path(file_path)
1741
+ if not file_path.exists():
1742
+ raise FileNotFoundError(f"{file_path} does not exist")
1743
+
1744
+ new_name = f"{file_path.stem}-{timestamp}{file_path.suffix}"
1745
+ new_path = file_path.with_name(new_name)
1746
+ file_path.rename(new_path)
1747
+ return str(new_path)
1748
+
1749
+
1610
1750
  def _report_results_for_one_run(
1611
1751
  outs_dir, pabot_args, options, start_time_string, tests_root_name, stats
1612
1752
  ):
1613
1753
  copied_artifacts = _copy_output_artifacts(
1614
- options, _get_timestamp_id(start_time_string), pabot_args["artifacts"], pabot_args["artifactsinsubfolders"]
1754
+ options, _get_timestamp_id(start_time_string, pabot_args["artifactstimestamps"]), pabot_args["artifacts"], pabot_args["artifactsinsubfolders"]
1615
1755
  )
1616
1756
  output_path = _merge_one_run(
1617
- outs_dir, options, tests_root_name, stats, copied_artifacts, _get_timestamp_id(start_time_string)
1757
+ outs_dir, options, tests_root_name, stats, copied_artifacts, _get_timestamp_id(start_time_string, pabot_args["artifactstimestamps"])
1618
1758
  )
1619
1759
  _write_stats(stats)
1760
+ ts = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
1761
+ if "timestampoutputs" in options and options["timestampoutputs"]:
1762
+ output_path = add_timestamp_to_filename(output_path, ts)
1620
1763
  if (
1621
1764
  "report" in options
1622
1765
  and options["report"].upper() == "NONE"
@@ -1629,7 +1772,7 @@ def _report_results_for_one_run(
1629
1772
  else:
1630
1773
  _write("Output: %s" % output_path)
1631
1774
  options["output"] = None # Do not write output again with rebot
1632
- return rebot(output_path, **_options_for_rebot(options, start_time_string, _now()))
1775
+ return rebot(output_path, **_options_for_rebot(options, start_time_string, ts))
1633
1776
 
1634
1777
 
1635
1778
  def _merge_one_run(
@@ -1640,7 +1783,16 @@ def _merge_one_run(
1640
1783
  os.path.join(options.get("outputdir", "."), outputfile)
1641
1784
  )
1642
1785
  filename = options.get("output") or "output.xml"
1643
- files = natsorted(glob(os.path.join(_glob_escape(outs_dir), f"**/*{filename}"), recursive=True))
1786
+ base_name, ext = os.path.splitext(filename)
1787
+ # Glob all candidates
1788
+ candidate_files = glob(os.path.join(outs_dir, "**", f"*{base_name}*{ext}"), recursive=True)
1789
+
1790
+ # Regex: basename or basename-YYYYMMDD-hhmmss.ext
1791
+ ts_pattern = re.compile(rf"^{re.escape(base_name)}(?:-\d{{8}}-\d{{6}})?{re.escape(ext)}$")
1792
+
1793
+ files = [f for f in candidate_files if ts_pattern.search(os.path.basename(f))]
1794
+ files = natsorted(files)
1795
+
1644
1796
  if not files:
1645
1797
  _write('WARN: No output files in "%s"' % outs_dir, Color.YELLOW)
1646
1798
  return ""
@@ -1691,19 +1843,9 @@ def _glob_escape(pathname):
1691
1843
  return drive + pathname
1692
1844
 
1693
1845
 
1694
- def _writer():
1695
- while True:
1696
- message = MESSAGE_QUEUE.get()
1697
- if message is None:
1698
- MESSAGE_QUEUE.task_done()
1699
- return
1700
- print(message)
1701
- sys.stdout.flush()
1702
- MESSAGE_QUEUE.task_done()
1703
-
1704
-
1705
1846
  def _write(message, color=None):
1706
- MESSAGE_QUEUE.put(_wrap_with(color, message))
1847
+ writer = get_writer()
1848
+ writer.write(message, color=color)
1707
1849
 
1708
1850
 
1709
1851
  def _wrap_with(color, message):
@@ -1716,19 +1858,19 @@ def _is_output_coloring_supported():
1716
1858
  return sys.stdout.isatty() and os.name in Color.SUPPORTED_OSES
1717
1859
 
1718
1860
 
1719
- def _start_message_writer():
1720
- t = threading.Thread(target=_writer)
1721
- t.start()
1722
-
1723
-
1724
- def _stop_message_writer():
1725
- MESSAGE_QUEUE.put(None)
1726
- MESSAGE_QUEUE.join()
1861
+ def _is_port_available(port):
1862
+ """Check if a given port on localhost is available."""
1863
+ with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
1864
+ try:
1865
+ s.bind(("localhost", port))
1866
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
1867
+ return True
1868
+ except OSError:
1869
+ return False
1727
1870
 
1728
1871
 
1729
- def _get_free_port(pabot_args):
1730
- if pabot_args["pabotlibport"] != 0:
1731
- return pabot_args["pabotlibport"]
1872
+ def _get_free_port():
1873
+ """Return a free TCP port on localhost."""
1732
1874
  with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
1733
1875
  s.bind(("localhost", 0))
1734
1876
  s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
@@ -1737,29 +1879,43 @@ def _get_free_port(pabot_args):
1737
1879
 
1738
1880
  def _start_remote_library(pabot_args): # type: (dict) -> Optional[subprocess.Popen]
1739
1881
  global _PABOTLIBURI
1740
- free_port = _get_free_port(pabot_args)
1741
- _PABOTLIBURI = "%s:%s" % (pabot_args["pabotlibhost"], free_port)
1742
- if not pabot_args["pabotlib"]:
1882
+ # If pabotlib is not enabled, do nothing
1883
+ if not pabot_args.get("pabotlib"):
1743
1884
  return None
1744
- if pabot_args.get("resourcefile") and not os.path.exists(
1745
- pabot_args["resourcefile"]
1746
- ):
1885
+
1886
+ host = pabot_args.get("pabotlibhost", "127.0.0.1")
1887
+ port = pabot_args.get("pabotlibport", 8270)
1888
+
1889
+ # If host is default and user specified a non-zero port, check if it's available
1890
+ if host == "127.0.0.1" and port != 0 and not _is_port_available(port):
1891
+ _write(
1892
+ f"Warning: specified pabotlibport {port} is already in use. "
1893
+ "A free port will be assigned automatically.",
1894
+ Color.YELLOW,
1895
+ )
1896
+ port = _get_free_port()
1897
+
1898
+ # If host is default and port = 0, assign a free port
1899
+ if host == "127.0.0.1" and port == 0:
1900
+ port = _get_free_port()
1901
+
1902
+ _PABOTLIBURI = f"{host}:{port}"
1903
+ resourcefile = pabot_args.get("resourcefile") or ""
1904
+ if resourcefile and not os.path.exists(resourcefile):
1747
1905
  _write(
1748
1906
  "Warning: specified resource file doesn't exist."
1749
1907
  " Some tests may fail or continue forever.",
1750
1908
  Color.YELLOW,
1751
1909
  )
1752
- pabot_args["resourcefile"] = None
1753
- return subprocess.Popen(
1754
- '"{python}" -m {pabotlibname} {resourcefile} {pabotlibhost} {pabotlibport}'.format(
1755
- python=sys.executable,
1756
- pabotlibname=pabotlib.__name__,
1757
- resourcefile=pabot_args.get("resourcefile"),
1758
- pabotlibhost=pabot_args["pabotlibhost"],
1759
- pabotlibport=free_port,
1760
- ),
1761
- shell=True,
1762
- )
1910
+ resourcefile = ""
1911
+ cmd = [
1912
+ sys.executable,
1913
+ "-m", pabotlib.__name__,
1914
+ resourcefile,
1915
+ pabot_args["pabotlibhost"],
1916
+ str(port),
1917
+ ]
1918
+ return subprocess.Popen(cmd)
1763
1919
 
1764
1920
 
1765
1921
  def _stop_remote_library(process): # type: (subprocess.Popen) -> None
@@ -1807,8 +1963,9 @@ class QueueItem(object):
1807
1963
  hive=None,
1808
1964
  processes=0,
1809
1965
  timeout=None,
1966
+ skip=False,
1810
1967
  ):
1811
- # type: (List[str], str, Dict[str, object], ExecutionItem, List[str], bool, Tuple[str, Optional[str]], Optional[str], int, Optional[int]) -> None
1968
+ # type: (List[str], str, Dict[str, object], ExecutionItem, List[str], bool, Tuple[str, Optional[str]], Optional[str], int, Optional[int], bool) -> None
1812
1969
  self.datasources = datasources
1813
1970
  self.outs_dir = (
1814
1971
  outs_dir.encode("utf-8") if PY2 and is_unicode(outs_dir) else outs_dir
@@ -1828,6 +1985,7 @@ class QueueItem(object):
1828
1985
  self.processes = processes
1829
1986
  self.timeout = timeout
1830
1987
  self.sleep_before_start = execution_item.get_sleep()
1988
+ self.skip = skip
1831
1989
 
1832
1990
  @property
1833
1991
  def index(self):
@@ -2022,7 +2180,16 @@ def _get_dynamically_created_execution_items(
2022
2180
  if not _pabotlib_in_use():
2023
2181
  return None
2024
2182
  plib = Remote(_PABOTLIBURI)
2025
- new_suites = plib.run_keyword("get_added_suites", [], {})
2183
+ try:
2184
+ new_suites = plib.run_keyword("get_added_suites", [], {})
2185
+ except RuntimeError as err:
2186
+ _write(
2187
+ "[WARN] PabotLib unreachable during post-run phase, "
2188
+ "assuming no dynamically added suites. "
2189
+ "Original error: %s",
2190
+ err,
2191
+ )
2192
+ new_suites = []
2026
2193
  if len(new_suites) == 0:
2027
2194
  return None
2028
2195
  suite_group = [DynamicSuiteItem(s, v) for s, v in new_suites]
@@ -2054,6 +2221,7 @@ def main(args=None):
2054
2221
 
2055
2222
  def main_program(args):
2056
2223
  global _PABOTLIBPROCESS
2224
+ outs_dir = None
2057
2225
  args = args or sys.argv[1:]
2058
2226
  if len(args) == 0:
2059
2227
  print(
@@ -2067,7 +2235,6 @@ def main_program(args):
2067
2235
  start_time_string = _now()
2068
2236
  # NOTE: timeout option
2069
2237
  try:
2070
- _start_message_writer()
2071
2238
  options, datasources, pabot_args, opts_for_run = parse_args(args)
2072
2239
  if pabot_args["help"]:
2073
2240
  help_print = __doc__.replace(
@@ -2075,15 +2242,22 @@ def main_program(args):
2075
2242
  read_args_from_readme()
2076
2243
  )
2077
2244
  print(help_print.replace("[PABOT_VERSION]", PABOT_VERSION))
2078
- return 0
2245
+ return 251
2079
2246
  if len(datasources) == 0:
2080
2247
  print("[ " + _wrap_with(Color.RED, "ERROR") + " ]: No datasources given.")
2081
2248
  print("Try --help for usage information.")
2082
2249
  return 252
2250
+ outs_dir = _output_dir(options)
2251
+
2252
+ # These ensure MessageWriter and ProcessManager are ready before any parallel execution.
2253
+ writer = get_writer(log_dir=outs_dir)
2254
+ _ensure_process_manager()
2255
+ _write(f"Initialized logging in {outs_dir}")
2256
+
2083
2257
  _PABOTLIBPROCESS = _start_remote_library(pabot_args)
2084
2258
  if _pabotlib_in_use():
2085
2259
  _initialize_queue_index()
2086
- outs_dir = _output_dir(options)
2260
+
2087
2261
  suite_groups = _group_suites(outs_dir, datasources, options, pabot_args)
2088
2262
  if pabot_args["verbose"]:
2089
2263
  _write("Suite names resolved in %s seconds" % str(time.time() - start_time))
@@ -2094,16 +2268,30 @@ def main_program(args):
2094
2268
  execution_items = _create_execution_items(
2095
2269
  suite_groups, datasources, outs_dir, options, opts_for_run, pabot_args
2096
2270
  )
2097
- while execution_items:
2098
- items = execution_items.pop(0)
2099
- _parallel_execute(
2100
- items,
2271
+ if pabot_args.get("ordering", {}).get("mode") == "dynamic":
2272
+ # flatten stages
2273
+ all_items = []
2274
+ for stage in execution_items:
2275
+ all_items.extend(stage)
2276
+ _parallel_execute_dynamic(
2277
+ all_items,
2101
2278
  pabot_args["processes"],
2102
2279
  datasources,
2103
2280
  outs_dir,
2104
2281
  opts_for_run,
2105
2282
  pabot_args,
2106
2283
  )
2284
+ else:
2285
+ while execution_items:
2286
+ items = execution_items.pop(0)
2287
+ _parallel_execute(
2288
+ items,
2289
+ pabot_args["processes"],
2290
+ datasources,
2291
+ outs_dir,
2292
+ opts_for_run,
2293
+ pabot_args,
2294
+ )
2107
2295
  if pabot_args["no-rebot"]:
2108
2296
  _write((
2109
2297
  "All tests were executed, but the --no-rebot argument was given, "
@@ -2111,7 +2299,7 @@ def main_program(args):
2111
2299
  f"All results have been saved in the {outs_dir} folder."
2112
2300
  ))
2113
2301
  _write("===================================================")
2114
- return 0 if not _ABNORMAL_EXIT_HAPPENED else 252
2302
+ return 253
2115
2303
  result_code = _report_results(
2116
2304
  outs_dir,
2117
2305
  pabot_args,
@@ -2124,24 +2312,62 @@ def main_program(args):
2124
2312
  version_print = __doc__.replace("\nPLACEHOLDER_README.MD\n", "")
2125
2313
  print(version_print.replace("[PABOT_VERSION]", PABOT_VERSION))
2126
2314
  print(i.message)
2315
+ return 251
2127
2316
  except DataError as err:
2128
2317
  print(err.message)
2129
2318
  return 252
2130
2319
  except Exception:
2131
- _write("[ERROR] EXCEPTION RAISED DURING PABOT EXECUTION", Color.RED)
2132
- _write(
2133
- "[ERROR] PLEASE CONSIDER REPORTING THIS ISSUE TO https://github.com/mkorpela/pabot/issues",
2134
- Color.RED,
2135
- )
2136
- _write("Pabot: %s" % PABOT_VERSION)
2137
- _write("Python: %s" % sys.version)
2138
- _write("Robot Framework: %s" % ROBOT_VERSION)
2139
- raise
2320
+ 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)
2329
+ import traceback
2330
+ traceback.print_exc()
2331
+ sys.exit(255)
2332
+ else:
2333
+ _write("[ERROR] Execution stopped by user (Ctrl+C)", Color.RED)
2334
+ sys.exit(253)
2140
2335
  finally:
2141
- if _PABOTLIBPROCESS:
2142
- _stop_remote_library(_PABOTLIBPROCESS)
2143
- _print_elapsed(start_time, time.time())
2144
- _stop_message_writer()
2336
+ # Ensure that writer exists
2337
+ writer = None
2338
+ try:
2339
+ if outs_dir is not None:
2340
+ writer = get_writer(log_dir=outs_dir)
2341
+ except Exception as e:
2342
+ print(f"[WARN] Could not initialize writer in finally: {e}")
2343
+ # Try to stop remote library
2344
+ try:
2345
+ if _PABOTLIBPROCESS:
2346
+ _stop_remote_library(_PABOTLIBPROCESS)
2347
+ except Exception as e:
2348
+ if writer:
2349
+ writer.write(f"[WARN] Failed to stop remote library cleanly: {e}", Color.YELLOW)
2350
+ else:
2351
+ print(f"[WARN] Failed to stop remote library cleanly: {e}")
2352
+ # print elapsed time
2353
+ try:
2354
+ _print_elapsed(start_time, time.time())
2355
+ except Exception as e:
2356
+ if writer:
2357
+ writer.write(f"[WARN] Failed to print elapsed time: {e}", Color.YELLOW)
2358
+ else:
2359
+ print(f"[WARN] Failed to print elapsed time: {e}")
2360
+ # 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}")
2145
2371
 
2146
2372
 
2147
2373
  def _parse_ordering(filename): # type: (str) -> List[ExecutionItem]
@@ -2189,7 +2415,7 @@ def _check_ordering(ordering_file, suite_names): # type: (List[ExecutionItem],
2189
2415
  def _group_suites(outs_dir, datasources, options, pabot_args):
2190
2416
  suite_names = solve_suite_names(outs_dir, datasources, options, pabot_args)
2191
2417
  _verify_depends(suite_names)
2192
- ordering_arg = _parse_ordering(pabot_args.get("ordering")) if (pabot_args.get("ordering")) is not None else None
2418
+ ordering_arg = _parse_ordering(pabot_args.get("ordering").get("file")) if (pabot_args.get("ordering")) is not None else None
2193
2419
  if ordering_arg:
2194
2420
  _verify_depends(ordering_arg)
2195
2421
  if options.get("name"):