hobots 1.0.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.
hobots/__about__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Única fonte da versão do pacote — lida pelo hatchling e por `_utils.SDK_VERSION`."""
2
+
3
+ __version__ = "1.0.0"
hobots/__init__.py ADDED
@@ -0,0 +1,159 @@
1
+ """SDK Python da Hobots — telemetria e execução remota de tarefas (tasks).
2
+
3
+ Duas capabilities, um único `init()`:
4
+
5
+ - **Telemetria** — erros, transactions de performance e heartbeat. Sempre ativa.
6
+ Alimenta os módulos **Issues** e **Agentes** do app.
7
+ - **Tasks** — execução sob demanda (polling, logs, cancelamento, anexos). Liga com
8
+ `tasks=True`. Alimenta o módulo **Solicitações**.
9
+
10
+ import hobots
11
+
12
+ hobots.init(client_secret="hb_<64 hex>", instance_id="vm-01", tasks=True)
13
+
14
+ @hobots.task("processar-nfe")
15
+ def processar(params, ctx):
16
+ ctx.log.info(f"processando lote {params['lote']}…")
17
+
18
+ hobots.run_forever()
19
+
20
+ Todas as durações da API são em **milissegundos** (`ctx.sleep(2000)` são 2 segundos),
21
+ igual ao SDK Node. Documentação completa em `docs/`.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from .__about__ import __version__
27
+ from ._client import Client
28
+ from ._config import DEFAULT_BASE_URL, ResolvedConfig
29
+ from ._core import (
30
+ add_breadcrumb,
31
+ capture_exception,
32
+ capture_message,
33
+ close,
34
+ flush,
35
+ get_client,
36
+ init,
37
+ register,
38
+ run_forever,
39
+ set_context,
40
+ set_extra,
41
+ set_tag,
42
+ set_tags,
43
+ set_user,
44
+ start,
45
+ start_transaction,
46
+ stop,
47
+ task,
48
+ with_scope,
49
+ )
50
+ from ._errors import HobotsError, RunCanceled, TasksApiError, TaskTimeout, TransportError
51
+ from ._scope import Scope
52
+ from ._scrub import scrub_secrets
53
+ from ._transaction import Step, Transaction
54
+ from ._types import (
55
+ BeforeSend,
56
+ BreadcrumbInput,
57
+ CaptureHint,
58
+ HeartbeatOptions,
59
+ InitOptions,
60
+ TasksOptions,
61
+ )
62
+ from .protocol import (
63
+ Breadcrumb,
64
+ Envelope,
65
+ EnvelopeItem,
66
+ EventPayload,
67
+ EventUser,
68
+ ExceptionData,
69
+ HeartbeatPayload,
70
+ Severity,
71
+ StackFrame,
72
+ StepData,
73
+ StepStatus,
74
+ TransactionPayload,
75
+ )
76
+ from .tasks._attachments import AttachResult
77
+ from .tasks._context import RunContext, RunLogger
78
+ from .tasks._types import TaskHandler
79
+ from .tasks.protocol import (
80
+ AgentInfo,
81
+ PollRequest,
82
+ PollResponse,
83
+ RunAttachmentContentType,
84
+ RunDescriptor,
85
+ RunError,
86
+ RunLogEntry,
87
+ RunLogLevel,
88
+ RunStatus,
89
+ RunUpdateRequest,
90
+ RunUpdateStatus,
91
+ )
92
+
93
+ __all__ = [
94
+ "DEFAULT_BASE_URL",
95
+ "AgentInfo",
96
+ "AttachResult",
97
+ "BeforeSend",
98
+ "Breadcrumb",
99
+ "BreadcrumbInput",
100
+ "CaptureHint",
101
+ "Client",
102
+ "Envelope",
103
+ "EnvelopeItem",
104
+ "EventPayload",
105
+ "EventUser",
106
+ "ExceptionData",
107
+ "HeartbeatOptions",
108
+ "HeartbeatPayload",
109
+ "HobotsError",
110
+ "InitOptions",
111
+ "PollRequest",
112
+ "PollResponse",
113
+ "ResolvedConfig",
114
+ "RunAttachmentContentType",
115
+ "RunCanceled",
116
+ "RunContext",
117
+ "RunDescriptor",
118
+ "RunError",
119
+ "RunLogEntry",
120
+ "RunLogLevel",
121
+ "RunLogger",
122
+ "RunStatus",
123
+ "RunUpdateRequest",
124
+ "RunUpdateStatus",
125
+ "Scope",
126
+ "Severity",
127
+ "StackFrame",
128
+ "Step",
129
+ "StepData",
130
+ "StepStatus",
131
+ "TaskHandler",
132
+ "TaskTimeout",
133
+ "TasksApiError",
134
+ "TasksOptions",
135
+ "Transaction",
136
+ "TransactionPayload",
137
+ "TransportError",
138
+ "__version__",
139
+ "add_breadcrumb",
140
+ "capture_exception",
141
+ "capture_message",
142
+ "close",
143
+ "flush",
144
+ "get_client",
145
+ "init",
146
+ "register",
147
+ "run_forever",
148
+ "scrub_secrets",
149
+ "set_context",
150
+ "set_extra",
151
+ "set_tag",
152
+ "set_tags",
153
+ "set_user",
154
+ "start",
155
+ "start_transaction",
156
+ "stop",
157
+ "task",
158
+ "with_scope",
159
+ ]
hobots/_client.py ADDED
@@ -0,0 +1,206 @@
1
+ """O `Client`: prepara eventos (merge de scope/hint) e é dono do transport."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import platform
6
+ import random
7
+ import sys
8
+ from typing import Any, Dict, List, Optional
9
+
10
+ from ._config import ResolvedConfig, resolve_config
11
+ from ._eventbuilder import event_from_exception, event_from_message
12
+ from ._scope import Scope
13
+ from ._transport import Transport
14
+ from ._types import CaptureHint, InitOptions
15
+ from ._utils import Logger, truncate
16
+ from .protocol import EventPayload, HeartbeatPayload, Severity, TransactionPayload
17
+
18
+ # Limites do servidor (zod). Um único campo acima do limite devolve 422 para o
19
+ # envelope INTEIRO — que pode carregar até 50 itens. Vale truncar no cliente.
20
+ MAX_TAG_VALUE_CHARS = 512
21
+ MAX_BREADCRUMB_MESSAGE_CHARS = 4_096
22
+ MAX_FINGERPRINT_ITEMS = 10
23
+ MAX_FINGERPRINT_ITEM_CHARS = 255
24
+ MAX_TRANSACTION_NAME_CHARS = 500
25
+ MAX_STEPS = 200
26
+
27
+
28
+ def _runtime_contexts() -> Dict[str, Dict[str, Any]]:
29
+ return {
30
+ "runtime": {
31
+ "name": "python",
32
+ "version": platform.python_version(),
33
+ "implementation": platform.python_implementation(),
34
+ },
35
+ "os": {"name": sys.platform, "arch": platform.machine()},
36
+ }
37
+
38
+
39
+ def _clip_tags(tags: Dict[str, str]) -> Dict[str, str]:
40
+ return {str(k): truncate(str(v), MAX_TAG_VALUE_CHARS) for k, v in tags.items()}
41
+
42
+
43
+ class Client:
44
+ """Cliente resolvido do SDK. Obtido com `hobots.get_client()`.
45
+
46
+ O Client Secret nunca aparece em `repr()`/serialização — só no acesso explícito
47
+ (`client.options.client_secret` / `client.config.key`).
48
+ """
49
+
50
+ def __init__(self, options: InitOptions, config: Optional[ResolvedConfig] = None) -> None:
51
+ self.__options = options
52
+ self.__config = config or resolve_config(options.client_secret)
53
+ self.logger = Logger(options.debug)
54
+ self._transport = Transport(
55
+ endpoint=self.__config.ingest_endpoint,
56
+ key=self.__config.key,
57
+ logger=self.logger,
58
+ flush_interval=options.flush_interval,
59
+ max_batch_size=options.max_batch_size,
60
+ )
61
+
62
+ @property
63
+ def options(self) -> InitOptions:
64
+ return self.__options
65
+
66
+ @property
67
+ def config(self) -> ResolvedConfig:
68
+ return self.__config
69
+
70
+ def __repr__(self) -> str:
71
+ return f"Client(base_url={self.__config.base_url!r})"
72
+
73
+ # ── Captura ───────────────────────────────────────────────────────────────
74
+
75
+ def capture_exception(self, error: Any, hint: Optional[CaptureHint], scope: Scope) -> str:
76
+ level: Severity = (hint or {}).get("level", "error")
77
+ return self.capture_event(event_from_exception(error, level), hint, scope)
78
+
79
+ def capture_message(self, message: str, level: Severity, hint: Optional[CaptureHint], scope: Scope) -> str:
80
+ return self.capture_event(event_from_message(message, level), hint, scope)
81
+
82
+ def capture_event(self, event: EventPayload, hint: Optional[CaptureHint], scope: Scope) -> str:
83
+ sample_rate = self.__options.sample_rate
84
+ if sample_rate < 1 and random.random() >= sample_rate:
85
+ self.logger.debug("evento descartado por sample_rate")
86
+ return event["event_id"]
87
+
88
+ prepared = self._prepare_event(event, hint, scope)
89
+
90
+ before_send = self.__options.before_send
91
+ if before_send is not None:
92
+ try:
93
+ final = before_send(prepared)
94
+ except Exception as error: # noqa: BLE001
95
+ # capture_exception costuma ser chamado dentro de um `except` do
96
+ # usuário: um before_send que levanta não pode derrubar o programa
97
+ self.logger.error("before_send levantou uma exceção, evento descartado:", error)
98
+ return event["event_id"]
99
+ if final is None:
100
+ self.logger.debug("evento descartado por before_send")
101
+ return event["event_id"]
102
+ else:
103
+ final = prepared
104
+
105
+ self._transport.enqueue({"type": "event", "payload": final})
106
+ return final["event_id"]
107
+
108
+ def _prepare_event(self, event: EventPayload, hint: Optional[CaptureHint], scope: Scope) -> EventPayload:
109
+ hint = hint or {}
110
+ options = self.__options
111
+
112
+ tags: Dict[str, str] = {}
113
+ tags.update(options.tags or {})
114
+ tags.update(scope.tags)
115
+ tags.update(event.get("tags") or {})
116
+ tags.update(hint.get("tags") or {})
117
+
118
+ extra: Dict[str, Any] = {}
119
+ extra.update(scope.extra)
120
+ extra.update(event.get("extra") or {})
121
+ extra.update(hint.get("extra") or {})
122
+
123
+ contexts: Dict[str, Dict[str, Any]] = _runtime_contexts()
124
+ contexts.update(scope.contexts)
125
+ contexts.update(event.get("contexts") or {})
126
+
127
+ prepared: EventPayload = dict(event) # type: ignore[assignment]
128
+ # `scope.level` não entra aqui de propósito: o eventbuilder já grava o level
129
+ # (error na exceção, o argumento na mensagem). Para sobrescrever, use o hint.
130
+ prepared["level"] = hint.get("level") or event["level"]
131
+ prepared["environment"] = event.get("environment") or options.environment or "production"
132
+ prepared["contexts"] = contexts
133
+
134
+ release = event.get("release") or options.release
135
+ if release:
136
+ prepared["release"] = release
137
+ if tags:
138
+ prepared["tags"] = _clip_tags(tags)
139
+ if extra:
140
+ prepared["extra"] = extra
141
+
142
+ user = event.get("user") or scope.user
143
+ if user:
144
+ prepared["user"] = user
145
+
146
+ breadcrumbs = list(scope.breadcrumbs) if scope.breadcrumbs else (event.get("breadcrumbs") or [])
147
+ if breadcrumbs:
148
+ prepared["breadcrumbs"] = [self._clip_breadcrumb(crumb) for crumb in breadcrumbs]
149
+
150
+ fingerprint = hint.get("fingerprint") or event.get("fingerprint") or scope.fingerprint
151
+ if fingerprint:
152
+ prepared["fingerprint"] = [
153
+ truncate(str(item), MAX_FINGERPRINT_ITEM_CHARS) for item in fingerprint[:MAX_FINGERPRINT_ITEMS]
154
+ ]
155
+
156
+ return prepared
157
+
158
+ @staticmethod
159
+ def _clip_breadcrumb(crumb: Any) -> Any:
160
+ message = crumb.get("message")
161
+ if isinstance(message, str) and len(message) > MAX_BREADCRUMB_MESSAGE_CHARS:
162
+ clipped = dict(crumb)
163
+ clipped["message"] = truncate(message, MAX_BREADCRUMB_MESSAGE_CHARS)
164
+ return clipped
165
+ return crumb
166
+
167
+ # ── Envio direto ──────────────────────────────────────────────────────────
168
+
169
+ def send_transaction(self, payload: TransactionPayload) -> None:
170
+ options = self.__options
171
+ final: TransactionPayload = dict(payload) # type: ignore[assignment]
172
+ final["name"] = truncate(payload["name"] or "(sem nome)", MAX_TRANSACTION_NAME_CHARS)
173
+ final["environment"] = payload.get("environment") or options.environment or "production"
174
+ if len(final["steps"]) > MAX_STEPS:
175
+ self.logger.warn(f"transaction com {len(final['steps'])} steps — enviando as primeiras {MAX_STEPS}")
176
+ final["steps"] = final["steps"][:MAX_STEPS]
177
+
178
+ release = payload.get("release") or options.release
179
+ if release:
180
+ final["release"] = release
181
+
182
+ tags: Dict[str, str] = {}
183
+ tags.update(options.tags or {})
184
+ tags.update(payload.get("tags") or {})
185
+ if tags:
186
+ final["tags"] = _clip_tags(tags)
187
+
188
+ self._transport.enqueue({"type": "transaction", "payload": final})
189
+
190
+ def send_heartbeat(self, payload: HeartbeatPayload) -> None:
191
+ final: HeartbeatPayload = dict(payload) # type: ignore[assignment]
192
+ release = payload.get("release") or self.__options.release
193
+ if release:
194
+ final["release"] = release
195
+ self._transport.enqueue({"type": "heartbeat", "payload": final})
196
+
197
+ # ── Ciclo de vida ─────────────────────────────────────────────────────────
198
+
199
+ def flush(self, timeout: int = 5_000) -> bool:
200
+ return self._transport.flush(timeout)
201
+
202
+ def close(self, timeout: int = 5_000) -> bool:
203
+ return self._transport.close(timeout)
204
+
205
+
206
+ __all__: List[str] = ["Client"]
hobots/_config.py ADDED
@@ -0,0 +1,126 @@
1
+ """Validação do Client Secret e resolução dos endpoints.
2
+
3
+ Nenhuma mensagem de erro aqui pode ecoar o valor do secret ou a URL completa —
4
+ a env pode conter credenciais embutidas por engano.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import re
11
+ import sys
12
+ from dataclasses import dataclass
13
+ from typing import Optional
14
+ from urllib.parse import urlsplit
15
+
16
+ #: API oficial da Hobots — o SDK sempre fala com ela em produção.
17
+ DEFAULT_BASE_URL = "https://api2.hobots.app"
18
+
19
+ #: hb_<64 hex>
20
+ _SECRET_PATTERN = re.compile(r"^hb_[a-f0-9]{64}$", re.IGNORECASE)
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class ResolvedConfig:
25
+ """Endpoints derivados do Client Secret, resolvidos uma vez por `init()`."""
26
+
27
+ #: Client Secret do agente (enviado como Authorization: Bearer, nunca na URL)
28
+ key: str
29
+ base_url: str
30
+ #: Ingestão de telemetria: <base_url>/v1/ingest
31
+ ingest_endpoint: str
32
+ #: API de tasks (execução sob demanda): <base_url>/v1/tasks
33
+ tasks_base_url: str
34
+
35
+ def __repr__(self) -> str:
36
+ # o secret nunca aparece na representação padrão da dataclass
37
+ return f"ResolvedConfig(base_url={self.base_url!r})"
38
+
39
+
40
+ def _env_base_url() -> Optional[str]:
41
+ """Override de desenvolvimento via env HOBOTS_API_URL — a única forma de apontar
42
+ o SDK para outro host. Em produção a URL é sempre a API oficial."""
43
+ raw = os.environ.get("HOBOTS_API_URL")
44
+ if raw is None:
45
+ return None
46
+ return raw.strip() or None
47
+
48
+
49
+ def _is_loopback_host(hostname: str) -> bool:
50
+ return (
51
+ hostname == "localhost"
52
+ or hostname.endswith(".localhost")
53
+ or hostname.startswith("127.")
54
+ or hostname in ("::1", "[::1]", "0.0.0.0")
55
+ )
56
+
57
+
58
+ # warning de http remoto sai uma vez por processo
59
+ _warned_insecure_base_url = False
60
+
61
+
62
+ def _reset_insecure_warning_for_testing() -> None:
63
+ global _warned_insecure_base_url
64
+ _warned_insecure_base_url = False
65
+
66
+
67
+ def _validate_base_url(raw: str, prefix: str) -> str:
68
+ """Valida a base_url sem nunca ecoar o valor completo em erro/log."""
69
+ global _warned_insecure_base_url
70
+
71
+ invalid = f"{prefix} HOBOTS_API_URL inválida — esperado URL http(s), ex.: http://localhost:3001"
72
+
73
+ try:
74
+ url = urlsplit(raw)
75
+ except ValueError:
76
+ raise ValueError(invalid) from None
77
+
78
+ if not url.scheme or not url.netloc:
79
+ raise ValueError(invalid)
80
+
81
+ if url.scheme not in ("http", "https"):
82
+ raise ValueError(f'{prefix} HOBOTS_API_URL com esquema "{url.scheme}:" não suportado — use http(s)')
83
+
84
+ try:
85
+ has_credentials = bool(url.username or url.password)
86
+ hostname = url.hostname or ""
87
+ except ValueError:
88
+ # netloc malformado (ex.: porta inválida) — não ecoar o valor
89
+ raise ValueError(invalid) from None
90
+
91
+ if has_credentials:
92
+ raise ValueError(
93
+ f"{prefix} HOBOTS_API_URL não pode conter credenciais na URL — a autenticação é só pelo Client Secret"
94
+ )
95
+
96
+ if url.scheme == "http" and not _is_loopback_host(hostname) and not _warned_insecure_base_url:
97
+ _warned_insecure_base_url = True
98
+ print(
99
+ f'{prefix} HOBOTS_API_URL usa http para o host remoto "{hostname}" — o Client Secret trafega sem TLS',
100
+ file=sys.stderr,
101
+ flush=True,
102
+ )
103
+
104
+ return raw.rstrip("/")
105
+
106
+
107
+ def resolve_config(client_secret: Optional[str], prefix: str = "[hobots]") -> ResolvedConfig:
108
+ """Resolve o client_secret nos endpoints de telemetria e de tasks."""
109
+ key = (client_secret or "").strip()
110
+ if not key:
111
+ raise ValueError(f"{prefix} client_secret ausente — copie o Client Secret do agente em Configurações → Agentes")
112
+ if not _SECRET_PATTERN.match(key):
113
+ # nunca incluir o valor na mensagem — só o tamanho, como pista de diagnóstico
114
+ raise ValueError(
115
+ f"{prefix} client_secret com formato inválido ({len(key)} caracteres) — esperado "
116
+ f'"hb_" + 64 hex; copie o Client Secret do agente em Configurações → Agentes'
117
+ )
118
+
119
+ base_url = _validate_base_url(_env_base_url() or DEFAULT_BASE_URL, prefix)
120
+
121
+ return ResolvedConfig(
122
+ key=key,
123
+ base_url=base_url,
124
+ ingest_endpoint=f"{base_url}/v1/ingest",
125
+ tasks_base_url=f"{base_url}/v1/tasks",
126
+ )