cli: Minor refactor and add tests

This commit is contained in:
Steve Milner 2025-08-14 14:46:55 -04:00 committed by Steve Milner
commit 1e653664ea
2 changed files with 243 additions and 24 deletions

View file

@ -28,7 +28,10 @@ def load_map(fname: str) -> dict:
mapping = {}
with open(fname, "r") as f:
for line in f.readlines():
username, name = line.replace("\n", "").split("=")
line = line.replace("\n", "")
if not line or "=" not in line:
continue
username, name = line.split("=", 1)
mapping[username] = name
return mapping
@ -45,7 +48,7 @@ class _TextOrFileAction(argparse.Action):
parser: argparse.ArgumentParser,
namespace: argparse.Namespace,
values: typing.Any,
option_string: str,
option_strings: str,
):
"""
Handles loading content from a file or passing the string value
@ -54,7 +57,7 @@ class _TextOrFileAction(argparse.Action):
:param parser: Argument parser in use
:param namespace: The namespace associated with the parser
:param values: The value provided via the argument
:param option_string: The option used to set the value
:param option_strings: The option used to set the value
"""
if isinstance(values, str):
if values.startswith("@"):
@ -68,35 +71,45 @@ class _TextOrFileAction(argparse.Action):
setattr(namespace, self.dest, values)
else:
raise argparse.ArgumentError(
f"{option_string} must be a path to a file starting with @ or text"
option_strings,
f"{option_strings} must be a path to a file starting with @ or text",
)
def _common_switches(parser: argparse.ArgumentParser) -> argparse.ArgumentParser:
def _make_parser() -> argparse.ArgumentParser: # pragma: no cover
"""
Append common switches to subparsers.
Creates and populates the parser for the command line. Uses an internal function
for common switches.
:param parser: Parser to attach common switches
This function isn't in unittests as it's overwhelmingly standard library boilerplate.
"""
parser.add_argument("mapping", help="Mapping file for Jira Account=Name")
parser.add_argument("output", help="The file to write")
parser.add_argument(
"-p", "--prompt", help="Text or @file prompt to use", action=_TextOrFileAction
)
parser.add_argument(
"-i",
"--ignore-unknowns",
default=False,
action="store_true",
help="Ignores issues that don't have a known assignee",
)
return parser
# -- Internal function for attaching common switches to subparsers
def _common_switches(parser: argparse.ArgumentParser) -> argparse.ArgumentParser:
"""
Append common switches to subparsers.
:param parser: Parser to attach common switches
"""
parser.add_argument("mapping", help="Mapping file for Jira Account=Name")
parser.add_argument("output", help="The file to write")
parser.add_argument(
"-p",
"--prompt",
help="Text or @file prompt to use",
action=_TextOrFileAction,
)
parser.add_argument(
"-i",
"--ignore-unknowns",
default=False,
action="store_true",
help="Ignores issues that don't have a known assignee",
)
return parser
# --
def main():
"""
CLI entry point.
"""
parser = argparse.ArgumentParser(
description="Turns Jira output into a Gemini prompt to generate status",
)
@ -123,6 +136,14 @@ def main():
csv_parser.add_argument("csvfile", help="Path to the CSV file to read")
csv_parser = _common_switches(csv_parser)
return parser
def main(): # pragma: no cover
"""
CLI entry point.
"""
parser = _make_parser()
# Parse the args so we can do the work
args = parser.parse_args()
# But if we don't have any input show the help and exit

198
test/test_cli.py Normal file
View file

@ -0,0 +1,198 @@
# This file is part of sbs
#
# Copyright (C) 2025 Steve Milner
#
# SPDX-License-Identifier: GPL-3.0-or-later
# Generated by Gemini 2.5 Flash
import pytest
import argparse
import typing
from unittest.mock import MagicMock, patch, mock_open
from sbs.cli import _TextOrFileAction, load_map
@pytest.fixture
def text_or_file_action_instance():
"""
Provides an instance of _TextOrFileAction with a predefined 'dest'.
"""
# Initialize with a dummy option_strings and dest for testing purposes
return _TextOrFileAction(
option_strings=["-t", "--text-or-file"], dest="content_param"
)
@pytest.fixture
def mock_parser():
"""
Provides a mock argparse.ArgumentParser.
"""
return MagicMock(spec=argparse.ArgumentParser)
@pytest.fixture
def mock_namespace():
"""
Provides a mock argparse.Namespace to simulate argument storage.
"""
return MagicMock(spec=argparse.Namespace)
def test_direct_string_input(text_or_file_action_instance, mock_parser, mock_namespace):
"""
Tests that a direct string value is correctly assigned to the namespace.
"""
value = "This is a direct string input."
option_string = "--text-or-file"
text_or_file_action_instance(mock_parser, mock_namespace, value, option_string)
# Assert that the namespace attribute 'content_param' was set to the direct string value
assert mock_namespace.content_param == value
# Ensure no file operations were attempted
with pytest.raises(AttributeError):
mock_open.assert_not_called()
def test_file_input_without_trailing_newline(
text_or_file_action_instance, mock_parser, mock_namespace
):
"""
Tests that content from a file without a trailing newline is correctly read and assigned.
"""
file_content = "Content from a test file."
value = "@path/to/test_file.txt"
option_string = "--text-or-file"
# Use mock_open to simulate reading from a file
with patch("builtins.open", mock_open(read_data=file_content)) as mock_file:
text_or_file_action_instance(mock_parser, mock_namespace, value, option_string)
# Assert that 'open' was called with the correct path (without '@')
mock_file.assert_called_once_with("path/to/test_file.txt")
# Assert that the namespace attribute was set with the file's content
assert mock_namespace.content_param == file_content
def test_file_input_with_trailing_newline(
text_or_file_action_instance, mock_parser, mock_namespace
):
"""
Tests that content from a file with a trailing newline is correctly read and the newline is stripped.
"""
file_content_with_newline = "Content with a newline.\n"
expected_content = "Content with a newline."
value = "@path/to/test_file_with_newline.txt"
option_string = "--text-or-file"
with patch(
"builtins.open", mock_open(read_data=file_content_with_newline)
) as mock_file:
text_or_file_action_instance(mock_parser, mock_namespace, value, option_string)
mock_file.assert_called_once_with("path/to/test_file_with_newline.txt")
# Assert that the newline was stripped from the content
assert mock_namespace.content_param == expected_content
def test_file_input_empty_file(
text_or_file_action_instance, mock_parser, mock_namespace
):
"""
Tests handling of an empty file.
"""
file_content = ""
value = "@path/to/empty_file.txt"
option_string = "--text-or-file"
with patch("builtins.open", mock_open(read_data=file_content)) as mock_file:
text_or_file_action_instance(mock_parser, mock_namespace, value, option_string)
mock_file.assert_called_once_with("path/to/empty_file.txt")
assert mock_namespace.content_param == ""
def test_file_not_found_error(
text_or_file_action_instance, mock_parser, mock_namespace
):
"""
Tests that a FileNotFoundError is propagated when the file specified by '@' does not exist.
"""
value = "@non_existent_file.txt"
option_string = "--text-or-file"
# Patch open to raise FileNotFoundError
with patch("builtins.open", side_effect=FileNotFoundError):
with pytest.raises(FileNotFoundError):
text_or_file_action_instance(
mock_parser, mock_namespace, value, option_string
)
# Ensure no attribute was set on the namespace
with pytest.raises(AttributeError):
mock_namespace.content_param
def test_load_map_single_entry():
"""
Test that load_map correctly parses a single username=name entry.
"""
file_content = "user1=Alice Smith\n"
# Use patch.object to mock builtins.open and simulate file reading
with patch("builtins.open", mock_open(read_data=file_content)) as mock_file:
result = load_map("dummy_map.txt")
# Assert that open was called with the correct filename and mode
mock_file.assert_called_once_with("dummy_map.txt", "r")
# Assert the mapping is correct
assert result == {"user1": "Alice Smith"}
def test_load_map_multiple_entries():
"""
Test that load_map correctly parses multiple username=name entries.
"""
file_content = "user1=Alice Smith\n" "user2=Bob Johnson\n" "admin=Charlie Brown\n"
with patch("builtins.open", mock_open(read_data=file_content)) as mock_file:
result = load_map("multi_entry_map.txt")
mock_file.assert_called_once_with("multi_entry_map.txt", "r")
assert result == {
"user1": "Alice Smith",
"user2": "Bob Johnson",
"admin": "Charlie Brown",
}
def test_load_map_empty_file():
"""
Test that load_map returns an empty dictionary for an empty file.
"""
file_content = ""
with patch("builtins.open", mock_open(read_data=file_content)) as mock_file:
result = load_map("empty_map.txt")
mock_file.assert_called_once_with("empty_map.txt", "r")
assert result == {}
def test_load_map_file_not_found():
"""
Test that load_map raises FileNotFoundError when the file does not exist.
"""
# Patch open to raise FileNotFoundError
with patch("builtins.open", side_effect=FileNotFoundError):
with pytest.raises(FileNotFoundError):
load_map("non_existent_map.txt")
def test_load_map_line_with_multiple_equals():
"""
Test that load_map handles lines with multiple '=' signs correctly (splits only on first).
"""
file_content = "user_extra=First Last=ExtraInfo\n"
with patch("builtins.open", mock_open(read_data=file_content)):
result = load_map("multiple_equals_map.txt")
assert result == {"user_extra": "First Last=ExtraInfo"}