From 65fb926d6ccada74237171cfc721dd8e0b7ca3e0 Mon Sep 17 00:00:00 2001 From: Wilson Freitas Date: Sun, 14 Jun 2026 21:43:45 -0300 Subject: [PATCH] Normalize PTAX date parameters --- bcb/odata/api.py | 56 +++++++++++++++++-- bcb/odata/framework.py | 7 ++- docs/currency.rst | 13 +++-- docs/odata.rst | 16 +++--- tests/test_odata.py | 120 ++++++++++++++++++++++++++++++++++++++++- 5 files changed, 192 insertions(+), 20 deletions(-) diff --git a/bcb/odata/api.py b/bcb/odata/api.py index 40f3769..88538f1 100644 --- a/bcb/odata/api.py +++ b/bcb/odata/api.py @@ -1,8 +1,9 @@ from __future__ import annotations -from typing import Any, Literal, Optional, Union, overload +from typing import Any, Callable, Literal, Optional, Union, overload from bcb.http import RequestTimeout +from bcb.utils import Date from bcb.odata.framework import ( ODataEntitySet, ODataFilterExpression, @@ -18,6 +19,22 @@ OLINDA_BASE_URL = "https://olinda.bcb.gov.br/olinda/servico" +def _format_ptax_date_parameter(value: Any) -> Any: + try: + parsed = Date(value).date + except ValueError: + return value + return f"{parsed.month}/{parsed.day}/{parsed.year}" + + +PTAX_DATE_PARAMETER_FORMATTERS: dict[str, Callable[[Any], Any]] = { + "dataCotacao": _format_ptax_date_parameter, + "dataInicial": _format_ptax_date_parameter, + "dataInicialCotacao": _format_ptax_date_parameter, + "dataFinalCotacao": _format_ptax_date_parameter, +} + + class EndpointMeta(type): def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) @@ -51,11 +68,19 @@ def __init__( entity: Any, url: str, date_columns: Optional[list[str]] = None, + parameter_formatters: Optional[dict[str, Callable[[Any], Any]]] = None, *, timeout: RequestTimeout = None, ) -> None: super().__init__(entity, url, timeout=timeout) self._date_columns: list[str] = date_columns or [] + self._parameter_formatters = parameter_formatters or {} + + def _format_parameter(self, parameter: Any, value: Any) -> str: + formatter = self._parameter_formatters.get(parameter.name) + if formatter is not None: + value = formatter(value) + return super()._format_parameter(parameter, value) @overload def collect( @@ -143,6 +168,7 @@ def __init__( entity: Any, url: str, date_columns: Optional[list[str]] = None, + parameter_formatters: Optional[dict[str, Callable[[Any], Any]]] = None, *, timeout: RequestTimeout = None, ) -> None: @@ -163,6 +189,7 @@ def __init__( self._entity = entity self._url = url self._date_columns: list[str] = date_columns or [] + self._parameter_formatters = parameter_formatters or {} self._timeout = timeout def get( @@ -207,7 +234,11 @@ def get( default; returns a raw JSON string when ``output='text'``. """ _query = EndpointQuery( - self._entity, self._url, self._date_columns, timeout=self._timeout + self._entity, + self._url, + self._date_columns, + self._parameter_formatters, + timeout=self._timeout, ) # Apply explicit kwargs first @@ -255,7 +286,11 @@ def query(self) -> EndpointQuery: bcb.odata.api.EndpointQuery """ return EndpointQuery( - self._entity, self._url, self._date_columns, timeout=self._timeout + self._entity, + self._url, + self._date_columns, + self._parameter_formatters, + timeout=self._timeout, ) def async_query(self) -> EndpointQuery: @@ -268,7 +303,11 @@ def async_query(self) -> EndpointQuery: Same as query(); call async_collect() on the result """ return EndpointQuery( - self._entity, self._url, self._date_columns, timeout=self._timeout + self._entity, + self._url, + self._date_columns, + self._parameter_formatters, + timeout=self._timeout, ) async def async_get( @@ -315,7 +354,11 @@ async def async_get( Resultado da consulta """ _query = EndpointQuery( - self._entity, self._url, self._date_columns, timeout=self._timeout + self._entity, + self._url, + self._date_columns, + self._parameter_formatters, + timeout=self._timeout, ) # Apply explicit kwargs first @@ -364,6 +407,7 @@ class BaseODataAPI: BASE_URL: str DATE_COLUMNS: list[str] = [] + PARAMETER_FORMATTERS: dict[str, Callable[[Any], Any]] = {} def __init__(self, *, timeout: RequestTimeout = None) -> None: """ @@ -423,6 +467,7 @@ def get_endpoint(self, endpoint: str) -> Endpoint: self.service[endpoint], self.service.url, self.DATE_COLUMNS or None, + self.PARAMETER_FORMATTERS or None, timeout=self._timeout, ) @@ -538,6 +583,7 @@ class PTAX(BaseODataAPI): """ BASE_URL = f"{OLINDA_BASE_URL}/PTAX/versao/v1/odata/" + PARAMETER_FORMATTERS = PTAX_DATE_PARAMETER_FORMATTERS class IFDATA(BaseODataAPI): diff --git a/bcb/odata/framework.py b/bcb/odata/framework.py index 5c2dbc8..04e4c40 100644 --- a/bcb/odata/framework.py +++ b/bcb/odata/framework.py @@ -660,6 +660,9 @@ def parameters(self, **kwargs: Any) -> Self: raise ODataError(f"Unknown parameter: {arg}") return self + def _format_parameter(self, parameter: ODataParameter, value: Any) -> str: + return parameter.format(value) + def filter(self, *args: ODataFilterExpression) -> Self: if len(args): self._filter.extend(args) @@ -731,7 +734,7 @@ async def async_text(self, *, timeout: RequestTimeout = None) -> str: val = self.function_parameters[p.name or ""] if p.required and val is None: raise ODataError("Parameter not set: " + (p.name or "")) - params["@" + (p.name or "")] = p.format(val) + params["@" + (p.name or "")] = self._format_parameter(p, val) qs = "&".join([f"{quote(k)}={quote(str(v))}" for k, v in params.items()]) headers = {"OData-Version": "4.0", "OData-MaxVersion": "4.0"} url = self.odata_url() @@ -771,7 +774,7 @@ def text(self, *, timeout: RequestTimeout = None) -> str: val = self.function_parameters[p.name or ""] if p.required and val is None: raise ODataError("Parameter not set: " + (p.name or "")) - params["@" + (p.name or "")] = p.format(val) + params["@" + (p.name or "")] = self._format_parameter(p, val) qs = "&".join([f"{quote(k)}={quote(str(v))}" for k, v in params.items()]) headers = {"OData-Version": "4.0", "OData-MaxVersion": "4.0"} url = self.odata_url() diff --git a/docs/currency.rst b/docs/currency.rst index 39dc068..e8d945e 100644 --- a/docs/currency.rst +++ b/docs/currency.rst @@ -39,11 +39,14 @@ O método ``describe`` mostra os *endpoints*, parâmetros e propriedades dispon ep = ptax.get_endpoint('CotacaoMoedaDia') (ep.query() - .parameters(moeda='AUD', dataCotacao='1/31/2022') + .parameters(moeda='AUD', dataCotacao='2022-01-31') .collect()) -É importante notar que as datas estão no formato mês/dia/ano e os números não -são preenchidos com 0 para ter 2 dígitos. +Os parâmetros de data da PTAX aceitam strings ISO (``YYYY-MM-DD``), +``datetime.date``, ``datetime.datetime`` e ``pandas.Timestamp``. Strings já no +formato PTAX também continuam aceitas. A biblioteca converte os valores +generalizados para o formato aceito pelo serviço PTAX: ``M/D/YYYY`` +(mês/dia/ano, sem zero à esquerda). .. ipython:: python @@ -52,8 +55,8 @@ são preenchidos com 0 para ter 2 dígitos. ep = ptax.get_endpoint('CotacaoMoedaPeriodo') (ep.query() .parameters(moeda='AUD', - dataInicial='1/1/2022', - dataFinalCotacao='1/5/2022') + dataInicial='2022-01-01', + dataFinalCotacao='2022-01-05') .collect()) Conversor de Moedas diff --git a/docs/odata.rst b/docs/odata.rst index 67f73af..449a535 100644 --- a/docs/odata.rst +++ b/docs/odata.rst @@ -361,9 +361,11 @@ Este *endpoint* tem 3 parâmetros: Para conhecer como os parâmetros devem ser definidos é necessário ler a documentação da API. Eventualmente a definição dos parâmetros não é óbvia. -Por exemplo, neste *endpoint*, os parâmetros ``dataInicial`` e ``dataFinalCotacao`` são formatados com -mês-dia-ano (formato americano), ao invés de ano-mês-dia (formato ISO), e como o tipo dos parâmetros é ``str``, -uma formatação incorreta não retorna um erro, apenas retorna um DataFrame vazio. +Os parâmetros de data da PTAX aceitam strings ISO (``YYYY-MM-DD``), +``datetime.date``, ``datetime.datetime`` e ``pandas.Timestamp``. Strings já no +formato PTAX também continuam aceitas. A biblioteca converte os valores +generalizados para o formato aceito pelo serviço PTAX: ``M/D/YYYY`` +(mês/dia/ano, sem zero à esquerda). Vamos realizar uma consulta para obter as cotações de dólar americano entre 2022-01-01 e 2022-01-05. @@ -372,8 +374,8 @@ Vamos realizar uma consulta para obter as cotações de dólar americano entre 2 ep = ptax.get_endpoint("CotacaoMoedaPeriodo") (ep.query() .parameters(moeda="USD", - dataInicial="1/1/2022", - dataFinalCotacao="1/5/2022") + dataInicial="2022-01-01", + dataFinalCotacao="2022-01-05") .collect()) Note que a primeira data é 2022-01-03, pois os primeiros dias do ano não são úteis. @@ -384,8 +386,8 @@ Podemos aplicar filtros nessa consulta utilizando o método ``filter``, da mesma (ep.query() .parameters(moeda="USD", - dataInicial="1/1/2022", - dataFinalCotacao="1/5/2022") + dataInicial="2022-01-01", + dataFinalCotacao="2022-01-05") .filter(ep.tipoBoletim == "Fechamento") .collect()) diff --git a/tests/test_odata.py b/tests/test_odata.py index bcc0e6a..e30a790 100644 --- a/tests/test_odata.py +++ b/tests/test_odata.py @@ -5,7 +5,7 @@ import pandas as pd import pytest -from bcb.odata.api import Expectativas, ODataAPI +from bcb.odata.api import Expectativas, ODataAPI, PTAX from bcb.odata.framework import ( ODataParameter, ODataProperty, @@ -66,6 +66,53 @@ FUNCTION_URL_PATTERN = re.compile(r".*CotacaoMoedaPeriodo.*") +PTAX_BASE_URL = "https://olinda.bcb.gov.br/olinda/servico/PTAX/versao/v1/odata/" +PTAX_METADATA_URL = ( + "https://olinda.bcb.gov.br/olinda/servico/PTAX/versao/v1/odata/$metadata" +) +PTAX_SERVICE_ROOT_JSON = """{ + "@odata.context": "https://olinda.bcb.gov.br/olinda/servico/PTAX/versao/v1/odata/$metadata", + "value": [ + {"name": "CotacaoMoedaPeriodo", "kind": "FunctionImport", "url": "CotacaoMoedaPeriodo"}, + {"name": "CotacaoMoedaDia", "kind": "FunctionImport", "url": "CotacaoMoedaDia"} + ] +}""" +PTAX_METADATA_XML = b""" + + + + + + + + + + + + + + + + + + + + + + + + + + +""" +PTAX_PERIODO_URL_PATTERN = re.compile(r".*CotacaoMoedaPeriodo.*") +PTAX_DIA_URL_PATTERN = re.compile(r".*CotacaoMoedaDia.*") + + def add_service_mocks(httpx_mock): """Add the two requests needed to instantiate any BaseODataAPI subclass.""" httpx_mock.add_response( @@ -93,6 +140,19 @@ def add_function_service_mocks(httpx_mock): ) +def add_ptax_service_mocks(httpx_mock): + httpx_mock.add_response( + url=PTAX_BASE_URL, + text=PTAX_SERVICE_ROOT_JSON, + status_code=200, + ) + httpx_mock.add_response( + url=PTAX_METADATA_URL, + content=PTAX_METADATA_XML, + status_code=200, + ) + + # --------------------------------------------------------------------------- # Service / metadata instantiation # --------------------------------------------------------------------------- @@ -678,6 +738,64 @@ def test_function_import_unknown_parameter_raises(httpx_mock): service.query(service["CotacaoMoedaPeriodo"]).parameters(unknown="x") +def test_ptax_period_parameters_accept_standard_date_inputs(httpx_mock): + add_ptax_service_mocks(httpx_mock) + httpx_mock.add_response( + url=PTAX_PERIODO_URL_PATTERN, + text=ODATA_QUERY_RESPONSE_JSON, + status_code=200, + ) + ptax = PTAX() + ep = ptax.get_endpoint("CotacaoMoedaPeriodo") + + ep.query().parameters( + moeda="USD", + dataInicial="2022-01-01", + dataFinalCotacao=date(2022, 1, 5), + ).text() + + request = httpx_mock.get_requests()[-1] + assert request.url.params["@dataInicial"] == "'1/1/2022'" + assert request.url.params["@dataFinalCotacao"] == "'1/5/2022'" + + +def test_ptax_day_parameter_accepts_datetime_and_timestamp(httpx_mock): + add_ptax_service_mocks(httpx_mock) + httpx_mock.add_response( + url=PTAX_DIA_URL_PATTERN, + text=ODATA_QUERY_RESPONSE_JSON, + status_code=200, + ) + ptax = PTAX() + ep = ptax.get_endpoint("CotacaoMoedaDia") + + ep.get(moeda="USD", dataCotacao=pd.Timestamp(datetime(2022, 1, 31)), output="text") + + request = httpx_mock.get_requests()[-1] + assert request.url.params["@dataCotacao"] == "'1/31/2022'" + + +def test_ptax_parameters_keep_existing_ptax_date_strings(httpx_mock): + add_ptax_service_mocks(httpx_mock) + httpx_mock.add_response( + url=PTAX_PERIODO_URL_PATTERN, + text=ODATA_QUERY_RESPONSE_JSON, + status_code=200, + ) + ptax = PTAX() + ep = ptax.get_endpoint("CotacaoMoedaPeriodo") + + ep.query().parameters( + moeda="USD", + dataInicial="1/1/2022", + dataFinalCotacao="1/5/2022", + ).text() + + request = httpx_mock.get_requests()[-1] + assert request.url.params["@dataInicial"] == "'1/1/2022'" + assert request.url.params["@dataFinalCotacao"] == "'1/5/2022'" + + # --------------------------------------------------------------------------- # DATE_COLUMNS — configurable date detection (Phase 7.1) # ---------------------------------------------------------------------------