Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,13 @@ repos:
- id: check-toml
- id: check-merge-conflict
- id: debug-statements

# Local hook - forbid new print() usage under src/
- repo: local
hooks:
- id: no-print-in-src
name: no-print-in-src
entry: "! grep -HFn -- 'print(' {}"
language: system
files: '^src/'
types: [python]
4 changes: 4 additions & 0 deletions src/threatxtension/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
"""ThreatXtension package."""
from .logging import configure_logging

configure_logging()
65 changes: 65 additions & 0 deletions src/threatxtension/logging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""Central structured logging configuration for ThreatXtension."""

from __future__ import annotations

import logging
import os
import sys

LOG_LEVEL_ENV = "THREATXTENSION_LOG_LEVEL"
FALLBACK_LOG_LEVEL_ENV = "LOG_LEVEL"
DEFAULT_LEVEL = "INFO"
DEFAULT_FORMAT = (
"%(asctime)s %(levelname)s %(name)s "
"[%(module)s:%(funcName)s:%(lineno)d] %(message)s"
)
DATE_FORMAT = "%Y-%m-%dT%H:%M:%S%z"


def _resolve_level(value: str | int | None = None) -> int:
"""Resolve a log level from an explicit value or environment."""
if isinstance(value, int):
return value

if value is None:
raw = os.getenv(LOG_LEVEL_ENV) or os.getenv(FALLBACK_LOG_LEVEL_ENV) or DEFAULT_LEVEL
else:
raw = str(value)

raw = raw.strip().upper()
level = getattr(logging, raw, None)
if isinstance(level, int):
return level

logging.getLogger(__name__).warning(
"Unsupported log level %r; using %s", raw, DEFAULT_LEVEL
)
return getattr(logging, DEFAULT_LEVEL, logging.INFO)


def configure_logging(level: str | int | None = None) -> logging.Logger:
"""Configure the ThreatXtension logger with timestamped output.

The level is read from ``THREATXTENSION_LOG_LEVEL`` (or ``LOG_LEVEL``)
when no explicit value is supplied. Only the ``threatxtension`` logger
is configured so application entry points can control their own output.
"""
logger = logging.getLogger("threatxtension")
logger.setLevel(_resolve_level(level))
for handler in list(logger.handlers):
logger.removeHandler(handler)

handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(logging.Formatter(DEFAULT_FORMAT, datefmt=DATE_FORMAT))
logger.addHandler(handler)
logger.propagate = False
return logger


def get_logger(name: str | None = None) -> logging.Logger:
"""Return a child logger under the structured ThreatXtension logger."""
if not name:
return logging.getLogger("threatxtension")
if name == "threatxtension" or name.startswith("threatxtension."):
return logging.getLogger(name)
return logging.getLogger(f"threatxtension.{name.lstrip('.')}")
37 changes: 37 additions & 0 deletions tests/test_logging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Tests for structured logging configuration."""

import importlib.util
import logging
from pathlib import Path

import pytest

MODULE_PATH = Path(__file__).resolve().parents[1] / "src/threatxtension/logging.py"


def _load_logging_module() -> importlib.util.ModuleType:
"""Load the logging module without requiring the package to be installed."""
spec = importlib.util.spec_from_file_location("threatxtension_logging", MODULE_PATH)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module


def test_configure_logging_uses_env_level_and_format(monkeypatch: pytest.MonkeyPatch) -> None:
module = _load_logging_module()
monkeypatch.setenv("THREATXTENSION_LOG_LEVEL", "warning")
monkeypatch.delenv("LOG_LEVEL", raising=False)
logger = module.configure_logging()
assert logger.name == "threatxtension"
assert logger.level == logging.WARNING
assert logger.handlers
formatter = logger.handlers[0].formatter
assert formatter is not None
record = logging.LogRecord(
"threatxtension", logging.WARNING, "threatxtension", 1, "hello", (), None
)
assert formatter.formatTime(record)
formatted = formatter.format(record)
assert "WARNING" in formatted
assert "hello" in formatted
assert module.get_logger("api.database").name == "threatxtension.api.database"