bfocus 0.1.0__py3-none-any.whl

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.
bfocus/_transport.py ADDED
@@ -0,0 +1,335 @@
1
+ """Transporte HTTP: headers, codificação, novas tentativas e tradução de erros.
2
+
3
+ Só biblioteca padrão (``urllib``). Nada aqui faz rede na importação/construção.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import http.client
9
+ import json
10
+ import random
11
+ import time
12
+ import urllib.error
13
+ import urllib.parse
14
+ import urllib.request
15
+ import uuid
16
+ from datetime import date, datetime, timezone
17
+ from email.utils import parsedate_to_datetime
18
+ from typing import Any, Callable, Dict, Mapping, Optional, Tuple
19
+
20
+ from ._version import __version__
21
+ from .errors import BfocusError, NetworkError, error_for_status
22
+ from .types import Unset
23
+
24
+ DEFAULT_BASE_URL = "https://api.bfocus.com.br"
25
+ API_PREFIX = "/api/v1/integration"
26
+ #: Vai em ``X-Bfocus-Client`` e ``User-Agent``. É por ele que a API sabe qual versão da
27
+ #: SDK a conta roda — e avisa quando uma correção exigir atualizar.
28
+ CLIENT_ID = f"bfocus-python/{__version__}"
29
+ DEFAULT_TIMEOUT = 30.0
30
+ DEFAULT_MAX_RETRIES = 2
31
+
32
+ RETRY_STATUSES = frozenset({429, 502, 503, 504})
33
+ WRITE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
34
+ MAX_RETRY_AFTER = 60.0
35
+ MAX_BACKOFF = 8.0
36
+
37
+ # Falhas de transporte que viram NetworkError (e são repetidas). ``socket.timeout`` e
38
+ # ``ConnectionError`` são OSError; ``RemoteDisconnected``/``IncompleteRead`` são HTTPException.
39
+ _NETWORK_FAILURES = (urllib.error.URLError, OSError, http.client.HTTPException)
40
+
41
+
42
+ # ── codificação ─────────────────────────────────────────────────────────────────
43
+
44
+
45
+ def path_segment(value: Any, name: str, *, allow_slash: bool = True) -> str:
46
+ """Percent-encode de UM segmento de caminho (``ERP 1042`` → ``ERP%201042``)."""
47
+ if isinstance(value, Unset) or value is None:
48
+ raise ValueError(f"{name} é obrigatório.")
49
+ text = str(value)
50
+ if text == "":
51
+ raise ValueError(f"{name} não pode ser vazio.")
52
+ if text in (".", ".."):
53
+ # O cliente HTTP resolveria "%2E%2E" como navegação de caminho e chamaria outra rota.
54
+ raise ValueError(f"{name} não pode ser {text!r}.")
55
+ if not allow_slash and "/" in text:
56
+ raise ValueError(
57
+ f"{name} não aceita '/' (a API recusa) — use ':' para hierarquia: {text!r}"
58
+ )
59
+ return urllib.parse.quote(text, safe="")
60
+
61
+
62
+ def iso_utc(value: datetime) -> str:
63
+ """``datetime`` → ISO 8601 em UTC com ``Z``. Sem fuso = já está em UTC."""
64
+ if value.tzinfo is None:
65
+ value = value.replace(tzinfo=timezone.utc)
66
+ else:
67
+ value = value.astimezone(timezone.utc)
68
+ return value.replace(tzinfo=None).isoformat() + "Z"
69
+
70
+
71
+ def query_value(value: Any) -> str:
72
+ if isinstance(value, bool):
73
+ return "true" if value else "false"
74
+ if isinstance(value, datetime):
75
+ return iso_utc(value)
76
+ if isinstance(value, date):
77
+ return value.isoformat() + "T00:00:00Z"
78
+ return str(value)
79
+
80
+
81
+ def compact(fields: Mapping[str, Any]) -> Dict[str, Any]:
82
+ """Corpo só com o que o usuário informou (``None`` fica e vira ``null``)."""
83
+ return {k: v for k, v in fields.items() if not isinstance(v, Unset)}
84
+
85
+
86
+ def _json_default(value: Any) -> Any:
87
+ if isinstance(value, datetime):
88
+ return iso_utc(value)
89
+ if isinstance(value, date):
90
+ return value.isoformat()
91
+ if isinstance(value, uuid.UUID):
92
+ return str(value)
93
+ if isinstance(value, (set, frozenset, tuple)):
94
+ return list(value)
95
+ raise TypeError(f"valor não serializável em JSON: {type(value).__name__}")
96
+
97
+
98
+ def parse_retry_after(raw: Optional[str]) -> Optional[float]:
99
+ """``Retry-After`` em segundos (número ou data HTTP); ``None`` se ausente/inválido."""
100
+ if not raw:
101
+ return None
102
+ raw = raw.strip()
103
+ try:
104
+ seconds = float(raw)
105
+ except ValueError:
106
+ try:
107
+ when = parsedate_to_datetime(raw)
108
+ except (TypeError, ValueError, IndexError):
109
+ return None
110
+ if when is None:
111
+ return None
112
+ if when.tzinfo is None:
113
+ when = when.replace(tzinfo=timezone.utc)
114
+ seconds = (when - datetime.now(timezone.utc)).total_seconds()
115
+ if seconds != seconds: # NaN
116
+ return None
117
+ seconds = max(0.0, seconds)
118
+ return int(seconds) if float(seconds).is_integer() else seconds
119
+
120
+
121
+ # ── erros ───────────────────────────────────────────────────────────────────────
122
+
123
+
124
+ def _decode_json(raw: bytes) -> Tuple[Any, str]:
125
+ text = raw.decode("utf-8", "replace") if raw else ""
126
+ if not text.strip():
127
+ return None, text
128
+ try:
129
+ return json.loads(text), text
130
+ except ValueError:
131
+ return None, text
132
+
133
+
134
+ def build_error(
135
+ status: int,
136
+ headers: Mapping[str, str],
137
+ raw: bytes,
138
+ sent_request_id: Optional[str] = None,
139
+ ) -> BfocusError:
140
+ """Resposta fora de 2xx → erro. ``request_id``: corpo → header → o id que a SDK enviou."""
141
+ payload, text = _decode_json(raw)
142
+ code: Optional[str] = None
143
+ human: Optional[str] = None
144
+ validation: Dict[str, Any] = {}
145
+ request_id: Optional[str] = None
146
+
147
+ if isinstance(payload, dict):
148
+ err, msg = payload.get("error"), payload.get("message")
149
+ if isinstance(err, str) and err:
150
+ code = err
151
+ elif isinstance(msg, str) and msg:
152
+ code = msg
153
+ if isinstance(msg, str) and msg and msg != code:
154
+ human = msg
155
+ if isinstance(payload.get("validation"), dict):
156
+ validation = payload["validation"]
157
+ rid = payload.get("request_id")
158
+ if isinstance(rid, str) and rid:
159
+ request_id = rid
160
+ elif text.strip():
161
+ human = text.strip()[:200]
162
+
163
+ if not code:
164
+ code = f"HTTP_{status}"
165
+ if not request_id:
166
+ request_id = headers.get("X-Request-Id") or sent_request_id or None
167
+ retry_after =parse_retry_after(headers.get("Retry-After")) if status == 429 else None
168
+ required_scope = (headers.get("X-Required-Scope") or None) if status == 403 else None
169
+
170
+ message = f"{code} (HTTP {status})"
171
+ if human:
172
+ message += f": {human}"
173
+ if required_scope:
174
+ message += f" — escopo exigido: {required_scope}"
175
+ if validation:
176
+ message += " — " + "; ".join(f"{k}: {v}" for k, v in validation.items())
177
+ if retry_after is not None:
178
+ message += f" — tente de novo em {retry_after}s"
179
+
180
+ cls = error_for_status(status)
181
+ return cls(
182
+ code,
183
+ message,
184
+ status,
185
+ request_id=request_id,
186
+ validation=validation,
187
+ retry_after=retry_after,
188
+ required_scope=required_scope,
189
+ body=payload if payload is not None else (text or None),
190
+ )
191
+
192
+
193
+ # ── transporte ──────────────────────────────────────────────────────────────────
194
+
195
+
196
+ class Transport:
197
+ """Faz UMA chamada lógica: 1 tentativa + até ``max_retries`` novas tentativas."""
198
+
199
+ def __init__(
200
+ self,
201
+ api_key: str,
202
+ *,
203
+ base_url: str,
204
+ timeout: float,
205
+ max_retries: int,
206
+ sleep: Optional[Callable[[float], Any]] = None,
207
+ ) -> None:
208
+ self.api_key = api_key
209
+ self.base_url = base_url.rstrip("/")
210
+ self.timeout = timeout
211
+ self.max_retries = max_retries
212
+ #: Espera entre tentativas. Substituível (os testes não dormem de verdade).
213
+ self.sleep: Callable[[float], Any] = sleep or time.sleep
214
+
215
+ # A espera exponencial usa ``random``; isolado para os testes poderem conferir.
216
+ @staticmethod
217
+ def backoff(attempt: int) -> float:
218
+ base = min(MAX_BACKOFF, 0.5 * (2 ** attempt))
219
+ return base + random.uniform(0, base * 0.25)
220
+
221
+ def retry_delay(self, attempt: int, retry_after: Optional[str]) -> float:
222
+ seconds = parse_retry_after(retry_after)
223
+ if seconds is not None:
224
+ return min(float(seconds), MAX_RETRY_AFTER)
225
+ return self.backoff(attempt)
226
+
227
+ def build_url(self, path: str, query: Optional[Mapping[str, Any]]) -> str:
228
+ url = self.base_url + API_PREFIX + path
229
+ if query:
230
+ pairs = [(k, query_value(v)) for k, v in query.items() if v is not None]
231
+ if pairs:
232
+ url += "?" + urllib.parse.urlencode(pairs, quote_via=urllib.parse.quote)
233
+ return url
234
+
235
+ def request(
236
+ self,
237
+ method: str,
238
+ path: str,
239
+ *,
240
+ query: Optional[Mapping[str, Any]] = None,
241
+ body: Any = None,
242
+ idempotency_key: Optional[str] = None,
243
+ timeout: Optional[float] = None,
244
+ ) -> Tuple[Any, Optional[Dict[str, Any]]]:
245
+ """Executa a chamada e devolve ``(data, pagination)`` do envelope.
246
+
247
+ ``body=None`` significa SEM corpo (o JSON ``null`` nunca é um corpo válido aqui).
248
+ """
249
+ url = self.build_url(path, query)
250
+ headers = {
251
+ "Authorization": f"Bearer {self.api_key}",
252
+ "Accept": "application/json",
253
+ "X-Bfocus-Client": CLIENT_ID,
254
+ "User-Agent": CLIENT_ID,
255
+ # Mesmo id em todas as tentativas desta chamada: é como o suporte correlaciona.
256
+ "X-Request-Id": uuid.uuid4().hex,
257
+ }
258
+ if method in WRITE_METHODS:
259
+ # Mesma chave em todas as tentativas: a API devolve a resposta original
260
+ # (Idempotent-Replayed: true) em vez de executar de novo.
261
+ headers["Idempotency-Key"] = idempotency_key or str(uuid.uuid4())
262
+ data: Optional[bytes] = None
263
+ if body is not None:
264
+ data = json.dumps(body, ensure_ascii=False, default=_json_default).encode("utf-8")
265
+ headers["Content-Type"] = "application/json"
266
+ per_try = self.timeout if timeout is None else timeout
267
+
268
+ attempt = 0
269
+ while True:
270
+ try:
271
+ status, resp_headers, raw = self._send(method, url, headers, data, per_try)
272
+ except _NETWORK_FAILURES as exc:
273
+ if attempt < self.max_retries:
274
+ self.sleep(self.backoff(attempt))
275
+ attempt += 1
276
+ continue
277
+ reason = getattr(exc, "reason", None) or exc
278
+ raise NetworkError(
279
+ f"NETWORK_ERROR: falha ao falar com {self.base_url} "
280
+ f"({type(exc).__name__}: {reason})",
281
+ request_id=headers["X-Request-Id"],
282
+ ) from exc
283
+
284
+ if 200 <= status < 300:
285
+ return self._unwrap(status, resp_headers, raw, headers["X-Request-Id"])
286
+
287
+ if status in RETRY_STATUSES and attempt < self.max_retries:
288
+ self.sleep(self.retry_delay(attempt, resp_headers.get("Retry-After")))
289
+ attempt += 1
290
+ continue
291
+ raise build_error(status, resp_headers, raw, headers["X-Request-Id"])
292
+
293
+ @staticmethod
294
+ def _send(
295
+ method: str,
296
+ url: str,
297
+ headers: Mapping[str, str],
298
+ data: Optional[bytes],
299
+ timeout: float,
300
+ ) -> Tuple[int, Mapping[str, str], bytes]:
301
+ req = urllib.request.Request(url, data=data, headers=dict(headers), method=method)
302
+ try:
303
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
304
+ return resp.status, resp.headers, resp.read()
305
+ except urllib.error.HTTPError as exc: # status fora de 2xx: é resposta, não rede
306
+ try:
307
+ raw = exc.read()
308
+ except _NETWORK_FAILURES:
309
+ raw = b""
310
+ finally:
311
+ exc.close()
312
+ return exc.code, exc.headers, raw
313
+
314
+ @staticmethod
315
+ def _unwrap(
316
+ status: int, headers: Mapping[str, str], raw: bytes, sent_request_id: str
317
+ ) -> Tuple[Any, Optional[Dict[str, Any]]]:
318
+ """2xx → ``(data, pagination)``. Sem envelope JSON válido → ``INVALID_RESPONSE``.
319
+
320
+ Nunca devolve ``None`` calado: um proxy que responde 200 com HTML (ou um corpo vazio)
321
+ vira erro, não "sucesso sem dados".
322
+ """
323
+ payload, text = _decode_json(raw)
324
+ if isinstance(payload, dict) and "data" in payload:
325
+ pagination = payload.get("pagination")
326
+ return payload["data"], pagination if isinstance(pagination, dict) else None
327
+ shown = text.strip()[:200]
328
+ raise BfocusError(
329
+ "INVALID_RESPONSE",
330
+ f"INVALID_RESPONSE (HTTP {status}): resposta sem o envelope JSON da API"
331
+ + (f": {shown!r}" if shown else " (corpo vazio)"),
332
+ status,
333
+ request_id=headers.get("X-Request-Id") or sent_request_id,
334
+ body=payload if payload is not None else (text or None),
335
+ )
bfocus/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """Versão da SDK. O ``scripts/release-sdks.sh`` bumpa esta linha e o ``pyproject.toml``."""
2
+
3
+ __version__ = "0.1.0"
bfocus/errors.py ADDED
@@ -0,0 +1,131 @@
1
+ """Erros da SDK do bFocus.
2
+
3
+ Toda resposta fora de 2xx vira um :class:`BfocusError` (ou uma subclasse, pelo status).
4
+ Falha de conexão/timeout vira :class:`NetworkError` (``status == 0``).
5
+
6
+ **Use** :attr:`BfocusError.code` **na sua lógica** — ele é estável (``CUSTOMER_NOT_FOUND``,
7
+ ``INTEGRATION_SCOPE_MISSING``…). O :attr:`~BfocusError.message` é texto para humanos.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Dict, Optional
13
+
14
+ __all__ = [
15
+ "BfocusError",
16
+ "AuthenticationError",
17
+ "PermissionDeniedError",
18
+ "NotFoundError",
19
+ "ConflictError",
20
+ "ValidationError",
21
+ "RateLimitError",
22
+ "ServerError",
23
+ "NetworkError",
24
+ "error_for_status",
25
+ ]
26
+
27
+
28
+ class BfocusError(Exception):
29
+ """Erro devolvido pela API do bFocus (ou de rede, em :class:`NetworkError`).
30
+
31
+ Attributes:
32
+ code: Código estável do erro (``body.error``; senão ``body.message``; senão
33
+ ``HTTP_<status>`` quando o corpo não é JSON; ``INVALID_RESPONSE`` quando um 2xx
34
+ chega sem o envelope JSON da API). **É nele que a sua lógica decide.**
35
+ message: Texto legível: o ``code`` e o contexto (status, escopo exigido, motivos).
36
+ status: Status HTTP (``0`` em erro de rede).
37
+ request_id: ``request_id`` do corpo, senão o header ``X-Request-Id``, senão o
38
+ ``X-Request-Id`` que a SDK enviou (a API ecoa o do cliente) — sempre preenchido;
39
+ informe-o ao suporte.
40
+ validation: Mapa campo → motivo (erros de validação); ``{}`` quando não há.
41
+ retry_after: Segundos sugeridos pelo header ``Retry-After`` (só em 429).
42
+ required_scope: Escopo que faltou na chave (header ``X-Required-Scope``, só em 403).
43
+ body: Corpo da resposta já decodificado (dict), ou o texto cru quando não é JSON.
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ code: str,
49
+ message: str,
50
+ status: int,
51
+ *,
52
+ request_id: Optional[str] = None,
53
+ validation: Optional[Dict[str, Any]] = None,
54
+ retry_after: Optional[float] = None,
55
+ required_scope: Optional[str] = None,
56
+ body: Any = None,
57
+ ) -> None:
58
+ super().__init__(message)
59
+ self.code = code
60
+ self.message = message
61
+ self.status = status
62
+ self.request_id = request_id
63
+ self.validation: Dict[str, Any] = dict(validation or {})
64
+ self.retry_after = retry_after
65
+ self.required_scope = required_scope
66
+ self.body = body
67
+
68
+ def __str__(self) -> str:
69
+ if self.request_id:
70
+ return f"{self.message} [request_id={self.request_id}]"
71
+ return self.message
72
+
73
+ def __repr__(self) -> str:
74
+ return (
75
+ f"{type(self).__name__}(code={self.code!r}, status={self.status!r}, "
76
+ f"request_id={self.request_id!r})"
77
+ )
78
+
79
+
80
+ class AuthenticationError(BfocusError):
81
+ """401 — chave ausente, inválida ou revogada."""
82
+
83
+
84
+ class PermissionDeniedError(BfocusError):
85
+ """403 — chave desligada, IP não liberado ou escopo faltando (veja ``required_scope``)."""
86
+
87
+
88
+ class NotFoundError(BfocusError):
89
+ """404 — o recurso (ou a conta) não existe."""
90
+
91
+
92
+ class ConflictError(BfocusError):
93
+ """409 — o estado atual impede a operação (ex.: ``KB_ARTICLE_EMPTY``, ``AI_DISABLED``)."""
94
+
95
+
96
+ class ValidationError(BfocusError):
97
+ """422 — corpo ou parâmetro inválido; os motivos por campo estão em ``validation``."""
98
+
99
+
100
+ class RateLimitError(BfocusError):
101
+ """429 — limite de requisições da chave; ``retry_after`` diz quanto esperar."""
102
+
103
+
104
+ class ServerError(BfocusError):
105
+ """5xx — erro do lado da API. Informe o ``request_id``."""
106
+
107
+
108
+ class NetworkError(BfocusError):
109
+ """Falha de conexão ou timeout (``status == 0``, ``code == "NETWORK_ERROR"``)."""
110
+
111
+ def __init__(self, message: str, *, request_id: Optional[str] = None) -> None:
112
+ super().__init__("NETWORK_ERROR", message, 0, request_id=request_id)
113
+
114
+
115
+ _BY_STATUS = {
116
+ 401: AuthenticationError,
117
+ 403: PermissionDeniedError,
118
+ 404: NotFoundError,
119
+ 409: ConflictError,
120
+ 422: ValidationError,
121
+ 429: RateLimitError,
122
+ }
123
+
124
+
125
+ def error_for_status(status: int) -> type:
126
+ """Classe de erro para um status HTTP (5xx → :class:`ServerError`, resto → base)."""
127
+ if status in _BY_STATUS:
128
+ return _BY_STATUS[status]
129
+ if 500 <= status <= 599:
130
+ return ServerError
131
+ return BfocusError
bfocus/py.typed ADDED
File without changes