Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
25 changes: 20 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand All @@ -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
Expand Down
88 changes: 78 additions & 10 deletions pylogkit/log_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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``.
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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":
Expand Down
140 changes: 140 additions & 0 deletions tests/test_environment.py
Original file line number Diff line number Diff line change
@@ -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")
Loading