bb_api 0.3.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.3.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,14 +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.3.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 (
13
+ BBSiaAPI,
14
+ CredenciaisNecessariasError,
15
+ FalhaDownload,
16
+ ResultadoDownloads,
17
+ )
12
18
  from .gestao_agil import (
13
19
  parse_retorno_abertura_massificada,
14
20
  ler_retorno_abertura_massificada,
@@ -18,6 +24,10 @@ __all__ = [
18
24
  "Ambiente",
19
25
  "AccountabilityV3RepasseAPI",
20
26
  "AccountabilityV3ControleAPI",
27
+ "BBSiaAPI",
28
+ "CredenciaisNecessariasError",
29
+ "FalhaDownload",
30
+ "ResultadoDownloads",
21
31
  "parse_retorno_abertura_massificada",
22
32
  "ler_retorno_abertura_massificada",
23
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"]
@@ -16,6 +16,10 @@ homo_api_domain = "https://api.hm.bb.com.br"
16
16
  homo_alt_api_domain = "https://api.sandbox.bb.com.br"
17
17
  prod_api_domain = "https://api.bb.com.br"
18
18
 
19
+ dese_sia_domain = "https://gmtedi.desenv.bb.com.br"
20
+ homo_sia_domain = "https://gmtedi.hm.bb.com.br"
21
+ prod_sia_domain = "https://gmtedi.bb.com.br"
22
+
19
23
  time_between_access_token_requests = timedelta(minutes=10)
20
24
 
21
25
 
@@ -30,6 +34,22 @@ class Ambiente(Enum):
30
34
  PRODUCAO = 3
31
35
 
32
36
 
37
+ _sia_domains = {
38
+ Ambiente.DESENVOLVIMENTO: dese_sia_domain,
39
+ Ambiente.HOMOLOGACAO: homo_sia_domain,
40
+ Ambiente.PRODUCAO: prod_sia_domain,
41
+ }
42
+
43
+
44
+ def sia_domain_for(ambiente: Ambiente) -> str:
45
+ domain = _sia_domains.get(ambiente)
46
+ if domain is None:
47
+ raise ValueError(
48
+ f"O ambiente '{ambiente.name}' não é suportado pela API BB Sia."
49
+ )
50
+ return domain
51
+
52
+
33
53
  def get_headers(access_token: str) -> dict[str, str]:
34
54
  return {
35
55
  "Authorization": f"Bearer {access_token}",
@@ -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)
@@ -0,0 +1,469 @@
1
+ import os
2
+ import base64
3
+ import hashlib
4
+ import datetime
5
+ from dataclasses import dataclass
6
+ from collections.abc import Callable, Mapping, Sequence
7
+ from typing import cast
8
+
9
+ import pandas as pd
10
+ import requests
11
+
12
+ import bb_api.common as common
13
+ import bb_api.gestao_agil as gestao_agil
14
+
15
+
16
+ class CredenciaisNecessariasError(Exception):
17
+ pass
18
+
19
+
20
+ def _to_dataframe(
21
+ res: object,
22
+ main_list: str | None = None,
23
+ rename_dict: Mapping[str, str] | None = None,
24
+ ) -> pd.DataFrame:
25
+ record = cast("Mapping[str, object]", res)
26
+
27
+ if main_list is not None and isinstance(record.get(main_list), list):
28
+ return common.handle_results(res, main_list=main_list, rename_dict=rename_dict)
29
+
30
+ for key, value in record.items():
31
+ if isinstance(value, list):
32
+ return common.handle_results(res, main_list=key, rename_dict=rename_dict)
33
+
34
+ return common.handle_results(res, rename_dict=rename_dict)
35
+
36
+
37
+ def _content_md5(conteudo: bytes) -> str:
38
+ digest = hashlib.md5(conteudo, usedforsecurity=False).digest()
39
+ return base64.b64encode(digest).decode("utf-8")
40
+
41
+
42
+ def _to_iso_z(value: common.DateLike, *, fim_do_dia: bool = False) -> str:
43
+ if isinstance(value, str):
44
+ return value
45
+
46
+ if isinstance(value, datetime.datetime):
47
+ dt = value
48
+ elif fim_do_dia:
49
+ dt = datetime.datetime.combine(value, datetime.time(23, 59, 59))
50
+ else:
51
+ dt = datetime.datetime.combine(value, datetime.time())
52
+
53
+ return dt.strftime("%Y-%m-%dT%H:%M:%S.000Z")
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class FalhaDownload:
58
+ id_arquivo: int | str | None
59
+ nome_arquivo: str | None
60
+ erro: str
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class ResultadoDownloads:
65
+ dados: pd.DataFrame
66
+ falhas: list[FalhaDownload]
67
+
68
+
69
+ class BBSiaAPI:
70
+ _scope: str
71
+ _sia_domain: str
72
+ _access_token: str
73
+ _username: str | None
74
+ _password: str | None
75
+ _refresh_token: str | None
76
+ _ambiente: common.Ambiente
77
+ _last_access_token_request_timestamp: datetime.datetime | None
78
+
79
+ def __init__(
80
+ self,
81
+ ambiente: common.Ambiente = common.Ambiente.HOMOLOGACAO,
82
+ username: str | None = None,
83
+ password: str | None = None,
84
+ refresh_token: str | None = None,
85
+ scope: str = "sia:usuario",
86
+ ):
87
+ self._ambiente = ambiente
88
+ self._sia_domain = common.sia_domain_for(ambiente)
89
+ self._scope = scope
90
+
91
+ self._username = username
92
+ self._password = password
93
+ self._refresh_token = refresh_token
94
+
95
+ if not self._refresh_token and not (self._username and self._password):
96
+ raise ValueError(
97
+ "Credenciais inválidas para o BB Sia. Forneça um 'refresh_token'"
98
+ + " ou o par usuário/senha (parâmetros 'username'/'password')."
99
+ )
100
+
101
+ self._access_token = ""
102
+ self._last_access_token_request_timestamp = None
103
+
104
+ def _store_access_token(self, data: dict[str, object]) -> dict[str, object]:
105
+ access_token = data.get("access_token")
106
+ if not access_token:
107
+ raise Exception(
108
+ "A resposta de autorização do BB Sia não trouxe um 'access_token'."
109
+ )
110
+
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
+
117
+ self._last_access_token_request_timestamp = datetime.datetime.now()
118
+ return data
119
+
120
+ def _password_grant(self) -> dict[str, object]:
121
+ if not (self._username and self._password):
122
+ raise ValueError(
123
+ "Usuário e senha são necessários para o grant 'password' do BB Sia."
124
+ )
125
+
126
+ res = requests.request(
127
+ "POST",
128
+ f"{self._sia_domain}/gmt-autorizador-api/autoriza",
129
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
130
+ data={
131
+ "grant_type": "password",
132
+ "username": self._username,
133
+ "password": self._password,
134
+ "scope": self._scope,
135
+ },
136
+ )
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
+ )
143
+ if res.status_code != 200:
144
+ raise Exception(
145
+ "Não foi possível autorizar o acesso ao BB Sia com usuário e senha."
146
+ )
147
+
148
+ return self._store_access_token(common.parse_json_object(res))
149
+
150
+ def _refresh_grant(
151
+ self,
152
+ validade_refresh_token: int | None = None,
153
+ ) -> dict[str, object]:
154
+ if not self._refresh_token:
155
+ raise ValueError(
156
+ "Nenhum refresh token disponível para renovar o acesso ao BB Sia."
157
+ )
158
+
159
+ data = {
160
+ "grant_type": "refresh_token",
161
+ "refresh_token": self._refresh_token,
162
+ }
163
+ if validade_refresh_token is not None:
164
+ data["validade_refresh_token"] = str(validade_refresh_token)
165
+
166
+ res = requests.request(
167
+ "POST",
168
+ f"{self._sia_domain}/gmt-autorizador-api/autoriza",
169
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
170
+ data=data,
171
+ )
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
+ )
179
+ if res.status_code != 200:
180
+ raise Exception("Não foi possível renovar o token de acesso ao BB Sia.")
181
+
182
+ return self._store_access_token(common.parse_json_object(res))
183
+
184
+ def _check_and_update_access_token(self) -> None:
185
+ now = datetime.datetime.now()
186
+ last_request = self._last_access_token_request_timestamp
187
+
188
+ is_token_valid = (
189
+ self._access_token != ""
190
+ and last_request is not None
191
+ and now - last_request <= common.time_between_access_token_requests
192
+ )
193
+ if is_token_valid:
194
+ return
195
+
196
+ if self._refresh_token:
197
+ try:
198
+ _ = self._refresh_grant()
199
+ return
200
+ except CredenciaisNecessariasError:
201
+ if not (self._username and self._password):
202
+ raise
203
+
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()
212
+
213
+ def _get_access_token(self) -> str:
214
+ self._check_and_update_access_token()
215
+ return self._access_token
216
+
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
226
+ return self._password_grant()
227
+
228
+ def renovar_token(
229
+ self,
230
+ validade_refresh_token: int | None = None,
231
+ ) -> dict[str, object]:
232
+ return self._refresh_grant(validade_refresh_token)
233
+
234
+ def revogar_token(self, token: str, token_type_hint: str) -> None:
235
+ res = requests.request(
236
+ "POST",
237
+ f"{self._sia_domain}/gmt-autorizador-api/revogar",
238
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
239
+ data={
240
+ "token": token,
241
+ "token_type_hint": token_type_hint,
242
+ },
243
+ )
244
+
245
+ if res.status_code not in (200, 204):
246
+ raise Exception("Não foi possível revogar o token do BB Sia.")
247
+
248
+ def listar_uploads_possiveis(self) -> pd.DataFrame:
249
+ access_token = self._get_access_token()
250
+
251
+ res = requests.request(
252
+ "GET",
253
+ f"{self._sia_domain}/gmt-catalogo-api/listaUploads/",
254
+ headers=common.get_headers(access_token),
255
+ )
256
+
257
+ if res.status_code != 200:
258
+ raise Exception("Não foi possível listar os uploads possíveis no BB Sia.")
259
+
260
+ return _to_dataframe(common.parse_json_object(res))
261
+
262
+ def _fetch_downloads(self) -> dict[str, object]:
263
+ access_token = self._get_access_token()
264
+
265
+ res = requests.request(
266
+ "GET",
267
+ f"{self._sia_domain}/gmt-sia-api/listaDownloads",
268
+ headers=common.get_headers(access_token),
269
+ )
270
+
271
+ if res.status_code != 200:
272
+ raise Exception("Não foi possível listar os downloads do BB Sia.")
273
+
274
+ return common.parse_json_object(res)
275
+
276
+ def listar_downloads(self) -> pd.DataFrame:
277
+ return _to_dataframe(self._fetch_downloads())
278
+
279
+ def consultar_metadados(
280
+ self,
281
+ id_arquivo: int | str,
282
+ nome_arquivo: str,
283
+ ) -> pd.DataFrame:
284
+ access_token = self._get_access_token()
285
+
286
+ res = requests.request(
287
+ "GET",
288
+ f"{self._sia_domain}/gmt-sia-api/listaDownloads/{id_arquivo}/{nome_arquivo}",
289
+ headers=common.get_headers(access_token),
290
+ )
291
+
292
+ if res.status_code != 200:
293
+ raise Exception(
294
+ "Não foi possível consultar os metadados do arquivo no BB Sia."
295
+ )
296
+
297
+ return _to_dataframe(common.parse_json_object(res))
298
+
299
+ def baixar_arquivo(
300
+ self,
301
+ id_arquivo: int | str,
302
+ nome_arquivo: str,
303
+ caminho: str | os.PathLike[str] | None = None,
304
+ ) -> bytes:
305
+ access_token = self._get_access_token()
306
+
307
+ res = requests.request(
308
+ "GET",
309
+ f"{self._sia_domain}/gmt-sia-api/download/{id_arquivo}/{nome_arquivo}",
310
+ headers=common.get_headers(access_token),
311
+ )
312
+
313
+ if res.status_code != 200:
314
+ raise Exception("Não foi possível baixar o arquivo do BB Sia.")
315
+
316
+ if caminho is not None:
317
+ with open(caminho, "wb") as arquivo:
318
+ _ = arquivo.write(res.content)
319
+
320
+ return res.content
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
+
372
+ def pre_upload(
373
+ self,
374
+ fta: int | str,
375
+ nome_arquivo: str,
376
+ conteudo: bytes,
377
+ evento: int | str = 1,
378
+ content_md5: str | None = None,
379
+ ) -> requests.Response:
380
+ access_token = self._get_access_token()
381
+ md5 = content_md5 if content_md5 is not None else _content_md5(conteudo)
382
+
383
+ return requests.request(
384
+ "HEAD",
385
+ f"{self._sia_domain}/gmt-sia-api/upload/{fta}/{evento}/{nome_arquivo}",
386
+ headers={
387
+ **common.get_headers(access_token),
388
+ "Content-MD5": md5,
389
+ "x-gmt-content-length": str(len(conteudo)),
390
+ },
391
+ allow_redirects=False,
392
+ )
393
+
394
+ def upload(
395
+ self,
396
+ fta: int | str,
397
+ nome_arquivo: str,
398
+ conteudo: bytes,
399
+ evento: int | str = 1,
400
+ byte_inicial: int = 0,
401
+ byte_final: int | None = None,
402
+ total_bytes: int | None = None,
403
+ content_md5: str | None = None,
404
+ ) -> requests.Response:
405
+ access_token = self._get_access_token()
406
+ md5 = content_md5 if content_md5 is not None else _content_md5(conteudo)
407
+
408
+ total = total_bytes if total_bytes is not None else len(conteudo)
409
+ fim = byte_final if byte_final is not None else byte_inicial + len(conteudo) - 1
410
+
411
+ res = requests.request(
412
+ "PUT",
413
+ f"{self._sia_domain}/gmt-sia-api/upload/{fta}/{evento}/{nome_arquivo}",
414
+ headers={
415
+ "Content-Type": "application/octet-stream",
416
+ **common.get_headers(access_token),
417
+ "Content-MD5": md5,
418
+ "Content-Length": str(len(conteudo)),
419
+ "Content-Range": f"bytes {byte_inicial}-{fim}/{total}",
420
+ },
421
+ data=conteudo,
422
+ allow_redirects=False,
423
+ )
424
+
425
+ if res.status_code >= 400:
426
+ raise Exception("Não foi possível enviar o arquivo para o BB Sia.")
427
+
428
+ return res
429
+
430
+ def listar_protocolos(
431
+ self,
432
+ pagina: int = 1,
433
+ por_pagina: int = 20,
434
+ protocolo: Sequence[int] | None = None,
435
+ cod_fta: Sequence[int] | None = None,
436
+ cod_estado_protocolo: Sequence[int] | None = None,
437
+ dt_criacao_min: common.DateLike | None = None,
438
+ dt_criacao_max: common.DateLike | None = None,
439
+ ) -> pd.DataFrame:
440
+ access_token = self._get_access_token()
441
+
442
+ body: dict[str, object] = {
443
+ "metadata": {
444
+ "pagina": pagina,
445
+ "porPagina": por_pagina,
446
+ },
447
+ }
448
+ if protocolo is not None:
449
+ body["protocolo"] = list(protocolo)
450
+ if cod_fta is not None:
451
+ body["codFta"] = list(cod_fta)
452
+ if cod_estado_protocolo is not None:
453
+ body["codEstadoProtocolo"] = list(cod_estado_protocolo)
454
+ if dt_criacao_min is not None:
455
+ body["dtCriacaoMin"] = _to_iso_z(dt_criacao_min)
456
+ if dt_criacao_max is not None:
457
+ body["dtCriacaoMax"] = _to_iso_z(dt_criacao_max, fim_do_dia=True)
458
+
459
+ res = requests.request(
460
+ "POST",
461
+ f"{self._sia_domain}/gmt-protocolo-api/listaProtocolos",
462
+ headers=common.get_headers(access_token),
463
+ json=body,
464
+ )
465
+
466
+ if res.status_code != 200:
467
+ raise Exception("Não foi possível listar os protocolos do BB Sia.")
468
+
469
+ return _to_dataframe(common.parse_json_object(res))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bb_api
3
- Version: 0.3.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
@@ -5,6 +5,7 @@ bb_api/__init__.py
5
5
  bb_api/accountability.py
6
6
  bb_api/common.py
7
7
  bb_api/gestao_agil.py
8
+ bb_api/sia.py
8
9
  bb_api.egg-info/PKG-INFO
9
10
  bb_api.egg-info/SOURCES.txt
10
11
  bb_api.egg-info/dependency_links.txt
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bb_api"
3
- version = "0.3.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