Skip to content

Commit 1eda8ac

Browse files
committed
Fix filter function to locate config.yaml in pyspi install location by default, or allow the user to provide own config file. In both cases, output is saved in current working directory. Add new unit tests.
1 parent 9b9a80b commit 1eda8ac

File tree

2 files changed

+142
-53
lines changed

2 files changed

+142
-53
lines changed

pyspi/utils.py

Lines changed: 56 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -142,19 +142,47 @@ def check_optional_deps():
142142

143143
return isAvailable
144144

145-
def filter_spis(configfile, keywords, name="filtered_config"):
146-
"""Filter a YAML using a list of keywords, and save the reduced
147-
set as a new YAML with a user-specified name in the current
148-
directory."""
149-
150-
# check that keywords is a list
145+
def filter_spis(keywords, output_name=None, configfile=None):
146+
"""
147+
Filter a YAML using a list of keywords, and save the reduced set as a new
148+
YAML with a user-specified name (or a random one if not provided) in the
149+
current directory.
150+
151+
Args:
152+
keywords (list): A list of keywords (as strings) to filter the YAML.
153+
output_name (str, optional): The desired name for the output file. Defaults to a random name.
154+
configfile (str, optional): The path to the input YAML file. Defaults to the `config.yaml' in the pyspi dir.
155+
156+
Raises:
157+
ValueError: If `keywords` is not a list or if no SPIs match the keywords.
158+
FileNotFoundError: If the specified `configfile` or the default `config.yaml` is not found.
159+
IOError: If there's an error reading the YAML file.
160+
"""
151161
if not isinstance(keywords, list):
152-
raise TypeError("Keywords must be passed as a list.")
153-
# load in the original YAML
154-
with open(configfile) as f:
155-
yf = yaml.load(f, Loader=yaml.FullLoader)
156-
157-
# new dictonary to be converted to final YAML
162+
raise ValueError("Keywords must be provided as a list of strings.")
163+
164+
# if no configfile and no keywords are provided, use the default 'config.yaml' in pyspi location
165+
if configfile is None:
166+
script_dir = os.path.dirname(os.path.abspath(__file__))
167+
default_config = os.path.join(script_dir, 'config.yaml')
168+
if not os.path.isfile(default_config):
169+
raise FileNotFoundError(f"Default 'config.yaml' file not found in {script_dir}.")
170+
configfile = default_config
171+
source_file_info = f"Default 'config.yaml' file from {script_dir} was used as the source file."
172+
else:
173+
source_file_info = f"User-specified config file '{configfile}' was used as the source file."
174+
175+
# load in user-specified yaml
176+
try:
177+
with open(configfile) as f:
178+
yf = yaml.load(f, Loader=yaml.FullLoader)
179+
except FileNotFoundError:
180+
raise FileNotFoundError(f"Config file '{configfile}' not found.")
181+
except Exception as e:
182+
# handle all other exceptions
183+
raise IOError(f"An error occurred while trying to read '{configfile}': {e}")
184+
185+
# new dictionary to be converted to final YAML
158186
filtered_subset = {}
159187
spis_found = 0
160188

@@ -164,24 +192,35 @@ def filter_spis(configfile, keywords, name="filtered_config"):
164192
spi_labels = yf[module][spi].get('labels')
165193
if all(keyword in spi_labels for keyword in keywords):
166194
module_spis[spi] = yf[module][spi]
167-
spis_found += len(yf[module][spi].get('configs'))
195+
if yf[module][spi].get('configs'):
196+
spis_found += len(yf[module][spi].get('configs'))
197+
else:
198+
spis_found += 1
199+
168200
if module_spis:
169201
filtered_subset[module] = module_spis
170202

171203
# check that > 0 SPIs found
172204
if spis_found == 0:
173205
raise ValueError(f"0 SPIs were found with the specific keywords: {keywords}.")
174206

207+
# construct output file path
208+
if output_name is None:
209+
# use a unique name
210+
output_name = "config_" + os.urandom(4).hex()
211+
212+
output_file = os.path.join(os.getcwd(), f"{output_name}.yaml")
213+
175214
# write to YAML
176-
with open(f"pyspi/{name}.yaml", "w") as outfile:
215+
with open(output_file, "w") as outfile:
177216
yaml.dump(filtered_subset, outfile, default_flow_style=False, sort_keys=False)
178217

179218
# output relevant information
180-
# output relevant information
181219
print(f"""\nOperation Summary:
182220
-----------------
221+
- {source_file_info}
183222
- Total SPIs Matched: {spis_found} SPI(s) were found with the specific keywords: {keywords}.
184-
- New File Created: A YAML file named `{name}.yaml` has been saved in the current directory: `pyspi/{name}.yaml'
223+
- New File Created: A YAML file named `{output_name}.yaml` has been saved in the current directory: `{output_file}'
185224
- Next Steps: To utilise the filtered set of SPIs, please initialise a new Calculator instance with the following command:
186-
`Calculator(configfile='pyspi/{name}.yaml')`
225+
`Calculator(configfile='{output_file}')`
187226
""")

tests/test_utils.py

Lines changed: 86 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,111 @@
11
from pyspi.utils import filter_spis
22
import pytest
33
import yaml
4+
from unittest.mock import mock_open, patch
5+
6+
@pytest.fixture
7+
def mock_yaml_content():
8+
return {
9+
"module1": {
10+
"spi1": {"labels": ["keyword1", "keyword2"], "configs": [1, 2]},
11+
"spi2": {"labels": ["keyword1"], "configs": [3]},
12+
},
13+
"module2": {
14+
"spi3": {"labels": ["keyword3"], "configs": [1, 2, 3]},
15+
},
16+
}
417

518
def test_filter_spis_invalid_keywords():
619
"""Pass in a dataype other than a list for the keywords"""
7-
with pytest.raises(TypeError) as excinfo:
8-
filter_spis("pyspi/config.yaml", "linear")
9-
assert "Keywords must be passed as a list" in str(excinfo.value), "Keywords must be passed as list error not shown."
20+
with pytest.raises(ValueError) as e:
21+
filter_spis(keywords="linear", configfile="pyspi/config.yaml")
1022

1123
def test_filter_spis_with_invalid_config():
1224
"""Pass in an invalid/missing config file"""
1325
with pytest.raises(FileNotFoundError):
14-
filter_spis("invalid_config.yaml", ["test"])
26+
filter_spis(keywords=["test"], configfile="invalid_config.yaml")
1527

16-
def test_filter_spis_no_matches():
28+
def test_filter_spis_no_matches(mock_yaml_content):
1729
"""Pass in keywords that return no spis and check for ValuError"""
18-
mock_yaml_content = {
19-
"module1": {
20-
"spi1": {"labels": ["keyword1", "keyword2"], "configs": [1, 2]},
21-
"spi2": {"labels": ["keyword1"], "configs": [3]}
22-
}
23-
}
30+
m = mock_open()
31+
m().read.return_value = yaml.dump(mock_yaml_content)
2432
keywords = ["random_keyword"]
2533

26-
# create temporary YAML to load into the function
27-
with open("pyspi/mock_config2.yaml", "w") as f:
28-
yaml.dump(mock_yaml_content, f)
29-
30-
with pytest.raises(ValueError) as excinfo:
31-
filter_spis("pyspi/mock_config2.yaml", keywords, name="mock_filtered_config")
34+
with patch("builtins.open", m), \
35+
patch("os.path.isfile", return_value=True), \
36+
patch("yaml.load", return_value=mock_yaml_content):
37+
with pytest.raises(ValueError) as excinfo:
38+
filter_spis(keywords=keywords, output_name="mock_filtered_config", configfile="./mock_config.yaml")
39+
3240
assert "0 SPIs were found" in str(excinfo.value), "Incorrect error message returned when no keywords match found."
3341

34-
def test_filter_spis_normal_operation():
42+
def test_filter_spis_normal_operation(mock_yaml_content):
3543
"""Test whether the filter spis function works as expected"""
36-
# create some mock content to filter
37-
mock_yaml_content = {
38-
"module1": {
39-
"spi1": {"labels": ["keyword1", "keyword2"], "configs": [1, 2]},
40-
"spi2": {"labels": ["keyword1"], "configs": [3]}
41-
}
42-
}
43-
keywords = ["keyword1", "keyword2"]
44+
m = mock_open()
45+
m().read_return_value = yaml.dump(mock_yaml_content)
46+
keywords = ["keyword1", "keyword2"] # filter keys
4447
expected_output_yaml = {
4548
"module1": {
4649
"spi1": {"labels": ["keyword1", "keyword2"], "configs": [1,2]}
4750
}
4851
}
4952

50-
# create temporary YAML to load into the function
51-
with open("pyspi/mock_config.yaml", "w") as f:
52-
yaml.dump(mock_yaml_content, f)
53+
with patch("builtins.open", m), patch("os.path.isfile", return_value=True), \
54+
patch("yaml.load", return_value=mock_yaml_content), \
55+
patch("yaml.dump") as mock_dump:
56+
57+
filter_spis(keywords=keywords, output_name="mock_filtered_config", configfile="./mock_config.yaml")
58+
59+
mock_dump.assert_called_once()
60+
args, _ = mock_dump.call_args # get call args for dump and intercept
61+
actual_output = args[0] # the first argument to yaml.dump should be the yaml
62+
63+
assert actual_output == expected_output_yaml, "Expected filtered YAML does not match actual filtered YAML."
64+
65+
def test_filter_spis_io_error_on_read():
66+
# check to see whether io error is raised when trying to access the configfile
67+
with patch("builtins.open", mock_open(read_data="data")) as mocked_file:
68+
mocked_file.side_effect = IOError("error")
69+
with pytest.raises(IOError):
70+
filter_spis(["keyword"], "output", "config.yaml")
71+
72+
def test_filter_spis_saves_with_random_name_if_no_name_provided(mock_yaml_content):
73+
# mock os.urandom to return a predictable name
74+
random_bytes = bytes([1, 2, 3, 4])
75+
expected_random_part = "01020304"
76+
77+
with patch("builtins.open", mock_open()) as mocked_file, patch("os.path.isfile", return_value=True), \
78+
patch("yaml.load", return_value=mock_yaml_content), patch("os.urandom", return_value=random_bytes):
79+
80+
# run the filter function without providing an output name
81+
filter_spis(["keyword1"])
82+
83+
# construct the expected output name
84+
expected_file_name_pattern = f"config_{expected_random_part}.yaml"
85+
86+
# check the mocked open function to see if file with expected name is opened (for writing)
87+
call_args_list = mocked_file.call_args_list
88+
found_expected_call = any(
89+
expected_file_name_pattern in call_args.args[0] and
90+
('w' in call_args.args[1] if len(call_args.args) > 1 else 'w' in call_args.kwargs.get('mode', ''))
91+
for call_args in call_args_list
92+
)
93+
94+
assert found_expected_call, f"no file with the expected name {expected_file_name_pattern} was saved."
95+
96+
def test_loads_default_config_if_no_config_specified(mock_yaml_content):
97+
script_dir = "/fake/script/directory"
98+
default_config_path = f"{script_dir}/config.yaml"
5399

54-
55-
filter_spis("pyspi/mock_config.yaml", keywords, name="mock_filtered_config")
100+
with patch("builtins.open", mock_open()) as mocked_open, \
101+
patch("os.path.isfile", return_value=True), \
102+
patch("yaml.load", return_value=mock_yaml_content), \
103+
patch("os.path.dirname", return_value=script_dir), \
104+
patch("os.path.abspath", return_value=script_dir):
105+
106+
# run filter func without specifying a config file
107+
filter_spis(["keyword1"])
56108

57-
# load in the output
58-
with open("pyspi/mock_filtered_config.yaml", "r") as f:
59-
actual_output = yaml.load(f, Loader=yaml.FullLoader)
60-
61-
assert actual_output == expected_output_yaml, "Expected filtered YAML does not match actual filtered YAML."
109+
# ensure the mock_open was called with the expected path
110+
assert any(call.args[0] == default_config_path for call in mocked_open.mock_calls), \
111+
"Expected default config file to be opened."

0 commit comments

Comments
 (0)