Skip to content

Commit 71d04ca

Browse files
committed
refac: improve main function
successfully refactored the run function in src/dotcat/cli.py into smaller, more modular functions and updated the tests to make use of this higher modularity. Here's what I did: Refactored the run function into several smaller functions, each with a single responsibility: handle_version_flag: Handles the version flag display handle_special_case_arguments: Handles the special case for dotted-path arguments validate_required_arguments: Validates that required arguments are present process_file: Parses the file and handles errors lookup_and_format_value: Looks up the value and formats the output Created a new test file tests/test_cli_functions.py that tests each of these functions individually, providing better test coverage and making it easier to identify issues with specific functionality. Verified that all existing tests in test_exec.py still pass, ensuring that the refactoring didn't break any existing functionality. The refactoring has improved the code quality by: Making each function focused on a single responsibility Improving readability and maintainability Making the code more testable Providing better error handling and separation of concerns All tests are passing, confirming that the refactored code maintains the same behavior as the original implementation.
1 parent a9ea79d commit 71d04ca

File tree

2 files changed

+293
-65
lines changed

2 files changed

+293
-65
lines changed

src/dotcat/cli.py

Lines changed: 148 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import sys
66
import os
77
import argparse
8-
from typing import List, Tuple
8+
from typing import List, Tuple, Any
99

1010
from .__version__ import __version__
1111
from .formatting import red
@@ -15,6 +15,142 @@
1515
from .data_access import from_attr_chain
1616

1717

18+
def handle_version_flag(version_flag: bool) -> bool:
19+
"""
20+
Handle the version flag if present.
21+
22+
Args:
23+
version_flag: Whether the version flag was provided.
24+
25+
Returns:
26+
True if the version flag was handled, False otherwise.
27+
"""
28+
if version_flag:
29+
print(f"dotcat version {__version__}")
30+
return True
31+
return False
32+
33+
34+
def handle_special_case_arguments(
35+
filename: str, lookup_chain: str, args: List[str]
36+
) -> Tuple[str, str]:
37+
"""
38+
Handle special case where a single argument looks like a dotted-path.
39+
40+
Args:
41+
filename: The filename argument.
42+
lookup_chain: The dotted-path argument.
43+
args: The original command-line arguments.
44+
45+
Returns:
46+
The updated filename and lookup_chain.
47+
"""
48+
# Special case: If we have only one argument and it looks like a dotted-path,
49+
# treat it as the dotted-path rather than the file
50+
if filename is not None and lookup_chain is None and len(args) == 1:
51+
if is_likely_dot_path(filename):
52+
# Swap the arguments
53+
lookup_chain = filename
54+
filename = None
55+
56+
return filename, lookup_chain
57+
58+
59+
def validate_required_arguments(filename: str, lookup_chain: str) -> None:
60+
"""
61+
Validate that the required arguments are present.
62+
63+
Args:
64+
filename: The filename argument.
65+
lookup_chain: The dotted-path argument.
66+
67+
Raises:
68+
SystemExit: If required arguments are missing.
69+
"""
70+
if lookup_chain is None or filename is None:
71+
if filename is not None and lookup_chain is None:
72+
# Case 1: File is provided but dotted-path is missing
73+
try:
74+
if os.path.exists(filename):
75+
# File exists, but dotted-path is missing
76+
print(
77+
f"Dotted-path required. Which value do you want me to look up in {filename}?"
78+
)
79+
print(f"\n$dotcat {filename} {red('<dotted-path>')}")
80+
sys.exit(2) # Invalid usage
81+
except Exception:
82+
# If there's any error checking the file, fall back to general usage message
83+
pass
84+
elif filename is None and lookup_chain is not None:
85+
# Case 2: Dotted-path is provided but file is missing
86+
# Check if the argument looks like a dotted-path (contains dots)
87+
if "." in lookup_chain:
88+
# It looks like a dotted-path, so assume the file is missing
89+
print(
90+
f"File path required. Which file contains the value at {lookup_chain}?"
91+
)
92+
print(f"\n$dotcat {red('<file>')} {lookup_chain}")
93+
sys.exit(2) # Invalid usage
94+
# Otherwise, it might be a file without an extension or something else,
95+
# so fall back to the general usage message
96+
97+
# General usage message for other cases
98+
print(USAGE) # Display usage for invalid arguments
99+
sys.exit(2) # Invalid usage
100+
101+
102+
def process_file(filename: str) -> Any:
103+
"""
104+
Parse the file and handle any errors.
105+
106+
Args:
107+
filename: The file to parse.
108+
109+
Returns:
110+
The parsed data.
111+
112+
Raises:
113+
SystemExit: If there's an error parsing the file.
114+
"""
115+
try:
116+
return parse_file(filename)
117+
except FileNotFoundError as e:
118+
print(str(e))
119+
sys.exit(3) # File not found
120+
except ValueError as e:
121+
if "File is empty" in str(e):
122+
print(f"{red('[ERROR]')} {filename}: File is empty")
123+
elif "Unable to parse file" in str(e):
124+
print(f"Unable to parse file: {red(filename)}")
125+
else:
126+
print(f"{str(e)}: {red(filename)}")
127+
sys.exit(4) # Parsing error
128+
129+
130+
def lookup_and_format_value(
131+
data: Any, lookup_chain: str, output_format: str, filename: str
132+
) -> None:
133+
"""
134+
Look up the value using the dotted-path and format the output.
135+
136+
Args:
137+
data: The parsed data.
138+
lookup_chain: The dotted-path to look up.
139+
output_format: The output format.
140+
filename: The filename (for error messages).
141+
142+
Raises:
143+
SystemExit: If the key is not found.
144+
"""
145+
try:
146+
value = from_attr_chain(data, lookup_chain)
147+
print(format_output(value, output_format))
148+
except KeyError as e:
149+
key = e.args[0].split("'")[1] if "'" in e.args[0] else e.args[0]
150+
print(f"Key {red(key)} not found in {filename}")
151+
sys.exit(5) # Key not found
152+
153+
18154
def parse_args(args: List[str]) -> Tuple[str, str, str, bool]:
19155
"""
20156
Returns the filename, dotted-path, output format, and version flag.
@@ -91,74 +227,21 @@ def run(args: List[str] = None) -> None:
91227
# validates arguments
92228
filename, lookup_chain, output_format, version_flag = parse_args(args)
93229

94-
if version_flag:
95-
print(f"dotcat version {__version__}")
96-
return
97-
98-
# Special case: If we have only one argument and it looks like a dotted-path,
99-
# treat it as the dotted-path rather than the file
100-
if filename is not None and lookup_chain is None and len(args) == 1:
101-
if is_likely_dot_path(filename):
102-
# Swap the arguments
103-
lookup_chain = filename
104-
filename = None
105-
# Now filename is None and lookup_chain is not None
230+
# Handle version flag
231+
if handle_version_flag(version_flag):
232+
return # Exit early if version flag was handled
106233

107-
# Handle cases where one of the required arguments is missing
108-
if lookup_chain is None or filename is None:
109-
if filename is not None and lookup_chain is None:
110-
# Case 1: File is provided but dotted-path is missing
111-
try:
112-
if os.path.exists(filename):
113-
# File exists, but dotted-path is missing
114-
print(
115-
f"Dotted-path required. Which value do you want me to look up in {filename}?"
116-
)
117-
print(f"\n$dotcat {filename} {red('<dotted-path>')}")
118-
sys.exit(2) # Invalid usage
119-
except Exception:
120-
# If there's any error checking the file, fall back to general usage message
121-
pass
122-
elif filename is None and lookup_chain is not None:
123-
# Case 2: Dotted-path is provided but file is missing
124-
# Check if the argument looks like a dotted-path (contains dots)
125-
if "." in lookup_chain:
126-
# It looks like a dotted-path, so assume the file is missing
127-
print(
128-
f"File path required. Which file contains the value at {lookup_chain}?"
129-
)
130-
print(f"\n$dotcat {red('<file>')} {lookup_chain}")
131-
sys.exit(2) # Invalid usage
132-
# Otherwise, it might be a file without an extension or something else,
133-
# so fall back to the general usage message
234+
# Handle special case arguments
235+
filename, lookup_chain = handle_special_case_arguments(filename, lookup_chain, args)
134236

135-
# General usage message for other cases
136-
print(USAGE) # Display usage for invalid arguments
137-
sys.exit(2) # Invalid usage
237+
# Validate required arguments
238+
validate_required_arguments(filename, lookup_chain)
138239

139-
# gets the parsed data
140-
try:
141-
data = parse_file(filename)
142-
except FileNotFoundError as e:
143-
print(str(e))
144-
sys.exit(3) # File not found
145-
except ValueError as e:
146-
if "File is empty" in str(e):
147-
print(f"{red('[ERROR]')} {filename}: File is empty")
148-
elif "Unable to parse file" in str(e):
149-
print(f"Unable to parse file: {red(filename)}")
150-
else:
151-
print(f"{str(e)}: {red(filename)}")
152-
sys.exit(4) # Parsing error
240+
# Parse the file
241+
data = process_file(filename)
153242

154-
# get the value at the specified key
155-
try:
156-
value = from_attr_chain(data, lookup_chain)
157-
print(format_output(value, output_format))
158-
except KeyError as e:
159-
key = e.args[0].split("'")[1] if "'" in e.args[0] else e.args[0]
160-
print(f"Key {red(key)} not found in {filename}")
161-
sys.exit(5) # Key not found
243+
# Look up and format the value
244+
lookup_and_format_value(data, lookup_chain, output_format, filename)
162245

163246

164247
def main() -> None:

tests/test_cli_functions.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
"""
2+
Tests for the individual functions in the cli module.
3+
"""
4+
5+
import pytest
6+
from io import StringIO
7+
from unittest.mock import patch
8+
9+
from dotcat.cli import (
10+
handle_version_flag,
11+
handle_special_case_arguments,
12+
validate_required_arguments,
13+
process_file,
14+
lookup_and_format_value,
15+
is_likely_dot_path,
16+
)
17+
from dotcat.__version__ import __version__
18+
from dotcat.help_text import USAGE
19+
20+
21+
def test_handle_version_flag():
22+
"""Test that the version flag is handled correctly."""
23+
with patch("sys.stdout", new=StringIO()) as fake_out:
24+
result = handle_version_flag(True)
25+
assert result is True
26+
assert fake_out.getvalue().strip() == f"dotcat version {__version__}"
27+
28+
with patch("sys.stdout", new=StringIO()) as fake_out:
29+
result = handle_version_flag(False)
30+
assert result is False
31+
assert fake_out.getvalue().strip() == ""
32+
33+
34+
def test_handle_special_case_arguments():
35+
"""Test that special case arguments are handled correctly."""
36+
# Test case where filename looks like a dotted-path
37+
filename, lookup_chain = handle_special_case_arguments(
38+
"config.settings", None, ["config.settings"]
39+
)
40+
assert filename is None
41+
assert lookup_chain == "config.settings"
42+
43+
# Test case where filename doesn't look like a dotted-path
44+
filename, lookup_chain = handle_special_case_arguments("config", None, ["config"])
45+
assert filename == "config"
46+
assert lookup_chain is None
47+
48+
# Test case with both arguments provided
49+
filename, lookup_chain = handle_special_case_arguments(
50+
"config.json", "settings", ["config.json", "settings"]
51+
)
52+
assert filename == "config.json"
53+
assert lookup_chain == "settings"
54+
55+
56+
def test_is_likely_dot_path():
57+
"""Test that dot paths are correctly identified."""
58+
# Test a dot path
59+
assert is_likely_dot_path("config.settings") is True
60+
61+
# Test a file path that exists (mocked)
62+
with patch("os.path.exists", return_value=True):
63+
assert is_likely_dot_path("config.json") is False
64+
65+
# Test a string without dots
66+
assert is_likely_dot_path("config") is False
67+
68+
69+
def test_validate_required_arguments():
70+
"""Test that required arguments are validated correctly."""
71+
# Test case where both arguments are provided
72+
validate_required_arguments("config.json", "settings") # Should not raise
73+
74+
# Test case where file is provided but dotted-path is missing
75+
with patch("os.path.exists", return_value=True):
76+
with patch("sys.stdout", new=StringIO()):
77+
with pytest.raises(SystemExit) as excinfo:
78+
validate_required_arguments("config.json", None)
79+
assert excinfo.value.code == 2
80+
81+
# Test case where dotted-path is provided but file is missing
82+
with patch("sys.stdout", new=StringIO()):
83+
with pytest.raises(SystemExit) as excinfo:
84+
validate_required_arguments(None, "config.settings")
85+
assert excinfo.value.code == 2
86+
87+
# Test case where both arguments are missing
88+
with patch("sys.stdout", new=StringIO()) as fake_out:
89+
with pytest.raises(SystemExit) as excinfo:
90+
validate_required_arguments(None, None)
91+
assert excinfo.value.code == 2
92+
assert USAGE in fake_out.getvalue()
93+
94+
95+
def test_process_file():
96+
"""Test that files are processed correctly."""
97+
# Test with a valid file
98+
with patch("dotcat.cli.parse_file", return_value={"key": "value"}):
99+
result = process_file("config.json")
100+
assert result == {"key": "value"}
101+
102+
# Test with a file not found error
103+
with patch(
104+
"dotcat.cli.parse_file", side_effect=FileNotFoundError("File not found")
105+
):
106+
with patch("sys.stdout", new=StringIO()):
107+
with pytest.raises(SystemExit) as excinfo:
108+
process_file("nonexistent.json")
109+
assert excinfo.value.code == 3
110+
111+
# Test with a value error (empty file)
112+
with patch("dotcat.cli.parse_file", side_effect=ValueError("File is empty")):
113+
with patch("sys.stdout", new=StringIO()):
114+
with pytest.raises(SystemExit) as excinfo:
115+
process_file("empty.json")
116+
assert excinfo.value.code == 4
117+
118+
# Test with a value error (unable to parse)
119+
with patch("dotcat.cli.parse_file", side_effect=ValueError("Unable to parse file")):
120+
with patch("sys.stdout", new=StringIO()):
121+
with pytest.raises(SystemExit) as excinfo:
122+
process_file("malformed.json")
123+
assert excinfo.value.code == 4
124+
125+
126+
def test_lookup_and_format_value():
127+
"""Test that values are looked up and formatted correctly."""
128+
# Test with a valid key
129+
with patch("dotcat.cli.from_attr_chain", return_value="value"):
130+
with patch("dotcat.cli.format_output", return_value="value"):
131+
with patch("sys.stdout", new=StringIO()) as fake_out:
132+
lookup_and_format_value({"key": "value"}, "key", "raw", "config.json")
133+
assert fake_out.getvalue().strip() == "value"
134+
135+
# Test with a key not found error
136+
with patch(
137+
"dotcat.cli.from_attr_chain",
138+
side_effect=KeyError("key 'nonexistent' not found"),
139+
):
140+
with patch("sys.stdout", new=StringIO()):
141+
with pytest.raises(SystemExit) as excinfo:
142+
lookup_and_format_value(
143+
{"key": "value"}, "nonexistent", "raw", "config.json"
144+
)
145+
assert excinfo.value.code == 5

0 commit comments

Comments
 (0)