From 457910901dc4e62b4e6c607ef4127e60efe1a8e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 12:01:10 +0100 Subject: [PATCH] Configure level, format and file from the environment ## Summary Deployments could not change logging behaviour without code changes. ## Changes - `setup_logging()` reads `LOG_LEVEL`, `LOG_FORMAT` (`pretty`, `compact`, `json` or `auto`) and `LOG_FILE` when the matching arguments are not passed. An explicit argument always wins. `LOG_FORMAT` is ignored once `mode` or `use_json` is passed. An unknown `LOG_FORMAT` raises `ValueError`. - To tell "not passed" from "passed the default", the defaults of `level`, `mode`, `use_json`, `to_file` and `file_path` become `None`. Behaviour without environment variables is unchanged, with one exception: `file_path=None` used to disable file output and now means "use the default", so use `to_file=False` for that. - README gains an environment variables section and updated options table, with tests for each variable, the overrides, and the error cases. ## Decision for review The issue suggests pretty output on a terminal and JSON otherwise as a default. Making that the default would silently change the output of every existing application that pipes its logs, so it is opt-in as `LOG_FORMAT=auto`. The default stays `pretty`. ## Verification - `pytest`: 123 passed (latest dependencies) and 103 passed with 20 skipped (no extras). `ruff` and `mypy --strict` are clean. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01U2nYHVGDdthQpcwNBPbp8r --- CHANGELOG.md | 7 ++ README.md | 25 +++++-- pylogkit/log_setup.py | 88 +++++++++++++++++++++--- tests/test_environment.py | 140 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 245 insertions(+), 15 deletions(-) create mode 100644 tests/test_environment.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d6beb4b..c8d65cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - `log_context(**fields)` is a context manager that adds fields to the log context for a `with` block and restores the previous context on exit, including on exceptions (#21). +- `setup_logging()` reads `LOG_LEVEL`, `LOG_FORMAT` (`pretty`, `compact`, + `json` or `auto`) and `LOG_FILE` when the matching arguments are not passed. + Explicit arguments always override the environment (#22). ### Changed @@ -77,6 +80,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - The global log context is held in a `contextvars.ContextVar` instead of `threading.local`, so it follows `asyncio` tasks as well as threads. A new thread still starts with an empty context (#21). +- The defaults of `level`, `mode`, `use_json`, `to_file` and `file_path` are + now `None`, meaning "use the environment, then the built-in default". + Behaviour without environment variables is unchanged, except that passing + `file_path=None` no longer disables file output: use `to_file=False` (#22). ### Deprecated diff --git a/README.md b/README.md index e952db0..086a3ea 100644 --- a/README.md +++ b/README.md @@ -125,15 +125,15 @@ log.info("outside") # carries neither | Argument | Default | Purpose | | --- | --- | --- | | `name` | `None` | Logger name. `None` configures the root logger. | -| `level` | `"INFO"` | Level for every handler unless overridden below. | +| `level` | `None` | Level for every handler unless overridden below. Falls back to `LOG_LEVEL`, then `"INFO"`. | | `to_console` | `True` | Log to the console. | | `console_level` | `None` | Console level, defaulting to `level`. | | `console_stream` | `None` | Console stream, for example `sys.stderr`. Defaults to `sys.stdout`. | | `use_emoji` | `True` | Show a level emoji in the verbose console layout. Set `False` for terminals that render emoji badly. | -| `mode` | `"verbose"` | Console layout: `"verbose"` (coloured, with source location and context) or `"compact"` (`[LEVEL] message`). | -| `use_json` | `False` | Write JSON lines to the console instead. Needs the `json` extra. | -| `to_file` | `False` | Log to `file_path`. | -| `file_path` | `"app.log"` | Plain-text log file. Parent directories are created. | +| `mode` | `None` | Console layout: `"verbose"` (coloured, with source location and context) or `"compact"` (`[LEVEL] message`). Falls back to `LOG_FORMAT`, then `"verbose"`. | +| `use_json` | `None` | Write JSON lines to the console instead. Needs the `json` extra. Falls back to `LOG_FORMAT`. | +| `to_file` | `None` | Log to `file_path`. Defaults to on when `LOG_FILE` is set, otherwise off. | +| `file_path` | `None` | Plain-text log file. Falls back to `LOG_FILE`, then `"app.log"`. Parent directories are created. | | `file_level` | `None` | File level, defaulting to `level`. | | `to_json_file` | `False` | Log JSON lines to `json_file_path`. Needs the `json` extra. | | `json_file_path` | `"app.json.log"` | JSON log file. | @@ -158,6 +158,21 @@ Things worth knowing: - Asking for JSON output without `python-json-logger` installed raises an `ImportError` with an install hint. +### Environment variables + +Deployments can change logging without code changes. An argument passed to `setup_logging()` always +wins over the environment. + +| Variable | Effect | +| --- | --- | +| `LOG_LEVEL` | Default level, for example `DEBUG`. Used when `level` is not passed. | +| `LOG_FORMAT` | Console layout: `pretty` (the default), `compact`, `json`, or `auto` (pretty on a terminal, JSON otherwise). Ignored when `mode` or `use_json` is passed. Any other value raises `ValueError`. | +| `LOG_FILE` | Path of a plain-text log file. Turns file logging on unless `to_file` is passed, and sets `file_path` unless that is passed. | + +```bash +LOG_LEVEL=DEBUG LOG_FORMAT=auto LOG_FILE=/var/log/app.log python app.py +``` + ### Console colour Colour is used only when the console stream is a terminal. It is switched off when output is diff --git a/pylogkit/log_setup.py b/pylogkit/log_setup.py index c46a2f9..656a1ab 100644 --- a/pylogkit/log_setup.py +++ b/pylogkit/log_setup.py @@ -378,22 +378,74 @@ def _prepare_log_file(path: str, overwrite: bool) -> None: open(path, "w", encoding="utf-8").close() +_LOG_FORMATS = ("pretty", "compact", "json", "auto") + + +def _resolve_environment( + to_file: bool | None, + file_path: str | None, + level: str | None, + mode: str | None, + use_json: bool | None, + console_stream: IO[str], +) -> tuple[bool, str | None, str, str, bool]: + """Fill the arguments that were not given from the environment. + + An argument that was passed explicitly (anything but ``None``) always wins + over ``LOG_LEVEL``, ``LOG_FORMAT`` and ``LOG_FILE``. ``LOG_FORMAT`` is + ignored as soon as ``mode`` or ``use_json`` is given. + + Returns: + ``(to_file, file_path, level, mode, use_json)`` with no ``None`` left, + except ``file_path``, which stays ``None`` only if it was never set. + + Raises: + ValueError: If ``LOG_FORMAT`` is not one of ``pretty``, ``compact``, + ``json`` or ``auto``. + """ + env_file = os.environ.get("LOG_FILE") or None + if to_file is None: + to_file = env_file is not None + if file_path is None: + file_path = env_file or "app.log" + if level is None: + level = os.environ.get("LOG_LEVEL") or "INFO" + + if mode is None and use_json is None: + log_format = (os.environ.get("LOG_FORMAT") or "pretty").strip().lower() + if log_format not in _LOG_FORMATS: + raise ValueError( + f"LOG_FORMAT must be one of {', '.join(_LOG_FORMATS)}, got {log_format!r}" + ) + if log_format == "auto": + log_format = "pretty" if _is_tty(console_stream) else "json" + mode = "compact" if log_format == "compact" else "verbose" + use_json = log_format == "json" + return to_file, file_path, level, mode or "verbose", bool(use_json) + + +def _is_tty(stream: IO[str]) -> bool: + """Return whether ``stream`` is an interactive terminal.""" + isatty = getattr(stream, "isatty", None) + return bool(isatty and isatty()) + + def setup_logging( name: str | None = None, overwrite: bool = False, to_console: bool = True, - to_file: bool = False, - file_path: str | None = "app.log", + to_file: bool | None = None, + file_path: str | None = None, to_json_file: bool = False, json_file_path: str | None = "app.json.log", to_syslog: bool = False, - level: str = "INFO", + level: str | None = None, console_level: str | None = None, file_level: str | None = None, json_level: str | None = None, syslog_level: str | None = None, - mode: str = "verbose", - use_json: bool = False, + mode: str | None = None, + use_json: bool | None = None, rotation: str = "size", max_bytes: int = 5 * 1024 * 1024, backup_count: int = 2, @@ -415,17 +467,25 @@ def setup_logging( from child loggers (``logging.getLogger("name.child")``), whereas handler filters see every record the handler emits. + Arguments left as ``None`` fall back to the environment variables + ``LOG_LEVEL``, ``LOG_FORMAT`` and ``LOG_FILE`` (see ``level``, ``mode``, + ``use_json``, ``to_file`` and ``file_path``). An explicit argument always + overrides the environment. + Args: name: Logger name. ``None`` configures the root logger. overwrite: Truncate the plain-text and JSON log files when logging is set up instead of appending to them. to_console: Log to the console (see ``console_stream``). - to_file: Log to ``file_path``. - file_path: Path of the plain-text log file. + to_file: Log to ``file_path``. Defaults to true when the ``LOG_FILE`` + environment variable is set, otherwise false. + file_path: Path of the plain-text log file. Defaults to ``LOG_FILE``, + then to ``"app.log"``. to_json_file: Log JSON lines to ``json_file_path``. json_file_path: Path of the JSON log file. to_syslog: Log to syslog (see ``syslog_address``). - level: Default level name for every handler. + level: Default level name for every handler. Defaults to ``LOG_LEVEL``, + then to ``"INFO"``. console_level: Console level, defaulting to ``level``. file_level: File level, defaulting to ``level``. json_level: JSON file level, defaulting to ``level``. @@ -434,6 +494,10 @@ def setup_logging( ``"compact"`` (``[LEVEL] message``) console layout. Ignored when ``use_json`` is true. use_json: Write JSON lines to the console instead of the text layout. + When neither ``mode`` nor ``use_json`` is given, ``LOG_FORMAT`` + chooses the layout: ``pretty`` (the default), ``compact``, + ``json``, or ``auto`` for pretty output on a terminal and JSON + otherwise. rotation: ``"size"`` or ``"time"`` file rotation. max_bytes: Rotation size for size-based rotation. backup_count: Number of rotated files to keep. @@ -459,8 +523,9 @@ def setup_logging( The configured :class:`logging.Logger`. Raises: - ImportError: If ``use_json`` or ``to_json_file`` is requested but - ``python-json-logger`` is not installed. + ImportError: If JSON output is requested but ``python-json-logger`` is + not installed. + ValueError: If ``LOG_FORMAT`` holds an unknown value. """ logger = logging.getLogger(name) logger.setLevel(logging.DEBUG) # set to DEBUG globally; control per handler @@ -471,6 +536,9 @@ def setup_logging( set_log_context(**context) stream = console_stream if console_stream is not None else sys.stdout + to_file, file_path, level, mode, use_json = _resolve_environment( + to_file, file_path, level, mode, use_json, stream + ) if use_json: formatter = _build_json_formatter() elif mode == "compact": diff --git a/tests/test_environment.py b/tests/test_environment.py new file mode 100644 index 0000000..a6f63ad --- /dev/null +++ b/tests/test_environment.py @@ -0,0 +1,140 @@ +"""Tests for configuration through ``LOG_LEVEL``, ``LOG_FORMAT`` and ``LOG_FILE``.""" + +import io +import json +import logging + +import pytest + +from pylogkit import setup_logging + + +@pytest.fixture(autouse=True) +def _clean_environment(monkeypatch): + """Start every test without any of the variables set.""" + for name in ("LOG_LEVEL", "LOG_FORMAT", "LOG_FILE"): + monkeypatch.delenv(name, raising=False) + + +def test_defaults_without_environment(): + """With nothing set the behaviour is unchanged: INFO, console only.""" + logger = setup_logging("env-default") + + assert logger.handlers[0].level == logging.INFO + assert len(logger.handlers) == 1 + + +def test_log_level_from_environment(monkeypatch): + """``LOG_LEVEL`` sets the level when ``level`` is not passed.""" + monkeypatch.setenv("LOG_LEVEL", "debug") + + logger = setup_logging("env-level") + + assert logger.handlers[0].level == logging.DEBUG + + +def test_explicit_level_overrides_environment(monkeypatch): + """An explicit argument beats the environment.""" + monkeypatch.setenv("LOG_LEVEL", "DEBUG") + + logger = setup_logging("env-level-arg", level="ERROR") + + assert logger.handlers[0].level == logging.ERROR + + +def test_invalid_log_level_is_rejected(monkeypatch): + """A bad level name fails loudly instead of being ignored.""" + monkeypatch.setenv("LOG_LEVEL", "LOUD") + + with pytest.raises(ValueError, match="LOUD"): + setup_logging("env-level-bad") + + +def test_log_file_enables_file_logging(monkeypatch, tmp_path): + """``LOG_FILE`` turns on the plain-text file at that path.""" + path = tmp_path / "from-env.log" + monkeypatch.setenv("LOG_FILE", str(path)) + + setup_logging("env-file", to_console=False).info("hello") + + assert "hello" in path.read_text(encoding="utf-8") + + +def test_explicit_file_arguments_override_environment(monkeypatch, tmp_path): + """``file_path`` and ``to_file=False`` both beat ``LOG_FILE``.""" + env_path = tmp_path / "env.log" + own_path = tmp_path / "own.log" + monkeypatch.setenv("LOG_FILE", str(env_path)) + + setup_logging("env-file-path", to_console=False, file_path=str(own_path)).info("a") + setup_logging("env-file-off", to_console=False, to_file=False).info("b") + + assert own_path.exists() + assert not env_path.exists() + + +def test_compact_format_from_environment(monkeypatch): + """``LOG_FORMAT=compact`` selects the compact layout.""" + monkeypatch.setenv("LOG_FORMAT", "compact") + stream = io.StringIO() + + setup_logging("env-compact", console_stream=stream).info("hi") + + assert stream.getvalue().strip() == "[INFO] hi" + + +@pytest.mark.json_logger +def test_json_format_from_environment(monkeypatch): + """``LOG_FORMAT=json`` writes JSON lines to the console.""" + monkeypatch.setenv("LOG_FORMAT", "json") + stream = io.StringIO() + + setup_logging("env-json", console_stream=stream).info("hi") + + assert json.loads(stream.getvalue())["message"] == "hi" + + +@pytest.mark.json_logger +def test_auto_format_is_json_when_not_a_terminal(monkeypatch): + """``auto`` picks JSON for a pipe or file.""" + monkeypatch.setenv("LOG_FORMAT", "auto") + stream = io.StringIO() + + setup_logging("env-auto-pipe", console_stream=stream).info("hi") + + assert json.loads(stream.getvalue())["message"] == "hi" + + +def test_auto_format_is_pretty_on_a_terminal(monkeypatch): + """``auto`` picks the verbose layout for a terminal.""" + monkeypatch.setenv("LOG_FORMAT", "auto") + monkeypatch.setenv("NO_COLOR", "1") + + class Terminal(io.StringIO): + def isatty(self) -> bool: + return True + + stream = Terminal() + + setup_logging("env-auto-tty", console_stream=stream).info("hi") + + assert "env-auto-tty" in stream.getvalue() + assert not stream.getvalue().lstrip().startswith("{") + + +def test_explicit_mode_overrides_log_format(monkeypatch): + """Passing ``mode`` or ``use_json`` makes ``LOG_FORMAT`` irrelevant, even when invalid.""" + monkeypatch.setenv("LOG_FORMAT", "nonsense") + stream = io.StringIO() + + setup_logging("env-mode", mode="compact", console_stream=stream).info("hi") + + assert stream.getvalue().strip() == "[INFO] hi" + + +def test_unknown_log_format_is_rejected(monkeypatch): + """A typo in ``LOG_FORMAT`` raises a clear error.""" + monkeypatch.setenv("LOG_FORMAT", "yaml") + + with pytest.raises(ValueError, match="LOG_FORMAT"): + setup_logging("env-format-bad")