diff --git a/src/imf_reader/sdr/__init__.py b/src/imf_reader/sdr/__init__.py index 87113c4..4e58738 100644 --- a/src/imf_reader/sdr/__init__.py +++ b/src/imf_reader/sdr/__init__.py @@ -45,8 +45,15 @@ sdr.fetch_exchange_rates("USD") ``` +Clear cached data + +```python +sdr.clear_cache() +``` + """ from imf_reader.sdr.read_interest_rate import fetch_interest_rates from imf_reader.sdr.read_exchange_rate import fetch_exchange_rates from imf_reader.sdr.read_announcements import fetch_allocations_holdings +from imf_reader.sdr.clear_cache import clear_cache diff --git a/src/imf_reader/sdr/clear_cache.py b/src/imf_reader/sdr/clear_cache.py new file mode 100644 index 0000000..9c41ce5 --- /dev/null +++ b/src/imf_reader/sdr/clear_cache.py @@ -0,0 +1,23 @@ +from imf_reader.sdr.read_announcements import ( + get_holdings_and_allocations_data, + get_latest_date, +) +from imf_reader.sdr.read_exchange_rate import fetch_exchange_rates +from imf_reader.sdr.read_interest_rate import fetch_interest_rates +from imf_reader.config import logger + + +def clear_cache(): + """Clear the cache for all lru_cache-decorated functions in the 3 sdr modules.""" + + # clear cache from read_announcements module + get_holdings_and_allocations_data.cache_clear() + get_latest_date.cache_clear() + + # clear cache from read_exchange_rate module + fetch_exchange_rates.cache_clear() + + # clear cache from read_interest_rate module + fetch_interest_rates.cache_clear() + + logger.info("Cache cleared") diff --git a/src/imf_reader/sdr/read_announcements.py b/src/imf_reader/sdr/read_announcements.py index 113acb9..7bafc3f 100644 --- a/src/imf_reader/sdr/read_announcements.py +++ b/src/imf_reader/sdr/read_announcements.py @@ -12,7 +12,7 @@ from datetime import datetime from imf_reader.utils import make_request -from imf_reader.config import logger, NoDataError +from imf_reader.config import logger BASE_URL = "https://www.imf.org/external/np/fin/tad/" MAIN_PAGE_URL = "https://www.imf.org/external/np/fin/tad/extsdr1.aspx" @@ -54,7 +54,10 @@ def format_date(month: int, year: int) -> str: @lru_cache -def get_holdings_and_allocations_data(year: int, month: int): +def get_holdings_and_allocations_data( + year: int, + month: int, +): """Get sdr allocations and holdings data for a given month and year""" date = format_date(month, year) diff --git a/tests/test_sdr/test_read_announcements.py b/tests/test_sdr/test_read_announcements.py index 14d3a00..3ac4ba9 100644 --- a/tests/test_sdr/test_read_announcements.py +++ b/tests/test_sdr/test_read_announcements.py @@ -1,102 +1,240 @@ -import unittest -from unittest.mock import patch, MagicMock +from unittest.mock import patch, Mock +import pytest import pandas as pd -from bs4 import BeautifulSoup -from imf_reader.sdr import read_announcements - - -class TestReadAnnouncements(unittest.TestCase): +from imf_reader import sdr +from imf_reader.sdr.read_announcements import ( + read_tsv, + clean_df, + format_date, + get_holdings_and_allocations_data, + get_latest_date, + fetch_allocations_holdings, + BASE_URL, + MAIN_PAGE_URL, +) + + +@pytest.fixture +def input_df(): + df = pd.DataFrame( + { + "SDR Allocations and Holdings": [ + "for all members as of June 30, 2020", + "(in SDRs)", + "Members\tSDR Holdings\tSDR Allocations", + "Spain\t123\t456", + "Total\t321\t654", + ] + } + ) + return df + + +class TestReadAnnouncements: """Tests functions in the read_announcements module.""" + @pytest.fixture(autouse=True) + def auto_clear_cache(self): + """Clear cache before each test.""" + sdr.clear_cache() + @patch("pandas.read_csv") - def test_read_tsv(self, mock_read_csv): - """Ensure read_tsv processes well-formated tsv correctly and raises ValueError on malformed data.""" - # Mock successful TSV read + def test_read_tsv_success(self, mock_read_csv): + """Test read_tsv successfully processes a well-formatted TSV.""" mock_read_csv.return_value = pd.DataFrame({"A": [1], "B": [2]}) - result = read_announcements.read_tsv("mock_url") - self.assertTrue(isinstance(result, pd.DataFrame)) + result = read_tsv("mock_url") + assert isinstance(result, pd.DataFrame) + assert result.equals(pd.DataFrame({"A": [1], "B": [2]})) - # Mock failure + @patch("pandas.read_csv") + def test_read_tsv_failure(self, mock_read_csv): + """Test read_tsv raises ValueError on malformed data.""" mock_read_csv.side_effect = pd.errors.ParserError - with self.assertRaises(ValueError): - read_announcements.read_tsv("mock_url") + with pytest.raises(ValueError, match="SDR _data not available for this date"): + read_tsv("mock_url") - def test_clean_df_correct_format(self): + def test_clean_df_correct_format(self, input_df): """Test clean_df with the expected format.""" # Mock input DataFrame - raw_data = pd.DataFrame({0: ["", "", "", "Country A\t$100\t$200"]}) - expected_data = pd.DataFrame( + + expected_df = pd.DataFrame( { - "entity": ["Country A", "Country A"], - "indicator": ["holdings", "allocations"], - "value": [100, 200], + "entity": ["Spain", "Total", "Spain", "Total"], + "indicator": ["holdings", "holdings", "allocations", "allocations"], + "value": [123, 321, 456, 654], } ) - result = read_announcements.clean_df(raw_data) - pd.testing.assert_frame_equal(result, expected_data) - - def test_clean_df_empty(self): - """Test clean_df with an empty DataFrame""" - input_df = pd.DataFrame() - with self.assertRaises(IndexError): - read_announcements.clean_df(input_df) - - def test_format_date(self): - """Test format_date computes last day of a given month/year.""" - self.assertEqual(read_announcements.format_date(2, 2024), "2024-2-29") - self.assertEqual(read_announcements.format_date(1, 2023), "2023-1-31") + result = clean_df(input_df) + pd.testing.assert_frame_equal(result, expected_df) + + @pytest.mark.parametrize( + "month, year, expected", + [ + (1, 2024, "2024-1-31"), # January 2024 + (2, 2024, "2024-2-29"), # February (Leap year) + (2, 2023, "2023-2-28"), # February (Non-leap year) + (4, 2024, "2024-4-30"), # April + (12, 2023, "2023-12-31"), # December + ], + ) + def test_format_date_valid(self, month, year, expected): + """Test format_date returns the correct last day of the month.""" + assert format_date(month, year) == expected + + def test_format_date_invalid_month(self): + """Test format_date raises ValueError for invalid month input.""" + with pytest.raises(ValueError): + format_date(0, 2024) # Invalid month (0) + + with pytest.raises(ValueError): + format_date(13, 2024) # Invalid month (13) @patch("imf_reader.sdr.read_announcements.read_tsv") @patch("imf_reader.sdr.read_announcements.clean_df") - def test_get_holdings_and_allocations_data(self, mock_clean_df, mock_read_tsv): - """Test get_holdings_and_allocations_data caches data properly.""" - mock_read_tsv.return_value = pd.DataFrame() - mock_clean_df.return_value = pd.DataFrame({"data": [1]}) + def test_get_holdings_and_allocations_data_success( + self, mock_clean_df, mock_read_tsv, input_df + ): + """Test get_holdings_and_allocations_data successfully returns processed data.""" + # Mock the read_tsv output + mock_read_tsv.return_value = input_df + # Mock the clean_df output + mock_clean_df.return_value = clean_df(input_df) + + # Expected final output + expected_df = pd.DataFrame( + { + "entity": ["Spain", "Total", "Spain", "Total"], + "indicator": ["holdings", "holdings", "allocations", "allocations"], + "value": [123, 321, 456, 654], + "date": [pd.to_datetime("2024-02-29")] * 4, + } + ) - result = read_announcements.get_holdings_and_allocations_data(2024, 11) - self.assertTrue("data" in result.columns) + # Call the function + result = get_holdings_and_allocations_data(2024, 2) + + # Assertions + mock_read_tsv.assert_called_once_with( + f"{BASE_URL}extsdr2.aspx?date1key=2024-2-29&tsvflag=Y" + ) + mock_clean_df.assert_called_once() + pd.testing.assert_frame_equal(result, expected_df) + + @patch( + "imf_reader.sdr.read_announcements.read_tsv", + side_effect=ValueError("Data not available"), + ) + def test_get_holdings_and_allocations_data_failure(self, mock_read_tsv): + """Test get_holdings_and_allocations_data raises ValueError when read_tsv fails.""" + with pytest.raises(ValueError, match="Data not available"): + get_holdings_and_allocations_data(2024, 2) @patch("imf_reader.sdr.read_announcements.make_request") - @patch("bs4.BeautifulSoup") - def test_get_latest_date(self, mock_soup, mock_make_request): - """Test correct extraction of get_latest_date.""" - # Simulate HTML content - html_content = """ + def test_get_latest_date_success(self, mock_make_request): + """Test get_latest_date successfully returns the latest date.""" + + # Mock HTML content + mock_html_content = """ -
- - + + +
Header
November 30, 2024
November 30, 2023
+
+
+
+ + +
November 30, 2023
""" - mock_make_request.return_value.content = html_content - mock_soup.return_value = BeautifulSoup(html_content, "html.parser") + + # Mock the make_request response + mock_response = Mock() + mock_response.content = mock_html_content + mock_make_request.return_value = mock_response # Call the function - year, month = read_announcements.get_latest_date() + result = get_latest_date() - # Assert expected output - self.assertEqual((year, month), (2024, 11)) + # Assertions + mock_make_request.assert_called_once_with(MAIN_PAGE_URL) + assert result == (2023, 11) # Expected year and month + + @patch("imf_reader.sdr.read_announcements.make_request") + def test_get_latest_date_invalid_html(self, mock_make_request): + """Test get_latest_date raises an error when HTML parsing fails.""" + + # Mock malformed HTML content + mock_response = Mock() + mock_response.content = "" # Missing tables + mock_make_request.return_value = mock_response + + # Call the function and expect an IndexError + with pytest.raises(IndexError): + get_latest_date() @patch("imf_reader.sdr.read_announcements.get_latest_date") + @patch("imf_reader.sdr.read_announcements.clean_df") + @patch("imf_reader.sdr.read_announcements.read_tsv") @patch("imf_reader.sdr.read_announcements.get_holdings_and_allocations_data") - def test_fetch_allocations_holdings(self, mock_get_data, mock_get_latest_date): - """Ensure fetch_allocations_holdings fetches data for the provided date or the latest date.""" - # Mock latest date and data fetch - mock_get_latest_date.return_value = (2024, 11) - mock_get_data.return_value = pd.DataFrame({"data": [1]}) + def test_fetch_allocations_holdings_default_date( + self, + mock_get_holdings_and_allocations_data, + mock_read_tsv, + mock_clean_df, + mock_get_latest_date, + input_df, + ): + """Test fetch_allocations_holdings when no date is provided.""" + # Mock get_latest_date to return a specific date + mock_get_latest_date.return_value = (2, 2024) + + # Mock read_tsv + mock_read_tsv.return_value = input_df + + # Mock clean_df to return a cleaned DataFrame + cleaned_df = pd.DataFrame( + { + "entity": ["Spain", "Total", "Spain", "Total"], + "indicator": ["holdings", "holdings", "allocations", "allocations"], + "value": [123, 321, 456, 654], + "date": [pd.to_datetime("2024-02-29")] * 4, + } + ) + mock_clean_df.return_value = cleaned_df - result = read_announcements.fetch_allocations_holdings() - self.assertTrue("data" in result.columns) + # Mock get_holdings_and_allocations_data to return the final DataFrame + mock_get_holdings_and_allocations_data.return_value = cleaned_df - # Test with specific date - result = read_announcements.fetch_allocations_holdings((2023, 10)) - mock_get_data.assert_called_with(2023, 10) + # Call the function + result = fetch_allocations_holdings() + # Assertions + mock_get_latest_date.assert_called_once() # Ensure get_latest_date was called + mock_get_holdings_and_allocations_data.assert_called_once_with( + 2, 2024 + ) # Ensure the correct call + pd.testing.assert_frame_equal( + result, cleaned_df + ) # Ensure the result matches the cleaned data -if __name__ == "__main__": - unittest.main() + @patch("imf_reader.sdr.read_announcements.get_holdings_and_allocations_data") + @patch("imf_reader.sdr.read_announcements.get_latest_date") + def test_fetch_allocations_holdings_failure( + self, mock_get_latest_date, mock_get_holdings_data + ): + """Test fetch_allocations_holdings raises ValueError when data fetching fails.""" + # Mock get_latest_date to return a date + mock_get_latest_date.return_value = (2, 2024) + + # Simulate a failure in get_holdings_and_allocations_data + mock_get_holdings_data.side_effect = ValueError("Data not available") + + # Assertions + with pytest.raises(ValueError, match="Data not available"): + fetch_allocations_holdings() diff --git a/tests/test_sdr/test_read_exchange_rate.py b/tests/test_sdr/test_read_exchange_rate.py index 0922fd3..160c6d5 100644 --- a/tests/test_sdr/test_read_exchange_rate.py +++ b/tests/test_sdr/test_read_exchange_rate.py @@ -2,6 +2,7 @@ import pytest import requests import pandas as pd +from imf_reader import sdr from imf_reader.sdr.read_exchange_rate import ( preprocess_data, fetch_exchange_rates, @@ -31,9 +32,9 @@ def input_df(): class TestExchangeRateModule: @pytest.fixture(autouse=True) - def clear_cache(self): + def auto_clear_cache(self): """Clear cache before each test.""" - fetch_exchange_rates.cache_clear() + sdr.clear_cache() @patch("requests.post") def test_get_exchange_rates_data_success(self, mock_post): @@ -191,7 +192,6 @@ def test_fetch_exchange_rates(self, mock_parse_data, mock_get_data, input_df): """Test fetching exchange rates""" # Mock return values for the patched functions mock_get_data.return_value = input_df - mock_get_data.return_value = input_df mock_parse_data.return_value = pd.DataFrame( {"date": pd.to_datetime(["2023-11-30"]), "exchange_rate": [0.123]} ) diff --git a/tests/test_sdr/test_read_interest_rate.py b/tests/test_sdr/test_read_interest_rate.py index 06693e1..3ddd653 100644 --- a/tests/test_sdr/test_read_interest_rate.py +++ b/tests/test_sdr/test_read_interest_rate.py @@ -2,7 +2,7 @@ import pandas as pd import requests from unittest.mock import patch, MagicMock, ANY -from io import BytesIO +from imf_reader import sdr from imf_reader.sdr.read_interest_rate import ( BASE_URL, get_interest_rates_data, @@ -36,9 +36,9 @@ def input_df(): class TestReadInterestRate: @pytest.fixture(autouse=True) - def clear_cache(self): + def auto_clear_cache(self): """Clear cache before each test.""" - fetch_interest_rates.cache_clear() + sdr.clear_cache() @patch("requests.post") def test_get_interest_rates_data(self, mock_post):