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()] 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()