Skip to content

Commit

Permalink
Merge pull request #64 from DynamicsAndNeuralSystems/jmoo2880-pypi-pu…
Browse files Browse the repository at this point in the history
…blish

Minor bug fix for the filter_spis function
  • Loading branch information
jmoo2880 committed Mar 15, 2024
2 parents 9b9a80b + 683e92d commit 6c8d9e0
Show file tree
Hide file tree
Showing 4 changed files with 157 additions and 56 deletions.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "pyspi"
version = "1.0.0"
version = "1.0.1"
authors = [
{ name ="Oliver M. Cliff", email="oliver.m.cliff@gmail.com"},
]
Expand Down
79 changes: 61 additions & 18 deletions pyspi/utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
"""pyspi utility functions."""
import numpy as np
from scipy.stats import zscore
import warnings
Expand Down Expand Up @@ -142,19 +141,52 @@ def check_optional_deps():

return isAvailable

def filter_spis(configfile, keywords, name="filtered_config"):
"""Filter a YAML using a list of keywords, and save the reduced
set as a new YAML with a user-specified name in the current
directory."""

# check that keywords is a list
def filter_spis(keywords, output_name = None, configfile= None):
"""
Filter a YAML using a list of keywords, and save the reduced set as a new
YAML with a user-specified name (or a random one if not provided) in the
current directory.
Args:
keywords (list): A list of keywords (as strings) to filter the YAML.
output_name (str, optional): The desired name for the output file. Defaults to a random name.
configfile (str, optional): The path to the input YAML file. Defaults to the `config.yaml' in the pyspi dir.
Raises:
ValueError: If `keywords` is not a list or if no SPIs match the keywords.
FileNotFoundError: If the specified `configfile` or the default `config.yaml` is not found.
IOError: If there's an error reading the YAML file.
"""
# handle invalid keyword input
if not keywords:
raise ValueError("At least one keyword must be provided.")
if not all(isinstance(keyword, str) for keyword in keywords):
raise ValueError("All keywords must be strings.")
if not isinstance(keywords, list):
raise TypeError("Keywords must be passed as a list.")
# load in the original YAML
with open(configfile) as f:
yf = yaml.load(f, Loader=yaml.FullLoader)

# new dictonary to be converted to final YAML
raise ValueError("Keywords must be provided as a list of strings.")

# if no configfile and no keywords are provided, use the default 'config.yaml' in pyspi location
if configfile is None:
script_dir = os.path.dirname(os.path.abspath(__file__))
default_config = os.path.join(script_dir, 'config.yaml')
if not os.path.isfile(default_config):
raise FileNotFoundError(f"Default 'config.yaml' file not found in {script_dir}.")
configfile = default_config
source_file_info = f"Default 'config.yaml' file from {script_dir} was used as the source file."
else:
source_file_info = f"User-specified config file '{configfile}' was used as the source file."

# load in user-specified yaml
try:
with open(configfile) as f:
yf = yaml.load(f, Loader=yaml.FullLoader)
except FileNotFoundError:
raise FileNotFoundError(f"Config file '{configfile}' not found.")
except Exception as e:
# handle all other exceptions
raise IOError(f"An error occurred while trying to read '{configfile}': {e}")

# new dictionary to be converted to final YAML
filtered_subset = {}
spis_found = 0

Expand All @@ -164,24 +196,35 @@ def filter_spis(configfile, keywords, name="filtered_config"):
spi_labels = yf[module][spi].get('labels')
if all(keyword in spi_labels for keyword in keywords):
module_spis[spi] = yf[module][spi]
spis_found += len(yf[module][spi].get('configs'))
if yf[module][spi].get('configs'):
spis_found += len(yf[module][spi].get('configs'))
else:
spis_found += 1

if module_spis:
filtered_subset[module] = module_spis

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

# construct output file path
if output_name is None:
# use a unique name
output_name = "config_" + os.urandom(4).hex()

output_file = os.path.join(os.getcwd(), f"{output_name}.yaml")

# write to YAML
with open(f"pyspi/{name}.yaml", "w") as outfile:
with open(output_file, "w") as outfile:
yaml.dump(filtered_subset, outfile, default_flow_style=False, sort_keys=False)

# output relevant information
# output relevant information
print(f"""\nOperation Summary:
-----------------
- {source_file_info}
- Total SPIs Matched: {spis_found} SPI(s) were found with the specific keywords: {keywords}.
- New File Created: A YAML file named `{name}.yaml` has been saved in the current directory: `pyspi/{name}.yaml'
- New File Created: A YAML file named `{output_name}.yaml` has been saved in the current directory: `{output_file}'
- Next Steps: To utilise the filtered set of SPIs, please initialise a new Calculator instance with the following command:
`Calculator(configfile='pyspi/{name}.yaml')`
`Calculator(configfile='{output_file}')`
""")
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
'data/standard_normal.npy',
'data/cml7.npy']},
include_package_data=True,
version='1.0.0',
version='1.0.1',
description='Library for pairwise analysis of time series data.',
author='Oliver M. Cliff',
author_email='oliver.m.cliff@gmail.com',
Expand Down
130 changes: 94 additions & 36 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -1,61 +1,119 @@
from pyspi.utils import filter_spis
import pytest
import yaml
from unittest.mock import mock_open, patch

@pytest.fixture
def mock_yaml_content():
return {
"module1": {
"spi1": {"labels": ["keyword1", "keyword2"], "configs": [1, 2]},
"spi2": {"labels": ["keyword1"], "configs": [3]},
},
"module2": {
"spi3": {"labels": ["keyword3"], "configs": [1, 2, 3]},
},
}

def test_filter_spis_invalid_keywords():
"""Pass in a dataype other than a list for the keywords"""
with pytest.raises(TypeError) as excinfo:
filter_spis("pyspi/config.yaml", "linear")
assert "Keywords must be passed as a list" in str(excinfo.value), "Keywords must be passed as list error not shown."
with pytest.raises(ValueError) as excinfo:
filter_spis(keywords="linear", configfile="pyspi/config.yaml")
assert "Keywords must be provided as a list of strings" in str(excinfo.value)
# check for passing in an empty list
with pytest.raises(ValueError) as excinfo:
filter_spis(keywords=[], configfile="pyspi/config.yaml")
assert "At least one keyword must be provided" in str(excinfo.value)
with pytest.raises(ValueError) as excinfo:
filter_spis(keywords=[4], configfile="pyspi/config.yaml")
assert "All keywords must be strings" in str(excinfo.value)

def test_filter_spis_with_invalid_config():
"""Pass in an invalid/missing config file"""
with pytest.raises(FileNotFoundError):
filter_spis("invalid_config.yaml", ["test"])
filter_spis(keywords=["test"], configfile="invalid_config.yaml")

def test_filter_spis_no_matches():
def test_filter_spis_no_matches(mock_yaml_content):
"""Pass in keywords that return no spis and check for ValuError"""
mock_yaml_content = {
"module1": {
"spi1": {"labels": ["keyword1", "keyword2"], "configs": [1, 2]},
"spi2": {"labels": ["keyword1"], "configs": [3]}
}
}
m = mock_open()
m().read.return_value = yaml.dump(mock_yaml_content)
keywords = ["random_keyword"]

# create temporary YAML to load into the function
with open("pyspi/mock_config2.yaml", "w") as f:
yaml.dump(mock_yaml_content, f)

with pytest.raises(ValueError) as excinfo:
filter_spis("pyspi/mock_config2.yaml", keywords, name="mock_filtered_config")
with patch("builtins.open", m), \
patch("os.path.isfile", return_value=True), \
patch("yaml.load", return_value=mock_yaml_content):
with pytest.raises(ValueError) as excinfo:
filter_spis(keywords=keywords, output_name="mock_filtered_config", configfile="./mock_config.yaml")

assert "0 SPIs were found" in str(excinfo.value), "Incorrect error message returned when no keywords match found."

def test_filter_spis_normal_operation():
def test_filter_spis_normal_operation(mock_yaml_content):
"""Test whether the filter spis function works as expected"""
# create some mock content to filter
mock_yaml_content = {
"module1": {
"spi1": {"labels": ["keyword1", "keyword2"], "configs": [1, 2]},
"spi2": {"labels": ["keyword1"], "configs": [3]}
}
}
keywords = ["keyword1", "keyword2"]
m = mock_open()
m().read_return_value = yaml.dump(mock_yaml_content)
keywords = ["keyword1", "keyword2"] # filter keys
expected_output_yaml = {
"module1": {
"spi1": {"labels": ["keyword1", "keyword2"], "configs": [1,2]}
}
}

# create temporary YAML to load into the function
with open("pyspi/mock_config.yaml", "w") as f:
yaml.dump(mock_yaml_content, f)
with patch("builtins.open", m), patch("os.path.isfile", return_value=True), \
patch("yaml.load", return_value=mock_yaml_content), \
patch("yaml.dump") as mock_dump:

filter_spis(keywords=keywords, output_name="mock_filtered_config", configfile="./mock_config.yaml")

mock_dump.assert_called_once()
args, _ = mock_dump.call_args # get call args for dump and intercept
actual_output = args[0] # the first argument to yaml.dump should be the yaml

assert actual_output == expected_output_yaml, "Expected filtered YAML does not match actual filtered YAML."

def test_filter_spis_io_error_on_read():
# check to see whether io error is raised when trying to access the configfile
with patch("builtins.open", mock_open(read_data="data")) as mocked_file:
mocked_file.side_effect = IOError("error")
with pytest.raises(IOError):
filter_spis(["keyword"], "output", "config.yaml")

def test_filter_spis_saves_with_random_name_if_no_name_provided(mock_yaml_content):
# mock os.urandom to return a predictable name
random_bytes = bytes([1, 2, 3, 4])
expected_random_part = "01020304"

with patch("builtins.open", mock_open()) as mocked_file, patch("os.path.isfile", return_value=True), \
patch("yaml.load", return_value=mock_yaml_content), patch("os.urandom", return_value=random_bytes):

# run the filter function without providing an output name
filter_spis(["keyword1"])

# construct the expected output name
expected_file_name_pattern = f"config_{expected_random_part}.yaml"

# check the mocked open function to see if file with expected name is opened (for writing)
call_args_list = mocked_file.call_args_list
found_expected_call = any(
expected_file_name_pattern in call_args.args[0] and
('w' in call_args.args[1] if len(call_args.args) > 1 else 'w' in call_args.kwargs.get('mode', ''))
for call_args in call_args_list
)

assert found_expected_call, f"no file with the expected name {expected_file_name_pattern} was saved."

def test_loads_default_config_if_no_config_specified(mock_yaml_content):
script_dir = "/fake/script/directory"
default_config_path = f"{script_dir}/config.yaml"


filter_spis("pyspi/mock_config.yaml", keywords, name="mock_filtered_config")
with patch("builtins.open", mock_open()) as mocked_open, \
patch("os.path.isfile", return_value=True), \
patch("yaml.load", return_value=mock_yaml_content), \
patch("os.path.dirname", return_value=script_dir), \
patch("os.path.abspath", return_value=script_dir):

# run filter func without specifying a config file
filter_spis(["keyword1"])

# load in the output
with open("pyspi/mock_filtered_config.yaml", "r") as f:
actual_output = yaml.load(f, Loader=yaml.FullLoader)

assert actual_output == expected_output_yaml, "Expected filtered YAML does not match actual filtered YAML."
# ensure the mock_open was called with the expected path
assert any(call.args[0] == default_config_path for call in mocked_open.mock_calls), \
"Expected default config file to be opened."

0 comments on commit 6c8d9e0

Please sign in to comment.