jaylog 0.2.8__tar.gz → 0.3.0a2__tar.gz

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.
Files changed (37) hide show
  1. jaylog-0.3.0a2/PKG-INFO +51 -0
  2. jaylog-0.3.0a2/README.md +32 -0
  3. {jaylog-0.2.8 → jaylog-0.3.0a2}/pyproject.toml +19 -1
  4. jaylog-0.3.0a2/src/jaylog/__init__.py +37 -0
  5. jaylog-0.3.0a2/src/jaylog/_compat.py +29 -0
  6. jaylog-0.3.0a2/src/jaylog/_version.py +19 -0
  7. jaylog-0.3.0a2/src/jaylog/context.py +93 -0
  8. jaylog-0.3.0a2/src/jaylog/endpoints.py +26 -0
  9. jaylog-0.3.0a2/src/jaylog/formatters.py +33 -0
  10. {jaylog-0.2.8 → jaylog-0.3.0a2}/src/jaylog/handlers/console_handler.py +8 -6
  11. jaylog-0.3.0a2/src/jaylog/handlers/http_handler.py +102 -0
  12. jaylog-0.3.0a2/src/jaylog/host/__init__.py +6 -0
  13. jaylog-0.3.0a2/src/jaylog/host/_safe.py +56 -0
  14. jaylog-0.3.0a2/src/jaylog/host/collectors.py +131 -0
  15. jaylog-0.3.0a2/src/jaylog/host/detect_execution.py +202 -0
  16. jaylog-0.3.0a2/src/jaylog/host/detect_git.py +209 -0
  17. jaylog-0.3.0a2/src/jaylog/host/detect_python.py +28 -0
  18. jaylog-0.3.0a2/src/jaylog/host/detect_venv.py +79 -0
  19. jaylog-0.3.0a2/src/jaylog/host/identity.py +53 -0
  20. jaylog-0.3.0a2/src/jaylog/host/models.py +92 -0
  21. jaylog-0.3.0a2/src/jaylog/host/payload.py +29 -0
  22. jaylog-0.3.0a2/src/jaylog/host/reporter.py +295 -0
  23. jaylog-0.3.0a2/src/jaylog/host/win32.py +148 -0
  24. {jaylog-0.2.8 → jaylog-0.3.0a2}/src/jaylog/logger.py +128 -18
  25. jaylog-0.3.0a2/src/jaylog/runtime.py +21 -0
  26. jaylog-0.3.0a2/src/jaylog/screenshot.py +102 -0
  27. {jaylog-0.2.8 → jaylog-0.3.0a2}/src/jaylog/settings.py +70 -19
  28. jaylog-0.2.8/PKG-INFO +0 -287
  29. jaylog-0.2.8/README.md +0 -268
  30. jaylog-0.2.8/src/jaylog/__init__.py +0 -5
  31. jaylog-0.2.8/src/jaylog/formatters.py +0 -118
  32. jaylog-0.2.8/src/jaylog/handlers/http_handler.py +0 -78
  33. jaylog-0.2.8/src/jaylog/models.py +0 -17
  34. {jaylog-0.2.8 → jaylog-0.3.0a2}/src/jaylog/colors.py +0 -0
  35. {jaylog-0.2.8 → jaylog-0.3.0a2}/src/jaylog/filters.py +0 -0
  36. {jaylog-0.2.8 → jaylog-0.3.0a2}/src/jaylog/handlers/__init__.py +0 -0
  37. {jaylog-0.2.8 → jaylog-0.3.0a2}/src/jaylog/handlers/file_handler.py +0 -0
@@ -0,0 +1,51 @@
1
+ Metadata-Version: 2.3
2
+ Name: jaylog
3
+ Version: 0.3.0a2
4
+ Summary: Customized Python logging library with file rotation and HTTP forwarding
5
+ Author: Gpocas
6
+ Author-email: Gpocas <gpocas01@gmail.com>
7
+ Classifier: Topic :: Software Development :: Build Tools
8
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
9
+ Classifier: Natural Language :: Portuguese (Brazilian)
10
+ Classifier: Development Status :: 5 - Production/Stable
11
+ Requires-Dist: pydantic>=2.0,<3.0
12
+ Requires-Dist: pydantic-settings>=2.0,<3.0
13
+ Requires-Dist: requests>=2.32,<3.0
14
+ Requires-Dist: pillow>=11.0,<13.0
15
+ Requires-Python: >=3.10, <3.14
16
+ Project-URL: repository, https://github.com/Gpocas/jaylog
17
+ Project-URL: Bug Tracker, https://github.com/Gpocas/jaylog/issues
18
+ Description-Content-Type: text/markdown
19
+
20
+ # jaylog
21
+
22
+ Biblioteca de logging para Python com rotação de arquivos, saída colorida no console, envio HTTP para um endpoint remoto e registro automático do ambiente de execução (host reporting).
23
+
24
+ 📖 **Documentação completa:** https://gpocas.github.io/jaylog-book
25
+
26
+ ## Instalação
27
+
28
+ ```bash
29
+ pip install -U --no-cache-dir jaylog
30
+ ```
31
+
32
+ ## Uso rápido
33
+
34
+ __*.env.logging*__
35
+ ```env
36
+ JAYLOG_APP_NAME=meu-bot
37
+ JAYLOG_LOG_DIR=C:\logs
38
+ ```
39
+
40
+ __*main.py*__
41
+ ```python
42
+ from jaylog import JaylogSettings, configure, get_logger
43
+
44
+ configure(JaylogSettings())
45
+
46
+ logger = get_logger()
47
+
48
+ logger.info("Olá, jaylog!")
49
+ ```
50
+
51
+ Para variáveis de ambiente, cenários de uso (console-only, múltiplos loggers, cores), preparação para produção (secrets, host reporting) e referência da API, veja a [documentação guiada](https://gpocas.github.io/jaylog-book).
@@ -0,0 +1,32 @@
1
+ # jaylog
2
+
3
+ Biblioteca de logging para Python com rotação de arquivos, saída colorida no console, envio HTTP para um endpoint remoto e registro automático do ambiente de execução (host reporting).
4
+
5
+ 📖 **Documentação completa:** https://gpocas.github.io/jaylog-book
6
+
7
+ ## Instalação
8
+
9
+ ```bash
10
+ pip install -U --no-cache-dir jaylog
11
+ ```
12
+
13
+ ## Uso rápido
14
+
15
+ __*.env.logging*__
16
+ ```env
17
+ JAYLOG_APP_NAME=meu-bot
18
+ JAYLOG_LOG_DIR=C:\logs
19
+ ```
20
+
21
+ __*main.py*__
22
+ ```python
23
+ from jaylog import JaylogSettings, configure, get_logger
24
+
25
+ configure(JaylogSettings())
26
+
27
+ logger = get_logger()
28
+
29
+ logger.info("Olá, jaylog!")
30
+ ```
31
+
32
+ Para variáveis de ambiente, cenários de uso (console-only, múltiplos loggers, cores), preparação para produção (secrets, host reporting) e referência da API, veja a [documentação guiada](https://gpocas.github.io/jaylog-book).
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "jaylog"
3
- version = "0.2.8"
3
+ version = "0.3.0a2"
4
4
  description = "Customized Python logging library with file rotation and HTTP forwarding"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -26,6 +26,24 @@ dependencies = [
26
26
  repository = "https://github.com/Gpocas/jaylog"
27
27
  "Bug Tracker" = "https://github.com/Gpocas/jaylog/issues"
28
28
 
29
+ [dependency-groups]
30
+ dev = [
31
+ "pytest>=8,<9",
32
+ "ruff>=0.6",
33
+ ]
34
+
29
35
  [build-system]
30
36
  requires = ["uv_build>=0.10.8,<0.11.0"]
31
37
  build-backend = "uv_build"
38
+
39
+ [tool.pytest.ini_options]
40
+ testpaths = ["tests"]
41
+ addopts = "-q"
42
+
43
+ [tool.ruff]
44
+ line-length = 100
45
+ src = ["src", "tests"]
46
+
47
+ [tool.ruff.lint]
48
+ select = ["E", "F", "I", "UP", "B"]
49
+ ignore = ["E501"]
@@ -0,0 +1,37 @@
1
+ from jaylog._version import PROTOCOL_VERSION, __version__
2
+ from jaylog.logger import configure, get_logger, shutdown
3
+ from jaylog.settings import JaylogSettings
4
+
5
+ __all__ = [
6
+ "configure",
7
+ "get_logger",
8
+ "shutdown",
9
+ "JaylogSettings",
10
+ "__version__",
11
+ "PROTOCOL_VERSION",
12
+ ]
13
+
14
+ _DEPRECATED = {"LogEntry"}
15
+
16
+
17
+ def __getattr__(name: str):
18
+ # `jaylog.models.LogEntry` era código morto — nunca foi construído, e o tipo
19
+ # declarado nem batia com o que o handler HTTP envia. Fica acessível por um
20
+ # release para não quebrar `from jaylog import LogEntry` em código de bot.
21
+ if name in _DEPRECATED:
22
+ import warnings
23
+
24
+ warnings.warn(
25
+ f"jaylog.{name} está depreciado e será removido na 0.4.0; "
26
+ "o modelo nunca foi usado pela biblioteca.",
27
+ DeprecationWarning,
28
+ stacklevel=2,
29
+ )
30
+ from jaylog import _compat
31
+
32
+ return getattr(_compat, name)
33
+ raise AttributeError(f"module 'jaylog' has no attribute '{name}'")
34
+
35
+
36
+ def __dir__() -> list[str]:
37
+ return sorted([*__all__, *_DEPRECATED])
@@ -0,0 +1,29 @@
1
+ """
2
+ Shim de depreciação de ``jaylog.models``.
3
+
4
+ ``LogEntry`` era código morto: nunca foi construído em lugar nenhum, e o tipo
5
+ declarado (``log_img: Optional[str]``) nem batia com os ``bytes`` que o handler
6
+ HTTP envia. Fica acessível por mais um release via ``jaylog.LogEntry``, que
7
+ emite ``DeprecationWarning``. Remover na 0.4.0.
8
+ """
9
+
10
+ from datetime import datetime
11
+
12
+ from pydantic import BaseModel
13
+
14
+
15
+ class LogEntry(BaseModel):
16
+ log_timestamp: datetime
17
+ log_level: str
18
+ is_exception: bool
19
+ log_message: str
20
+ service: str
21
+ username: str
22
+ hostname: str
23
+ ipv4: str
24
+ service_path: str
25
+ line_number: int
26
+ log_img: str | None = None
27
+
28
+
29
+ __all__ = ["LogEntry"]
@@ -0,0 +1,19 @@
1
+ """
2
+ Versão do pacote e versão do protocolo de ingestão.
3
+
4
+ Lookup único de ``importlib.metadata``: o valor era recalculado em
5
+ ``handlers/http_handler.py`` e voltaria a ser recalculado no reporter de host.
6
+ """
7
+
8
+ from importlib.metadata import PackageNotFoundError, version
9
+
10
+ #: Versão do contrato cliente <-> backend. Vai no header ``x-jaylog-protocol``
11
+ #: e no payload de host. Mede o rollout; não dirige lógica no backend.
12
+ PROTOCOL_VERSION = 2
13
+
14
+ try:
15
+ __version__ = version("jaylog")
16
+ except PackageNotFoundError: # rodando do fonte, sem instalação
17
+ __version__ = "unknown"
18
+
19
+ __all__ = ["__version__", "PROTOCOL_VERSION"]
@@ -0,0 +1,93 @@
1
+ """
2
+ Enriquecimento do ``LogRecord``, memoizado **no próprio record**.
3
+
4
+ O problema que isto resolve: ``build_log_entry_dict()`` é chamado uma vez por
5
+ handler downstream (arquivo, console, HTTP) e mais uma vez dentro do
6
+ ``PlainTextFormatter``. Com screenshot ligado, o mesmo erro capturava a tela até
7
+ três vezes — três ``ImageGrab.grab()`` sequenciais, cada um na casa das centenas
8
+ de milissegundos, para produzir três imagens quase idênticas das quais duas eram
9
+ descartadas. Memoizar no record resolve porque o record é exatamente o escopo do
10
+ "uma vez por evento de log".
11
+
12
+ O screenshot saiu do caminho compartilhado: ``record_screenshot()`` é chamado só
13
+ pelo ``JaylogHttpHandler``, que é o único com para onde mandá-lo.
14
+
15
+ .. warning::
16
+ Este enriquecimento tem que continuar **downstream da fila**.
17
+ ``QueueHandler.prepare()`` já faz ``record.msg = self.format(record)`` e zera
18
+ ``exc_info``; por isso, aqui embaixo, ``record.exc_info`` é sempre ``None`` e
19
+ o traceback sobrevive apenas por já estar embutido em ``getMessage()``. Mover
20
+ o enriquecimento para antes da fila (um ``Filter`` no ``QueueHandler``, por
21
+ exemplo) duplicaria o traceback — e ainda rodaria na thread do chamador.
22
+ Há um teste de regressão travando isso.
23
+ """
24
+
25
+ import logging
26
+ import traceback
27
+ from datetime import datetime, timezone
28
+
29
+ from jaylog.host import identity
30
+ from jaylog.screenshot import capture_screenshot
31
+
32
+ _ENTRY_ATTR = "_jaylog_entry"
33
+ _SCREENSHOT_ATTR = "_jaylog_screenshot"
34
+
35
+
36
+ def build_log_entry_dict(record: logging.LogRecord) -> dict:
37
+ """
38
+ Campos do log, sem ``log_img``.
39
+
40
+ ``hostname``/``username``/``ipv4`` continuam no corpo de todo log — a
41
+ tabela ``logs`` os mantém desnormalizados de forma permanente, então não há
42
+ o que migrar.
43
+ """
44
+ is_exception = getattr(record, "is_exception", False)
45
+
46
+ log_message = record.getMessage()
47
+ if is_exception and record.exc_info:
48
+ tb = "".join(traceback.format_exception(*record.exc_info)).strip()
49
+ log_message = f"{log_message}\n{tb}"
50
+
51
+ return {
52
+ "log_timestamp": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
53
+ "log_level": "EXCEPTION" if is_exception else record.levelname,
54
+ "is_exception": is_exception,
55
+ "log_message": log_message,
56
+ "service": record.name,
57
+ "username": identity.username(),
58
+ "hostname": identity.hostname(),
59
+ "ipv4": identity.ipv4(),
60
+ "service_path": record.pathname,
61
+ "line_number": record.lineno,
62
+ }
63
+
64
+
65
+ def record_entry(record: logging.LogRecord) -> dict:
66
+ """``build_log_entry_dict`` com o resultado guardado no record."""
67
+ entry = getattr(record, _ENTRY_ATTR, None)
68
+ if entry is None:
69
+ entry = build_log_entry_dict(record)
70
+ setattr(record, _ENTRY_ATTR, entry)
71
+ return entry
72
+
73
+
74
+ def wants_screenshot(record: logging.LogRecord) -> bool:
75
+ return record.levelno >= logging.ERROR or bool(getattr(record, "is_exception", False))
76
+
77
+
78
+ def record_screenshot(record: logging.LogRecord) -> bytes | None:
79
+ """
80
+ Screenshot do evento, capturado no máximo uma vez por record.
81
+
82
+ O sentinela guardado no record distingue "ainda não tentamos" de "tentamos e
83
+ não veio nada" — sem ele, um ``None`` legítimo faria a próxima chamada
84
+ capturar de novo.
85
+ """
86
+ if _SCREENSHOT_ATTR in record.__dict__:
87
+ return record.__dict__[_SCREENSHOT_ATTR]
88
+ image = capture_screenshot() if wants_screenshot(record) else None
89
+ record.__dict__[_SCREENSHOT_ATTR] = image
90
+ return image
91
+
92
+
93
+ __all__ = ["build_log_entry_dict", "record_entry", "record_screenshot", "wants_screenshot"]
@@ -0,0 +1,26 @@
1
+ """
2
+ Derivação da URL do endpoint de host a partir da URL do endpoint de log.
3
+
4
+ Deploys existentes já configuram ``JAYLOG_LOG_HTTP_ENDPOINT``; trocar o último
5
+ segmento do path (``/logs/add`` -> ``/logs/host``) evita exigir uma variável
6
+ nova de todo mundo. Quem precisar de outro caminho usa
7
+ ``JAYLOG_HOST_HTTP_ENDPOINT``.
8
+ """
9
+
10
+ from urllib.parse import urlsplit, urlunsplit
11
+
12
+
13
+ def derive_host_endpoint(log_endpoint: str) -> str:
14
+ """
15
+ ``https://api/logs/add`` -> ``https://api/logs/host``.
16
+
17
+ Query string e fragmento são preservados; barra final é ignorada. Uma URL
18
+ sem path (``https://api``) vira ``https://api/host``.
19
+ """
20
+ parts = urlsplit(log_endpoint)
21
+ head, sep, _last = parts.path.rstrip("/").rpartition("/")
22
+ path = f"{head}/host" if sep else "/host"
23
+ return urlunsplit((parts.scheme, parts.netloc, path, parts.query, parts.fragment))
24
+
25
+
26
+ __all__ = ["derive_host_endpoint"]
@@ -0,0 +1,33 @@
1
+ """
2
+ Formatação legível para arquivo.
3
+
4
+ O módulo encolheu: identidade da máquina foi para ``host/identity.py`` (lazy),
5
+ screenshot para ``screenshot.py`` e a montagem do dicionário de log para
6
+ ``context.py``. ``build_log_entry_dict`` continua reexportado aqui por compat —
7
+ remover na 0.4.0.
8
+ """
9
+
10
+ import logging
11
+ from datetime import datetime
12
+
13
+ from jaylog.context import build_log_entry_dict, record_entry
14
+ from jaylog.screenshot import configure_screenshot
15
+
16
+ __all__ = ["PlainTextFormatter", "build_log_entry_dict", "configure_screenshot"]
17
+
18
+
19
+ class PlainTextFormatter(logging.Formatter):
20
+ """Human-readable single-line formatter for .log files."""
21
+
22
+ def __init__(self, show_service: bool = True) -> None:
23
+ super().__init__()
24
+ self.show_service = show_service
25
+
26
+ def format(self, record: logging.LogRecord) -> str:
27
+ entry = record_entry(record)
28
+ log_timestamp = (
29
+ datetime.fromisoformat(entry["log_timestamp"]).astimezone().strftime("%d/%m/%Y %X")
30
+ )
31
+ log_level = f"[{entry['log_level']}]"
32
+ service_segment = f"[{entry['service']}] | " if self.show_service else ""
33
+ return f"{log_timestamp} {log_level.ljust(11)} | {service_segment}{entry['log_message']}"
@@ -4,7 +4,7 @@ from datetime import datetime
4
4
  from typing import IO
5
5
 
6
6
  from jaylog.colors import BLUE, GREEN, PURPLE, RED, RESET, YELLOW, supports_color
7
- from jaylog.formatters import build_log_entry_dict
7
+ from jaylog.context import record_entry
8
8
 
9
9
  _LEVEL_COLORS = {
10
10
  "DEBUG": PURPLE,
@@ -37,12 +37,14 @@ class ConsoleFormatter(logging.Formatter):
37
37
  return f"{color}{text}{RESET}"
38
38
 
39
39
  def format(self, record: logging.LogRecord) -> str:
40
- entry = build_log_entry_dict(record)
41
- log_timestamp = datetime.fromisoformat(entry["log_timestamp"]).astimezone().strftime("%d/%m/%Y %X")
42
- log_level = f'[{entry["log_level"]}]'
40
+ entry = record_entry(record)
41
+ log_timestamp = (
42
+ datetime.fromisoformat(entry["log_timestamp"]).astimezone().strftime("%d/%m/%Y %X")
43
+ )
44
+ log_level = f"[{entry['log_level']}]"
43
45
  colored_timestamp = self._paint(log_timestamp, GREEN)
44
- colored_level = self._paint(log_level.ljust(11), _level_color(entry['log_level']))
45
- service_segment = f'[{entry["service"]}] | ' if self.show_service else ''
46
+ colored_level = self._paint(log_level.ljust(11), _level_color(entry["log_level"]))
47
+ service_segment = f"[{entry['service']}] | " if self.show_service else ""
46
48
  return f"{colored_timestamp} {colored_level} | {service_segment}{entry['log_message']}"
47
49
 
48
50
 
@@ -0,0 +1,102 @@
1
+ import json
2
+ import logging
3
+ import os
4
+ import warnings
5
+
6
+ import requests
7
+ import urllib3
8
+
9
+ from jaylog._version import PROTOCOL_VERSION, __version__
10
+ from jaylog.context import record_entry, record_screenshot
11
+ from jaylog.host import reporter
12
+ from jaylog.runtime import RUN_ID
13
+
14
+ #: O backend sinaliza por header que não conhece este `run_id` — normalmente
15
+ #: porque o log correu na frente do POST de host. Ver `reporter.request_resend`.
16
+ HOST_REQUIRED_HEADER = "x-jaylog-host-required"
17
+
18
+
19
+ def _to_multipart(fields: dict) -> dict:
20
+ result = {}
21
+ for key, value in fields.items():
22
+ if value is None:
23
+ continue
24
+ if isinstance(value, bytes):
25
+ result[key] = ("screenshot.jpg", value, "image/jpeg")
26
+ elif isinstance(value, bool):
27
+ result[key] = (None, "true" if value else "false")
28
+ elif not isinstance(value, str):
29
+ result[key] = (None, json.dumps(value))
30
+ else:
31
+ result[key] = (None, value)
32
+ return result
33
+
34
+
35
+ class JaylogHttpHandler(logging.Handler):
36
+ """
37
+ HTTP handler that POSTs log records as multipart/form-data to a remote endpoint.
38
+
39
+ Non-blocking behaviour is guaranteed by the QueueListener that drives this
40
+ handler — emit() runs in the listener's background thread.
41
+
42
+ O **corpo** do POST é byte-a-byte o mesmo da 0.2.x: ``logs`` mantém
43
+ ``hostname``/``username``/``ipv4`` desnormalizados de forma permanente, então
44
+ não há campo a remover. O que muda são dois headers novos
45
+ (``x-jaylog-protocol`` e ``x-jaylog-run-id``); um backend antigo simplesmente
46
+ os ignora.
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ endpoint: str,
52
+ api_key: str,
53
+ timeout: float = 5.0,
54
+ proxy: str | None = None,
55
+ verify: bool | str = False,
56
+ ) -> None:
57
+ super().__init__()
58
+ self.endpoint = endpoint
59
+ self.timeout = timeout
60
+ self.verify = verify
61
+ self.proxy = {"http": proxy, "https": proxy} if proxy else None
62
+ self._session = requests.Session()
63
+ self._session.headers["x-api-key"] = api_key
64
+ self._session.headers["x-jaylog-version"] = __version__
65
+ self._session.headers["x-jaylog-protocol"] = str(PROTOCOL_VERSION)
66
+ self._session.headers["x-jaylog-run-id"] = RUN_ID
67
+
68
+ def mapLogRecord(self, record: logging.LogRecord) -> dict:
69
+ # Único ponto do programa que pede screenshot: é o único com para onde
70
+ # mandá-la. Console e arquivo não capturam mais nada.
71
+ entry = dict(record_entry(record))
72
+ entry["log_img"] = record_screenshot(record)
73
+ return entry
74
+
75
+ def emit(self, record: logging.LogRecord) -> None:
76
+ try:
77
+ if self.proxy:
78
+ self._session.proxies.update(self.proxy)
79
+
80
+ with warnings.catch_warnings():
81
+ warnings.simplefilter("ignore", urllib3.exceptions.InsecureRequestWarning)
82
+ response = self._session.post(
83
+ self.endpoint,
84
+ files=_to_multipart(self.mapLogRecord(record)),
85
+ timeout=self.timeout,
86
+ verify=self.verify,
87
+ )
88
+ if os.getenv("JAYLOG_HTTP_DEBUG") == "1":
89
+ print(response.status_code)
90
+ print(response.content)
91
+
92
+ if response.headers.get(HOST_REQUIRED_HEADER) == "1":
93
+ # O log foi aceito (202) — não há nada a reenviar aqui. O que
94
+ # falta é o registro de ambiente, que o reporter reenvia (com
95
+ # debounce, porque o header vem em toda linha afetada).
96
+ reporter.request_resend(record.name)
97
+ except Exception:
98
+ # `handleError` respeita `logging.raiseExceptions`: barulho em dev,
99
+ # silêncio em produção. O `except: pass` anterior era um buraco
100
+ # negro — era por causa dele que ninguém descobria que o caminho
101
+ # HTTP estava falhando.
102
+ self.handleError(record)
@@ -0,0 +1,6 @@
1
+ # Intencionalmente vazio.
2
+ #
3
+ # `settings.py` precisa de `host.identity` sem arrastar `requests` (via
4
+ # `reporter`) nem `subprocess` (via `detect_git`). Re-exportar os submódulos
5
+ # aqui recriaria esse acoplamento no import. Importe sempre o submódulo
6
+ # explícito: `from jaylog.host import identity`.
@@ -0,0 +1,56 @@
1
+ """
2
+ ``@safe``: nenhum coletor de host pode derrubar a aplicação.
3
+
4
+ A coleta é observabilidade — se a leitura do Toolhelp falhar, se o antivírus
5
+ bloquear o ``git``, se o ``pyvenv.cfg`` estiver corrompido, o campo vira
6
+ ``None`` e o resto do payload segue. O contrato é que um coletor **nunca**
7
+ levanta.
8
+ """
9
+
10
+ import copy
11
+ import functools
12
+ import os
13
+ import sys
14
+ import traceback
15
+ from collections.abc import Callable
16
+ from typing import Any, TypeVar
17
+
18
+ F = TypeVar("F", bound=Callable[..., Any])
19
+
20
+
21
+ def _debug_enabled() -> bool:
22
+ return os.environ.get("JAYLOG_DEBUG") == "1"
23
+
24
+
25
+ def safe(default: Any = None) -> Callable[[F], F]:
26
+ """
27
+ Devolve ``default`` quando a função levanta.
28
+
29
+ Captura ``Exception``, **não** ``BaseException``: engolir ``KeyboardInterrupt``
30
+ numa thread de coleta esconderia o Ctrl-C do usuário, e ``SystemExit`` tem
31
+ que continuar subindo.
32
+
33
+ O traceback só é impresso em stderr com ``JAYLOG_DEBUG=1`` — em produção a
34
+ falha é silenciosa por desenho, já que o payload carrega a evidência
35
+ (o campo fica ``None``).
36
+ """
37
+
38
+ def decorator(func: F) -> F:
39
+ @functools.wraps(func)
40
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
41
+ try:
42
+ return func(*args, **kwargs)
43
+ except Exception:
44
+ if _debug_enabled():
45
+ print(f"[jaylog] host: falha em {func.__name__}", file=sys.stderr)
46
+ traceback.print_exc(file=sys.stderr)
47
+ # copy para que um default mutável (`{}`, `[]`) não seja
48
+ # compartilhado entre chamadas.
49
+ return copy.copy(default)
50
+
51
+ return wrapper # type: ignore[return-value]
52
+
53
+ return decorator
54
+
55
+
56
+ __all__ = ["safe"]
@@ -0,0 +1,131 @@
1
+ """
2
+ ``collect_host_info()``: junta todos os detectores num ``HostInfo``.
3
+
4
+ Memoizado por processo e thread-safe. O custo da coleta (~100-500 ms: um
5
+ snapshot Toolhelp mais até seis subprocessos ``git``) é pago **uma vez**, na
6
+ thread de startup — nunca na thread do chamador de ``configure()``.
7
+
8
+ Os parâmetros de git vêm da primeira chamada: dois ``app_name`` no mesmo
9
+ processo compartilham o mesmo ambiente, então coletar duas vezes só gastaria
10
+ tempo para produzir o mesmo retrato.
11
+ """
12
+
13
+ import os
14
+ import platform
15
+ import threading
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+
19
+ from jaylog.host import detect_execution, detect_git, detect_python, detect_venv, identity
20
+ from jaylog.host._safe import safe
21
+ from jaylog.host.models import HostInfo
22
+
23
+ _lock = threading.Lock()
24
+ _cached: HostInfo | None = None
25
+
26
+
27
+ @safe(default=(None, None, None, None))
28
+ def _os_info() -> tuple[str | None, str | None, str | None, str | None]:
29
+ uname = platform.uname()
30
+ return (
31
+ uname.system or None,
32
+ uname.release or None,
33
+ uname.version or None,
34
+ uname.machine or None,
35
+ )
36
+
37
+
38
+ @safe(default=None)
39
+ def _cwd() -> str | None:
40
+ return os.getcwd()
41
+
42
+
43
+ @safe(default=None)
44
+ def _collect(
45
+ git_enabled: bool,
46
+ git_dir: Path | str | None,
47
+ git_timeout: float,
48
+ git_dirty_enabled: bool,
49
+ git_remote_enabled: bool,
50
+ ) -> HostInfo:
51
+ os_system, os_release, os_version, machine = _os_info()
52
+ execution = detect_execution.detect_execution()
53
+ python = detect_python.detect_python()
54
+ venv = detect_venv.detect_venv()
55
+ git = detect_git.detect_git(
56
+ enabled=git_enabled,
57
+ git_dir=git_dir,
58
+ timeout=git_timeout,
59
+ dirty_enabled=git_dirty_enabled,
60
+ remote_enabled=git_remote_enabled,
61
+ )
62
+
63
+ return HostInfo(
64
+ collected_at=datetime.now(timezone.utc).isoformat(),
65
+ hostname=identity.hostname(),
66
+ username=identity.username(),
67
+ ipv4=identity.ipv4(),
68
+ os_system=os_system,
69
+ os_release=os_release,
70
+ os_version=os_version,
71
+ machine=machine,
72
+ execution_mode=execution.mode,
73
+ execution_detail=execution.detail,
74
+ session_id=execution.session_id,
75
+ process_id=execution.process_id,
76
+ parent_process_name=execution.parent_process_name,
77
+ python_version=python.version,
78
+ python_implementation=python.implementation,
79
+ python_executable=python.executable,
80
+ python_frozen=python.frozen,
81
+ venv_active=venv.active,
82
+ venv_kind=venv.kind,
83
+ venv_path=venv.path,
84
+ git_available=git.available,
85
+ git_version=git.version,
86
+ git_repo=git.repo,
87
+ git_root=git.root,
88
+ git_branch=git.branch,
89
+ git_commit=git.commit,
90
+ git_commit_short=git.commit_short,
91
+ git_commit_msg=git.commit_msg,
92
+ git_dirty=git.dirty,
93
+ git_remote_url=git.remote_url,
94
+ cwd=_cwd(),
95
+ entrypoint=detect_git.entrypoint_dir(),
96
+ )
97
+
98
+
99
+ def collect_host_info(
100
+ *,
101
+ git_enabled: bool = True,
102
+ git_dir: Path | str | None = None,
103
+ git_timeout: float = 3.0,
104
+ git_dirty_enabled: bool = True,
105
+ git_remote_enabled: bool = True,
106
+ ) -> HostInfo:
107
+ """
108
+ Retrato do ambiente, coletado uma vez por processo.
109
+
110
+ Nunca levanta: se o ``@safe`` de backstop devolver ``None``, cai para
111
+ ``HostInfo.minimal()``, que ainda carrega ``run_id`` e a identidade da
112
+ máquina.
113
+ """
114
+ global _cached
115
+ with _lock:
116
+ if _cached is None:
117
+ _cached = (
118
+ _collect(git_enabled, git_dir, git_timeout, git_dirty_enabled, git_remote_enabled)
119
+ or HostInfo.minimal()
120
+ )
121
+ return _cached
122
+
123
+
124
+ def reset_cache() -> None:
125
+ """Limpa a memoização. Existe para os testes; não use em produção."""
126
+ global _cached
127
+ with _lock:
128
+ _cached = None
129
+
130
+
131
+ __all__ = ["collect_host_info", "reset_cache"]