Skip to content

Commit

Permalink
Merge pull request #15 from narenaryan/feat/4
Browse files Browse the repository at this point in the history
test(ISSUE-4): Improve code coverage
  • Loading branch information
narenaryan authored Nov 10, 2024
2 parents 5d458ac + e4b31fc commit 6277d32
Show file tree
Hide file tree
Showing 11 changed files with 314 additions and 74 deletions.
2 changes: 2 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[run]
source = whispr
3 changes: 1 addition & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ on:
pull_request:
branches:
- "*"
env:
PYTHONPATH: ./src # Needed for tests to discover whispr package

jobs:
test:
runs-on: ubuntu-22.04
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,4 @@ lib64/
.env
*.creds
.coverage*
!.coveragerc
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,7 @@ packages = ["src/whispr"]
[tool.pyright]
venvPath = "/opt/homebrew/Caskroom/miniconda"
venv = "whispr"

[tool.pytest.ini_options]
addopts = "--cov=whispr"
pythonpath = ["src"]
17 changes: 10 additions & 7 deletions src/whispr/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,17 @@
import click

from whispr.logging import logger
from whispr.utils import (
execute_command,
fetch_secrets,
get_filled_secrets,
from whispr.utils.io import (
load_config,
prepare_vault_config,
write_to_yaml_file,
)
from whispr.utils.process import execute_command

from whispr.utils.vault import (
fetch_secrets,
get_filled_secrets,
prepare_vault_config
)

CONFIG_FILE = "whispr.yaml"

Expand Down Expand Up @@ -66,8 +69,8 @@ def run(command):

filled_env_vars = get_filled_secrets(env_file, vault_secrets)

no_env = config.get("no_env")
execute_command(command, no_env, filled_env_vars)
no_env = config.get("no_env", False)
execute_command(command, no_env=no_env, secrets=filled_env_vars)


cli.add_command(init)
Expand Down
20 changes: 20 additions & 0 deletions src/whispr/utils/io.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import os

import yaml

from whispr.logging import logger

def write_to_yaml_file(config: dict, file_path: str):
"""Writes a given config object to a file in YAML format"""
if not os.path.exists(file_path):
with open(file_path, "w", encoding="utf-8") as file:
yaml.dump(config, file)
logger.info(f"{file_path} has been created.")

def load_config(file_path: str) -> dict:
"""Loads a given config file"""
try:
with open(file_path, "r", encoding="utf-8") as file:
return yaml.safe_load(file)
except Exception as e:
raise e
31 changes: 31 additions & 0 deletions src/whispr/utils/process.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import os
import subprocess
import shlex

from whispr.logging import logger

def execute_command(command: tuple, no_env: bool, secrets: dict):
"""Executes a Unix/Windows command.
Arg: `no_env` decides whether secrets are passed vai environment or K:V pairs in command arguments.
"""
if not secrets:
secrets = {}

try:
usr_command = shlex.split(command[0])

if no_env:
# Pass as --env K=V format (secure)
usr_command.extend([
f"{k}={v}" for k,v in secrets.items()
])
else:
# Pass via environment (slightly insecure)
os.environ.update(secrets)

sp = subprocess.run(usr_command, env=os.environ, shell=False, check=True)
except subprocess.CalledProcessError as e:
logger.error(
f"Encountered a problem while running command: '{command[0]}'. Aborting."
)
raise e
83 changes: 18 additions & 65 deletions src/whispr/utils.py → src/whispr/utils/vault.py
Original file line number Diff line number Diff line change
@@ -1,69 +1,12 @@
"""Functions used by CLI"""

import json
import os
import shlex
import subprocess

import yaml
from dotenv import dotenv_values

from whispr.factory import VaultFactory
from whispr.logging import logger
from whispr.enums import VaultType


def write_to_yaml_file(config: dict, file_name: str):
"""Writes a given config object to a file in YAML format"""
if not os.path.exists(file_name):
with open(file_name, "w", encoding="utf-8") as file:
yaml.dump(config, file)
logger.info(f"{file_name} has been created.")


def prepare_vault_config(vault_name: str) -> dict:
"""Prepares configuration for a given vault"""
config = {
"env_file": ".env",
"secret_name": "<your_secret_name>",
"vault": VaultType.AWS.value,
}

# Add more configuration fields as needed for other secret managers.
if vault_name == VaultType.GCP.value:
config["project_id"] = "<gcp_project_id>"
config["vault"] = VaultType.GCP.value
elif vault_name == VaultType.AZURE.value:
config["vault_url"] = "<azure_vault_url>"
config["vault"] = VaultType.AZURE.value

return config


def execute_command(command: tuple, no_env: bool, creds: dict):
"""Executes a Unix/Windows command"""
if not creds:
creds = {}

try:
usr_command = shlex.split(command[0])

if no_env:
# Pass as --env K=V format (secure)
usr_command.extend([
f"{k}={v}" for k,v in creds.items()
])
else:
# Pass via environment (slightly insecure)
os.environ.update(creds)

subprocess.run(usr_command, env=os.environ, shell=False, check=True)
except subprocess.CalledProcessError:
logger.error(
f"Encountered a problem while running command: '{command[0]}'. Aborting."
)


def fetch_secrets(config: dict) -> dict:
"""Fetch secret from relevant vault"""
kwargs = config
Expand All @@ -81,7 +24,7 @@ def fetch_secrets(config: dict) -> dict:
try:
vault_instance = VaultFactory.get_vault(**kwargs)
except ValueError as e:
logger.error(e)
logger.error(f"Error creating vault instance: {str(e)}")
return {}

secret_string = vault_instance.fetch_secrets(secret_name)
Expand Down Expand Up @@ -110,10 +53,20 @@ def get_filled_secrets(env_file: str, vault_secrets: dict) -> dict:
return filled_secrets


def load_config(config_file: str) -> dict:
"""Loads a given config file"""
try:
with open(config_file, "r", encoding="utf-8") as file:
return yaml.safe_load(file)
except Exception as e:
raise e
def prepare_vault_config(vault_type: str) -> dict:
"""Prepares in-memory configuration for a given vault"""
config = {
"env_file": ".env",
"secret_name": "<your_secret_name>",
"vault": VaultType.AWS.value,
}

# Add more configuration fields as needed for other secret managers.
if vault_type == VaultType.GCP.value:
config["project_id"] = "<gcp_project_id>"
config["vault"] = VaultType.GCP.value
elif vault_type == VaultType.AZURE.value:
config["vault_url"] = "<azure_vault_url>"
config["vault"] = VaultType.AZURE.value

return config
61 changes: 61 additions & 0 deletions tests/test_io_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@

import os
import yaml
import unittest

from unittest.mock import MagicMock, patch, mock_open
from whispr.utils.io import write_to_yaml_file, load_config

class IOUtilsTestCase(unittest.TestCase):
"""Unit tests for the file utilities: write_to_yaml_file and load_config."""

def setUp(self):
"""Set up mocks for logger and os.path methods."""
self.mock_logger = MagicMock()
self.config = {"key": "value"}
self.file_path = "test_config.yaml"

@patch("whispr.utils.io.logger", new_callable=lambda: MagicMock())
@patch("builtins.open", new_callable=mock_open)
@patch("os.path.exists", return_value=False)
def test_write_to_yaml_file_creates_file(self, mock_exists, mock_open_file, mock_logger):
"""Test that write_to_yaml_file creates a new file and writes config data as YAML."""
write_to_yaml_file(self.config, self.file_path)

mock_open_file.assert_called_once_with(self.file_path, "w", encoding="utf-8")
mock_open_file().write.assert_called() # Ensures that some content was written
mock_logger.info.assert_called_once_with(f"{self.file_path} has been created.")

@patch("whispr.utils.io.logger", new_callable=lambda: MagicMock())
@patch("builtins.open", new_callable=mock_open)
@patch("os.path.exists", return_value=True)
def test_write_to_yaml_file_does_not_overwrite_existing_file(self, mock_exists, mock_open_file, mock_logger):
"""Test that write_to_yaml_file does not overwrite an existing file."""
write_to_yaml_file(self.config, self.file_path)

mock_open_file.assert_not_called()
mock_logger.info.assert_not_called()

@patch("builtins.open", new_callable=mock_open, read_data="key: value")
def test_load_config_success(self, mock_open_file):
"""Test that load_config loads a YAML file and returns a config dictionary."""
result = load_config(self.file_path)

mock_open_file.assert_called_once_with(self.file_path, "r", encoding="utf-8")
self.assertEqual(result, {"key": "value"})

@patch("builtins.open", new_callable=mock_open)
def test_load_config_file_not_found(self, mock_open_file):
"""Test load_config raises an error if the file does not exist."""
mock_open_file.side_effect = FileNotFoundError

with self.assertRaises(FileNotFoundError):
load_config("non_existent.yaml")

@patch("builtins.open", new_callable=mock_open)
def test_load_config_yaml_error(self, mock_open_file):
"""Test load_config raises an error for an invalid YAML file."""
mock_open_file.side_effect = yaml.YAMLError

with self.assertRaises(yaml.YAMLError):
load_config(self.file_path)
58 changes: 58 additions & 0 deletions tests/test_process_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import unittest
from unittest.mock import patch, MagicMock
import subprocess
import os

from whispr.utils.process import execute_command


class ProcessUtilsTestCase(unittest.TestCase):
"""Unit tests for the execute_command function, which executes commands with optional secrets."""

def setUp(self):
"""Set up test data and mocks for logger and os environment."""
self.command = ("echo Hello",)
self.secrets = {"API_KEY": "123456"}
self.no_env = True
self.mock_logger = MagicMock()

@patch("whispr.utils.process.logger", new_callable=lambda: MagicMock())
@patch("subprocess.run")
def test_execute_command_with_no_env(self, mock_subprocess_run, mock_logger):
"""Test execute_command with `no_env=True`, passing secrets as command arguments."""
execute_command(self.command, self.no_env, self.secrets)

expected_command = ["echo", "Hello", "API_KEY=123456"]
mock_subprocess_run.assert_called_once_with(expected_command, env=os.environ, shell=False, check=True)

@patch("whispr.utils.process.logger", new_callable=lambda: MagicMock())
@patch("subprocess.run")
@patch("os.environ.update")
def test_execute_command_with_env(self, mock_env_update, mock_subprocess_run, mock_logger):
"""Test execute_command with `no_env=False`, passing secrets via environment variables."""
execute_command(self.command, no_env=False, secrets=self.secrets)

mock_env_update.assert_called_once_with(self.secrets)
expected_command = ["echo", "Hello"]
mock_subprocess_run.assert_called_once_with(expected_command, env=os.environ, shell=False, check=True)

@patch("whispr.utils.process.logger", new_callable=lambda: MagicMock())
@patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "test"))
def test_execute_command_called_process_error(self, mock_subprocess_run, mock_logger):
"""Test execute_command handles CalledProcessError and logs an error message."""
with self.assertRaises(subprocess.CalledProcessError):
execute_command(self.command, no_env=True, secrets=self.secrets)

mock_logger.error.assert_called_once_with(
f"Encountered a problem while running command: '{self.command[0]}'. Aborting."
)

@patch("whispr.utils.process.logger", new_callable=lambda: MagicMock())
@patch("subprocess.run")
def test_execute_command_without_secrets(self, mock_subprocess_run, mock_logger):
"""Test execute_command without any secrets."""
execute_command(self.command, no_env=True, secrets={})

expected_command = ["echo", "Hello"]
mock_subprocess_run.assert_called_once_with(expected_command, env=os.environ, shell=False, check=True)
mock_logger.error.assert_not_called()
Loading

0 comments on commit 6277d32

Please sign in to comment.