backstage-agents 3.3.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.
- backstage/agents/__init__.py +38 -0
- backstage/agents/_client.py +353 -0
- backstage/agents/_config.py +125 -0
- backstage/agents/_consumer.py +360 -0
- backstage/agents/_context.py +55 -0
- backstage/agents/_delivery.py +329 -0
- backstage/agents/_endpoints.py +62 -0
- backstage/agents/_envelope.py +408 -0
- backstage/agents/_lifecycle.py +107 -0
- backstage/agents/_refs.py +50 -0
- backstage/agents/_sink.py +69 -0
- backstage/agents/_transport.py +212 -0
- backstage/agents/_types.py +107 -0
- backstage/agents/_version.py +3 -0
- backstage/agents/integrations/__init__.py +1 -0
- backstage/agents/integrations/agno/__init__.py +17 -0
- backstage/agents/integrations/agno/__main__.py +66 -0
- backstage/agents/integrations/agno/_bridge.py +449 -0
- backstage/agents/integrations/agno/_derive.py +407 -0
- backstage/agents/integrations/agno/_engine.py +109 -0
- backstage/agents/integrations/agno/_heartbeat.py +90 -0
- backstage/agents/integrations/agno/_hook.py +397 -0
- backstage/agents/integrations/agno/_instrument.py +119 -0
- backstage/agents/integrations/agno/_outbox.py +507 -0
- backstage/agents/integrations/agno/_ports.py +70 -0
- backstage/agents/integrations/agno/_sender.py +107 -0
- backstage/agents/integrations/agno/_spool.py +107 -0
- backstage/agents/integrations/agno/_sweep.py +86 -0
- backstage/agents/integrations/agno/_tools.py +297 -0
- backstage/agents/integrations/agno/_worker.py +483 -0
- backstage/agents/py.typed +0 -0
- backstage/agents/relay/__init__.py +255 -0
- backstage/agents/relay/__main__.py +54 -0
- backstage_agents-3.3.0.dist-info/METADATA +37 -0
- backstage_agents-3.3.0.dist-info/RECORD +36 -0
- backstage_agents-3.3.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Khal Backstage — o SDK que o agente usa para escrever na Khal.
|
|
2
|
+
|
|
3
|
+
from backstage.agents import Backstage
|
|
4
|
+
|
|
5
|
+
backstage = Backstage(instance="https://agents-api.suaempresa.khal.app")
|
|
6
|
+
|
|
7
|
+
with backstage.session("conv-abc", contact="cus_987"):
|
|
8
|
+
backstage.identify("cus_987", identity={"name": "Maria Souza"},
|
|
9
|
+
analytics={"cidade": "Recife"})
|
|
10
|
+
backstage.track("proposta.aceita", analytics={"valor": 850})
|
|
11
|
+
backstage.set_status("em-negociacao")
|
|
12
|
+
|
|
13
|
+
As escritas voltam na hora: a rede é de uma thread de fundo, em lote, e nunca levanta.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from ._client import Backstage
|
|
17
|
+
from ._types import (
|
|
18
|
+
BackstageConfigurationError,
|
|
19
|
+
BackstageError,
|
|
20
|
+
BackstageInvalidEvent,
|
|
21
|
+
BackstageOrphanEvent,
|
|
22
|
+
Identity,
|
|
23
|
+
OutboxStats,
|
|
24
|
+
Stats,
|
|
25
|
+
)
|
|
26
|
+
from ._version import __version__
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"Identity",
|
|
30
|
+
"Backstage",
|
|
31
|
+
"BackstageConfigurationError",
|
|
32
|
+
"BackstageError",
|
|
33
|
+
"BackstageInvalidEvent",
|
|
34
|
+
"BackstageOrphanEvent",
|
|
35
|
+
"OutboxStats",
|
|
36
|
+
"Stats",
|
|
37
|
+
"__version__",
|
|
38
|
+
]
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""A classe `Backstage`, a única superfície pública que escreve eventos."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import Any, Callable, List, Literal, Mapping, Optional, Tuple
|
|
10
|
+
|
|
11
|
+
from . import _envelope, _lifecycle
|
|
12
|
+
from ._config import Settings, env_flag, new_transport, resolve_settings
|
|
13
|
+
from ._consumer import Consumer
|
|
14
|
+
from ._context import SessionScope, current_contact, current_session
|
|
15
|
+
from ._sink import NullSink, Sink
|
|
16
|
+
from ._types import (
|
|
17
|
+
BackstageConfigurationError,
|
|
18
|
+
BackstageError,
|
|
19
|
+
BackstageInvalidEvent,
|
|
20
|
+
BackstageOrphanEvent,
|
|
21
|
+
ErrorHook,
|
|
22
|
+
Identity,
|
|
23
|
+
Stats,
|
|
24
|
+
notify_error,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Backstage:
|
|
31
|
+
"""O cliente. ⚠️ Um por processo, no escopo do módulo: um por requisição é uma fila e uma thread cada."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
*,
|
|
36
|
+
instance: Optional[str] = None,
|
|
37
|
+
key_id: Optional[str] = None,
|
|
38
|
+
secret: Optional[str] = None,
|
|
39
|
+
enabled: Optional[bool] = None,
|
|
40
|
+
strict: bool = False,
|
|
41
|
+
on_error: Optional[ErrorHook] = None,
|
|
42
|
+
max_queue_size: int = 10_000,
|
|
43
|
+
batch_size: int = 100,
|
|
44
|
+
flush_interval: float = 2.0,
|
|
45
|
+
timeout: float = 10.0,
|
|
46
|
+
) -> None:
|
|
47
|
+
self._strict = strict
|
|
48
|
+
self._on_error = on_error
|
|
49
|
+
self._orphans = 0
|
|
50
|
+
# Eventos que nem viraram envelope; entram em `stats().failed`.
|
|
51
|
+
self._invalid = 0
|
|
52
|
+
self._counters_lock = threading.Lock()
|
|
53
|
+
self._closed = False
|
|
54
|
+
# UM destino. A fila HTTP nasce na primeira escrita: com o outbox ligado antes dela, nunca nasce.
|
|
55
|
+
self._sink: Sink = NullSink()
|
|
56
|
+
self._sink_lock = threading.Lock()
|
|
57
|
+
# O que o finalizador enxerga: o sink atual, nunca `self`.
|
|
58
|
+
self._sink_box: List[Sink] = []
|
|
59
|
+
|
|
60
|
+
resolution = resolve_settings(
|
|
61
|
+
instance=instance,
|
|
62
|
+
key_id=key_id,
|
|
63
|
+
secret=secret,
|
|
64
|
+
enabled=enabled,
|
|
65
|
+
strict=strict,
|
|
66
|
+
on_error=on_error,
|
|
67
|
+
max_queue_size=max_queue_size,
|
|
68
|
+
batch_size=batch_size,
|
|
69
|
+
flush_interval=flush_interval,
|
|
70
|
+
timeout=timeout,
|
|
71
|
+
)
|
|
72
|
+
for warning in resolution.warnings:
|
|
73
|
+
logger.warning(warning)
|
|
74
|
+
if resolution.problem is not None:
|
|
75
|
+
if strict:
|
|
76
|
+
raise BackstageConfigurationError(resolution.problem)
|
|
77
|
+
logger.log(resolution.level, resolution.problem)
|
|
78
|
+
self._settings: Optional[Settings] = resolution.settings
|
|
79
|
+
if self._settings is None:
|
|
80
|
+
# Desligado é desligado: nenhuma thread, nenhuma fila, nenhum gancho.
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
logger.info("backstage: enviando para %s", self._settings.base_url)
|
|
84
|
+
# Sem isto a thread de um `Backstage()` coletado sem `close()` gira para sempre.
|
|
85
|
+
self._finalizer = _lifecycle.finalize_sink(self, self._sink_box)
|
|
86
|
+
_lifecycle.register(self)
|
|
87
|
+
|
|
88
|
+
# -- escrita -------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
def track(
|
|
91
|
+
self,
|
|
92
|
+
name: str,
|
|
93
|
+
properties: Optional[Mapping[str, Any]] = None,
|
|
94
|
+
*,
|
|
95
|
+
analytics: Optional[Mapping[str, Any]] = None,
|
|
96
|
+
display: Optional[Mapping[str, Any]] = None,
|
|
97
|
+
session: Optional[str] = None,
|
|
98
|
+
user: Optional[str] = None,
|
|
99
|
+
occurred_at: Optional[datetime] = None,
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Registra que algo aconteceu. Volta na hora; nunca levanta.
|
|
102
|
+
|
|
103
|
+
`analytics`: candidato a dimensão de análise; `display`: texto para ler, nunca analisado;
|
|
104
|
+
`properties`: lido como `analytics` (que vence na chave repetida). Sem sessão nem `user=`,
|
|
105
|
+
o evento é órfão, e isso é válido.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
if not self._writable():
|
|
109
|
+
return
|
|
110
|
+
session_ref, contact_ref = _scope_refs(session, user)
|
|
111
|
+
if session_ref is None and contact_ref is None:
|
|
112
|
+
if self._strict:
|
|
113
|
+
raise BackstageOrphanEvent(
|
|
114
|
+
f"backstage: track({name!r}) sem session e sem user. "
|
|
115
|
+
"Abra um `with backstage.session(...)` ou passe session=/user=."
|
|
116
|
+
)
|
|
117
|
+
# Sem log: um cron noturno é órfão por natureza.
|
|
118
|
+
with self._counters_lock:
|
|
119
|
+
self._orphans += 1
|
|
120
|
+
self._dispatch(
|
|
121
|
+
f"track({name!r})",
|
|
122
|
+
lambda: _envelope.track(
|
|
123
|
+
name=name,
|
|
124
|
+
session_ref=session_ref,
|
|
125
|
+
contact_ref=contact_ref,
|
|
126
|
+
occurred_at=occurred_at,
|
|
127
|
+
properties=properties,
|
|
128
|
+
analytics=analytics,
|
|
129
|
+
display=display,
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def identify(
|
|
134
|
+
self,
|
|
135
|
+
user: str,
|
|
136
|
+
traits: Optional[Mapping[str, Any]] = None,
|
|
137
|
+
*,
|
|
138
|
+
identity: Optional[Identity] = None,
|
|
139
|
+
display: Optional[Mapping[str, Any]] = None,
|
|
140
|
+
analytics: Optional[Mapping[str, Any]] = None,
|
|
141
|
+
session: Optional[str] = None,
|
|
142
|
+
occurred_at: Optional[datetime] = None,
|
|
143
|
+
) -> None:
|
|
144
|
+
"""Diz quem é a pessoa por trás do `contact_ref` (`user`). Volta na hora.
|
|
145
|
+
|
|
146
|
+
`identity`: a lista fechada que resolve a pessoa, nunca analisada; `display`: para ler;
|
|
147
|
+
`analytics`: candidato a dimensão; `traits`: o saco sem declaração.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
if not self._writable():
|
|
151
|
+
return
|
|
152
|
+
session_ref, _ = _scope_refs(session, None)
|
|
153
|
+
# ⚠️ O rótulo não leva o `user`: é telefone ou e-mail, e iria para o log (LGPD).
|
|
154
|
+
self._dispatch(
|
|
155
|
+
"identify()",
|
|
156
|
+
lambda: _envelope.identify(
|
|
157
|
+
contact_ref=user,
|
|
158
|
+
session_ref=session_ref,
|
|
159
|
+
occurred_at=occurred_at,
|
|
160
|
+
traits=traits,
|
|
161
|
+
identity=identity,
|
|
162
|
+
analytics=analytics,
|
|
163
|
+
display=display,
|
|
164
|
+
),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def set_status(
|
|
168
|
+
self,
|
|
169
|
+
status: str,
|
|
170
|
+
*,
|
|
171
|
+
session: Optional[str] = None,
|
|
172
|
+
note: Optional[str] = None,
|
|
173
|
+
occurred_at: Optional[datetime] = None,
|
|
174
|
+
) -> None:
|
|
175
|
+
"""Move a conversa para um status do catálogo (o slug ou o uuid). Volta na hora.
|
|
176
|
+
|
|
177
|
+
⚠️ Exige a conversa (`session()` ou `session=`): sem ela, avisa sempre e conta em `failed`.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
if not self._writable():
|
|
181
|
+
return
|
|
182
|
+
session_ref, _ = _scope_refs(session, None)
|
|
183
|
+
self._dispatch(
|
|
184
|
+
f"set_status({status!r})",
|
|
185
|
+
lambda: _envelope.status(
|
|
186
|
+
status_ref=status, session_ref=session_ref, note=note, occurred_at=occurred_at
|
|
187
|
+
),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
def session(self, session_id: str, *, contact: Optional[str] = None) -> SessionScope:
|
|
191
|
+
"""Abre o escopo de uma conversa. Aninhar funciona; o argumento explícito vence o escopo."""
|
|
192
|
+
|
|
193
|
+
return SessionScope(session_id, contact)
|
|
194
|
+
|
|
195
|
+
# -- controle ------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
def flush(self, timeout: float = 5.0) -> bool:
|
|
198
|
+
"""Espera o que já foi aceito ter veredito; com o outbox, até a memória entrar no banco e
|
|
199
|
+
`pending == 0`. `False` se estourou o prazo. Não é para cada turno: desfaz o lote."""
|
|
200
|
+
|
|
201
|
+
return self._sink.flush(timeout)
|
|
202
|
+
|
|
203
|
+
def close(self, timeout: float = 5.0) -> None:
|
|
204
|
+
"""Drena com teto e para a thread. Idempotente; depois disto, no-op.
|
|
205
|
+
|
|
206
|
+
O prazo é UM para tudo: cada etapa recebe o que sobrou, não o `timeout` inteiro.
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
deadline = time.monotonic() + max(0.0, timeout)
|
|
210
|
+
# Sob o lock do `_ensure_sink`: um write que já passou do `_writable()` não cria fila depois daqui.
|
|
211
|
+
with self._sink_lock:
|
|
212
|
+
self._closed = True
|
|
213
|
+
sink = self._sink
|
|
214
|
+
sink.close(max(0.0, deadline - time.monotonic()))
|
|
215
|
+
|
|
216
|
+
def stats(self) -> Stats:
|
|
217
|
+
current = self._sink
|
|
218
|
+
sink = current.stats()
|
|
219
|
+
with self._counters_lock:
|
|
220
|
+
orphans, invalid = self._orphans, self._invalid
|
|
221
|
+
# O que não virou envelope entra em `failed`; `dropped` é só fila cheia.
|
|
222
|
+
return Stats(
|
|
223
|
+
queued=sink.queued,
|
|
224
|
+
sent=sink.sent,
|
|
225
|
+
dropped=sink.dropped,
|
|
226
|
+
failed=sink.failed + invalid,
|
|
227
|
+
orphans=orphans,
|
|
228
|
+
outbox=current.outbox_stats(),
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
@property
|
|
232
|
+
def enabled(self) -> bool:
|
|
233
|
+
return self._settings is not None
|
|
234
|
+
|
|
235
|
+
def __enter__(self) -> Backstage:
|
|
236
|
+
return self
|
|
237
|
+
|
|
238
|
+
def __exit__(self, *exc_info: object) -> Literal[False]:
|
|
239
|
+
self.close()
|
|
240
|
+
return False
|
|
241
|
+
|
|
242
|
+
# -- para as integrações -------------------------------------------------
|
|
243
|
+
|
|
244
|
+
@property
|
|
245
|
+
def _error_hook(self) -> Optional[ErrorHook]:
|
|
246
|
+
"""O `on_error` do cliente, para quem precisa avisar pelo mesmo gancho (o tracker)."""
|
|
247
|
+
|
|
248
|
+
return self._on_error
|
|
249
|
+
|
|
250
|
+
def _attach_sink(self, sink: Sink, retire_timeout: float = 2.0) -> bool:
|
|
251
|
+
"""Troca o destino (o outbox do Agno). A fila HTTP que já existia drena para a porta e fecha.
|
|
252
|
+
|
|
253
|
+
`False` com o cliente fechado: nada é trocado.
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
with self._sink_lock:
|
|
257
|
+
if self._closed:
|
|
258
|
+
return False
|
|
259
|
+
old = self._sink
|
|
260
|
+
self._set_sink(sink)
|
|
261
|
+
old.close(retire_timeout)
|
|
262
|
+
return True
|
|
263
|
+
|
|
264
|
+
# -- interno -------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
def _writable(self) -> bool:
|
|
267
|
+
# Lido a cada evento: o kill switch vale sem reiniciar o processo.
|
|
268
|
+
if self._settings is None or env_flag("BACKSTAGE_DISABLED"):
|
|
269
|
+
return False
|
|
270
|
+
if self._closed:
|
|
271
|
+
logger.warning("backstage: cliente ja fechado, evento ignorado")
|
|
272
|
+
return False
|
|
273
|
+
return True
|
|
274
|
+
|
|
275
|
+
def _dispatch(self, label: str, build: Callable[[], dict[str, Any]]) -> None:
|
|
276
|
+
"""Monta, serializa e enfileira. Fora de `strict`, não levanta por nada.
|
|
277
|
+
|
|
278
|
+
`label` nomeia o evento no warning e nunca leva dado pessoal.
|
|
279
|
+
"""
|
|
280
|
+
|
|
281
|
+
try:
|
|
282
|
+
# Montar e serializar no MESMO try: `dict(properties)` com o argumento errado
|
|
283
|
+
# também falha aqui, e não na cara de quem chamou.
|
|
284
|
+
envelope = build()
|
|
285
|
+
payload = _envelope.serialize(envelope, label)
|
|
286
|
+
except Exception as exc: # noqa: BLE001 — nunca `BaseException`: o Ctrl-C passa
|
|
287
|
+
self._reject(label, exc)
|
|
288
|
+
return
|
|
289
|
+
sink = self._ensure_sink()
|
|
290
|
+
# Um sink fechado no meio da troca (`_attach_sink`) recusa; o novo recebe.
|
|
291
|
+
if not sink.submit(payload, envelope) and self._sink is not sink:
|
|
292
|
+
self._sink.submit(payload, envelope)
|
|
293
|
+
|
|
294
|
+
def _reject(self, label: str, exc: Exception) -> None:
|
|
295
|
+
"""O único lugar que descarta um evento antes da fila."""
|
|
296
|
+
|
|
297
|
+
if self._strict:
|
|
298
|
+
if isinstance(exc, BackstageError):
|
|
299
|
+
raise exc
|
|
300
|
+
raise BackstageInvalidEvent(f"backstage: {label} nao virou envelope") from exc
|
|
301
|
+
logger.warning("backstage: %s nao virou envelope e foi ignorado: %s", label, exc)
|
|
302
|
+
with self._counters_lock:
|
|
303
|
+
self._invalid += 1
|
|
304
|
+
notify_error(self._on_error, exc)
|
|
305
|
+
|
|
306
|
+
def _ensure_sink(self) -> Sink:
|
|
307
|
+
sink = self._sink
|
|
308
|
+
if not isinstance(sink, NullSink) or self._settings is None:
|
|
309
|
+
return sink
|
|
310
|
+
with self._sink_lock:
|
|
311
|
+
if isinstance(self._sink, NullSink) and not self._closed:
|
|
312
|
+
settings = self._settings
|
|
313
|
+
self._set_sink(
|
|
314
|
+
Consumer(
|
|
315
|
+
new_transport(settings),
|
|
316
|
+
max_queue_size=settings.max_queue_size,
|
|
317
|
+
batch_size=settings.batch_size,
|
|
318
|
+
flush_interval=settings.flush_interval,
|
|
319
|
+
on_error=settings.on_error,
|
|
320
|
+
)
|
|
321
|
+
)
|
|
322
|
+
return self._sink
|
|
323
|
+
|
|
324
|
+
def _set_sink(self, sink: Sink) -> None:
|
|
325
|
+
self._sink = sink
|
|
326
|
+
# A caixa aponta para o sink ATUAL, senão o finalizador pararia o velho depois do fork.
|
|
327
|
+
self._sink_box[:] = [sink]
|
|
328
|
+
|
|
329
|
+
# -- _lifecycle.Component ------------------------------------------------
|
|
330
|
+
|
|
331
|
+
def _wake(self) -> None:
|
|
332
|
+
self._sink.wake()
|
|
333
|
+
|
|
334
|
+
def _drain(self, deadline: float) -> int:
|
|
335
|
+
if self._sink.flush(max(0.0, deadline - time.monotonic())):
|
|
336
|
+
return 0
|
|
337
|
+
return self.stats().queued
|
|
338
|
+
|
|
339
|
+
def _after_fork(self) -> None:
|
|
340
|
+
# O fork não copia threads, e a fila herdada pode ter um lock travado.
|
|
341
|
+
self._sink_lock = threading.Lock()
|
|
342
|
+
self._set_sink(self._sink.after_fork())
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _scope_refs(session: Optional[str], user: Optional[str]) -> Tuple[Optional[str], Optional[str]]:
|
|
346
|
+
"""O argumento explícito vence o escopo; `""` é descuido, não referência."""
|
|
347
|
+
|
|
348
|
+
session_ref = session if session is not None else current_session()
|
|
349
|
+
contact_ref = user if user is not None else current_contact()
|
|
350
|
+
return (session_ref if session_ref != "" else None, contact_ref if contact_ref != "" else None)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
__all__ = ["Backstage"]
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""A configuração do cliente, resolvida UMA vez: argumentos, ambiente e o que desliga."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Mapping, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
from ._endpoints import normalize_instance
|
|
11
|
+
from ._transport import Transport
|
|
12
|
+
from ._types import ErrorHook
|
|
13
|
+
|
|
14
|
+
_TRUTHY = frozenset({"1", "true", "yes", "on"})
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Settings:
|
|
19
|
+
"""O cliente ligado. Só existe com instância e credenciais válidas."""
|
|
20
|
+
|
|
21
|
+
base_url: str
|
|
22
|
+
key_id: str
|
|
23
|
+
secret: str
|
|
24
|
+
timeout: float
|
|
25
|
+
strict: bool
|
|
26
|
+
on_error: Optional[ErrorHook]
|
|
27
|
+
max_queue_size: int
|
|
28
|
+
batch_size: int
|
|
29
|
+
flush_interval: float
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Resolution:
|
|
34
|
+
"""`settings` é `None` quando o cliente fica em no-op; `problem` diz por quê (com o nível do log)."""
|
|
35
|
+
|
|
36
|
+
settings: Optional[Settings]
|
|
37
|
+
problem: Optional[str] = None
|
|
38
|
+
level: int = logging.WARNING
|
|
39
|
+
warnings: Tuple[str, ...] = ()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def env_flag(name: str, environ: Optional[Mapping[str, str]] = None) -> bool:
|
|
43
|
+
value = (os.environ if environ is None else environ).get(name)
|
|
44
|
+
return value is not None and value.strip().lower() in _TRUTHY
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def resolve_settings(
|
|
48
|
+
*,
|
|
49
|
+
instance: Optional[str],
|
|
50
|
+
key_id: Optional[str],
|
|
51
|
+
secret: Optional[str],
|
|
52
|
+
enabled: Optional[bool],
|
|
53
|
+
strict: bool,
|
|
54
|
+
on_error: Optional[ErrorHook],
|
|
55
|
+
max_queue_size: int,
|
|
56
|
+
batch_size: int,
|
|
57
|
+
flush_interval: float,
|
|
58
|
+
timeout: float,
|
|
59
|
+
environ: Optional[Mapping[str, str]] = None,
|
|
60
|
+
) -> Resolution:
|
|
61
|
+
"""Pura: não loga nem levanta. Quem chama decide entre `logger` e `strict`."""
|
|
62
|
+
|
|
63
|
+
env = os.environ if environ is None else environ
|
|
64
|
+
if enabled is None:
|
|
65
|
+
enabled = not env_flag("BACKSTAGE_DISABLED", env)
|
|
66
|
+
if not enabled:
|
|
67
|
+
return Resolution(None)
|
|
68
|
+
|
|
69
|
+
warnings = []
|
|
70
|
+
credentials = []
|
|
71
|
+
for name, value in (("BACKSTAGE_KEY_ID", key_id), ("BACKSTAGE_SECRET", secret)):
|
|
72
|
+
value = value if value is not None else env.get(name)
|
|
73
|
+
# Um `\n` do `echo` ou do arquivo de segredo vira 401 eterno; o valor nunca vai ao log.
|
|
74
|
+
if value is not None and value != value.strip():
|
|
75
|
+
value = value.strip()
|
|
76
|
+
warnings.append(f"backstage: {name} tinha espaco ou quebra de linha nas pontas — removidos")
|
|
77
|
+
credentials.append(value or None)
|
|
78
|
+
|
|
79
|
+
if instance is None:
|
|
80
|
+
instance = env.get("BACKSTAGE_INSTANCE") or None
|
|
81
|
+
if instance is None:
|
|
82
|
+
return Resolution(
|
|
83
|
+
None,
|
|
84
|
+
"backstage: BACKSTAGE_INSTANCE ausente — diga a raiz da porta da sua instancia "
|
|
85
|
+
"(Backstage(instance=\"https://agents-api.suaempresa.khal.app\")). O cliente fica em no-op.",
|
|
86
|
+
logging.ERROR,
|
|
87
|
+
tuple(warnings),
|
|
88
|
+
)
|
|
89
|
+
url, reason = normalize_instance(instance)
|
|
90
|
+
if url is None:
|
|
91
|
+
return Resolution(None, f"backstage: {reason}. O cliente fica em no-op.", logging.ERROR, tuple(warnings))
|
|
92
|
+
|
|
93
|
+
key, sec = credentials
|
|
94
|
+
if not (key and sec):
|
|
95
|
+
return Resolution(
|
|
96
|
+
None,
|
|
97
|
+
"backstage: BACKSTAGE_KEY_ID e/ou BACKSTAGE_SECRET ausentes — o cliente fica em "
|
|
98
|
+
"no-op. Nenhum evento sera enviado.",
|
|
99
|
+
logging.WARNING,
|
|
100
|
+
tuple(warnings),
|
|
101
|
+
)
|
|
102
|
+
settings = Settings(
|
|
103
|
+
base_url=url,
|
|
104
|
+
key_id=key,
|
|
105
|
+
secret=sec,
|
|
106
|
+
timeout=timeout,
|
|
107
|
+
strict=strict,
|
|
108
|
+
on_error=on_error,
|
|
109
|
+
max_queue_size=max_queue_size,
|
|
110
|
+
batch_size=batch_size,
|
|
111
|
+
flush_interval=flush_interval,
|
|
112
|
+
)
|
|
113
|
+
return Resolution(settings, warnings=tuple(warnings))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def new_transport(settings: Settings, timeout: Optional[float] = None) -> Transport:
|
|
117
|
+
return Transport(
|
|
118
|
+
base_url=settings.base_url,
|
|
119
|
+
key_id=settings.key_id,
|
|
120
|
+
secret=settings.secret,
|
|
121
|
+
timeout=settings.timeout if timeout is None else timeout,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
__all__ = ["Resolution", "Settings", "env_flag", "new_transport", "resolve_settings"]
|