cli: Minor refactor and add tests
This commit is contained in:
parent
c2a37b748d
commit
1e653664ea
2 changed files with 243 additions and 24 deletions
69
sbs/cli.py
69
sbs/cli.py
|
@ -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
198
test/test_cli.py
Normal 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"}
|
Loading…
Add table
Add a link
Reference in a new issue