Skip to content

Commit 6c8d9e0

Browse files
authored
Merge pull request #64 from DynamicsAndNeuralSystems/jmoo2880-pypi-publish
Minor bug fix for the filter_spis function
2 parents 9b9a80b + 683e92d commit 6c8d9e0

File tree

4 files changed

+157
-56
lines changed

4 files changed

+157
-56
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "pyspi"
7-
version = "1.0.0"
7+
version = "1.0.1"
88
authors = [
99
{ name ="Oliver M. Cliff", email="[email protected]"},
1010
]

pyspi/utils.py

Lines changed: 61 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
"""pyspi utility functions."""
21
import numpy as np
32
from scipy.stats import zscore
43
import warnings
@@ -142,19 +141,52 @@ def check_optional_deps():
142141

143142
return isAvailable
144143

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

@@ -164,24 +196,35 @@ def filter_spis(configfile, keywords, name="filtered_config"):
164196
spi_labels = yf[module][spi].get('labels')
165197
if all(keyword in spi_labels for keyword in keywords):
166198
module_spis[spi] = yf[module][spi]
167-
spis_found += len(yf[module][spi].get('configs'))
199+
if yf[module][spi].get('configs'):
200+
spis_found += len(yf[module][spi].get('configs'))
201+
else:
202+
spis_found += 1
203+
168204
if module_spis:
169205
filtered_subset[module] = module_spis
170206

171207
# check that > 0 SPIs found
172208
if spis_found == 0:
173209
raise ValueError(f"0 SPIs were found with the specific keywords: {keywords}.")
174210

211+
# construct output file path
212+
if output_name is None:
213+
# use a unique name
214+
output_name = "config_" + os.urandom(4).hex()
215+
216+
output_file = os.path.join(os.getcwd(), f"{output_name}.yaml")
217+
175218
# write to YAML
176-
with open(f"pyspi/{name}.yaml", "w") as outfile:
219+
with open(output_file, "w") as outfile:
177220
yaml.dump(filtered_subset, outfile, default_flow_style=False, sort_keys=False)
178221

179222
# output relevant information
180-
# output relevant information
181223
print(f"""\nOperation Summary:
182224
-----------------
225+
- {source_file_info}
183226
- 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'
227+
- New File Created: A YAML file named `{output_name}.yaml` has been saved in the current directory: `{output_file}'
185228
- 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')`
229+
`Calculator(configfile='{output_file}')`
187230
""")

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161
'data/standard_normal.npy',
6262
'data/cml7.npy']},
6363
include_package_data=True,
64-
version='1.0.0',
64+
version='1.0.1',
6565
description='Library for pairwise analysis of time series data.',
6666
author='Oliver M. Cliff',
6767
author_email='[email protected]',

tests/test_utils.py

Lines changed: 94 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,119 @@
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 excinfo:
21+
filter_spis(keywords="linear", configfile="pyspi/config.yaml")
22+
assert "Keywords must be provided as a list of strings" in str(excinfo.value)
23+
# check for passing in an empty list
24+
with pytest.raises(ValueError) as excinfo:
25+
filter_spis(keywords=[], configfile="pyspi/config.yaml")
26+
assert "At least one keyword must be provided" in str(excinfo.value)
27+
with pytest.raises(ValueError) as excinfo:
28+
filter_spis(keywords=[4], configfile="pyspi/config.yaml")
29+
assert "All keywords must be strings" in str(excinfo.value)
1030

1131
def test_filter_spis_with_invalid_config():
1232
"""Pass in an invalid/missing config file"""
1333
with pytest.raises(FileNotFoundError):
14-
filter_spis("invalid_config.yaml", ["test"])
34+
filter_spis(keywords=["test"], configfile="invalid_config.yaml")
1535

16-
def test_filter_spis_no_matches():
36+
def test_filter_spis_no_matches(mock_yaml_content):
1737
"""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-
}
38+
m = mock_open()
39+
m().read.return_value = yaml.dump(mock_yaml_content)
2440
keywords = ["random_keyword"]
2541

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")
42+
with patch("builtins.open", m), \
43+
patch("os.path.isfile", return_value=True), \
44+
patch("yaml.load", return_value=mock_yaml_content):
45+
with pytest.raises(ValueError) as excinfo:
46+
filter_spis(keywords=keywords, output_name="mock_filtered_config", configfile="./mock_config.yaml")
47+
3248
assert "0 SPIs were found" in str(excinfo.value), "Incorrect error message returned when no keywords match found."
3349

34-
def test_filter_spis_normal_operation():
50+
def test_filter_spis_normal_operation(mock_yaml_content):
3551
"""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"]
52+
m = mock_open()
53+
m().read_return_value = yaml.dump(mock_yaml_content)
54+
keywords = ["keyword1", "keyword2"] # filter keys
4455
expected_output_yaml = {
4556
"module1": {
4657
"spi1": {"labels": ["keyword1", "keyword2"], "configs": [1,2]}
4758
}
4859
}
4960

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

54-
55-
filter_spis("pyspi/mock_config.yaml", keywords, name="mock_filtered_config")
108+
with patch("builtins.open", mock_open()) as mocked_open, \
109+
patch("os.path.isfile", return_value=True), \
110+
patch("yaml.load", return_value=mock_yaml_content), \
111+
patch("os.path.dirname", return_value=script_dir), \
112+
patch("os.path.abspath", return_value=script_dir):
113+
114+
# run filter func without specifying a config file
115+
filter_spis(["keyword1"])
56116

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."
117+
# ensure the mock_open was called with the expected path
118+
assert any(call.args[0] == default_config_path for call in mocked_open.mock_calls), \
119+
"Expected default config file to be opened."

0 commit comments

Comments
 (0)