Metadata-Version: 2.4
Name: hobots
Version: 3.3.0
Summary: SDK Python da Hobots para RPAs e integrações — tudo é uma task: register() para execuções sob demanda (solicitações do app) e start() para robôs sempre ligados, com logs, itens, progresso, prints, erros e heartbeat. Sem init().
Project-URL: Homepage, https://hobots.app
Author: Hobots
License: MIT License
        
        Copyright (c) 2026 Hobots
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
License-File: LICENSE
Keywords: automation,hobots,monitoring,rpa,tasks,telemetry
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: System :: Monitoring
Classifier: Typing :: Typed
Requires-Python: >=3.9
Provides-Extra: dev
Requires-Dist: coverage>=7.0; extra == 'dev'
Requires-Dist: mypy>=1.10; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Description-Content-Type: text/markdown

# hobots

SDK Python da **Hobots** para robôs (RPAs) e integrações. **Tudo é uma task**: você registra o que o robô faz e a plataforma passa a receber logs, itens processados, progresso, prints, erros e o "estou vivo" do robô.

- **`hobots.register(task, handler)`** — **sob demanda**: o robô fica online aguardando **solicitações** criadas no app (formulários e agendamentos) e executa o handler a cada uma. Módulo **Solicitações**.
- **`hobots.start(task, handler)`** — **sempre ligado**: o robô abre a própria execução ao iniciar e roda o handler (o seu loop) dentro dela; a plataforma só recebe logs, itens e progresso. Sem formulário, sem solicitante.

Cliente HTTP puro, **sem dependências de runtime** (só a stdlib). Requer **Python 3.9+**. A API é **síncrona**: handlers são funções normais (`def`) e o SDK administra as threads de fundo — combina com Selenium, Playwright síncrono, pyautogui e código legado.

> **Todas as durações são em milissegundos**, como no SDK Node: `retry_delay=5000` são 5 segundos, `hobots.sleep(2000)` dorme 2 segundos. Os nomes não levam sufixo `_ms`.

---

## Instalação

```bash
pip install hobots
```

```python
import hobots  # o próprio módulo é o objeto global
```

---

## Em 30 segundos

```bash
export HOBOTS_CLIENT_SECRET=hb_<64 hex>   # Configurações → Agentes
export HOBOTS_INSTANCE_ID=vm-cliente-01   # opcional — default: hostname da máquina
export HOBOTS_ENVIRONMENT=staging         # opcional — default: production
```

```python
import hobots


# sob demanda: uma execução por solicitação do app (o task_slug casa com o formulário)
def processar_nfe(params, ctx):
    hobots.log.info(f"lote {params['lote']}")
    hobots.progress.total(len(params["notas"]))
    for nota in params["notas"]:
        baixar(nota)
        hobots.items.succeeded({"numero_nota": nota}, id=nota)
        hobots.progress.advance()


# sempre ligado: o handler é o loop do robô; cada iteração reporta o que fez
def monitor_pasta(params, ctx):
    while True:
        hobots.sleep(60_000)  # cancelável
        for arquivo in novos_arquivos():
            hobots.items.succeeded({"arquivo": arquivo})
        hobots.log.info("ciclo ok")


hobots.register("processar-nfe", processar_nfe)
hobots.start("monitor-pasta", monitor_pasta)
# as duas chamadas voltam na hora; o processo fica vivo (heartbeat) até hobots.close()
# ou Ctrl-C — encerramento gracioso em "Encerramento", abaixo
```

O **módulo `hobots`** é o objeto global: `hobots.log`, `hobots.items`, `hobots.progress`, `hobots.transaction`… resolvem a execução **atual** de qualquer módulo do programa — sem passar contexto de mão em mão, mesmo com várias execuções em paralelo.

---

## Credenciais e identidade

| Env | Opção | O que é |
| --- | --- | --- |
| `HOBOTS_CLIENT_SECRET` | `client_secret` | O **Client Secret do agente** (`hb_<64 hex>`), copiado em **Configurações → Agentes**. Viaja só no header `Authorization: Bearer`, nunca na URL. Ausente ou inválido → `register()`/`start()` levanta `ValueError` na hora, citando a env (sem ecoar o valor). |
| `HOBOTS_INSTANCE_ID` | `instance_id` | Identidade **estável** desta instância do robô (`vm-cliente-01`). Sem ela, o hostname. Heartbeat e tasks convergem nela: **um agente só** no painel. |
| `HOBOTS_ENVIRONMENT` | `environment` | Ambiente lógico do robô (`development` \| `testing` \| `staging` \| `production`). Sem ela, `production`. |
| `HOBOTS_API_URL` | — | Só em desenvolvimento: aponta o SDK para outro host (`http://localhost:3001`). Em produção é sempre `https://api2.hobots.app`. |

A opção passada em `register()`/`start()` tem precedência sobre a env — útil para copiar e colar o snippet do app. O secret nunca aparece em `repr()` do SDK.

---

## Os dois modos

| | `register(task, handler, **opções)` | `start(task, handler, **opções)` |
| --- | --- | --- |
| Quem dispara | Uma **solicitação** no app (formulário com esse `task_slug`, ou agendamento) | O próprio robô, ao iniciar |
| `params` do handler | O dict validado do formulário | `{}` |
| Quantas execuções | Uma por solicitação; `concurrency` em paralelo (1..5) | **Uma só** — o handler é o seu loop; quando ele retorna, a execução fecha |
| O processo | Fica vivo fazendo polling | Fica vivo pelo heartbeat, mesmo depois do handler retornar |
| Reexecutar pelo app | Sim | Não — só reiniciando o robô |

As duas chamadas **voltam na hora**: o polling e o handler do modo start rodam em threads de fundo. Os dois modos podem coexistir no mesmo processo: um monitor sempre ligado + tasks sob demanda, um heartbeat, uma instância. Cada slug só pode ser registrado uma vez.

O handler é sempre `handler(params, ctx)` — dois argumentos posicionais, também no modo start (onde `params` é `{}`). O retorno é ignorado.

> **Modo start e o limite de logs:** o servidor guarda no máximo **1000 linhas de log por execução**. Num loop que roda por dias, logue o que importa (ciclos com novidade, avisos, erros) e registre o trabalho em `hobots.items` — itens não têm esse teto tão baixo (10.000 por execução).

---

## O módulo `hobots`

| Membro | O que faz |
| --- | --- |
| `hobots.log.debug/info/warn/error/success(msg, *rest, image=None)` | Log da execução, enviado incrementalmente ao app (e ecoado no terminal). Aceita vários argumentos como `print()` — `hobots.log.info("nota", numero, "ok")` — cada um vira texto, unidos por espaço. `image=` pendura um print na linha. **Nunca levanta** — pode ir em `finally`. |
| `hobots.items.succeeded(payload=None, *, id=None, image=None)` · `.failed(msg, payload=None, *, id=None, image=None)` · `.occurrence(msg, …)` · `.add(status, …)` | **A entrega do robô**: cada item processado, com status e atributos (viram colunas no app). `image=` pendura um print no item. Bufferizado, em lotes. |
| `hobots.items.each(lista, handler, *, id=…, payload=…)` | **Loop com o contrato garantido**: cada elemento vira step + item; exceção no handler = item `failed` com a mensagem e o loop **segue** até o fim; progresso automático. Só cancelamento/timeout interrompem. |
| `hobots.progress.total(n)` · `.advance(k=1)` · `.set(atual, total=None)` · `.current` · `.snapshot` | Barra, percentual e tempo restante na solicitação. Sem `total()` nada é enviado. |
| `hobots.transaction.start_child(op, descrição)` | Uma **step** no waterfall de performance da execução (também como `with`). A transaction da execução já existe e é finalizada pelo SDK. `step.set_status(status, msg=None, image=…)` / `set_error(err, msg=None, image=…)` penduram um print na step. |
| `hobots.image(bytes \| caminho, *, caption=None, content_type=None)` | Embrulha um **print** (PNG/JPEG/WEBP, até 5 MB, 20 por execução) para ir em `image=` de um log, item ou step. Puro: o upload roda em segundo plano quando o evento é registrado; nunca levanta nem bloqueia. |
| `hobots.capture_exception(erro, hint=None)` | Abre uma **issue** (módulo Issues). `hint["attachment"]` anexa um print à linha de log correspondente. |
| `hobots.retry(fn, *, retries=3, delay=1000, should_retry=None)` | Retry ciente do cancelamento da execução. `fn` recebe o número da tentativa; `delay` aceita `callable(tentativa) -> ms`. |
| `hobots.sleep(ms)` | Espera **cancelável**: levanta `RunCanceled` se a execução for cancelada. Use no lugar de `time.sleep()`. |
| `hobots.signal` · `hobots.raise_if_canceled()` | Cancelamento cooperativo manual (`signal` é um `threading.Event`), para controle fino. |
| `hobots.run_id` · `hobots.task` · `hobots.params` | Identidade da execução atual. |
| `hobots.register(...)` · `hobots.start(...)` · `hobots.close(timeout=30_000)` | Ciclo de vida do processo. |

O handler também recebe o mesmo contexto como 2º argumento (`ctx.log`, `ctx.items`, `ctx.progress`, `ctx.transaction`, `ctx.signal`, `ctx.params`, `ctx.sleep`, `ctx.attach`, `ctx.retry`, `ctx.raise_if_canceled`), para quem prefere explícito. Fora de qualquer execução, os membros de contexto caem em fallbacks seguros e avisam uma vez — `hobots.log` continua imprimindo no terminal.

> **Evite `from hobots import log`** (ou `items`, `progress`…) no topo de um módulo: o valor capturado no import é o de fora de execução. Importe o módulo e chame `hobots.log.info(...)` — cada acesso resolve a execução atual.

---

## Cancelamento automático

Uma execução cancelada no app **não mata** o handler — o SDK dispara `hobots.signal` e o próximo ponto de parada levanta **`RunCanceled`**:

- `hobots.items.*`, `hobots.progress.total/advance/set`, `hobots.transaction.start_child()` (e `step.start_child()`), `hobots.sleep()` e cada tentativa de `hobots.retry()`.
- **Nunca `hobots.log.*`** — um log dentro de um `finally` de limpeza não pode explodir.

Num loop de itens, isso costuma bastar: registrar o item processado já é o ponto de parada. Para controle fino, `hobots.raise_if_canceled()` e `hobots.signal.is_set()` continuam disponíveis. Quando o cancelamento veio do app, o `RunCanceled` é **suprimido** — a execução já está `cancelada` no servidor e nada é reportado por cima. Limpeza (fechar navegador, desfazer estado) vai em `try/finally`.

> **Atenção:** não use `if hobots.signal.is_set(): return`. Retornar normalmente reporta a execução como **`succeeded`**. É preciso **levantar** — e é o que os pontos de parada fazem. Se capturar o `RunCanceled` para limpar, **re-levante**.

---

## Retry

Quando o handler levanta, o SDK **tenta de novo na mesma execução**: aviso no log (`tentativa 1/2 falhou: … — nova tentativa em 5000ms`), espera cancelável, re-invocação com os mesmos `params`. Default: **1 tentativa extra** com 5 s. Itens e progresso já enviados permanecem — escreva handlers **idempotentes** (use o `id` de negócio nos itens: repetido, o servidor ignora).

```python
hobots.register("processar-nfe", handler, retries=3, retry_delay=lambda tentativa: tentativa * 10_000)
```

Nunca há nova tentativa em **cancelamento**, **timeout** (`TaskTimeout` é terminal) ou execução já cancelada no app. Para uma chamada instável dentro do handler, use `hobots.retry()`.

---

## Opções

```python
hobots.register(task, handler, *, client_secret=None, ..., concurrency=None, poll_interval=None)
hobots.start(task, handler, *, client_secret=None, ...)
```

Todas as opções são **keyword-only**.

| Opção | Default | Descrição |
| --- | --- | --- |
| `client_secret` | env `HOBOTS_CLIENT_SECRET` | Client Secret do agente |
| `instance_id` | env `HOBOTS_INSTANCE_ID` → hostname | Identidade da instância |
| `environment` | env `HOBOTS_ENVIRONMENT` → `"production"` | `development` \| `testing` \| `staging` \| `production` — decide quem é alertado |
| `release` | — | Versão do programa, ex. `robo-nfe@1.4.2` |
| `tags` | — | Tags de todos os eventos |
| `debug` | `False` | Loga a atividade do SDK |
| `machine_metrics` | `True` | CPU/memória/disco no heartbeat e em cada evento |
| `capture_unhandled` | `True` | `sys.excepthook`/`threading.excepthook` viram issues |
| `retries` | `1` | Tentativas extras do handler (`0` desliga) |
| `retry_delay` | `5000` | ms fixo ou `callable(tentativa) -> ms` |
| `timeout` | — (infinito) | Timeout por tentativa, em ms; terminal (`TaskTimeout`). O handler não é morto: o signal dispara e o slot é liberado |
| `concurrency` (register) | `1` | Execuções simultâneas **desta task** (1..5) |
| `poll_interval` (register) | `2000` | Intervalo entre polls sem trabalho (mín. `1000`) |
| avançadas: `install_exit_handlers`, `log_flush_interval`, `cancel_check_interval`, `cancel_grace` | `True`, `2000`, `5000`, `10000` | Ver [Referência de API](./docs/api-reference.md) |

As opções de **processo** (`client_secret`, `instance_id`, `environment`, `release`, `tags`, `debug`, `machine_metrics`, `capture_unhandled`, `install_exit_handlers`, e `poll_interval` entre os `register()`) valem as da **primeira** chamada; chamadas seguintes com valor diferente avisam e são ignoradas.

---

## Encerramento

O heartbeat mantém o processo vivo por design — é a única thread **não daemon** do SDK, o que faz o script continuar rodando depois da última linha. `hobots.close(timeout=30_000)` para o polling, aguarda as execuções em voo, para o heartbeat (libera o processo) e faz o flush final; devolve `False` se algo não terminou a tempo.

Para um encerramento **gracioso** no Ctrl-C/SIGTERM (esperar a execução em andamento terminar), instale o seu handler de sinal e segure a main thread até ele disparar:

```python
import signal
import threading

import hobots

parar = threading.Event()
signal.signal(signal.SIGINT, lambda signum, frame: parar.set())
signal.signal(signal.SIGTERM, lambda signum, frame: parar.set())

hobots.register("processar-nfe", processar_nfe)

while not parar.wait(1):  # o timeout mantém o Ctrl-C responsivo também no Windows
    pass
hobots.close(15_000)  # espera a execução em voo, para o heartbeat e faz o flush
```

Com handler próprio de sinal, o SDK não interfere. **Sem** ele, um `SIGINT`/`SIGTERM` com execuções ativas reporta cada uma como `failed`/`AgentTerminated`, faz o flush da telemetria e o processo sai com 130/143 — o app mostra a falha na hora. Uma exceção não tratada na main thread também encerra o processo (exit 1), mesmo com o heartbeat vivo.

> Não chame `sys.exit()` de dentro do handler de sinal quando o script principal já terminou: o `SystemExit` cai dentro do `threading._shutdown` do interpretador ("Exception ignored in…"). O padrão acima — sinal só seta um `Event`, a main thread chama `close()` — evita isso.

> **Dados sensíveis:** logs, itens, mensagens de erro e prints são enviados ao servidor e ficam visíveis no app. O SDK redige padrões óbvios de segredo (`hb_…`, Bearer, `sk-…`, credenciais em URL), mas não borra prints nem filtra dados pessoais. Capture só o necessário.

---

## Migrando da 2.x

A 3.0 remove o `init()` e a telemetria "solta": tudo passa a acontecer dentro de uma task.

| 2.x | 3.0 |
| --- | --- |
| `hobots.init(client_secret=…, instance_id=…, tasks=…, heartbeat=…)` | Nada — env `HOBOTS_CLIENT_SECRET`/`HOBOTS_INSTANCE_ID` (ou as opções de `register()`/`start()`); heartbeat sempre ligado |
| `@hobots.task("slug")` + `hobots.run_forever()` | `hobots.register("slug", handler)` (volta na hora; o processo fica vivo sozinho) |
| Robô que roda sozinho em loop + `start_transaction` | `hobots.start("slug", handler)` — uma execução própria, com logs/itens/progresso |
| `hobots.stop()` / `hobots.flush()` | `hobots.close(timeout)` |
| `ctx.tx` | `hobots.transaction` / `ctx.transaction` |
| `ctx.cancel_event` · `ctx.is_cancelled()` · `ctx.raise_if_cancelled()` | `hobots.signal` · `hobots.signal.is_set()` · `hobots.raise_if_canceled()` |
| `ctx.attach(...)` (bloqueia) · `ctx.attach_nowait(...)` · `hobots.attach(...)` (3.x) | `image=` nos próprios eventos: `hobots.log.info("…", image=png)`, `hobots.items.failed("…", image=png)`, `step.set_error(err, image=png)` — não bloqueia |
| `tasks={"task_timeout": …, "concurrency": …, "poll_interval": …}` | `register(..., timeout=…, concurrency=…, poll_interval=…)` — `concurrency` agora é **por task** |
| `capture_message`, `add_breadcrumb`, `set_tag(s)`/`set_user`/`set_context`/`set_extra`, `with_scope` | Removidos — narração vai em `hobots.log`, dados de negócio em `hobots.items`; tags fixas em `tags=` |
| `get_client`, `Client`, `Scope`, `InitOptions`, `TasksOptions`, `HeartbeatOptions`, `sample_rate`, `traces_sample_rate`, `before_send`, `max_breadcrumbs` | Removidos |
| Handler sem retry | `retries=1` por padrão — o handler é re-invocado na mesma execução |

Antes (2.x):

```python
import hobots

hobots.init(client_secret="hb_…", instance_id="vm-01", heartbeat=True, tasks={"concurrency": 2})


@hobots.task("processar-nfe")
def processar(params, ctx):
    for nota in params["notas"]:
        ctx.raise_if_cancelled()
        with ctx.tx.start_child("http", f"GET /notas/{nota}"):
            baixar(nota)
        ctx.items.succeeded({"numero_nota": nota}, id=nota)


hobots.run_forever()
```

Depois (3.0):

```python
import hobots  # HOBOTS_CLIENT_SECRET e HOBOTS_INSTANCE_ID no ambiente


def processar(params, ctx):
    for nota in params["notas"]:
        with hobots.transaction.start_child("http", f"GET /notas/{nota}"):  # ponto de parada
            baixar(nota)
        hobots.items.succeeded({"numero_nota": nota}, id=nota)  # ponto de parada


hobots.register("processar-nfe", processar, concurrency=2)
```

---

## Diferenças em relação ao SDK Node

Mesmo protocolo, mesmos defaults, nomes em `snake_case`. O que muda por causa da linguagem:

- **Boot síncrono:** o primeiro `register()`/`start()` configura o processo na hora (no Node, num microtask). Tasks registradas depois entram no próximo poll, antecipado na hora.
- **Sem `globalThis`:** `import hobots` já é o objeto global — os membros de contexto são resolvidos por `__getattr__` do módulo, com `contextvars` no lugar do `AsyncLocalStorage`.
- **`attach` devolve `concurrent.futures.Future`** em vez de `Promise`; `hobots.signal` é um `threading.Event` em vez de `AbortSignal`.
- **Threads próprias do handler** não herdam o contexto da execução. Com uma execução só ativa, `hobots.*` ainda a encontra; com `concurrency > 1`, passe o `ctx` ou rode a thread com `contextvars.copy_context().run(...)`.
- **Requisições em voo não são interrompíveis** (`urllib` não tem `AbortSignal`): um `close()` durante um poll espera a resposta e devolve à plataforma, como `AgentTerminated`, qualquer run que ele ainda entregue.

### Changelog resumido

- **3.1.0** — `hobots.log.*` aceita vários argumentos (como `print`); eco de transaction/steps no terminal só com `debug=True`; `None` dentro de `extra`/`payload`/`params`/`metadata` viaja como `null`; heartbeat com jitter de ±10% e `interval_ms` clampado à faixa do servidor; fila da telemetria nunca descarta heartbeat; prazos do reporter (flush, progresso, heartbeat de cancelamento) valem mesmo com o handler num loop apertado; logs emitidos depois do fim da execução seguem como log tardio; `close()` nunca deixa uma run reivindicada sem dono; PATCH terminal nunca passa de 100 logs; `progress.set(inf)`/`advance` concorrente corrigidos; tipos `Environment`/`EnvironmentInput` e todos os tipos do protocolo exportados.

---

## Documentação

- [Getting started](./docs/getting-started.md) — instalação, envs, primeira task nos dois modos, encerramento.
- [Tasks](./docs/tasks.md) — handler, `hobots.*`, itens, progresso, anexos, cancelamento, retry, concorrência, idempotência.
- [Conceitos](./docs/concepts.md) — o vocabulário: task, execução, evento, transaction, step, heartbeat, instância, claim…
- [Referência de API](./docs/api-reference.md) — assinaturas, tabelas de opções e tempos.
- [Protocolo & transporte](./docs/protocol.md) — endpoints, envelope, retries, claim.
- [Exemplos](./examples) — scripts executáveis, um por funcionalidade.

## Desenvolvimento

```bash
pip install -e ".[dev]"
pytest            # sem rede (servidor HTTP local nas fixtures)
ruff check . && mypy src
coverage run -m pytest && coverage report   # reprova abaixo de 97% (linhas + branches)
```

### Publicação

0. **Rode a suíte** — não publique com ela vermelha:
   ```bash
   pytest && ruff check . && mypy src && coverage run -m pytest && coverage report
   ```
1. **Ajuste a versão** em `src/hobots/__about__.py` (semver).
2. **Construa o pacote**:
   ```bash
   python3 -m pip install build twine
   python3 -m build
   ```
3. **Publique no PyPI** (requer credenciais em `~/.pypirc`):
   ```bash
   python3 -m twine upload dist/*
   ```
4. **Confirme a publicação** em [pypi.org/project/hobots](https://pypi.org/project/hobots).
5. **Crie um commit** com a nova versão.

Os artifacts em `dist/` podem ser descartados após a publicação.

## Licença

[MIT](./LICENSE) © Hobots
