Repository: Mr-Sunglasses/bitssh

Issue:
[Feature] Get Away from any kind of setuptools file.
Get away from setuptools, no need of it.

Hints from issue comments:

Solution patch:
diff --git a/.github/workflows/publish_dockerhub.yml b/.github/workflows/publish_dockerhub.yml
new file mode 100644
index 0000000..823e9d4
--- /dev/null
+++ b/.github/workflows/publish_dockerhub.yml
@@ -0,0 +1,23 @@
+name: CI Pipeline
+
+on:
+  release:
+    types:
+      - created
+
+jobs:
+  build:
+    runs-on: ubuntu-latest
+
+    steps:
+      - uses: actions/checkout@v3
+      - name: Login Dockerhub
+        env:
+          DOCKER_USERNAME: ${{secrets.DOCKER_USERNAME}}
+          DOCKER_PASSWORD: ${{secrets.DOCKER_PASSWORD}}
+        run: docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD
+
+      - name: Build the Docker image
+        run: docker build -t mrsunglasses/bitssh .
+      - name: Push to Dockerhub
+        run: docker push mrsunglasses/bitssh:latest
\ No newline at end of file
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 7ec5092..d51c84e 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -7,12 +7,12 @@ on:
 
 jobs:
     build:
-  
+
       runs-on: ubuntu-latest
       strategy:
         matrix:
           python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
-  
+
       steps:
         - uses: actions/checkout@v4
         - name: Set up Python ${{ matrix.python-version }}
@@ -22,6 +22,6 @@ jobs:
         - name: Create config mock
           run: mkdir ~/.ssh && touch ~/.ssh/config
         - name: Install dependencies
-          run: pip3 install . && pip3 install pytest
+          run: pip3 install -e . && pip3 install pytest
         - name: Run Test
-          run: pytest
\ No newline at end of file
+          run: pytest
diff --git a/Dockerfile b/Dockerfile
index 15cf26f..914f40e 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -13,4 +13,4 @@ RUN python -m pip install --upgrade pip
 RUN pip install bitssh
 
 # Command to run when container starts
-CMD ["bitssh"]
\ No newline at end of file
+CMD ["bitssh"]
diff --git a/README.md b/README.md
index f0ab7d8..6100143 100644
--- a/README.md
+++ b/README.md
@@ -43,7 +43,7 @@ Install from source
 
   cd bitssh
 
-  python3 -m pip3 install .
+  python3 -m pip3 install -e .
 
   bitssh
 ```
diff --git a/pyproject.toml b/pyproject.toml
index a07711e..95cf27d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,10 +1,10 @@
 [build-system]
-requires = ["setuptools>=61.0.0", "wheel"]
+requires = ["setuptools>=80.9.0", "wheel"]
 build-backend = "setuptools.build_meta"
 
 [project]
 name = "bitssh"
-version = "3.0.0"
+version = "3.1.0"
 description = "A New and Modern SSH connector written in Python."
 readme = "README.md"
 authors = [
@@ -23,7 +23,7 @@ classifiers = [ # List of https://pypi.org/classifiers/
 ]
 keywords = ["bitssh", "sshmanager", "commandline"]
 dependencies = [
-    "rich",
+    "rich>=14.0.0",
     "pre-commit>=3.6.0",
     "path>=16.9.0",
     "InquirerPy>=0.3.4",
@@ -41,7 +41,7 @@ Docs = "https://github.com/Mr-Sunglasses/bitssh/blob/master/docs/docs.md"
 bitssh = "bitssh.__main__:main"
 
 [tool.bumpver]
-current_version = "3.0.0"
+current_version = "3.1.0"
 version_pattern = "MAJOR.MINOR.PATCH"
 commit_message  = "Bump version {old_version} -> {new_version}"
 commit          = true
diff --git a/setup.py b/setup.py
deleted file mode 100644
index 6068493..0000000
--- a/setup.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from setuptools import setup
-
-setup()
diff --git a/src/bitssh/__init__.py b/src/bitssh/__init__.py
index c279046..6629147 100644
--- a/src/bitssh/__init__.py
+++ b/src/bitssh/__init__.py
@@ -5,4 +5,4 @@
     pass
 
 
-__version__ = "3.0.0"
+__version__ = "3.1.0"
diff --git a/src/bitssh/argument_parser.py b/src/bitssh/argument_parser.py
index b6d0f43..d994f08 100644
--- a/src/bitssh/argument_parser.py
+++ b/src/bitssh/argument_parser.py
@@ -8,6 +8,7 @@ def __init__(self) -> None:
         )
         parser.add_argument(
             "--version",
+            "-v",
             action="store_true",
             default=False,
             help="Show the bitssh version.",
diff --git a/src/bitssh/cli.py b/src/bitssh/cli.py
index dbfb5c0..20d12bb 100644
--- a/src/bitssh/cli.py
+++ b/src/bitssh/cli.py
@@ -1,5 +1,3 @@
-import os
-
 from bitssh import __version__
 
 from .argument_parser import Config
diff --git a/src/bitssh/prompt.py b/src/bitssh/prompt.py
index b4b4a37..f540897 100644
--- a/src/bitssh/prompt.py
+++ b/src/bitssh/prompt.py
@@ -1,31 +1,39 @@
 import os
+import subprocess
 from typing import Dict, List, Optional
 
 from InquirerPy import inquirer
 
 from .ui import console
-from .utils import ConfigPathUtility
+from .utils import get_config_file_host_data
 
 
 def ask_host_prompt():
-    HOST: List[str] = ConfigPathUtility.get_config_file_host_data()
-    questions: List[List] = inquirer.fuzzy(
+    HOST: List[str] = get_config_file_host_data()
+    questions = inquirer.fuzzy(
         message="Select the Host Given in the Above List: ",
         choices=HOST,
     )
     try:
-        answers: Optional[Dict[str, str]] = questions.execute()
+        answers = questions.execute()
         if answers is None:
             return
 
         cmd: str = answers
-        cmd = cmd[7::]
-        cmd = f"ssh {cmd}"
-        os.system("cls" if os.name == "nt" else "clear")
+        try:
+            _cmd_exec_data = cmd.split("-> ")[1]  # clean the data from answers
+        except IndexError:
+            raise ValueError("Invalid format: expected '-> ' delimiter in the answer.")
+        if os.name == "nt":  # Windows
+            subprocess.run(["cls"], shell=True, check=True)
+        else:  # Unix-like systems
+            subprocess.run(["clear"], check=True)
         console.print(
             "Please Wait While Your System is Connecting to the Remote Server 🖥️",
             style="green",
         )
-        os.system(cmd)
+        subprocess.run(["ssh", _cmd_exec_data], check=True)
+    except subprocess.CalledProcessError as e:
+        print(f"Error: {e.stdout}")
     except Exception as Error:
         print(f"\nInterrupted by {Error}")
diff --git a/src/bitssh/ui.py b/src/bitssh/ui.py
index 3bc63e5..be42a7d 100644
--- a/src/bitssh/ui.py
+++ b/src/bitssh/ui.py
@@ -1,9 +1,11 @@
 from rich.console import Console
 from rich.table import Table
-from .utils import ConfigPathUtility
+
+from .utils import get_config_file_row_data
 
 console = Console()
 
+
 def draw_table():
     table: Table = Table(title="SSH Servers in Config File 📁")
     table.add_column("Hostname", justify="left", style="cyan", no_wrap=True)
@@ -11,7 +13,7 @@ def draw_table():
     table.add_column("Port", justify="right", style="green")
     table.add_column("User", justify="right", style="yellow")
 
-    for i in ConfigPathUtility.get_config_file_row_data():
+    for i in get_config_file_row_data():
         table.add_row(i[0], i[1], i[2], i[3])
 
     console.print(table)
diff --git a/src/bitssh/utils.py b/src/bitssh/utils.py
index d243732..419762f 100644
--- a/src/bitssh/utils.py
+++ b/src/bitssh/utils.py
@@ -1,62 +1,62 @@
 import os
 import re
-from typing import Dict, List, Match, Optional, Pattern, Tuple
+from typing import Dict, List, Tuple
 
-from path import Path
+CONFIG_FILE_PATH: str = os.path.expanduser("~/.ssh/config")
 
 
-class ConfigPathUtility:
-    config_file_path: str = os.path.expanduser("~/.ssh/config")
+def _validate_config_file() -> None:
+    if not os.path.exists(CONFIG_FILE_PATH):
+        raise FileNotFoundError(
+            f"Config file not found at {CONFIG_FILE_PATH}. "
+            "Please see the documentation for bitssh."
+        )
 
-    @classmethod
-    def _validate_config_file(cls) -> None:
-        if not os.path.exists(cls.config_file_path):
-            raise FileNotFoundError(
-                f"Config file not found at {cls.config_file_path}. Please see the documentation for bitssh."
-            )
 
-    @classmethod
-    def get_config_content(cls) -> Dict[str, Dict[str, str]]:
-        cls._validate_config_file()
+def get_config_content():
+    _validate_config_file()
 
-        with open(cls.config_file_path, "r") as file:
-            lines = file.read()
+    with open(CONFIG_FILE_PATH, "r") as file:
+        lines = file.read()
 
-        host_pattern: Pattern[str] = re.compile(r"Host\s+(\w+)", re.MULTILINE)
-        hostname_pattern: Pattern[str] = re.compile(
-            r"(?:HostName|Hostname)\s+(\S+)", re.MULTILINE
-        )
-        user_pattern: Pattern[str] = re.compile(r"User\s+(\S+)", re.MULTILINE)
+    host_pattern = re.compile(r"Host\s+(\w+)", re.MULTILINE)
+    hostname_pattern = re.compile(r"(?:HostName|Hostname)\s+(\S+)", re.MULTILINE)
+    user_pattern = re.compile(r"User\s+(\S+)", re.MULTILINE)
+    port_pattern = re.compile(r"port\s+(\d+)", re.MULTILINE | re.IGNORECASE)
+
+    host_dict = {}
+    for match in host_pattern.finditer(lines):
+        host = match.group(1)
+        host_end = match.end()
+
+        hostname_match = hostname_pattern.search(lines, host_end)
+        hostname = hostname_match.group(1) if hostname_match else host
 
-        host_dict: Dict[str, Dict[str, str]] = {}
-        for match in host_pattern.finditer(lines):
-            host: str = match.group(1)
-            hostname_match: Optional[Match[str]] = hostname_pattern.search(
-                lines, match.end()
-            )
-            hostname: str = hostname_match.group(1) if hostname_match else host
+        user_match = user_pattern.search(lines, host_end)
+        user = user_match.group(1) if user_match else None
 
-            user_match: Optional[Match[str]] = user_pattern.search(lines, match.end())
-            user: Optional[str] = user_match.group(1) if user_match else None
+        port_match = port_pattern.search(lines, host_end)
+        port = port_match.group(1) if port_match else "22"
 
-            host_dict[host]: Dict[str, str] = {
-                "Hostname": hostname,
-                "User": user,
-            }
+        host_dict[host] = {
+            "Hostname": hostname,
+            "User": user,
+            "Port": port,
+        }
 
-        return host_dict
+    return host_dict
 
-    @classmethod
-    def get_config_file_row_data(cls) -> List[Tuple[str, str, str, str]]:
-        config_content = cls.get_config_content()
 
-        rows: List[Tuple[str, str, str, str]] = [
-            (attributes["Hostname"], host, "22", attributes["User"])
-            for host, attributes in config_content.items()
-        ]
+def get_config_file_row_data():
+    config_content = get_config_content()
+    rows = []
+    for host, attributes in config_content.items():
+        hostname = attributes["Hostname"]
+        user = attributes["User"]
+        port = attributes["Port"]
+        rows.append((hostname, host, port, user))
+    return rows
 
-        return rows
 
-    @classmethod
-    def get_config_file_host_data(cls) -> List[str]:
-        return [f"🖥️  -> {host[1]}" for host in cls.get_config_file_row_data()]
+def get_config_file_host_data() -> List[str]:
+    return [f"🖥️  -> {row[1]}" for row in get_config_file_row_data()]


Test patch:
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 5d539bb..acea215 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,9 +1,13 @@
 import os
 import unittest
-from unittest.mock import patch
-from bitssh.utils import ConfigPathUtility
-
 from typing import Dict, List, Tuple
+from unittest.mock import mock_open, patch
+
+from bitssh.utils import (
+    get_config_content,
+    get_config_file_host_data,
+    get_config_file_row_data,
+)
 
 
 class TestConfigPathUtility(unittest.TestCase):
@@ -13,53 +17,51 @@ def setUp(self) -> None:
     HostName test.hostname1.com
     User testUser1
 Host testHost2
-    HostName test.hostname2.com
+    Hostname test.hostname2.com
     User testUser2
 """
 
-    def tearDown(self) -> None:
-        if os.path.exists("config"):
-            os.remove("config")
-
     @patch("os.path.exists", return_value=True)
-    @patch("builtins.open")
-    def test_get_config_content_success(self, mock_open, mock_exists) -> None:
-        mock_open.return_value.__enter__.return_value.read.return_value = (
-            self.mock_config_data
-        )
-        expected: Dict[str, Dict[str, str]] = {
-            "testHost1": {"Hostname": "test.hostname1.com", "User": "testUser1"},
-            "testHost2": {"Hostname": "test.hostname2.com", "User": "testUser2"},
-        }
-        result: ConfigPathUtility = ConfigPathUtility.get_config_content()
-        self.assertEqual(result, expected)
+    def test_get_config_content_success(self, mock_exists) -> None:
+        with patch("builtins.open", mock_open(read_data=self.mock_config_data)):
+            expected: Dict[str, Dict[str, str]] = {
+                "testHost1": {
+                    "Hostname": "test.hostname1.com",
+                    "User": "testUser1",
+                    "Port": "22",
+                },
+                "testHost2": {
+                    "Hostname": "test.hostname2.com",
+                    "User": "testUser2",
+                    "Port": "22",
+                },
+            }
+            result = get_config_content()
+            self.assertEqual(result, expected)
 
     @patch("os.path.exists", return_value=True)
-    @patch("builtins.open")
-    def test_get_config_file_row_data(self, mock_open, mock_exists) -> None:
-        mock_open.return_value.__enter__.return_value.read.return_value = (
-            self.mock_config_data
-        )
-        expected_rows: List[Tuple[str, str, str, str]] = [
-            ("test.hostname1.com", "testHost1", "22", "testUser1"),
-            ("test.hostname2.com", "testHost2", "22", "testUser2"),
-        ]
-        rows: ConfigPathUtility = ConfigPathUtility.get_config_file_row_data()
-        self.assertEqual(rows, expected_rows)
+    def test_get_config_file_row_data(self, mock_exists) -> None:
+        with patch("builtins.open", mock_open(read_data=self.mock_config_data)):
+            expected_rows: List[Tuple[str, str, str, str]] = [
+                ("test.hostname1.com", "testHost1", "22", "testUser1"),
+                ("test.hostname2.com", "testHost2", "22", "testUser2"),
+            ]
+            rows = get_config_file_row_data()
+            self.assertEqual(rows, expected_rows)
 
     @patch("os.path.exists", return_value=True)
-    @patch("builtins.open")
-    def test_get_config_file_host_data(self, mock_open, mock_exists) -> None:
-        mock_open.return_value.__enter__.return_value.read.return_value = (
-            self.mock_config_data
-        )
-        expected_hosts: List = [
-            "🖥️  -> testHost1",
-            "🖥️  -> testHost2",
-        ]
-        hosts: ConfigPathUtility = ConfigPathUtility.get_config_file_host_data()
-        self.assertEqual(hosts, expected_hosts)
-
+    def test_get_config_file_host_data(self, mock_exists) -> None:
+        with patch("builtins.open", mock_open(read_data=self.mock_config_data)):
+            expected_hosts: List[str] = [
+                "🖥️  -> testHost1",
+                "🖥️  -> testHost2",
+            ]
+            hosts: List[str] = get_config_file_host_data()
+            self.assertEqual(hosts, expected_hosts)
 
-if __name__ == "__main__":
-    unittest.main()
+    def test_file_not_found_error(self) -> None:
+        with (
+            patch("os.path.exists", return_value=False),
+            self.assertRaises(FileNotFoundError),
+        ):
+            get_config_content()