Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 51 additions & 5 deletions bcb/odata/api.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
"""
Expand Down Expand Up @@ -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,
)

Expand Down Expand Up @@ -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):
Expand Down
7 changes: 5 additions & 2 deletions bcb/odata/framework.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
13 changes: 8 additions & 5 deletions docs/currency.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
16 changes: 9 additions & 7 deletions docs/odata.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.
Expand All @@ -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())

Expand Down
120 changes: 119 additions & 1 deletion tests/test_odata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"""<?xml version="1.0" encoding="utf-8"?>
<edmx:Edmx Version="4.0" xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx">
<edmx:DataServices>
<Schema Namespace="br.gov.bcb.olinda.servico.PTAX" xmlns="http://docs.oasis-open.org/odata/ns/edm">
<EntityType Name="TipoCotacaoMoeda">
<Property Name="cotacaoCompra" Type="Edm.Decimal"/>
<Property Name="dataHoraCotacao" Type="Edm.String"/>
</EntityType>
<Function Name="CotacaoMoedaPeriodo">
<Parameter Name="moeda" Type="Edm.String" Nullable="false"/>
<Parameter Name="dataInicial" Type="Edm.String" Nullable="false"/>
<Parameter Name="dataFinalCotacao" Type="Edm.String" Nullable="false"/>
<ReturnType Type="Collection(br.gov.bcb.olinda.servico.PTAX.TipoCotacaoMoeda)"/>
</Function>
<Function Name="CotacaoMoedaDia">
<Parameter Name="moeda" Type="Edm.String" Nullable="false"/>
<Parameter Name="dataCotacao" Type="Edm.String" Nullable="false"/>
<ReturnType Type="Collection(br.gov.bcb.olinda.servico.PTAX.TipoCotacaoMoeda)"/>
</Function>
<EntityContainer Name="Container">
<EntitySet Name="_CotacaoMoedaPeriodo" EntityType="br.gov.bcb.olinda.servico.PTAX.TipoCotacaoMoeda"/>
<EntitySet Name="_CotacaoMoedaDia" EntityType="br.gov.bcb.olinda.servico.PTAX.TipoCotacaoMoeda"/>
<FunctionImport Name="CotacaoMoedaPeriodo"
Function="br.gov.bcb.olinda.servico.PTAX.CotacaoMoedaPeriodo"
EntitySet="br.gov.bcb.olinda.servico.PTAX._CotacaoMoedaPeriodo"/>
<FunctionImport Name="CotacaoMoedaDia"
Function="br.gov.bcb.olinda.servico.PTAX.CotacaoMoedaDia"
EntitySet="br.gov.bcb.olinda.servico.PTAX._CotacaoMoedaDia"/>
</EntityContainer>
</Schema>
</edmx:DataServices>
</edmx:Edmx>"""
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(
Expand Down Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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)
# ---------------------------------------------------------------------------
Expand Down
Loading