bb_api 0.4.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bb_api
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Wrapper da API do Banco do Brasil.
5
5
  Requires-Python: >=3.13
6
6
  Description-Content-Type: text/markdown
@@ -1,15 +1,20 @@
1
- from importlib.metadata import version
1
+ from importlib.metadata import PackageNotFoundError, version
2
2
 
3
3
 
4
4
  try:
5
5
  __version__ = version("bb_api")
6
- except:
7
- __version__ = "0.4.0"
6
+ except PackageNotFoundError:
7
+ __version__ = "0.5.0"
8
8
 
9
9
 
10
10
  from .common import Ambiente
11
11
  from .accountability import AccountabilityV3RepasseAPI, AccountabilityV3ControleAPI
12
- from .sia import BBSiaAPI
12
+ from .sia import (
13
+ BBSiaAPI,
14
+ CredenciaisNecessariasError,
15
+ FalhaDownload,
16
+ ResultadoDownloads,
17
+ )
13
18
  from .gestao_agil import (
14
19
  parse_retorno_abertura_massificada,
15
20
  ler_retorno_abertura_massificada,
@@ -20,6 +25,9 @@ __all__ = [
20
25
  "AccountabilityV3RepasseAPI",
21
26
  "AccountabilityV3ControleAPI",
22
27
  "BBSiaAPI",
28
+ "CredenciaisNecessariasError",
29
+ "FalhaDownload",
30
+ "ResultadoDownloads",
23
31
  "parse_retorno_abertura_massificada",
24
32
  "ler_retorno_abertura_massificada",
25
33
  ]
@@ -85,13 +85,9 @@ class _AccountabilityV3BaseAPI:
85
85
  + "ambiente 'BB_API_CLIENT_SECRET'."
86
86
  )
87
87
 
88
- base64_credentials = (
89
- base64
90
- .b64encode(
91
- f"{client_id}:{client_secret}".encode("utf-8")
92
- )
93
- .decode("utf-8")
94
- )
88
+ base64_credentials = base64.b64encode(
89
+ f"{client_id}:{client_secret}".encode("utf-8")
90
+ ).decode("utf-8")
95
91
  self._access_token = ""
96
92
  self._base64_credentials = base64_credentials
97
93
  self._last_access_token_request_timestamp = None
@@ -126,9 +122,7 @@ class _AccountabilityV3BaseAPI:
126
122
  )
127
123
 
128
124
  if res.status_code != 200:
129
- raise Exception(
130
- "Não foi possível adquirir as novas credenciais de acesso."
131
- )
125
+ raise Exception("Não foi possível adquirir as novas credenciais de acesso.")
132
126
 
133
127
  data = common.parse_json_object(res)
134
128
  self._access_token = cast("str", data["access_token"])
@@ -230,9 +224,7 @@ class AccountabilityV3RepasseAPI(_AccountabilityV3BaseAPI):
230
224
  )
231
225
 
232
226
  if res.status_code != 200:
233
- raise Exception(
234
- "Não foi possível reaver o extrato do órgão repassador."
235
- )
227
+ raise Exception("Não foi possível reaver o extrato do órgão repassador.")
236
228
 
237
229
  res = common.parse_json_object(res)
238
230
 
@@ -306,9 +298,7 @@ class AccountabilityV3RepasseAPI(_AccountabilityV3BaseAPI):
306
298
  )
307
299
 
308
300
  if res.status_code != 200:
309
- raise Exception(
310
- "Não foi possível reaver o extrato do órgão repassador."
311
- )
301
+ raise Exception("Não foi possível reaver o extrato do órgão repassador.")
312
302
 
313
303
  res = common.parse_json_object(res)
314
304
 
@@ -439,9 +429,7 @@ class AccountabilityV3RepasseAPI(_AccountabilityV3BaseAPI):
439
429
  )
440
430
 
441
431
  if res.status_code != 200:
442
- raise Exception(
443
- "Não foi possível reaver o extrato do órgão repassador."
444
- )
432
+ raise Exception("Não foi possível reaver o extrato do órgão repassador.")
445
433
 
446
434
  res = common.parse_json_object(res)
447
435
 
@@ -573,9 +561,7 @@ class AccountabilityV3RepasseAPI(_AccountabilityV3BaseAPI):
573
561
  )
574
562
 
575
563
  if res.status_code != 200:
576
- raise Exception(
577
- "Não foi possível reaver o extrato do órgão repassador."
578
- )
564
+ raise Exception("Não foi possível reaver o extrato do órgão repassador.")
579
565
 
580
566
  res = common.parse_json_object(res)
581
567
 
@@ -762,7 +748,9 @@ class AccountabilityV3RepasseAPI(_AccountabilityV3BaseAPI):
762
748
  },
763
749
  )
764
750
 
765
- df["Código Programa Governo"] = cast("common.Scalar", res["codigoProgramaGoverno"])
751
+ df["Código Programa Governo"] = cast(
752
+ "common.Scalar", res["codigoProgramaGoverno"]
753
+ )
766
754
  df["Nome Programa Governo"] = cast("common.Scalar", res["nomeProgramaGoverno"])
767
755
  df["Código SubPrograma Governo"] = cast(
768
756
  "common.Scalar", res["codigoSubProgramaGoverno"]
@@ -1245,9 +1233,7 @@ class AccountabilityV3ControleAPI(_AccountabilityV3BaseAPI):
1245
1233
  )
1246
1234
 
1247
1235
  if res.status_code != 200:
1248
- raise Exception(
1249
- "Não foi possível reaver o extrato do órgão repassador."
1250
- )
1236
+ raise Exception("Não foi possível reaver o extrato do órgão repassador.")
1251
1237
 
1252
1238
  res = common.parse_json_object(res)
1253
1239
 
@@ -1321,9 +1307,7 @@ class AccountabilityV3ControleAPI(_AccountabilityV3BaseAPI):
1321
1307
  )
1322
1308
 
1323
1309
  if res.status_code != 200:
1324
- raise Exception(
1325
- "Não foi possível reaver o extrato do órgão repassador."
1326
- )
1310
+ raise Exception("Não foi possível reaver o extrato do órgão repassador.")
1327
1311
 
1328
1312
  res = common.parse_json_object(res)
1329
1313
 
@@ -1454,9 +1438,7 @@ class AccountabilityV3ControleAPI(_AccountabilityV3BaseAPI):
1454
1438
  )
1455
1439
 
1456
1440
  if res.status_code != 200:
1457
- raise Exception(
1458
- "Não foi possível reaver o extrato do órgão repassador."
1459
- )
1441
+ raise Exception("Não foi possível reaver o extrato do órgão repassador.")
1460
1442
 
1461
1443
  res = common.parse_json_object(res)
1462
1444
 
@@ -1770,7 +1752,9 @@ class AccountabilityV3ControleAPI(_AccountabilityV3BaseAPI):
1770
1752
  },
1771
1753
  )
1772
1754
 
1773
- df["Código Programa Governo"] = cast("common.Scalar", res["codigoProgramaGoverno"])
1755
+ df["Código Programa Governo"] = cast(
1756
+ "common.Scalar", res["codigoProgramaGoverno"]
1757
+ )
1774
1758
  df["Nome Programa Governo"] = cast("common.Scalar", res["nomeProgramaGoverno"])
1775
1759
  df["Código SubPrograma Governo"] = cast(
1776
1760
  "common.Scalar", res["codigoSubProgramaGoverno"]
@@ -42,11 +42,6 @@ _sia_domains = {
42
42
 
43
43
 
44
44
  def sia_domain_for(ambiente: Ambiente) -> str:
45
- """Devolve o domínio do BB Sia (GMT-EDI) correspondente ao ``ambiente``.
46
-
47
- Lança ``ValueError`` para ambientes que o BB Sia não atende (como o
48
- ``HOMOLOGACAO_ALTERNATIVO``).
49
- """
50
45
  domain = _sia_domains.get(ambiente)
51
46
  if domain is None:
52
47
  raise ValueError(
@@ -1,19 +1,7 @@
1
- """Parser para arquivos de retorno MCIF470 (abertura de contas massificadas).
2
-
3
- Esses arquivos são disponibilizados pela API GMT-SIA do Banco do Brasil
4
- (``gmtedi.bb.com.br/gmt-sia-api``), no contexto do sistema Gestão Ágil, e seguem
5
- um layout de largura fixa de 150 posições por linha, conforme a aba
6
- "MCIF470-Abertura RETORNO" da planilha de leiaute de abertura massificada.
7
-
8
- Cada linha é identificada pelas 5 primeiras posições:
9
-
10
- HEADER -> "00000"
11
- TRAILER -> "99999"
12
- DETALHE -> demais
13
- """
14
-
15
1
  import os
2
+ import warnings
16
3
  from collections.abc import Mapping, Sequence
4
+ from datetime import date
17
5
 
18
6
  import pandas as pd
19
7
 
@@ -24,11 +12,9 @@ _HEADER_ID = "00000"
24
12
  _TRAILER_ID = "99999"
25
13
  _LINE_LENGTH = 150
26
14
 
27
- # Cada campo: (nome, posição inicial, posição final, formato).
28
- # Posições 1-based e inclusivas, como na planilha de leiaute.
29
15
  _HEADER_LAYOUT = [
30
16
  ("preenchimento", 1, 5, "N"),
31
- ("data_remessa", 6, 13, "N"), # DDMMAAAA
17
+ ("data_remessa", 6, 13, "A"),
32
18
  ("nome_arquivo", 14, 21, "A"),
33
19
  ("numero_processo", 22, 26, "N"),
34
20
  ("sequencial_remessa", 27, 31, "N"),
@@ -38,21 +24,21 @@ _HEADER_LAYOUT = [
38
24
 
39
25
  _DETALHE_LAYOUT = [
40
26
  ("sequencial", 1, 5, "N"),
41
- ("cpf_cnpj", 6, 19, "N"),
42
- ("data_nascimento", 20, 27, "N"), # DDMMAAAA
27
+ ("cpf_cnpj", 6, 19, "ID"),
28
+ ("data_nascimento", 20, 27, "A"),
43
29
  ("nome_cliente", 28, 87, "A"),
44
30
  ("uso_cliente", 88, 95, "A"),
45
- ("numero_programa_gestao_agil", 96, 104, "A"),
46
- ("agencia_cliente", 105, 108, "N"),
47
- ("dv_agencia_cliente", 109, 109, "N"),
48
- ("grupo_setex", 110, 111, "N"),
49
- ("dv_grupo_setex", 112, 112, "N"),
50
- ("conta", 113, 123, "N"),
51
- ("dv_conta", 124, 124, "N"),
52
- ("ocorrencia_cliente", 125, 127, "N"),
53
- ("ocorrencia_conta", 128, 130, "N"),
54
- ("ocorrencia_limite_credito", 131, 133, "N"),
55
- ("codigo_mci", 134, 142, "N"),
31
+ ("numero_programa_gestao_agil", 96, 104, "ID"),
32
+ ("agencia_cliente", 105, 108, "ID"),
33
+ ("dv_agencia_cliente", 109, 109, "ID"),
34
+ ("grupo_setex", 110, 111, "ID"),
35
+ ("dv_grupo_setex", 112, 112, "ID"),
36
+ ("conta", 113, 123, "ID"),
37
+ ("dv_conta", 124, 124, "ID"),
38
+ ("ocorrencia_cliente", 125, 127, "ID"),
39
+ ("ocorrencia_conta", 128, 130, "ID"),
40
+ ("ocorrencia_limite_credito", 131, 133, "ID"),
41
+ ("codigo_mci", 134, 142, "ID"),
56
42
  ("espacos_em_branco", 143, 150, "A"),
57
43
  ]
58
44
 
@@ -62,7 +48,6 @@ _TRAILER_LAYOUT = [
62
48
  ("espacos_em_branco", 15, 150, "A"),
63
49
  ]
64
50
 
65
- # Tabela 1 - Ocorrências do Cliente
66
51
  _TABELA_OCORRENCIA_CLIENTE = {
67
52
  "001": "tipo pessoa inválido",
68
53
  "002": "tipo CPF/CNPJ inválido",
@@ -82,7 +67,6 @@ _TABELA_OCORRENCIA_CLIENTE = {
82
67
  "017": "tipo de pessoa não permitido para esse processo/tipo de repasse",
83
68
  }
84
69
 
85
- # Tabela 2 - Ocorrências da Conta
86
70
  _TABELA_OCORRENCIA_CONTA = {
87
71
  "001": "ind cheque especial inválido",
88
72
  "002": "setex/dv inválido",
@@ -90,7 +74,6 @@ _TABELA_OCORRENCIA_CONTA = {
90
74
  "004": "dados pessoa física divergente",
91
75
  }
92
76
 
93
- # Tabela 3 - Ocorrências do Limite de Crédito
94
77
  _TABELA_OCORRENCIA_LIMITE = {
95
78
  "001": "cod estado civil inválido",
96
79
  "002": "cod natureza ocupação inválido",
@@ -102,7 +85,6 @@ _TABELA_OCORRENCIA_LIMITE = {
102
85
  "008": "nao atende ao credit scoring",
103
86
  }
104
87
 
105
- # Colunas de preenchimento/controle que não interessam ao DataFrame final.
106
88
  _COLUNAS_DESCARTADAS = ["tipo", "espacos_em_branco"]
107
89
 
108
90
  _RENAME_DETALHE = {
@@ -136,7 +118,6 @@ def _parse_fields(
136
118
  linha: str,
137
119
  layout: Sequence[tuple[str, int, int, str]],
138
120
  ) -> dict[str, str | int]:
139
- """Fatia a linha conforme o layout. Campos "N" viram int quando possível."""
140
121
  rec: dict[str, str | int] = {}
141
122
  for nome, ini, fim, fmt in layout:
142
123
  valor = linha[ini - 1:fim].strip()
@@ -147,8 +128,28 @@ def _parse_fields(
147
128
  return rec
148
129
 
149
130
 
131
+ def _parse_data_ddmmaaaa(valor: object) -> str | None:
132
+ texto = str(valor).strip().zfill(8)
133
+ if texto == "0" * 8 or len(texto) != 8 or not texto.isdigit():
134
+ return None
135
+ dia, mes, ano = texto[:2], texto[2:4], texto[4:]
136
+ try:
137
+ return date(int(ano), int(mes), int(dia)).isoformat()
138
+ except ValueError:
139
+ return None
140
+
141
+
142
+ def _parse_cpf_cnpj(valor: str) -> dict[str, str] | None:
143
+ digitos = "".join(c for c in valor if c.isdigit())
144
+ if not digitos.strip("0"):
145
+ return None
146
+ significativos = digitos.lstrip("0")
147
+ if len(significativos) <= 11:
148
+ return {"valor": digitos[-11:].zfill(11), "tipo": "cpf"}
149
+ return {"valor": digitos.zfill(14), "tipo": "cnpj"}
150
+
151
+
150
152
  def _decode_ocorrencia(codigo: object, tabela: Mapping[str, str]) -> str | None:
151
- """Descrição da ocorrência; None para "000"/vazio/sem código."""
152
153
  cod = str(codigo).zfill(3) if str(codigo).strip() else ""
153
154
  if cod in ("", "000"):
154
155
  return None
@@ -156,7 +157,6 @@ def _decode_ocorrencia(codigo: object, tabela: Mapping[str, str]) -> str | None:
156
157
 
157
158
 
158
159
  def _parse_linha(linha: str, numero: int) -> dict[str, object] | None:
159
- """Parseia uma linha e devolve um dict com o tipo de registro e os campos."""
160
160
  linha = linha.rstrip("\r\n")
161
161
  if not linha.strip():
162
162
  return None
@@ -166,15 +166,27 @@ def _parse_linha(linha: str, numero: int) -> dict[str, object] | None:
166
166
 
167
167
  ident = linha[:5]
168
168
  if ident == _HEADER_ID:
169
- return {"tipo": "HEADER", "linha": numero, **_parse_fields(linha, _HEADER_LAYOUT)}
169
+ header: dict[str, object] = {
170
+ "tipo": "HEADER",
171
+ "linha": numero,
172
+ **_parse_fields(linha, _HEADER_LAYOUT),
173
+ }
174
+ header["data_remessa"] = _parse_data_ddmmaaaa(header["data_remessa"])
175
+ return header
170
176
  if ident == _TRAILER_ID:
171
- return {"tipo": "TRAILER", "linha": numero, **_parse_fields(linha, _TRAILER_LAYOUT)}
177
+ return {
178
+ "tipo": "TRAILER",
179
+ "linha": numero,
180
+ **_parse_fields(linha, _TRAILER_LAYOUT),
181
+ }
172
182
 
173
183
  rec: dict[str, object] = {
174
184
  "tipo": "DETALHE",
175
185
  "linha": numero,
176
186
  **_parse_fields(linha, _DETALHE_LAYOUT),
177
187
  }
188
+ rec["cpf_cnpj"] = _parse_cpf_cnpj(str(rec["cpf_cnpj"]))
189
+ rec["data_nascimento"] = _parse_data_ddmmaaaa(rec["data_nascimento"])
178
190
  rec["ocorrencia_cliente_desc"] = _decode_ocorrencia(
179
191
  rec["ocorrencia_cliente"], _TABELA_OCORRENCIA_CLIENTE)
180
192
  rec["ocorrencia_conta_desc"] = _decode_ocorrencia(
@@ -185,18 +197,6 @@ def _parse_linha(linha: str, numero: int) -> dict[str, object] | None:
185
197
 
186
198
 
187
199
  def parse_retorno_abertura_massificada(conteudo: str) -> pd.DataFrame:
188
- """Parseia o conteúdo de um arquivo de retorno MCIF470 (abertura de contas
189
- massificadas) e devolve um ``DataFrame`` com os registros de DETALHE.
190
-
191
- Os códigos de ocorrência são decodificados em colunas descritivas e os
192
- metadados do cabeçalho (número do processo, data e sequencial da remessa)
193
- são repetidos em todas as linhas para facilitar a rastreabilidade.
194
-
195
- Parâmetros
196
- ----------
197
- conteudo: str
198
- Conteúdo textual completo do arquivo de retorno.
199
- """
200
200
  registros: list[dict[str, object]] = []
201
201
  for numero, linha in enumerate(conteudo.splitlines(), start=1):
202
202
  rec = _parse_linha(linha, numero)
@@ -211,6 +211,16 @@ def parse_retorno_abertura_massificada(conteudo: str) -> pd.DataFrame:
211
211
  header = registro
212
212
  break
213
213
 
214
+ trailer = next((r for r in registros if r["tipo"] == "TRAILER"), None)
215
+ if trailer is not None:
216
+ esperado = trailer.get("quantidade_registros")
217
+ if isinstance(esperado, int) and esperado != len(registros):
218
+ warnings.warn(
219
+ f"Trailer indica {esperado} registros, mas {len(registros)}"
220
+ + " foram lidos do arquivo.",
221
+ stacklevel=2,
222
+ )
223
+
214
224
  data = {
215
225
  "detalhes": detalhes,
216
226
  "numero_processo": header.get("numero_processo"),
@@ -234,18 +244,6 @@ def ler_retorno_abertura_massificada(
234
244
  caminho: str | os.PathLike[str],
235
245
  encoding: str = "latin-1",
236
246
  ) -> pd.DataFrame:
237
- """Lê um arquivo de retorno MCIF470 do disco e o parseia.
238
-
239
- Atalho para ``parse_retorno_abertura_massificada`` quando o arquivo já está
240
- salvo localmente.
241
-
242
- Parâmetros
243
- ----------
244
- caminho: str | os.PathLike
245
- Caminho do arquivo de retorno a ser lido.
246
- encoding: str
247
- Codificação do arquivo (padrão: ``latin-1``).
248
- """
249
247
  with open(caminho, "r", encoding=encoding) as arquivo:
250
248
  conteudo = arquivo.read()
251
249
  return parse_retorno_abertura_massificada(conteudo)
@@ -1,39 +1,20 @@
1
- """Encapsulador da API BB Sia (Gestão Ágil) do Banco do Brasil.
2
-
3
- O BB Sia é acessado pelo domínio do GMT-EDI (``gmtedi.bb.com.br`` em produção) e
4
- expõe, entre outros, os seguintes grupos de endpoints:
5
-
6
- * ``gmt-autorizador-api`` -- autorização (usuário/senha e refresh token) e
7
- revogação de tokens;
8
- * ``gmt-catalogo-api`` -- catálogo dos uploads possíveis;
9
- * ``gmt-sia-api`` -- envio (upload) e recebimento (download) de arquivos, além
10
- da consulta de downloads e dos seus metadados;
11
- * ``gmt-protocolo-api`` -- consulta de protocolos.
12
-
13
- Diferente da API ``Accountability``, a autorização do BB Sia não usa o fluxo
14
- ``client_credentials`` do ``oauth.bb.com.br``: ela usa o *grant* ``password``
15
- (usuário e senha) ou ``refresh_token`` contra o próprio GMT-EDI, devolvendo um
16
- ``access_token`` e um ``refresh_token``.
17
-
18
- .. note::
19
- Os nomes das listas e colunas devolvidas pelos endpoints de consulta
20
- (``listaUploads``, ``listaDownloads``, ``listaProtocolos`` e metadados) são
21
- inferidos a partir da semântica de cada endpoint, pois não há um leiaute de
22
- resposta publicado na coleção de referência. Por isso esses métodos não
23
- renomeiam as colunas: ajuste o tratamento conforme a resposta real da API.
24
- """
25
-
26
1
  import os
27
2
  import base64
28
3
  import hashlib
29
4
  import datetime
30
- from collections.abc import Mapping, Sequence
5
+ from dataclasses import dataclass
6
+ from collections.abc import Callable, Mapping, Sequence
31
7
  from typing import cast
32
8
 
33
9
  import pandas as pd
34
10
  import requests
35
11
 
36
12
  import bb_api.common as common
13
+ import bb_api.gestao_agil as gestao_agil
14
+
15
+
16
+ class CredenciaisNecessariasError(Exception):
17
+ pass
37
18
 
38
19
 
39
20
  def _to_dataframe(
@@ -41,13 +22,6 @@ def _to_dataframe(
41
22
  main_list: str | None = None,
42
23
  rename_dict: Mapping[str, str] | None = None,
43
24
  ) -> pd.DataFrame:
44
- """Constrói um ``DataFrame`` a partir de uma resposta de esquema desconhecido.
45
-
46
- Usa ``main_list`` quando essa chave existe e aponta para uma lista; caso
47
- contrário, usa a primeira lista encontrada na resposta e, se não houver
48
- nenhuma, trata a resposta inteira como um único registro. Assim os endpoints
49
- de consulta do BB Sia não quebram mesmo sem um leiaute de resposta conhecido.
50
- """
51
25
  record = cast("Mapping[str, object]", res)
52
26
 
53
27
  if main_list is not None and isinstance(record.get(main_list), list):
@@ -61,22 +35,11 @@ def _to_dataframe(
61
35
 
62
36
 
63
37
  def _content_md5(conteudo: bytes) -> str:
64
- """Calcula o cabeçalho ``Content-MD5`` (digest MD5 em base64, RFC 1864).
65
-
66
- Usa ``usedforsecurity=False`` porque o MD5 aqui é de integridade, não
67
- criptográfico -- isso também evita ``ValueError`` em builds FIPS do Python.
68
- """
69
38
  digest = hashlib.md5(conteudo, usedforsecurity=False).digest()
70
39
  return base64.b64encode(digest).decode("utf-8")
71
40
 
72
41
 
73
42
  def _to_iso_z(value: common.DateLike, *, fim_do_dia: bool = False) -> str:
74
- """Formata uma data no padrão ISO-8601 com milissegundos e sufixo ``Z``.
75
-
76
- Strings são repassadas sem alteração (o chamador controla o formato exato).
77
- Para ``date``/``datetime`` sem hora, usa ``00:00:00`` ou ``23:59:59`` (quando
78
- ``fim_do_dia``), como nos exemplos de ``dtCriacaoMin``/``dtCriacaoMax``.
79
- """
80
43
  if isinstance(value, str):
81
44
  return value
82
45
 
@@ -90,15 +53,20 @@ def _to_iso_z(value: common.DateLike, *, fim_do_dia: bool = False) -> str:
90
53
  return dt.strftime("%Y-%m-%dT%H:%M:%S.000Z")
91
54
 
92
55
 
93
- class BBSiaAPI:
94
- """Encapsulador da API BB Sia (Gestão Ágil) do Banco do Brasil.
56
+ @dataclass(frozen=True)
57
+ class FalhaDownload:
58
+ id_arquivo: int | str | None
59
+ nome_arquivo: str | None
60
+ erro: str
95
61
 
96
- O token de acesso é gerenciado automaticamente: a instância reaproveita o
97
- token por 10 minutos e gera um novo quando necessário, preferindo o
98
- ``refresh_token`` e caindo para usuário/senha quando ele não está disponível
99
- ou expirou.
100
- """
101
62
 
63
+ @dataclass(frozen=True)
64
+ class ResultadoDownloads:
65
+ dados: pd.DataFrame
66
+ falhas: list[FalhaDownload]
67
+
68
+
69
+ class BBSiaAPI:
102
70
  _scope: str
103
71
  _sia_domain: str
104
72
  _access_token: str
@@ -116,43 +84,18 @@ class BBSiaAPI:
116
84
  refresh_token: str | None = None,
117
85
  scope: str = "sia:usuario",
118
86
  ):
119
- """Inicia uma instância do encapsulador da API BB Sia do Banco do Brasil.
120
-
121
- Parâmetros
122
- ----------
123
- ambiente: common.Ambiente
124
- Ambiente de execução. O BB Sia não possui ambiente alternativo
125
- (sandbox).
126
- username: str | None
127
- Nome de usuário para o *grant* ``password``. Lido da variável de
128
- ambiente ``BBS_USERNAME`` quando não informado.
129
- password: str | None
130
- Senha para o *grant* ``password``. Lida da variável de ambiente
131
- ``BBS_PASSWORD`` quando não informada.
132
- refresh_token: str | None
133
- Refresh token para o *grant* ``refresh_token``. Lido da variável de
134
- ambiente ``BBS_REFRESH_TOKEN`` quando não informado.
135
- scope: str
136
- Escopo solicitado no *grant* ``password`` (padrão ``sia:usuario``).
137
-
138
- É obrigatório fornecer um ``refresh_token`` ou o par usuário/senha.
139
- """
140
87
  self._ambiente = ambiente
141
88
  self._sia_domain = common.sia_domain_for(ambiente)
142
89
  self._scope = scope
143
90
 
144
- self._username = username if username is not None else os.getenv("BBS_USERNAME")
145
- self._password = password if password is not None else os.getenv("BBS_PASSWORD")
146
- self._refresh_token = (
147
- refresh_token if refresh_token is not None else os.getenv("BBS_REFRESH_TOKEN")
148
- )
91
+ self._username = username
92
+ self._password = password
93
+ self._refresh_token = refresh_token
149
94
 
150
95
  if not self._refresh_token and not (self._username and self._password):
151
96
  raise ValueError(
152
97
  "Credenciais inválidas para o BB Sia. Forneça um 'refresh_token'"
153
- + " (ou a variável de ambiente 'BBS_REFRESH_TOKEN') ou o par"
154
- + " usuário/senha (parâmetros 'username'/'password' ou as"
155
- + " variáveis de ambiente 'BBS_USERNAME'/'BBS_PASSWORD')."
98
+ + " ou o par usuário/senha (parâmetros 'username'/'password')."
156
99
  )
157
100
 
158
101
  self._access_token = ""
@@ -161,9 +104,16 @@ class BBSiaAPI:
161
104
  def _store_access_token(self, data: dict[str, object]) -> dict[str, object]:
162
105
  access_token = data.get("access_token")
163
106
  if not access_token:
164
- raise Exception("A resposta de autorização do BB Sia não trouxe um 'access_token'.")
107
+ raise Exception(
108
+ "A resposta de autorização do BB Sia não trouxe um 'access_token'."
109
+ )
165
110
 
166
111
  self._access_token = cast("str", access_token)
112
+
113
+ refresh_token = data.get("refresh_token")
114
+ if refresh_token:
115
+ self._refresh_token = cast("str", refresh_token)
116
+
167
117
  self._last_access_token_request_timestamp = datetime.datetime.now()
168
118
  return data
169
119
 
@@ -185,22 +135,26 @@ class BBSiaAPI:
185
135
  },
186
136
  )
187
137
 
138
+ if res.status_code in (400, 401):
139
+ raise CredenciaisNecessariasError(
140
+ "Usuário ou senha do BB Sia inválidos ou expirados. Forneça"
141
+ + " credenciais válidas."
142
+ )
188
143
  if res.status_code != 200:
189
- raise Exception("Não foi possível autorizar o acesso ao BB Sia com usuário e senha.")
190
-
191
- data = common.parse_json_object(res)
192
- refresh_token = data.get("refresh_token")
193
- if refresh_token:
194
- self._refresh_token = cast("str", refresh_token)
144
+ raise Exception(
145
+ "Não foi possível autorizar o acesso ao BB Sia com usuário e senha."
146
+ )
195
147
 
196
- return self._store_access_token(data)
148
+ return self._store_access_token(common.parse_json_object(res))
197
149
 
198
150
  def _refresh_grant(
199
151
  self,
200
152
  validade_refresh_token: int | None = None,
201
153
  ) -> dict[str, object]:
202
154
  if not self._refresh_token:
203
- raise ValueError("Nenhum refresh token disponível para renovar o acesso ao BB Sia.")
155
+ raise ValueError(
156
+ "Nenhum refresh token disponível para renovar o acesso ao BB Sia."
157
+ )
204
158
 
205
159
  data = {
206
160
  "grant_type": "refresh_token",
@@ -216,6 +170,12 @@ class BBSiaAPI:
216
170
  data=data,
217
171
  )
218
172
 
173
+ if res.status_code in (400, 401):
174
+ raise CredenciaisNecessariasError(
175
+ "O refresh token do BB Sia expirou ou é inválido. Informe usuário"
176
+ + " e senha para reautenticar"
177
+ + " ('autorizar_novo_token(username, password)')."
178
+ )
219
179
  if res.status_code != 200:
220
180
  raise Exception("Não foi possível renovar o token de acesso ao BB Sia.")
221
181
 
@@ -235,55 +195,43 @@ class BBSiaAPI:
235
195
 
236
196
  if self._refresh_token:
237
197
  try:
238
- self._refresh_grant()
198
+ _ = self._refresh_grant()
239
199
  return
240
- except Exception:
200
+ except CredenciaisNecessariasError:
241
201
  if not (self._username and self._password):
242
202
  raise
243
203
 
244
- self._password_grant()
204
+ if not (self._username and self._password):
205
+ raise CredenciaisNecessariasError(
206
+ "Não há refresh token nem usuário/senha para autenticar no BB"
207
+ + " Sia. Forneça as credenciais"
208
+ + " ('autorizar_novo_token(username, password)')."
209
+ )
210
+
211
+ _ = self._password_grant()
245
212
 
246
213
  def _get_access_token(self) -> str:
247
214
  self._check_and_update_access_token()
248
215
  return self._access_token
249
216
 
250
- def autorizar_novo_token(self) -> dict[str, object]:
251
- """Solicita um novo token via *grant* ``password`` (usuário e senha).
252
-
253
- Atualiza o token de acesso e o refresh token da instância e devolve a
254
- resposta crua da API (com ``access_token``, ``refresh_token`` etc.).
255
- """
217
+ def autorizar_novo_token(
218
+ self,
219
+ username: str | None = None,
220
+ password: str | None = None,
221
+ ) -> dict[str, object]:
222
+ if username is not None:
223
+ self._username = username
224
+ if password is not None:
225
+ self._password = password
256
226
  return self._password_grant()
257
227
 
258
228
  def renovar_token(
259
229
  self,
260
230
  validade_refresh_token: int | None = None,
261
231
  ) -> dict[str, object]:
262
- """Obtém um novo **token de acesso** a partir do refresh token.
263
-
264
- Usa o *grant* ``refresh_token``. O refresh token em si não é renovado
265
- nem substituído por esta chamada -- apenas um novo ``access_token`` é
266
- devolvido (e passa a ser usado pela instância).
267
-
268
- Parâmetros
269
- ----------
270
- validade_refresh_token: int | None
271
- Validade, em dias, do refresh token atual. ``0`` deixa o refresh
272
- token com validade indefinida. Quando ``None``, o parâmetro não é
273
- enviado e vale o padrão do Banco do Brasil (1 dia).
274
- """
275
232
  return self._refresh_grant(validade_refresh_token)
276
233
 
277
234
  def revogar_token(self, token: str, token_type_hint: str) -> None:
278
- """Revoga um token de acesso ou de refresh.
279
-
280
- Parâmetros
281
- ----------
282
- token: str
283
- Valor do token a ser revogado.
284
- token_type_hint: str
285
- ``"access_token"`` ou ``"refresh_token"``.
286
- """
287
235
  res = requests.request(
288
236
  "POST",
289
237
  f"{self._sia_domain}/gmt-autorizador-api/revogar",
@@ -298,10 +246,6 @@ class BBSiaAPI:
298
246
  raise Exception("Não foi possível revogar o token do BB Sia.")
299
247
 
300
248
  def listar_uploads_possiveis(self) -> pd.DataFrame:
301
- """Lista os uploads (FTAs) que o usuário pode enviar.
302
-
303
- Endpoint ``GET /gmt-catalogo-api/listaUploads/``.
304
- """
305
249
  access_token = self._get_access_token()
306
250
 
307
251
  res = requests.request(
@@ -315,11 +259,7 @@ class BBSiaAPI:
315
259
 
316
260
  return _to_dataframe(common.parse_json_object(res))
317
261
 
318
- def listar_downloads(self) -> pd.DataFrame:
319
- """Lista os arquivos disponíveis para download.
320
-
321
- Endpoint ``GET /gmt-sia-api/listaDownloads``.
322
- """
262
+ def _fetch_downloads(self) -> dict[str, object]:
323
263
  access_token = self._get_access_token()
324
264
 
325
265
  res = requests.request(
@@ -331,24 +271,16 @@ class BBSiaAPI:
331
271
  if res.status_code != 200:
332
272
  raise Exception("Não foi possível listar os downloads do BB Sia.")
333
273
 
334
- return _to_dataframe(common.parse_json_object(res))
274
+ return common.parse_json_object(res)
275
+
276
+ def listar_downloads(self) -> pd.DataFrame:
277
+ return _to_dataframe(self._fetch_downloads())
335
278
 
336
279
  def consultar_metadados(
337
280
  self,
338
281
  id_arquivo: int | str,
339
282
  nome_arquivo: str,
340
283
  ) -> pd.DataFrame:
341
- """Consulta os metadados de um arquivo disponível para download.
342
-
343
- Endpoint ``GET /gmt-sia-api/listaDownloads/{id_arquivo}/{nome_arquivo}``.
344
-
345
- Parâmetros
346
- ----------
347
- id_arquivo: int | str
348
- Identificador do arquivo, como consta ao consultar os downloads.
349
- nome_arquivo: str
350
- Nome do arquivo, como consta ao consultar os downloads.
351
- """
352
284
  access_token = self._get_access_token()
353
285
 
354
286
  res = requests.request(
@@ -358,7 +290,9 @@ class BBSiaAPI:
358
290
  )
359
291
 
360
292
  if res.status_code != 200:
361
- raise Exception("Não foi possível consultar os metadados do arquivo no BB Sia.")
293
+ raise Exception(
294
+ "Não foi possível consultar os metadados do arquivo no BB Sia."
295
+ )
362
296
 
363
297
  return _to_dataframe(common.parse_json_object(res))
364
298
 
@@ -368,21 +302,6 @@ class BBSiaAPI:
368
302
  nome_arquivo: str,
369
303
  caminho: str | os.PathLike[str] | None = None,
370
304
  ) -> bytes:
371
- """Baixa o conteúdo de um arquivo do BB Sia.
372
-
373
- Endpoint ``GET /gmt-sia-api/download/{id_arquivo}/{nome_arquivo}``.
374
-
375
- Parâmetros
376
- ----------
377
- id_arquivo: int | str
378
- Identificador do arquivo, como consta ao consultar os downloads.
379
- nome_arquivo: str
380
- Nome do arquivo, como consta ao consultar os downloads.
381
- caminho: str | os.PathLike | None
382
- Quando informado, o conteúdo também é gravado nesse caminho.
383
-
384
- Devolve o conteúdo do arquivo em ``bytes``.
385
- """
386
305
  access_token = self._get_access_token()
387
306
 
388
307
  res = requests.request(
@@ -396,10 +315,60 @@ class BBSiaAPI:
396
315
 
397
316
  if caminho is not None:
398
317
  with open(caminho, "wb") as arquivo:
399
- arquivo.write(res.content)
318
+ _ = arquivo.write(res.content)
400
319
 
401
320
  return res.content
402
321
 
322
+ def processar_downloads(
323
+ self,
324
+ parser: Callable[[str], pd.DataFrame] = gestao_agil.parse_retorno_abertura_massificada,
325
+ encoding: str = "latin-1",
326
+ ) -> ResultadoDownloads:
327
+ listagem = self._fetch_downloads()
328
+ arquivos = listagem.get("arquivos")
329
+ if not isinstance(arquivos, list):
330
+ return ResultadoDownloads(dados=pd.DataFrame(), falhas=[])
331
+
332
+ frames: list[pd.DataFrame] = []
333
+ falhas: list[FalhaDownload] = []
334
+
335
+ for arquivo in cast("list[object]", arquivos):
336
+ meta = cast("Mapping[str, object]", arquivo)
337
+ id_arquivo = cast("int | str | None", meta.get("id"))
338
+ nome_arquivo = cast("str | None", meta.get("nome"))
339
+ cod_fta = cast("int | str | None", meta.get("codFta"))
340
+
341
+ if id_arquivo is None or nome_arquivo is None:
342
+ falhas.append(
343
+ FalhaDownload(
344
+ id_arquivo=id_arquivo,
345
+ nome_arquivo=nome_arquivo,
346
+ erro="Download sem 'id' ou 'nome' na listagem do BB Sia.",
347
+ )
348
+ )
349
+ continue
350
+
351
+ try:
352
+ conteudo = self.baixar_arquivo(id_arquivo, nome_arquivo)
353
+ df = parser(conteudo.decode(encoding))
354
+ except Exception as exc:
355
+ falhas.append(
356
+ FalhaDownload(
357
+ id_arquivo=id_arquivo,
358
+ nome_arquivo=nome_arquivo,
359
+ erro=str(exc),
360
+ )
361
+ )
362
+ continue
363
+
364
+ df.insert(0, "ID Arquivo", id_arquivo)
365
+ df.insert(1, "Nome Arquivo", nome_arquivo)
366
+ df.insert(2, "Código FTA", cod_fta)
367
+ frames.append(df)
368
+
369
+ dados = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()
370
+ return ResultadoDownloads(dados=dados, falhas=falhas)
371
+
403
372
  def pre_upload(
404
373
  self,
405
374
  fta: int | str,
@@ -408,28 +377,6 @@ class BBSiaAPI:
408
377
  evento: int | str = 1,
409
378
  content_md5: str | None = None,
410
379
  ) -> requests.Response:
411
- """Negocia (HEAD) o envio de um arquivo antes do upload.
412
-
413
- Endpoint ``HEAD /gmt-sia-api/upload/{fta}/{evento}/{nome_arquivo}``.
414
-
415
- Não levanta exceção em respostas não-2xx nem segue redirecionamentos
416
- automaticamente: o ``status_code`` e os cabeçalhos da resposta fazem
417
- parte da negociação (por exemplo, para retomar um envio). Inspecione a
418
- ``requests.Response`` devolvida.
419
-
420
- Parâmetros
421
- ----------
422
- fta: int | str
423
- Número do FTA para o qual o arquivo será enviado.
424
- nome_arquivo: str
425
- Nome do arquivo a ser enviado.
426
- conteudo: bytes
427
- Conteúdo do arquivo, usado para calcular MD5 e tamanho.
428
- evento: int | str
429
- Etapa de recepção (por padrão, ``1``).
430
- content_md5: str | None
431
- Sobrescreve o ``Content-MD5`` calculado (digest MD5 em base64).
432
- """
433
380
  access_token = self._get_access_token()
434
381
  md5 = content_md5 if content_md5 is not None else _content_md5(conteudo)
435
382
 
@@ -455,35 +402,6 @@ class BBSiaAPI:
455
402
  total_bytes: int | None = None,
456
403
  content_md5: str | None = None,
457
404
  ) -> requests.Response:
458
- """Envia (PUT) um arquivo, ou um trecho dele, para o BB Sia.
459
-
460
- Endpoint ``PUT /gmt-sia-api/upload/{fta}/{evento}/{nome_arquivo}``.
461
-
462
- Levanta exceção apenas em respostas de erro (``status_code >= 400``).
463
- Redirecionamentos não são seguidos automaticamente: respostas de
464
- continuação (como ``308``) são devolvidas para o chamador decidir, o
465
- que permite envios fracionados.
466
-
467
- Parâmetros
468
- ----------
469
- fta: int | str
470
- Número do FTA para o qual o arquivo será enviado.
471
- nome_arquivo: str
472
- Nome do arquivo a ser enviado.
473
- conteudo: bytes
474
- Bytes a serem enviados nesta requisição (o arquivo todo ou um trecho).
475
- evento: int | str
476
- Etapa de recepção (por padrão, ``1``).
477
- byte_inicial: int
478
- Primeiro byte deste trecho, para o cabeçalho ``Content-Range``.
479
- byte_final: int | None
480
- Último byte deste trecho. Quando ``None``, assume o último byte de
481
- ``conteudo`` (``byte_inicial + len(conteudo) - 1``).
482
- total_bytes: int | None
483
- Tamanho total do arquivo. Quando ``None``, assume ``len(conteudo)``.
484
- content_md5: str | None
485
- Sobrescreve o ``Content-MD5`` calculado (digest MD5 em base64).
486
- """
487
405
  access_token = self._get_access_token()
488
406
  md5 = content_md5 if content_md5 is not None else _content_md5(conteudo)
489
407
 
@@ -519,30 +437,6 @@ class BBSiaAPI:
519
437
  dt_criacao_min: common.DateLike | None = None,
520
438
  dt_criacao_max: common.DateLike | None = None,
521
439
  ) -> pd.DataFrame:
522
- """Consulta protocolos no BB Sia.
523
-
524
- Endpoint ``POST /gmt-protocolo-api/listaProtocolos``. Apenas os filtros
525
- informados (diferentes de ``None``) são enviados no corpo da requisição.
526
-
527
- Parâmetros
528
- ----------
529
- pagina: int
530
- Página da consulta (``metadata.pagina``).
531
- por_pagina: int
532
- Quantidade de itens por página (``metadata.porPagina``).
533
- protocolo: Sequence[int] | None
534
- Números de protocolo a filtrar.
535
- cod_fta: Sequence[int] | None
536
- Códigos de FTA a filtrar.
537
- cod_estado_protocolo: Sequence[int] | None
538
- Códigos de estado do protocolo a filtrar.
539
- dt_criacao_min: common.DateLike | None
540
- Data de criação mínima. ``date``/``datetime`` viram o início do dia
541
- em ISO-8601 com sufixo ``Z``; ``str`` é repassada como veio.
542
- dt_criacao_max: common.DateLike | None
543
- Data de criação máxima. ``date``/``datetime`` viram o fim do dia em
544
- ISO-8601 com sufixo ``Z``; ``str`` é repassada como veio.
545
- """
546
440
  access_token = self._get_access_token()
547
441
 
548
442
  body: dict[str, object] = {
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bb_api
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Wrapper da API do Banco do Brasil.
5
5
  Requires-Python: >=3.13
6
6
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bb_api"
3
- version = "0.4.0"
3
+ version = "0.5.0"
4
4
  description = "Wrapper da API do Banco do Brasil."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -16,9 +16,14 @@ build-backend = "setuptools.build_meta"
16
16
  [tool.setuptools]
17
17
  packages = ["bb_api"]
18
18
 
19
+ [tool.ruff]
20
+ line-length = 99
21
+
19
22
  [dependency-groups]
20
23
  dev = [
24
+ "basedpyright>=1.39.8",
21
25
  "pandas-stubs>=3.0.3.260530",
26
+ "ruff>=0.15.19",
22
27
  ]
23
28
 
24
29
  [tool.semantic_release]
File without changes
File without changes
File without changes