E5EE feat(logging): structured logging, colorama removal, OutputFormatter by tony · Pull Request #1017 · tmux-python/tmuxp · GitHub
[go: up one dir, main page]

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
14 changes: 13 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,12 +206,24 @@ Assert on `caplog.records` attributes, not string matching on `caplog.text`:
- Assert on schema: `record.tmux_exit_code == 0` not `"exit code 0" in caplog.text`
- `caplog.record_tuples` cannot access extra fields — always use `caplog.records`

### Output channels

Two output channels serve different audiences:

1. **Diagnostics** (`logger.*()` with `extra`): System events for log files, `caplog`, and aggregators. Never styled.
2. **User-facing output**: What the human sees. Styled via `Colors` class.
- Commands with output modes (`--json`/`--ndjson`): prefer `OutputFormatter.emit_text()` from `tmuxp.cli._output` — silenced in non-human modes.
- Human-only commands: use `tmuxp_echo()` from `tmuxp.log` (re-exported via `tmuxp.cli.utils`) for user-facing messages.
- **Undefined contracts:** Machine-output behavior for error and empty-result paths (e.g., `search` with no matches) is not yet defined. These paths currently emit styled text through `formatter.emit_text()`, which is a no-op in machine modes.

Raw `print()` is forbidden in command/business logic. The `print()` call lives only inside the presenter layer (`_output.py`) or `tmuxp_echo`.

### Avoid

- f-strings/`.format()` in log calls
- Unguarded logging in hot loops (guard with `isEnabledFor()`)
- Catch-log-reraise without adding new context
- `print()` for diagnostics
- `print()` for debugging or internal diagnostics — use `logger.debug()` with structured `extra` instead
- Logging secret env var values (log key names only)
- Non-scalar ad-hoc objects in `extra`
- Requiring custom `extra` fields in format strings without safe defaults (missing keys raise `KeyError`)
Expand Down
20 changes: 20 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,26 @@ $ pipx install --suffix=@next 'tmuxp' --pip-args '\--pre' --force
_Notes on the upcoming release will go here._
<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE -->

### Bug fixes

- Fix default CLI log level from INFO to WARNING so normal usage is not noisy (#1017)
- Suppress raw Python tracebacks on workspace build failure; error details available via `--log-level debug` while the user sees only `[Error] <message>` (#1017)
- Fix `get_pane()` to match sibling methods: widen catch to `Exception`, preserve exception chain via `from e`, replace bare `print()` with structured debug log (#1017)
- Route `ls --json` and `debug-info --json` through `OutputFormatter` for consistent machine-readable output (#1017)

### Development

#### Structured logging with `extra` context across all modules (#1017)

All modules now use `logging.getLogger(__name__)` with structured `extra` keys
(`tmux_session`, `tmux_window`, `tmux_pane`, `tmux_config_path`, etc.) for
filtering and aggregation. Library `__init__.py` adds `NullHandler` per Python
best practices. A new `TmuxpLoggerAdapter` provides persistent context for
objects with stable identity.

- Remove `colorama` runtime and type-stub dependencies; replace with stdlib ANSI constants (#1017)
- Route all raw `print()` calls through `tmuxp_echo()` for consistent output channels (#1017)

## tmuxp 1.65.0 (2026-03-08)

### Breaking Changes
Expand Down
3 changes: 0 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ include = [
]
dependencies = [
"libtmux~=0.55.0",
"colorama>=0.3.9",
"PyYAML>=6.0"
]

Expand Down Expand Up @@ -82,7 +81,6 @@ dev = [
# Lint
"ruff",
"mypy",
"types-colorama",
"types-docutils",
"types-Pygments",
"types-PyYAML",
Expand Down Expand Up @@ -118,7 +116,6 @@ coverage =[
lint = [
"ruff",
"mypy",
"types-colorama",
"types-docutils",
"types-Pygments",
"types-PyYAML",
Expand Down
4 changes: 4 additions & 0 deletions src/tmuxp/__about__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

from __future__ import annotations

import logging

logger = logging.getLogger(__name__)

__title__ = "tmuxp"
__package_name__ = "tmuxp"
__version__ = "1.65.0"
Expand Down
5 changes: 5 additions & 0 deletions src/tmuxp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

from __future__ import annotations

import logging

from . import cli, util
from .__about__ import (
__author__,
Expand All @@ -17,3 +19,6 @@
__title__,
__version__,
)

logger = logging.getLogger(__name__)
logger.addHandler(logging.NullHandler())
31 changes: 27 additions & 4 deletions src/tmuxp/_compat.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,41 @@
# flake8: NOQA
from __future__ import annotations

import logging
import sys

logger = logging.getLogger(__name__)

PY3 = sys.version_info[0] == 3
PYMINOR = sys.version_info[1]
PYPATCH = sys.version_info[2]

_identity = lambda x: x

def _identity(x: object) -> object:
"""Return *x* unchanged — used as a no-op decorator.

Examples
--------
>>> from tmuxp._compat import _identity

Strings pass through unchanged:

>>> _identity("hello")
'hello'

Integers pass through unchanged:

>>> _identity(42)
42
"""
return x


if PY3 and PYMINOR >= 7:
breakpoint = breakpoint
breakpoint = breakpoint # noqa: A001
else:
import pdb

breakpoint = pdb.set_trace
breakpoint = pdb.set_trace # noqa: A001


implements_to_string = _identity
6 changes: 6 additions & 0 deletions src/tmuxp/_internal/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,7 @@
"""Internal APIs for tmuxp."""

from __future__ import annotations

import logging

logger = logging.getLogger(__name__)
30 changes: 30 additions & 0 deletions src/tmuxp/_internal/colors.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,14 @@
from __future__ import annotations

import enum
import logging
import os
import re
import sys
import typing as t

logger = logging.getLogger(__name__)

if t.TYPE_CHECKING:
from typing import TypeAlias

Expand Down Expand Up @@ -470,6 +473,33 @@ def format_separator(self, length: int = 25) -> str:
"""
return self.muted("-" * length)

def format_rule(self, width: int = 40, char: str = "─") -> str:
"""Format a horizontal rule using Unicode box-drawing characters.
A richer alternative to ``format_separator()`` which uses plain hyphens.
Parameters
----------
width : int
Number of characters. Default is 40.
char : str
Character to repeat. Default is ``"─"`` (U+2500).
Returns
-------
str
Muted (blue) rule when colors enabled, plain rule otherwise.
Examples
--------
>>> colors = Colors(ColorMode.NEVER)
>>> colors.format_rule(10)
'──────────'
>>> colors.format_rule(5, char="=")
'====='
"""
return self.muted(char * width)

def format_kv(self, key: str, value: str) -> str:
"""Format key: value pair with syntax highlighting.
Expand Down
4 changes: 4 additions & 0 deletions src/tmuxp/_internal/config_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@
from __future__ import annotations

import json
import logging
import pathlib
6387 import typing as t

import yaml

logger = logging.getLogger(__name__)

if t.TYPE_CHECKING:
from typing import TypeAlias

Expand Down Expand Up @@ -106,6 +109,7 @@ def _from_file(cls, path: pathlib.Path) -> dict[str, t.Any]:
{'session_name': 'my session'}
"""
assert isinstance(path, pathlib.Path)
logger.debug("loading config", extra={"tmux_config_path": str(path)})
content = path.open(encoding="utf-8").read()

if path.suffix in {".yaml", ".yml"}:
Expand Down
3 changes: 3 additions & 0 deletions src/tmuxp/_internal/private_path.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@

from __future__ import annotations

import logging
import os
import pathlib
import typing as t

logger = logging.getLogger(__name__)

if t.TYPE_CHECKING:
PrivatePathBase = pathlib.Path
else:
Expand Down
3 changes: 3 additions & 0 deletions src/tmuxp/_internal/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@

from __future__ import annotations

import logging
import typing as t
from typing import TypedDict

logger = logging.getLogger(__name__)

if t.TYPE_CHECKING:
import sys

Expand Down
6 changes: 3 additions & 3 deletions src/tmuxp/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,9 +181,9 @@ def create_parser() -> argparse.ArgumentParser:
"--log-level",
action="store",
metavar="log-level",
default="info",
default="warning",
choices=["debug", "info", "warning", "error", "critical"],
help='log level (debug, info, warning, error, critical) (default "info")',
help='log level (debug, info, warning, error, critical) (default "warning")',
)
parser.add_argument(
"--color",
Expand Down Expand Up @@ -297,7 +297,7 @@ def cli(_args: list[str] | None = None) -> None:
parser = create_parser()
args = parser.parse_args(_args, namespace=ns)

setup_logger(logger=logger, level=args.log_level.upper())
setup_logger(level=args.log_level.upper())

if args.subparser_name is None:
parser.print_help()
Expand Down
4 changes: 4 additions & 0 deletions src/tmuxp/cli/_colors.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

from __future__ import annotations

import logging

from tmuxp._internal.colors import (
ColorMode,
Colors,
Expand All @@ -20,6 +22,8 @@
unstyle,
)

logger = logging.getLogger(__name__)

__all__ = [
"ColorMode",
"Colors",
Expand Down
3 changes: 3 additions & 0 deletions src/tmuxp/cli/_formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@
from __future__ import annotations

import argparse
import logging
import re
import typing as t

logger = logging.getLogger(__name__)

# Options that expect a value (set externally or via --option=value)
OPTIONS_EXPECTING_VALUE = frozenset(
{
Expand Down
46 changes: 46 additions & 0 deletions src/tmuxp/cli/_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,12 @@

import enum
import json
import logging
import sys
import typing as t

logger = logging.getLogger(__name__)


class OutputMode(enum.Enum):
"""Output format modes for CLI commands.
Expand Down Expand Up @@ -117,6 +120,49 @@ def emit_text(self, text: str) -> None:
sys.stdout.write(text + "\n")
sys.stdout.flush()

def emit_object(self, data: dict[str, t.Any]) -> None:
"""Emit a single top-level JSON object (not a list of records).

For commands that produce one structured object rather than a stream of
records. Writes immediately without buffering; does not affect
``_json_buffer``.

In JSON mode, writes indented JSON followed by a newline.
In NDJSON mode, writes compact single-line JSON followed by a newline.
In HUMAN mode, does nothing (use ``emit_text`` for human output).

Parameters
----------
data : dict
The object to emit.

Examples
--------
>>> import io, sys
>>> formatter = OutputFormatter(OutputMode.JSON)
>>> formatter.emit_object({"status": "ok", "count": 3})
{
"status": "ok",
"count": 3
}
>>> formatter._json_buffer # buffer is unaffected
[]

>>> formatter2 = OutputFormatter(OutputMode.NDJSON)
>>> formatter2.emit_object({"status": "ok", "count": 3})
{"status": "ok", "count": 3}

>>> formatter3 = OutputFormatter(OutputMode.HUMAN)
>>> formatter3.emit_object({"status": "ok"}) # no output in HUMAN mode
"""
if self.mode == OutputMode.JSON:
sys.stdout.write(json.dumps(data, indent=2) + "\n")
sys.stdout.flush()
elif self.mode == OutputMode.NDJSON:
sys.stdout.write(json.dumps(data) + "\n")
sys.stdout.flush()
# HUMAN: no-op

def finalize(self) -> None:
"""Finalize output (flush JSON buffer if needed).

Expand Down
7 changes: 5 additions & 2 deletions src/tmuxp/cli/convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import locale
import logging
import os
import pathlib
import typing as t
Expand All @@ -13,7 +14,9 @@
from tmuxp.workspace.finders import find_workspace_file, get_workspace_dir

from ._colors import Colors, build_description, get_color_mode
from .utils import prompt_yes_no
from .utils import prompt_yes_no, tmuxp_echo

logger = logging.getLogger(__name__)

CONVERT_DESCRIPTION = build_description(
"""
Expand Down Expand Up @@ -130,7 +133,7 @@ def command_convert(
new_workspace,
encoding=locale.getpreferredencoding(False),
)
print( # NOQA: T201 RUF100
tmuxp_echo(
colors.success("New workspace file saved to ")
+ colors.info(str(PrivatePath(newfile)))
+ ".",
Expand Down
Loading
0