diff --git a/bcb/currency.py b/bcb/currency.py index 18d6db6..05d25a7 100644 --- a/bcb/currency.py +++ b/bcb/currency.py @@ -563,6 +563,39 @@ def _get_symbol_text(symbol: str, start_date: DateInput, end_date: DateInput) -> # Type alias for text output with multiple symbols CurrencyTextResult = Dict[str, str] # Maps symbol → CSV text +CurrencySide = Literal["ask", "bid", "both"] +CurrencyGroupBy = Literal["symbol", "side"] +CurrencyOutput = Literal["dataframe", "text"] + + +def _normalize_currency_symbols(symbols: Union[str, List[str]]) -> List[str]: + if isinstance(symbols, str): + symbols = [symbols] + if not symbols: + raise ValueError("At least one currency symbol must be provided") + for symbol in symbols: + if not isinstance(symbol, str) or not symbol.strip(): + raise ValueError(f"Currency symbols must be non-empty strings: {symbol!r}") + return symbols + + +def _validate_currency_query_inputs( + symbols: Union[str, List[str]], + start: DateInput, + end: DateInput, + side: str, + groupby: str, + output: str, +) -> List[str]: + if output not in ("dataframe", "text"): + raise ValueError("Unknown output value, use: dataframe, text") + if side not in ("bid", "ask", "both"): + raise ValueError("Unknown side value, use: bid, ask, both") + if groupby not in ("symbol", "side"): + raise ValueError("Unknown groupby value, use: symbol, side") + Date(start) + Date(end) + return _normalize_currency_symbols(symbols) @overload @@ -570,8 +603,8 @@ def get( symbols: str, start: DateInput, end: DateInput, - side: str = ..., - groupby: str = ..., + side: CurrencySide = ..., + groupby: CurrencyGroupBy = ..., output: Literal["dataframe"] = ..., ) -> pd.DataFrame: ... @@ -581,8 +614,8 @@ def get( symbols: List[str], start: DateInput, end: DateInput, - side: str = ..., - groupby: str = ..., + side: CurrencySide = ..., + groupby: CurrencyGroupBy = ..., output: Literal["dataframe"] = ..., ) -> pd.DataFrame: ... @@ -592,8 +625,8 @@ def get( symbols: str, start: DateInput, end: DateInput, - side: str = ..., - groupby: str = ..., + side: CurrencySide = ..., + groupby: CurrencyGroupBy = ..., output: Literal["text"] = ..., ) -> str: ... @@ -603,8 +636,8 @@ def get( symbols: List[str], start: DateInput, end: DateInput, - side: str = ..., - groupby: str = ..., + side: CurrencySide = ..., + groupby: CurrencyGroupBy = ..., output: Literal["text"] = ..., ) -> CurrencyTextResult: ... @@ -613,9 +646,9 @@ def get( symbols: Union[str, List[str]], start: DateInput, end: DateInput, - side: str = "ask", - groupby: str = "symbol", - output: str = "dataframe", + side: CurrencySide = "ask", + groupby: CurrencyGroupBy = "symbol", + output: CurrencyOutput = "dataframe", ) -> Union[pd.DataFrame, str, Dict[str, str]]: """ Retorna um DataFrame pandas com séries temporais com taxas de câmbio. @@ -633,12 +666,14 @@ def get( end : string, int, date, datetime, Timestamp Data de início da série. Interpreta diferentes tipos e formatos de datas. - side : str + side : {"ask", "bid", "both"}, default "ask" Define se a série retornada vem com os ``ask`` prices, ``bid`` prices ou ``both`` para ambos. - groupby : str + groupby : {"symbol", "side"}, default "symbol" Define se os índices de coluna são agrupados por ``symbol`` ou por ``side``. + output : {"dataframe", "text"}, default "dataframe" + Define o formato de saída. Use ``"text"`` para retornar o CSV bruto. Returns ------- @@ -653,8 +688,9 @@ def get( DataFrame : Série temporal com cotações diárias das moedas solicitadas. """ - if isinstance(symbols, str): - symbols = [symbols] + symbols = _validate_currency_query_inputs( + symbols, start, end, side, groupby, output + ) if output == "text": results: Dict[str, str] = {} @@ -848,9 +884,9 @@ async def async_get( symbols: Union[str, List[str]], start: DateInput, end: DateInput, - side: str = "ask", - groupby: str = "symbol", - output: str = "dataframe", + side: CurrencySide = "ask", + groupby: CurrencyGroupBy = "symbol", + output: CurrencyOutput = "dataframe", ) -> Union[pd.DataFrame, str, Dict[str, str]]: """ Retorna um DataFrame pandas com séries temporais com taxas de câmbio (async version). @@ -867,11 +903,11 @@ async def async_get( Data de início da série end : string, int, date, datetime, Timestamp Data final da série - side : str + side : {"ask", "bid", "both"} ``'ask'``, ``'bid'`` ou ``'both'`` - groupby : str + groupby : {"symbol", "side"} ``'symbol'`` ou ``'side'`` - output : str + output : {"dataframe", "text"} ``'dataframe'`` ou ``'text'`` Returns @@ -879,8 +915,9 @@ async def async_get( Union[pd.DataFrame, str, Dict[str, str]] Série temporal conforme especificado """ - if isinstance(symbols, str): - symbols = [symbols] + symbols = _validate_currency_query_inputs( + symbols, start, end, side, groupby, output + ) if output == "text": results: Dict[str, str] = {} diff --git a/bcb/sgs/__init__.py b/bcb/sgs/__init__.py index 0591f53..6643960 100644 --- a/bcb/sgs/__init__.py +++ b/bcb/sgs/__init__.py @@ -105,6 +105,16 @@ def __repr__(self) -> str: ] +def _validate_sgs_output(output: str) -> None: + if output not in ("dataframe", "text"): + raise ValueError("Unknown output value, use: dataframe, text") + + +def _validate_last(last: int) -> None: + if not isinstance(last, int) or last < 0: + raise ValueError(f"last must be a non-negative integer, got {last!r}") + + def _validate_sgs_code(code: SGSCode) -> None: """Validate SGSCode value. @@ -145,22 +155,32 @@ def _codes(codes: SGSCodeInput) -> Generator[SGSCode, None, None]: _validate_sgs_code(code_obj) yield code_obj elif isinstance(codes, tuple): + if len(codes) != 2: + raise ValueError("Named SGS code tuples must contain (name, code)") code_obj = SGSCode.from_named(codes[1], codes[0]) _validate_sgs_code(code_obj) yield code_obj elif isinstance(codes, list): + if not codes: + raise ValueError("At least one SGS code must be provided") for cd in codes: if isinstance(cd, tuple): + if len(cd) != 2: + raise ValueError("Named SGS code tuples must contain (name, code)") code_obj = SGSCode.from_named(cd[1], cd[0]) else: code_obj = SGSCode.from_code(cd) _validate_sgs_code(code_obj) yield code_obj elif isinstance(codes, Mapping): + if not codes: + raise ValueError("At least one SGS code must be provided") for name, code in codes.items(): code_obj = SGSCode.from_named(code, name) _validate_sgs_code(code_obj) yield code_obj + else: + raise ValueError(f"Unsupported SGS code input: {codes!r}") def _get_url_and_payload( @@ -169,6 +189,7 @@ def _get_url_and_payload( end_date: Optional[DateInput], last: int, ) -> Tuple[str, Dict[str, str]]: + _validate_last(last) payload: Dict[str, str] = {"formato": "json"} if last == 0: if start_date is not None or end_date is not None: @@ -252,7 +273,7 @@ def get( last: int = 0, multi: bool = True, freq: Optional[str] = None, - output: str = "dataframe", + output: Literal["dataframe", "text"] = "dataframe", ) -> Union[pd.DataFrame, List[pd.DataFrame], str, Dict[int, str]]: """ Retorna um DataFrame pandas com séries temporais obtidas do SGS. @@ -309,9 +330,12 @@ def get( Mapeamento de código → JSON bruto (quando ``output='text'`` e múltiplos códigos). """ + _validate_sgs_output(output) + code_list = list(_codes(codes)) + if output == "text": results: Dict[int, str] = {} - for code in _codes(codes): + for code in code_list: results[code.value] = get_json(code.value, start, end, last) values = list(results.values()) if len(values) == 1: @@ -319,7 +343,7 @@ def get( return results dfs = [] - for code in _codes(codes): + for code in code_list: text = get_json(code.value, start, end, last) df = pd.read_json(StringIO(text)) df = _format_df(df, code, freq) @@ -334,7 +358,7 @@ def get( def get_json( - code: int, + code: int | str, start: Optional[DateInput] = None, end: Optional[DateInput] = None, last: int = 0, @@ -364,23 +388,27 @@ def get_json( JSON : série temporal univariada em formato JSON. """ - url, payload = _get_url_and_payload(code, start, end, last) - logger.debug(f"Fetching SGS time series code={code} from {url.split('/dados')[0]}") + code_obj = SGSCode.from_code(code) + _validate_sgs_code(code_obj) + url, payload = _get_url_and_payload(code_obj.value, start, end, last) + logger.debug( + f"Fetching SGS time series code={code_obj.value} from {url.split('/dados')[0]}" + ) try: res = get_client().get(url, params=payload) except httpx.HTTPError as ex: raise_for_request_error( - ex, context=f"SGS time series code={code}", error_cls=SGSError + ex, context=f"SGS time series code={code_obj.value}", error_cls=SGSError ) logger.debug(f"SGS response: status={res.status_code}, length={len(res.text)}") if res.status_code != 200: - _raise_sgs_response_error(res, code) + _raise_sgs_response_error(res, code_obj.value) return str(res.text) async def async_get_json( - code: int, + code: int | str, start: Optional[DateInput] = None, end: Optional[DateInput] = None, last: int = 0, @@ -411,22 +439,25 @@ async def async_get_json( SGSError Se a API retorna um erro """ - url, payload = _get_url_and_payload(code, start, end, last) + code_obj = SGSCode.from_code(code) + _validate_sgs_code(code_obj) + url, payload = _get_url_and_payload(code_obj.value, start, end, last) logger.debug( - f"Fetching SGS time series (async) code={code} from {url.split('/dados')[0]}" + f"Fetching SGS time series (async) code={code_obj.value} " + f"from {url.split('/dados')[0]}" ) try: res = await get_async_client().get(url, params=payload) except httpx.HTTPError as ex: raise_for_request_error( - ex, context=f"SGS time series code={code}", error_cls=SGSError + ex, context=f"SGS time series code={code_obj.value}", error_cls=SGSError ) logger.debug( f"SGS (async) response: status={res.status_code}, length={len(res.text)}" ) if res.status_code != 200: - _raise_sgs_response_error(res, code) + _raise_sgs_response_error(res, code_obj.value) return str(res.text) @@ -437,7 +468,7 @@ async def async_get( last: int = 0, multi: bool = True, freq: Optional[str] = None, - output: str = "dataframe", + output: Literal["dataframe", "text"] = "dataframe", ) -> Union[pd.DataFrame, List[pd.DataFrame], str, Dict[int, str]]: """ Retorna um DataFrame pandas com séries temporais obtidas do SGS (async version). @@ -467,6 +498,7 @@ async def async_get( Union[pd.DataFrame, List[pd.DataFrame], str, Dict[int, str]] Série(s) temporal(is) conforme especificado """ + _validate_sgs_output(output) code_list = list(_codes(codes)) # Concurrent HTTP requests via asyncio.gather() diff --git a/bcb/sgs/regional_economy.py b/bcb/sgs/regional_economy.py index 2413975..e52e814 100644 --- a/bcb/sgs/regional_economy.py +++ b/bcb/sgs/regional_economy.py @@ -126,46 +126,69 @@ } +def _normalize_mode(mode: str) -> str: + if not isinstance(mode, str): + raise ValueError("mode must be one of: PF, PJ, total") + normalized = mode.upper() + if normalized == "ALL": + normalized = "TOTAL" + if normalized not in ("PF", "PJ", "TOTAL"): + raise ValueError("Unknown mode value, use: PF, PJ, total") + return normalized + + +def _normalize_locations(states_or_region: Union[str, List[str]]) -> List[str]: + locations = ( + [states_or_region] if isinstance(states_or_region, str) else states_or_region + ) + if not isinstance(locations, list) or not locations: + raise ValueError("At least one state or region must be provided") + + normalized = [] + for location in locations: + if not isinstance(location, str) or not location.strip(): + raise ValueError(f"Not a valid state or region: {location!r}") + normalized.append(location.upper()) + return normalized + + def get_non_performing_loans_codes( states_or_region: Union[str, List[str]], mode: str = "total" ) -> Dict[str, str]: - is_state = False - is_region = False - states_or_region = ( - [states_or_region] if isinstance(states_or_region, str) else states_or_region - ) - states_or_region = [location.upper() for location in states_or_region] - if any( - location in list(NON_PERFORMING_LOANS_BY_STATE_TOTAL.keys()) - for location in states_or_region - ): - is_state = True - elif any( - location in list(NON_PERFORMING_LOANS_BY_REGION_TOTAL.keys()) - for location in states_or_region - ): - is_region = True - - if not is_state and not is_region: - raise Exception(f"Not a valid state or region: {states_or_region}") - - codes = {} - non_performing_loans_by_location = NON_PERFORMING_LOANS_BY_STATE_TOTAL - if is_state: - if mode.upper() == "PF": - non_performing_loans_by_location = NON_PERFORMING_LOANS_BY_STATE_PF - elif mode.upper() == "PJ": - non_performing_loans_by_location = NON_PERFORMING_LOANS_BY_STATE_PJ - elif is_region: - non_performing_loans_by_location = NON_PERFORMING_LOANS_BY_REGION_TOTAL - if mode.upper() == "PF": - non_performing_loans_by_location = NON_PERFORMING_LOANS_BY_REGION_PF - elif mode.upper() == "PJ": - non_performing_loans_by_location = NON_PERFORMING_LOANS_BY_REGION_PJ - - for location in states_or_region: - codes[location] = non_performing_loans_by_location[location] - return codes + locations = _normalize_locations(states_or_region) + normalized_mode = _normalize_mode(mode) + + states = set(NON_PERFORMING_LOANS_BY_STATE_TOTAL) + regions = set(NON_PERFORMING_LOANS_BY_REGION_TOTAL) + invalid_locations = [ + location + for location in locations + if location not in states and location not in regions + ] + if invalid_locations: + raise ValueError(f"Not a valid state or region: {invalid_locations}") + + # Some codes are ambiguous: "SE" is both Sergipe and Sudeste. + # Preserve the historical state-first behavior for all-state requests. + if all(location in states for location in locations): + mappings = { + "PF": NON_PERFORMING_LOANS_BY_STATE_PF, + "PJ": NON_PERFORMING_LOANS_BY_STATE_PJ, + "TOTAL": NON_PERFORMING_LOANS_BY_STATE_TOTAL, + } + elif all(location in regions for location in locations): + mappings = { + "PF": NON_PERFORMING_LOANS_BY_REGION_PF, + "PJ": NON_PERFORMING_LOANS_BY_REGION_PJ, + "TOTAL": NON_PERFORMING_LOANS_BY_REGION_TOTAL, + } + else: + raise ValueError("Cannot mix states and regions in the same request") + + non_performing_loans_by_location = mappings[normalized_mode] + return { + location: non_performing_loans_by_location[location] for location in locations + } def get_non_performing_loans( @@ -194,7 +217,8 @@ def get_non_performing_loans( states_or_region (List[str]): Uma lista com estado ou região. mode (str): O tipo de inadimplência. Pode ser "PF" (pessoas físicas), - "PJ" (pessoas jurídicas) ou "total" (inadimplência total). + "PJ" (pessoas jurídicas), "total" ou "all" + (inadimplência total). start : str, int, date, datetime, Timestamp Data de início da série. Interpreta diferentes tipos e formatos de datas. diff --git a/tests/sgs/test_regional_economy.py b/tests/sgs/test_regional_economy.py index acdd8b6..fe992d4 100644 --- a/tests/sgs/test_regional_economy.py +++ b/tests/sgs/test_regional_economy.py @@ -22,6 +22,21 @@ def test_get_non_performing_loans_codes_by_state_total( ): assert get_non_performing_loans_codes(states) == expected_codes + def test_get_non_performing_loans_codes_all_alias(self): + assert get_non_performing_loans_codes(["BA"], mode="all") == {"BA": "15929"} + + def test_get_non_performing_loans_codes_invalid_mode_raises(self): + with pytest.raises(ValueError, match="mode"): + get_non_performing_loans_codes(["BA"], mode="company") + + def test_get_non_performing_loans_codes_invalid_location_raises(self): + with pytest.raises(ValueError, match="Not a valid state or region"): + get_non_performing_loans_codes(["XX"]) + + def test_get_non_performing_loans_codes_mixed_state_region_raises(self): + with pytest.raises(ValueError, match="Cannot mix"): + get_non_performing_loans_codes(["BA", "N"]) + @pytest.mark.integration class TestGetNonPerformingLoans: diff --git a/tests/sgs/test_series.py b/tests/sgs/test_series.py index b02c0d3..b348a7f 100644 --- a/tests/sgs/test_series.py +++ b/tests/sgs/test_series.py @@ -88,9 +88,8 @@ def test_series_code_iter_dict(): def test_series_code_iter_unknown_type(): - # None falls through all isinstance checks → empty generator - x = list(sgs._codes(None)) # type: ignore[arg-type] - assert len(x) == 0 + with pytest.raises(ValueError, match="Unsupported SGS code input"): + list(sgs._codes(None)) # type: ignore[arg-type] # --------------------------------------------------------------------------- diff --git a/tests/test_async.py b/tests/test_async.py index 7c496a6..9618972 100644 --- a/tests/test_async.py +++ b/tests/test_async.py @@ -133,6 +133,16 @@ async def test_async_get_json_rate_limit_raises(httpx_mock): await sgs.async_get_json(1) +async def test_async_get_empty_sgs_code_list_raises(): + with pytest.raises(ValueError, match="At least one SGS code"): + await sgs.async_get([]) + + +async def test_async_get_invalid_sgs_output_raises(): + with pytest.raises(ValueError, match="output"): + await sgs.async_get(1, output="xml") # type: ignore[arg-type] + + # --------------------------------------------------------------------------- # Currency async tests # --------------------------------------------------------------------------- @@ -156,6 +166,16 @@ async def test_async_get_single_symbol_returns_dataframe(httpx_mock): assert df is not None +async def test_async_get_invalid_currency_side_raises(): + with pytest.raises(ValueError, match="Unknown side"): + await currency.async_get("USD", START, END, side="mid") # type: ignore[arg-type] + + +async def test_async_get_invalid_currency_output_raises(): + with pytest.raises(ValueError, match="Unknown output"): + await currency.async_get("USD", START, END, output="json") # type: ignore[arg-type] + + async def test_async_get_mixed_valid_invalid_symbols_returns_valid_dataframe( httpx_mock, ): diff --git a/tests/test_currency.py b/tests/test_currency.py index 30eeae2..cc9557e 100644 --- a/tests/test_currency.py +++ b/tests/test_currency.py @@ -183,12 +183,25 @@ def test_currency_get_both_side_groupby(httpx_mock): assert ("ask", "USD") in df.columns -def test_currency_get_invalid_side(httpx_mock): - add_id_list_mock(httpx_mock) - add_currency_list_mock(httpx_mock) - add_rate_mock(httpx_mock) +def test_currency_get_invalid_side(): with pytest.raises(ValueError, match="Unknown side"): - currency.get("USD", START, END, side="mid") + currency.get("USD", START, END, side="mid") # type: ignore[arg-type] + + +def test_currency_get_invalid_groupby(): + with pytest.raises(ValueError, match="Unknown groupby"): + currency.get( + "USD", + START, + END, + side="both", + groupby="market", # type: ignore[arg-type] + ) + + +def test_currency_get_invalid_output(): + with pytest.raises(ValueError, match="Unknown output"): + currency.get("USD", START, END, output="json") # type: ignore[arg-type] def test_currency_get_unknown_symbol_raises(httpx_mock): diff --git a/tests/test_currency_negative.py b/tests/test_currency_negative.py index df112eb..692f5f6 100644 --- a/tests/test_currency_negative.py +++ b/tests/test_currency_negative.py @@ -330,10 +330,16 @@ def test_get_symbol_malformed_csv_invalid_numeric_conversion_raises(httpx_mock): def test_get_empty_symbol_list_raises(httpx_mock): """Test that empty symbol list raises an error.""" - with pytest.raises((ValueError, CurrencyNotFoundError)): + with pytest.raises(ValueError, match="At least one currency symbol"): currency.get([], START, END) +def test_get_blank_symbol_raises(): + """Blank currency symbols fail before HTTP requests.""" + with pytest.raises(ValueError, match="non-empty"): + currency.get("", START, END) + + def test_get_invalid_date_input_raises(): """Test that invalid date input is handled properly.""" # Invalid date format should raise ValueError (from Date class) diff --git a/tests/test_sgs_negative.py b/tests/test_sgs_negative.py index d2f272e..339a114 100644 --- a/tests/test_sgs_negative.py +++ b/tests/test_sgs_negative.py @@ -151,11 +151,34 @@ def test_sgs_code_string_non_numeric_raises(httpx_mock): def test_get_empty_code_list(): """Test that empty code list raises ValueError.""" - # Empty list results in no DataFrames to concat, which raises ValueError - with pytest.raises(ValueError, match="No objects to concatenate"): + with pytest.raises(ValueError, match="At least one SGS code"): sgs.get([]) +def test_get_empty_code_mapping(): + """Test that empty code mappings raise ValueError.""" + with pytest.raises(ValueError, match="At least one SGS code"): + sgs.get({}) + + +def test_get_invalid_output_raises(): + """Unsupported output values fail before HTTP requests.""" + with pytest.raises(ValueError, match="output"): + sgs.get(1, output="xml") # type: ignore[arg-type] + + +def test_get_negative_last_raises(): + """Negative last values fail before HTTP requests.""" + with pytest.raises(ValueError, match="last"): + sgs.get(1, last=-1) + + +def test_get_json_negative_code_raises(): + """get_json validates single public code inputs.""" + with pytest.raises(ValueError, match="positive"): + sgs.get_json(-1) + + # --------------------------------------------------------------------------- # Edge cases and boundary conditions # ---------------------------------------------------------------------------