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 +3 -0
- hobots/__init__.py +159 -0
- hobots/_client.py +206 -0
- hobots/_config.py +126 -0
- hobots/_core.py +405 -0
- hobots/_errors.py +48 -0
- hobots/_eventbuilder.py +86 -0
- hobots/_heartbeat.py +61 -0
- hobots/_http.py +195 -0
- hobots/_instrument.py +134 -0
- hobots/_scope.py +90 -0
- hobots/_scrub.py +28 -0
- hobots/_stacktrace.py +78 -0
- hobots/_transaction.py +282 -0
- hobots/_transport.py +208 -0
- hobots/_types.py +137 -0
- hobots/_utils.py +149 -0
- hobots/protocol.py +235 -0
- hobots/py.typed +0 -0
- hobots/tasks/__init__.py +1 -0
- hobots/tasks/_agent.py +444 -0
- hobots/tasks/_api.py +208 -0
- hobots/tasks/_attachments.py +98 -0
- hobots/tasks/_context.py +659 -0
- hobots/tasks/_run_context.py +49 -0
- hobots/tasks/_shutdown.py +93 -0
- hobots/tasks/_types.py +15 -0
- hobots/tasks/protocol.py +188 -0
- hobots-1.0.0.dist-info/METADATA +405 -0
- hobots-1.0.0.dist-info/RECORD +32 -0
- hobots-1.0.0.dist-info/WHEEL +4 -0
- hobots-1.0.0.dist-info/licenses/LICENSE +21 -0
hobots/__about__.py
ADDED
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
|
+
)
|