Repository: mkorpela/pabot

Issue:
Missing README.md in 4.1.0
Congrats on 4.1.0!

It looks like `pabot --help` is broken, as it tries to read the `README.md` (without any kind of path prefix).

```
pabot --help
[ERROR] EXCEPTION RAISED DURING PABOT EXECUTION
[ERROR] PLEASE CONSIDER REPORTING THIS ISSUE TO https://github.com/mkorpela/pabot/issues
Pabot: 4.1.0
Python: 3.9.21 | packaged by conda-forge | (main, Dec  5 2024, 13:51:40) 
[GCC 13.3.0]
Robot Framework: 7.2
Total testing: 0.0 seconds
Elapsed time:  0.0 seconds
Traceback (most recent call last):
  File "~/.venv/bin/pabot", line 11, in <module>
    sys.exit(main())
  File "~/.venv/lib/python3.9/site-packages/pabot/pabot.py", line 1893, in main
    return sys.exit(main_program(args))
  File "~/.venv/lib/python3.9/site-packages/pabot/pabot.py", line 1916, in main_program
    extract_section("README.md", "<!-- START DOCSTRING -->", "<!-- END DOCSTRING -->")
  File "~/.venv/lib/python3.9/site-packages/pabot/pabot.py", line 127, in extract_section
    with open(filename, "r", encoding="utf-8") as f:
FileNotFoundError: [Errno 2] No such file or directory: 'README.md'
```

Reading from `importlib.resources.path("pabot", "README.md")`, would probably do the trick, but would get complicated to support back to 3.6, adding conditional dependencies.

Hints from issue comments:
@bollwyvl: Fixed by #618.

Solution patch:
diff --git a/README.md b/README.md
index f6917697..34b8e5cb 100644
--- a/README.md
+++ b/README.md
@@ -23,6 +23,10 @@ OR clone this repository and run:
 
      setup.py  install
 
+OR clone this repository and run:
+
+     pip install --editable .
+
 ## Basic use
 
 Split execution to suite files.
@@ -61,7 +65,8 @@ pabot [--verbose|--testlevelsplit|--command .. --end-command|
         --artifacts extensions|--artifactsinsubfolders|
         --resourcefile file|--argumentfile[num] file|--suitesfrom file|--ordering file
         --chunk
-        --pabotprerunmodifier modifier] 
+        --pabotprerunmodifier modifier
+        --help|--version]
       [robot options] [path ...]
 
 PabotLib remote server is started by default to enable locking and resource distribution between parallel test executions.
@@ -148,6 +153,12 @@ Supports all [Robot Framework command line options](https://robotframework.org/r
   pabot subprocesses. Depending on the intended use, this may be desirable as well as more efficient. Can be used, for 
   example, to modify the list of tests to be performed.
 
+ --help             
+ Print usage instructions.
+ 
+ --version                
+ Print version information.
+
 Example usages:
 
      pabot test_directory
diff --git a/setup.cfg b/setup.cfg
index 2bf652b0..ae881a02 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -8,7 +8,8 @@ project_urls =
     Source = https://github.com/mkorpela/pabot
 version = attr: pabot.__version__
 description = Parallel test runner for Robot Framework
-long_description = A parallel executor for Robot Framework tests. With Pabot you can split one execution into multiple and save test execution time.
+long_description = file: README.md
+long_description_content_type = text/markdown
 license = Apache License, Version 2.0
 classifiers =
     Intended Audience :: Developers
diff --git a/src/pabot/arguments.py b/src/pabot/arguments.py
index 4d80ea4f..95259c15 100644
--- a/src/pabot/arguments.py
+++ b/src/pabot/arguments.py
@@ -79,6 +79,7 @@ def _parse_pabot_args(args):  # type: (List[str]) -> Tuple[List[str], Dict[str,
         "command": ["pybot" if ROBOT_VERSION < "3.1" else "robot"],
         "verbose": False,
         "help": False,
+        "version": False,
         "testlevelsplit": False,
         "pabotlib": True,
         "pabotlibhost": "127.0.0.1",
diff --git a/src/pabot/pabot.py b/src/pabot/pabot.py
index a6288325..3b04f673 100644
--- a/src/pabot/pabot.py
+++ b/src/pabot/pabot.py
@@ -91,6 +91,12 @@
 except ImportError:
     from pipes import quote  # type: ignore
 
+try:
+    import importlib.metadata
+    METADATA_AVAILABLE = True
+except ImportError:
+    METADATA_AVAILABLE = False
+
 from typing import IO, Any, Dict, List, Optional, Tuple, Union
 
 CTRL_C_PRESSED = False
@@ -122,11 +128,63 @@
 ]
 _ALL_ELAPSED = []  # type: List[Union[int, float]]
 
+# Python version check for supporting importlib.metadata (requires Python 3.8+)
+IS_PYTHON_3_8_OR_NEWER = sys.version_info >= (3, 8)
+
+
+def read_args_from_readme():
+    """Reads a specific section from package METADATA or development README.md if available."""
+
+    # 1. Try to read from METADATA (only if available and Python version is compatible)
+    metadata_section = read_from_metadata()
+    if metadata_section:
+        return f"Extracted from METADATA:\n\n{metadata_section}"
+
+    # 2. If METADATA is not available, fall back to development environment README.md
+    dev_readme_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "README.md"))
+    if os.path.exists(dev_readme_path):
+        with open(dev_readme_path, encoding="utf-8") as f:
+            lines = f.readlines()
+            help_args = extract_section(lines)
+            if help_args:
+                return f"Extracted from README.md ({dev_readme_path}):\n\n{help_args}"
+
+    if not IS_PYTHON_3_8_OR_NEWER:
+        return (
+            "Warning: Your Python version is too old and does not support importlib.metadata.\n"
+            "Please consider upgrading to Python 3.8 or newer for better compatibility.\n\n"
+            "To view any possible arguments, please kindly read the README.md here:\n"
+            "https://github.com/mkorpela/pabot"
+        )
+
+    return (
+        "Error: README.md or METADATA long_description not found.\n"
+        "If you believe this is an issue, please report it at:\n"
+        "https://github.com/mkorpela/pabot/issues"
+    )
+
+
+def read_from_metadata():
+    """Reads the long_description section from package METADATA if available."""
+    if not METADATA_AVAILABLE:
+        return None
+
+    try:
+        metadata = importlib.metadata.metadata("robotframework-pabot")
+        description = metadata.get("Description", "")
+
+        if not description:
+            return None
+
+        lines = description.splitlines(True)
+        return extract_section(lines)
+
+    except importlib.metadata.PackageNotFoundError:
+        return None
 
-def extract_section(filename, start_marker, end_marker):
-    with open(filename, "r", encoding="utf-8") as f:
-        lines = f.readlines()
 
+def extract_section(lines, start_marker="<!-- START DOCSTRING -->", end_marker="<!-- END DOCSTRING -->"):
+    """Extracts content between two markers in a list of lines."""
     inside_section = False
     extracted_lines = []
 
@@ -135,10 +193,9 @@ def extract_section(filename, start_marker, end_marker):
             inside_section = True
             continue
         if end_marker in line:
-            inside_section = False
             break
         if inside_section:
-            # Add line from README.md without [] and (https: address)
+            # Remove Markdown links but keep the text
             extracted_lines.append(re.sub(r'\[([^\]]+)\]\(https?://[^\)]+\)', r'\1', line))
 
     return "".join(extracted_lines).strip()
@@ -1913,7 +1970,7 @@ def main_program(args):
         if pabot_args["help"]:
             help_print = __doc__.replace(
                 "PLACEHOLDER_README.MD",
-                extract_section("README.md", "<!-- START DOCSTRING -->", "<!-- END DOCSTRING -->")
+                read_args_from_readme()
                 )
             print(help_print.replace("[PABOT_VERSION]", PABOT_VERSION))
             return 0


Test patch:
diff --git a/tests/test_basic_arguments.py b/tests/test_basic_arguments.py
new file mode 100644
index 00000000..542a0339
--- /dev/null
+++ b/tests/test_basic_arguments.py
@@ -0,0 +1,62 @@
+import unittest
+import tempfile
+import shutil
+import subprocess
+import sys
+import re
+
+EXPECTED_VERSION_PATTERN = (
+    rb"A parallel executor for Robot Framework test cases\.\r?\n"
+    rb"Version \d+\.\d+\.\d+\r?\n\r?\n"
+    rb"Copyright 20\d{2} Mikko Korpela - Apache 2 License"
+)
+
+EXPECTED_HELP_PATTERN = (
+    rb"A parallel executor for Robot Framework test cases\.\r?\n"
+    rb"Version \d+\.\d+\.\d+\r?\n\r?\n"
+    rb"Extracted from "
+)
+
+
+class BasicArgumentsTests(unittest.TestCase):
+    @classmethod
+    def setUpClass(self):
+        self.tmpdir = tempfile.mkdtemp()
+
+    def test_pabot_version(self):
+        process = subprocess.Popen(
+            [
+                sys.executable,
+                "-m", "pabot.pabot",
+                "--version"
+            ],
+            cwd=self.tmpdir,
+            stdout=subprocess.PIPE,
+            stderr=subprocess.PIPE
+        )
+
+        stdout, stderr = process.communicate()
+        match = re.match(EXPECTED_VERSION_PATTERN, stdout)
+        assert match, f"Version output does not match expected format, output: {repr(stdout)}"
+        self.assertEqual(b"", stderr)
+
+    def test_pabot_help(self):
+        process = subprocess.Popen(
+            [
+                sys.executable,
+                "-m", "pabot.pabot",
+                "--help"
+            ],
+            cwd=self.tmpdir,
+            stdout=subprocess.PIPE,
+            stderr=subprocess.PIPE
+        )
+
+        stdout, stderr = process.communicate()
+        match = re.match(EXPECTED_HELP_PATTERN, stdout)
+        assert match, f"Help output does not match expected format, output: {repr(stdout)}"
+        self.assertEqual(b"", stderr)
+
+    @classmethod
+    def tearDownClass(self):
+        shutil.rmtree(self.tmpdir)
diff --git a/tests/test_pabotprerunmodifier.py b/tests/test_pabotprerunmodifier.py
index 804958b2..cc8724ba 100644
--- a/tests/test_pabotprerunmodifier.py
+++ b/tests/test_pabotprerunmodifier.py
@@ -19,12 +19,26 @@ def check_robot_version_and_return_name():
         return "longname"
 
 
+def get_tmpdir_name(input: str) -> str:
+    # Remove everything before the first occurrence of two or more consecutive underscores,
+    # while preserving underscores as spaces with the same count
+    result = re.sub(r'^.*?(__+)', lambda m: ' ' * len(m.group(1)), input).strip()
+
+    # Capitalize letters following space, underscores or digits (e.g. after ' ', '_', '1')
+    result = re.sub(r'([ _\d])([a-z])', lambda m: m.group(1) + m.group(2).upper(), result)
+
+    result = result.replace("_", " ").strip()
+    # Capitalize the first letter of the result
+    result = result[0].upper() + result[1:] if result else result
+
+    return result.strip()
+
+
 class PabotPrerunModifierTests(unittest.TestCase):
     @classmethod
     def setUpClass(self):
         self.tmpdir = tempfile.mkdtemp()
-        self.tmpdir_name = (re.sub(r'(\d)([a-z])', lambda m: m.group(1) + m.group(2).upper(),
-                                   " ".join(word.capitalize().strip() for word in os.path.basename(self.tmpdir).split("_")))).strip()
+        self.tmpdir_name = get_tmpdir_name(os.path.basename(self.tmpdir))
 
         # robot case file 1
         self.robot_file_path_1 = f'{self.tmpdir}/test_1.robot'
@@ -117,6 +131,24 @@ def start_suite(self, suite):
 """))
 
 
+    # This is only for testing test tool
+    """
+    def test_get_tmpdir_name(self):
+        # This is what get_tmpdir_name will return
+        self.assertEqual(get_tmpdir_name("tmp3v__d5jk"), "D5Jk")
+        self.assertEqual(get_tmpdir_name("abc__xyz__test123"), "Xyz  Test123")
+        self.assertEqual(get_tmpdir_name("hello_world__test"), "Test")
+        self.assertEqual(get_tmpdir_name("tmp__data__42a"), "Data  42A")
+        self.assertEqual(get_tmpdir_name("2__1__g"), "1  G")
+        self.assertEqual(get_tmpdir_name("abc___xyz__123"), "Xyz  123") #(three and two spaces)
+        self.assertEqual(get_tmpdir_name("test__data__42a"), "Data  42A")
+        self.assertEqual(get_tmpdir_name("__1___2__3__4"), "1   2  3  4")
+        self.assertEqual(get_tmpdir_name("1___2__3__4"), "2  3  4")
+        self.assertEqual(get_tmpdir_name("tmp_ddl9fmr"), "Tmp Ddl9Fmr")
+        self.assertEqual(get_tmpdir_name("tmp1ddl9fmr"), "Tmp1Ddl9Fmr")
+        self.assertEqual(get_tmpdir_name(" mpe 74cy0ty"), "Mpe 74Cy0Ty")
+    """
+
     def test_pabotprerunmodifier(self):
         process = subprocess.Popen(
             [