diff --git a/AGENTS.md b/AGENTS.md index 3fe760a6e0..bdbe24f926 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`) diff --git a/CHANGES b/CHANGES index 95d8c3e57e..5e8bf5cdcd 100644 --- a/CHANGES +++ b/CHANGES @@ -27,7 +27,7 @@ $ pipx install --suffix=@next 'tmuxp' --pip-args '\--pre' --force // Usage: tmuxp@next load yoursession ``` -## tmuxp 1.66.0 (Yet to be released) +## tmuxp 1.67.0 (Yet to be released) @@ -35,6 +35,28 @@ $ pipx install --suffix=@next 'tmuxp' --pip-args '\--pre' --force _Notes on the upcoming release will go here._ +## tmuxp 1.66.0 (2026-03-08) + +### 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] ` (#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 diff --git a/pyproject.toml b/pyproject.toml index 4e908892de..ceb1aae0c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tmuxp" -version = "1.65.0" +version = "1.66.0" description = "Session manager for tmux, which allows users to save and load tmux sessions through simple configuration files." requires-python = ">=3.10,<4.0" authors = [ @@ -40,7 +40,6 @@ include = [ ] dependencies = [ "libtmux~=0.55.0", - "colorama>=0.3.9", "PyYAML>=6.0" ] @@ -82,7 +81,6 @@ dev = [ # Lint "ruff", "mypy", - "types-colorama", "types-docutils", "types-Pygments", "types-PyYAML", @@ -118,7 +116,6 @@ coverage =[ lint = [ "ruff", "mypy", - "types-colorama", "types-docutils", "types-Pygments", "types-PyYAML", diff --git a/src/tmuxp/__about__.py b/src/tmuxp/__about__.py index adf89c7b0c..f1cfd86085 100644 --- a/src/tmuxp/__about__.py +++ b/src/tmuxp/__about__.py @@ -2,9 +2,13 @@ from __future__ import annotations +import logging + +logger = logging.getLogger(__name__) + __title__ = "tmuxp" __package_name__ = "tmuxp" -__version__ = "1.65.0" +__version__ = "1.66.0" __description__ = "tmux session manager" __email__ = "tony@git-pull.com" __author__ = "Tony Narlock" diff --git a/src/tmuxp/__init__.py b/src/tmuxp/__init__.py index 25c46f4d21..78e2118ea5 100644 --- a/src/tmuxp/__init__.py +++ b/src/tmuxp/__init__.py @@ -6,6 +6,8 @@ from __future__ import annotations +import logging + from . import cli, util from .__about__ import ( __author__, @@ -17,3 +19,6 @@ __title__, __version__, ) + +logger = logging.getLogger(__name__) +logger.addHandler(logging.NullHandler()) diff --git a/src/tmuxp/_compat.py b/src/tmuxp/_compat.py index ca83962641..51a055f7f5 100644 --- a/src/tmuxp/_compat.py +++ b/src/tmuxp/_compat.py @@ -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 diff --git a/src/tmuxp/_internal/__init__.py b/src/tmuxp/_internal/__init__.py index 01dccbcfcb..baae40fd2c 100644 --- a/src/tmuxp/_internal/__init__.py +++ b/src/tmuxp/_internal/__init__.py @@ -1 +1,7 @@ """Internal APIs for tmuxp.""" + +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) diff --git a/src/tmuxp/_internal/colors.py b/src/tmuxp/_internal/colors.py index a25442d83c..73db6062fa 100644 --- a/src/tmuxp/_internal/colors.py +++ b/src/tmuxp/_internal/colors.py @@ -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 @@ -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. diff --git a/src/tmuxp/_internal/config_reader.py b/src/tmuxp/_internal/config_reader.py index 3c667bbceb..6da248dea7 100644 --- a/src/tmuxp/_internal/config_reader.py +++ b/src/tmuxp/_internal/config_reader.py @@ -3,11 +3,14 @@ from __future__ import annotations import json +import logging import pathlib import typing as t import yaml +logger = logging.getLogger(__name__) + if t.TYPE_CHECKING: from typing import TypeAlias @@ -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"}: diff --git a/src/tmuxp/_internal/private_path.py b/src/tmuxp/_internal/private_path.py index 2ab8a998ae..8fa0cec972 100644 --- a/src/tmuxp/_internal/private_path.py +++ b/src/tmuxp/_internal/private_path.py @@ -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: diff --git a/src/tmuxp/_internal/types.py b/src/tmuxp/_internal/types.py index a3521f5832..41498cebd0 100644 --- a/src/tmuxp/_internal/types.py +++ b/src/tmuxp/_internal/types.py @@ -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 diff --git a/src/tmuxp/cli/__init__.py b/src/tmuxp/cli/__init__.py index b05708321f..860a9200cb 100644 --- a/src/tmuxp/cli/__init__.py +++ b/src/tmuxp/cli/__init__.py @@ -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", @@ -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() diff --git a/src/tmuxp/cli/_colors.py b/src/tmuxp/cli/_colors.py index 9932218fb6..2513d118a8 100644 --- a/src/tmuxp/cli/_colors.py +++ b/src/tmuxp/cli/_colors.py @@ -9,6 +9,8 @@ from __future__ import annotations +import logging + from tmuxp._internal.colors import ( ColorMode, Colors, @@ -20,6 +22,8 @@ unstyle, ) +logger = logging.getLogger(__name__) + __all__ = [ "ColorMode", "Colors", diff --git a/src/tmuxp/cli/_formatter.py b/src/tmuxp/cli/_formatter.py index 9dfceaf29e..d4efb9ff47 100644 --- a/src/tmuxp/cli/_formatter.py +++ b/src/tmuxp/cli/_formatter.py @@ -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( { diff --git a/src/tmuxp/cli/_output.py b/src/tmuxp/cli/_output.py index 7ac8df92ef..b62f1cc05c 100644 --- a/src/tmuxp/cli/_output.py +++ b/src/tmuxp/cli/_output.py @@ -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. @@ -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). diff --git a/src/tmuxp/cli/convert.py b/src/tmuxp/cli/convert.py index 97d2d8cd25..a92cfebbfa 100644 --- a/src/tmuxp/cli/convert.py +++ b/src/tmuxp/cli/convert.py @@ -3,6 +3,7 @@ from __future__ import annotations import locale +import logging import os import pathlib import typing as t @@ -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( """ @@ -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))) + ".", diff --git a/src/tmuxp/cli/debug_info.py b/src/tmuxp/cli/debug_info.py index 284ee462b3..8a69f81bdd 100644 --- a/src/tmuxp/cli/debug_info.py +++ b/src/tmuxp/cli/debug_info.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import logging import os import pathlib import platform @@ -17,8 +18,11 @@ from tmuxp._internal.private_path import PrivatePath, collapse_home_in_string from ._colors import Colors, build_description, get_color_mode +from ._output import OutputFormatter, OutputMode from .utils import tmuxp_echo +logger = logging.getLogger(__name__) + DEBUG_INFO_DESCRIPTION = build_description( """ Print diagnostic information for debugging and issue reports. @@ -243,9 +247,6 @@ def command_debug_info( parser: argparse.ArgumentParser | None = None, ) -> None: """Entrypoint for ``tmuxp debug-info`` to print debug info to submit with issues.""" - import json - import sys - # Get output mode output_json = args.output_json if args else False @@ -259,7 +260,6 @@ def command_debug_info( # Output based on mode if output_json: # Single object, not wrapped in array - sys.stdout.write(json.dumps(data, indent=2) + "\n") - sys.stdout.flush() + OutputFormatter(OutputMode.JSON).emit_object(data) else: tmuxp_echo(_format_human_output(data, colors)) diff --git a/src/tmuxp/cli/edit.py b/src/tmuxp/cli/edit.py index 006ad6bb12..7308f2eba7 100644 --- a/src/tmuxp/cli/edit.py +++ b/src/tmuxp/cli/edit.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import os import subprocess import typing as t @@ -10,6 +11,9 @@ from tmuxp.workspace.finders import find_workspace_file from ._colors import Colors, build_description, get_color_mode +from .utils import tmuxp_echo + +logger = logging.getLogger(__name__) EDIT_DESCRIPTION = build_description( """ @@ -59,7 +63,7 @@ def command_edit( workspace_file = find_workspace_file(workspace_file) sys_editor = os.environ.get("EDITOR", "vim") - print( # NOQA: T201 RUF100 + tmuxp_echo( colors.muted("Opening ") + colors.info(str(PrivatePath(workspace_file))) + colors.muted(" in ") diff --git a/src/tmuxp/cli/freeze.py b/src/tmuxp/cli/freeze.py index 9b48ebf01e..fa26569ca7 100644 --- a/src/tmuxp/cli/freeze.py +++ b/src/tmuxp/cli/freeze.py @@ -4,6 +4,7 @@ import argparse import locale +import logging import os import pathlib import sys @@ -19,7 +20,9 @@ from tmuxp.workspace.finders import get_workspace_dir from ._colors import Colors, build_description, get_color_mode -from .utils import prompt, prompt_choices, prompt_yes_no +from .utils import prompt, prompt_choices, prompt_yes_no, tmuxp_echo + +logger = logging.getLogger(__name__) FREEZE_DESCRIPTION = build_description( """ @@ -141,7 +144,7 @@ def command_freeze( if not session: raise exc.SessionNotFound except TmuxpException as e: - print(colors.error(str(e))) # NOQA: T201 RUF100 + tmuxp_echo(colors.error(str(e))) return frozen_workspace = freezer.freeze(session) @@ -149,7 +152,7 @@ def command_freeze( configparser = ConfigReader(workspace) if not args.quiet: - print( # NOQA: T201 RUF100 + tmuxp_echo( colors.format_separator(63) + "\n" + colors.muted("Freeze does its best to snapshot live tmux sessions.") @@ -163,7 +166,7 @@ def command_freeze( ) ): if not args.quiet: - print( # NOQA: T201 RUF100 + tmuxp_echo( colors.muted("tmuxp has examples in JSON and YAML format at ") + colors.info("") + "\n" @@ -190,7 +193,7 @@ def command_freeze( color_mode=color_mode, ) if not args.force and os.path.exists(dest_prompt): - print( # NOQA: T201 RUF100 + tmuxp_echo( colors.warning(f"{PrivatePath(dest_prompt)} exists.") + " " + colors.muted("Pick a new filename."), @@ -252,8 +255,9 @@ def extract_workspace_format( workspace, encoding=locale.getpreferredencoding(False), ) + logger.info("workspace saved", extra={"tmux_config_path": str(dest)}) if not args.quiet: - print( # NOQA: T201 RUF100 + tmuxp_echo( colors.success("Saved to ") + colors.info(str(PrivatePath(dest))) + ".", ) diff --git a/src/tmuxp/cli/import_config.py b/src/tmuxp/cli/import_config.py index 63c2d24a30..df49c221ba 100644 --- a/src/tmuxp/cli/import_config.py +++ b/src/tmuxp/cli/import_config.py @@ -3,6 +3,7 @@ from __future__ import annotations import locale +import logging import os import pathlib import sys @@ -16,6 +17,8 @@ from ._colors import ColorMode, Colors, build_description, get_color_mode from .utils import prompt, prompt_choices, prompt_yes_no, tmuxp_echo +logger = logging.getLogger(__name__) + IMPORT_DESCRIPTION = build_description( """ Import workspaces from teamocil and tmuxinator configuration files. @@ -220,6 +223,10 @@ def import_config( encoding=locale.getpreferredencoding(False), ) + logger.info( + "workspace saved", + extra={"tmux_config_path": str(dest)}, + ) tmuxp_echo( colors.success("Saved to ") + colors.info(str(PrivatePath(dest))) + ".", ) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index 3e6edbd2b7..4673c4cde6 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -23,6 +23,8 @@ from ._colors import ColorMode, Colors, build_description, get_color_mode from .utils import prompt_choices, prompt_yes_no, tmuxp_echo +logger = logging.getLogger(__name__) + LOAD_DESCRIPTION = build_description( """ Load tmuxp workspace file(s) and create or attach to a tmux session. @@ -74,6 +76,7 @@ class CLILoadNamespace(argparse.Namespace): colors: CLIColorsLiteral | None color: CLIColorModeLiteral log_file: str | None + log_level: str def load_plugins( @@ -120,6 +123,7 @@ def load_plugins( module_name = ".".join(module_name[:-1]) plugin_name = plugin.split(".")[-1] except AttributeError as error: + logger.debug("plugin load failed", exc_info=True) tmuxp_echo( colors.error("[Plugin Error]") + f" Couldn't load {plugin}\n" @@ -136,12 +140,16 @@ def load_plugins( default=True, color_mode=colors.mode, ): + logger.warning( + "plugin version constraint not met, user declined skip", + ) tmuxp_echo( colors.warning("[Not Skipping]") + " Plugin versions constraint not met. Exiting...", ) sys.exit(1) except (ImportError, AttributeError) as error: + logger.debug("plugin import failed", exc_info=True) tmuxp_echo( colors.error("[Plugin Error]") + f" Couldn't load {plugin}\n" @@ -175,7 +183,11 @@ def _reattach(builder: WorkspaceBuilder, colors: Colors | None = None) -> None: plugin.reattach(builder.session) proc = builder.session.cmd("display-message", "-p", "'#S'") for line in proc.stdout: - print(colors.info(line) if colors else line) # NOQA: T201 RUF100 + tmuxp_echo(colors.info(line) if colors else line) + logger.debug( + "reattach display-message output", + extra={"tmux_stdout": [line.strip()]}, + ) if "TMUX" in os.environ: builder.session.switch_client() @@ -222,7 +234,8 @@ def _load_detached(builder: WorkspaceBuilder, colors: Colors | None = None) -> N assert builder.session is not None msg = "Session created in detached state." - print(colors.info(msg) if colors else msg) # NOQA: T201 RUF100 + tmuxp_echo(colors.info(msg) if colors else msg) + logger.info("session created in detached state") def _load_append_windows_to_current_session(builder: WorkspaceBuilder) -> None: @@ -344,6 +357,10 @@ def load_workspace( if isinstance(workspace_file, (str, os.PathLike)): workspace_file = pathlib.Path(workspace_file) + logger.info( + "loading workspace", + extra={"tmux_config_path": str(workspace_file)}, + ) tmuxp_echo( cli_colors.info("[Loading]") + " " @@ -375,13 +392,18 @@ def load_workspace( shutil.which("tmux") # raise exception if tmux not found - try: # load WorkspaceBuilder object for tmuxp workspace / tmux server + # WorkspaceBuilder creation — outside spinner so plugin prompts are safe + try: builder = WorkspaceBuilder( session_config=expanded_workspace, plugins=load_plugins(expanded_workspace, colors=cli_colors), server=t, ) except exc.EmptyWorkspaceException: + logger.warning( + "workspace file is empty", + extra={"tmux_config_path": str(workspace_file)}, + ) tmuxp_echo( cli_colors.warning("[Warning]") + f" {PrivatePath(workspace_file)} is empty or parsed no workspace data", @@ -390,7 +412,7 @@ def load_workspace( session_name = expanded_workspace["session_name"] - # if the session already exists, prompt the user to attach + # Session-exists check — outside spinner so prompt_yes_no is safe if builder.session_exists(session_name) and not append: if not detached and ( answer_yes @@ -439,9 +461,7 @@ def load_workspace( _load_attached(builder, detached) except exc.TmuxpException as e: - import traceback - - tmuxp_echo(traceback.format_exc()) + logger.debug("workspace build failed", exc_info=True) tmuxp_echo(cli_colors.error("[Error]") + f" {e}") choice = prompt_choices( @@ -456,6 +476,7 @@ def load_workspace( if builder.session is not None: builder.session.kill() tmuxp_echo(cli_colors.muted("Session killed.")) + logger.info("session killed by user after build error") elif choice == "a": _reattach(builder, cli_colors) else: @@ -590,12 +611,7 @@ def command_load( cli_colors = Colors(get_color_mode(args.color)) if args.log_file: - logfile_handler = logging.FileHandler(args.log_file) - logfile_handler.setFormatter(log.LogFormatter()) - # Add handler to tmuxp root logger to capture all tmuxp log messages - tmuxp_logger = logging.getLogger("tmuxp") - tmuxp_logger.setLevel(logging.INFO) # Ensure logger level allows INFO - tmuxp_logger.addHandler(logfile_handler) + log.setup_log_file(args.log_file, args.log_level) if args.workspace_files is None or len(args.workspace_files) == 0: tmuxp_echo(cli_colors.error("Enter at least one config")) diff --git a/src/tmuxp/cli/ls.py b/src/tmuxp/cli/ls.py index de8ec2dcd2..cd1ff5da75 100644 --- a/src/tmuxp/cli/ls.py +++ b/src/tmuxp/cli/ls.py @@ -29,6 +29,7 @@ import argparse import datetime import json +import logging import pathlib import typing as t @@ -46,6 +47,8 @@ from ._colors import Colors, build_description, get_color_mode from ._output import OutputFormatter, OutputMode, get_output_mode +logger = logging.getLogger(__name__) + LS_DESCRIPTION = build_description( """ List workspace files in the tmuxp configuration directory. @@ -567,9 +570,6 @@ def command_ls( -------- >>> # command_ls() lists workspaces from cwd/parents and ~/.tmuxp/ """ - import json - import sys - # Get color mode from args or default to AUTO color_mode = get_color_mode(args.color if args else None) colors = Colors(color_mode) @@ -612,8 +612,7 @@ def command_ls( "workspaces": [], "global_workspace_dirs": global_dir_candidates, } - sys.stdout.write(json.dumps(output_data, indent=2) + "\n") - sys.stdout.flush() + formatter.emit_object(output_data) # NDJSON: just output nothing for empty workspaces return @@ -623,8 +622,7 @@ def command_ls( "workspaces": workspaces, "global_workspace_dirs": global_dir_candidates, } - sys.stdout.write(json.dumps(output_data, indent=2) + "\n") - sys.stdout.flush() + formatter.emit_object(output_data) return # Human and NDJSON output diff --git a/src/tmuxp/cli/search.py b/src/tmuxp/cli/search.py index 93368fd04f..3be4cb1974 100644 --- a/src/tmuxp/cli/search.py +++ b/src/tmuxp/cli/search.py @@ -25,6 +25,7 @@ import argparse import json +import logging import pathlib import re import typing as t @@ -39,6 +40,8 @@ from ._colors import Colors, build_description, get_color_mode from ._output import OutputFormatter, get_output_mode +logger = logging.getLogger(__name__) + if t.TYPE_CHECKING: from typing import TypeAlias diff --git a/src/tmuxp/cli/shell.py b/src/tmuxp/cli/shell.py index e62f0a0758..57a5ae8e4b 100644 --- a/src/tmuxp/cli/shell.py +++ b/src/tmuxp/cli/shell.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import logging import os import pathlib import typing as t @@ -13,6 +14,9 @@ from tmuxp._compat import PY3, PYMINOR from ._colors import Colors, build_description, get_color_mode +from .utils import tmuxp_echo + +logger = logging.getLogger(__name__) SHELL_DESCRIPTION = build_description( """ @@ -222,7 +226,7 @@ def command_shell( ): from tmuxp._compat import breakpoint as tmuxp_breakpoint - print( # NOQA: T201 RUF100 + tmuxp_echo( cli_colors.muted("Launching ") + cli_colors.highlight("pdb", bold=False) + cli_colors.muted(" shell..."), @@ -233,7 +237,7 @@ def command_shell( from tmuxp.shell import launch shell_name = args.shell or "best" - print( # NOQA: T201 RUF100 + tmuxp_echo( cli_colors.muted("Launching ") + cli_colors.highlight(shell_name, bold=False) + cli_colors.muted(" shell for session ") diff --git a/src/tmuxp/cli/utils.py b/src/tmuxp/cli/utils.py index 58896deb0f..034c98b2ed 100644 --- a/src/tmuxp/cli/utils.py +++ b/src/tmuxp/cli/utils.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import typing as t from tmuxp._internal.colors import ( @@ -15,6 +16,8 @@ from tmuxp._internal.private_path import PrivatePath from tmuxp.log import tmuxp_echo +logger = logging.getLogger(__name__) + if t.TYPE_CHECKING: from collections.abc import Callable, Sequence @@ -215,7 +218,7 @@ def prompt_choices( return None if rv in choices_: return rv - print( + tmuxp_echo( colors.warning(f"Invalid choice '{rv}'. ") + f"Please choose from: {', '.join(choices_)}" ) diff --git a/src/tmuxp/exc.py b/src/tmuxp/exc.py index 525599270f..545038d7ca 100644 --- a/src/tmuxp/exc.py +++ b/src/tmuxp/exc.py @@ -2,10 +2,14 @@ from __future__ import annotations +import logging + from libtmux._internal.query_list import ObjectDoesNotExist from ._compat import implements_to_string +logger = logging.getLogger(__name__) + class TmuxpException(Exception): """Base Exception for Tmuxp Errors.""" diff --git a/src/tmuxp/log.py b/src/tmuxp/log.py index e4429eda6a..d4b9684957 100644 --- a/src/tmuxp/log.py +++ b/src/tmuxp/log.py @@ -4,29 +4,57 @@ from __future__ import annotations import logging +import sys import time import typing as t -from colorama import Fore, Style +from tmuxp._internal.colors import _ansi_colors, _ansi_reset_all -from tmuxp._internal.colors import unstyle +logger = logging.getLogger(__name__) + +_ANSI_RESET = _ansi_reset_all # "\033[0m" +_ANSI_BRIGHT = "\033[1m" +_ANSI_FG_RESET = "\033[39m" LEVEL_COLORS = { - "DEBUG": Fore.BLUE, # Blue - "INFO": Fore.GREEN, # Green - "WARNING": Fore.YELLOW, - "ERROR": Fore.RED, - "CRITICAL": Fore.RED, + "DEBUG": f"\033[{_ansi_colors['blue']}m", + "INFO": f"\033[{_ansi_colors['green']}m", + "WARNING": f"\033[{_ansi_colors['yellow']}m", + "ERROR": f"\033[{_ansi_colors['red']}m", + "CRITICAL": f"\033[{_ansi_colors['red']}m", } -LOG_LEVELS = { - "CRITICAL": 50, - "ERROR": 40, - "WARNING": 30, - "INFO": 20, - "DEBUG": 10, - "NOTSET": 0, -} + +class TmuxpLoggerAdapter(logging.LoggerAdapter): # type: ignore[type-arg] + """LoggerAdapter that merges extra dictionary on Python < 3.13. + + Follows the portable pattern to avoid repeating the same `extra` on every call + while preserving the ability to add per-call `extra` kwargs. + + Examples + -------- + >>> adapter = TmuxpLoggerAdapter( + ... logging.getLogger("test"), + ... {"tmux_session": "my-session"}, + ... ) + >>> msg, kwargs = adapter.process("hello %s", {"extra": {"tmux_window": "editor"}}) + >>> msg + 'hello %s' + >>> kwargs["extra"]["tmux_session"] + 'my-session' + >>> kwargs["extra"]["tmux_window"] + 'editor' + """ + + def process( + self, msg: t.Any, kwargs: t.MutableMapping[str, t.Any] + ) -> tuple[t.Any, t.MutableMapping[str, t.Any]]: + """Merge extra dictionary on Python < 3.13.""" + extra = dict(self.extra) if self.extra else {} + if "extra" in kwargs: + extra.update(kwargs["extra"]) + kwargs["extra"] = extra + return msg, kwargs def setup_logger( @@ -43,10 +71,17 @@ def setup_logger( logger instance for tmuxp """ if not logger: # if no logger exists, make one - logger = logging.getLogger() + logger = logging.getLogger("tmuxp") + + has_handlers = any(not isinstance(h, logging.NullHandler) for h in logger.handlers) + + if not has_handlers: # setup logger handlers + channel = logging.StreamHandler() + formatter = DebugLogFormatter() if level == "DEBUG" else LogFormatter() + channel.setFormatter(formatter) + logger.addHandler(channel) - if not logger.handlers: # setup logger handlers - logger.setLevel(level) + logger.setLevel(level) def set_style( @@ -86,27 +121,27 @@ def template( str Template for logger message. """ - reset = Style.RESET_ALL + reset = _ANSI_RESET levelname = set_style( "(%(levelname)s)", stylized, - style_before=(LEVEL_COLORS.get(record.levelname, "") + Style.BRIGHT), - style_after=Style.RESET_ALL, + style_before=(LEVEL_COLORS.get(record.levelname, "") + _ANSI_BRIGHT), + style_after=_ANSI_RESET, suffix=" ", ) asctime = set_style( "%(asctime)s", stylized, - style_before=(Fore.BLACK + Style.DIM + Style.BRIGHT), - style_after=(Fore.RESET + Style.RESET_ALL), + style_before=(f"\033[{_ansi_colors['black']}m" + _ANSI_BRIGHT), + style_after=(_ANSI_FG_RESET + _ANSI_RESET), prefix="[", suffix="]", ) name = set_style( "%(name)s", stylized, - style_before=(Fore.WHITE + Style.DIM + Style.BRIGHT), - style_after=(Fore.RESET + Style.RESET_ALL), + style_before=(f"\033[{_ansi_colors['white']}m" + _ANSI_BRIGHT), + style_after=(_ANSI_FG_RESET + _ANSI_RESET), prefix=" ", suffix=" ", ) @@ -126,7 +161,7 @@ def format(self, record: logging.LogRecord) -> str: except Exception as e: record.message = f"Bad message ({e!r}): {record.__dict__!r}" - date_format = "%H:%m:%S" + date_format = "%H:%M:%S" formatting = self.converter(record.created) record.asctime = time.strftime(date_format, formatting) @@ -156,42 +191,41 @@ def debug_log_template( str Log template. """ - reset = Style.RESET_ALL + reset = _ANSI_RESET levelname = ( LEVEL_COLORS.get(record.levelname, "") - + Style.BRIGHT + + _ANSI_BRIGHT + "(%(levelname)1.1s)" - + Style.RESET_ALL + + _ANSI_RESET + " " ) asctime = ( "[" - + Fore.BLACK - + Style.DIM - + Style.BRIGHT + + f"\033[{_ansi_colors['black']}m" + + _ANSI_BRIGHT + "%(asctime)s" - + Fore.RESET - + Style.RESET_ALL + + _ANSI_FG_RESET + + _ANSI_RESET + "]" ) name = ( " " - + Fore.WHITE - + Style.DIM - + Style.BRIGHT + + f"\033[{_ansi_colors['white']}m" + + _ANSI_BRIGHT + "%(name)s" - + Fore.RESET - + Style.RESET_ALL + + _ANSI_FG_RESET + + _ANSI_RESET + " " ) - module_funcName = Fore.GREEN + Style.BRIGHT + "%(module)s.%(funcName)s()" + module_funcName = ( + f"\033[{_ansi_colors['green']}m" + _ANSI_BRIGHT + "%(module)s.%(funcName)s()" + ) lineno = ( - Fore.BLACK - + Style.DIM - + Style.BRIGHT + f"\033[{_ansi_colors['black']}m" + + _ANSI_BRIGHT + ":" - + Style.RESET_ALL - + Fore.CYAN + + _ANSI_RESET + + f"\033[{_ansi_colors['cyan']}m" + "%(lineno)d" ) @@ -204,42 +238,61 @@ class DebugLogFormatter(LogFormatter): template = debug_log_template -# Use tmuxp root logger so messages propagate to CLI handlers -_echo_logger = logging.getLogger("tmuxp") +def setup_log_file(log_file: str, level: str = "INFO") -> None: + """Attach a file handler to the tmuxp logger. + + Parameters + ---------- + log_file : str + Path to the log file. + level : str + Log level name (e.g. "DEBUG", "INFO"). Selects formatter and sets + handler filtering level. + + Examples + -------- + >>> import tempfile, os, logging + >>> f = tempfile.NamedTemporaryFile(suffix=".log", delete=False) + >>> f.close() + >>> setup_log_file(f.name, level="INFO") + >>> tmuxp_logger = logging.getLogger("tmuxp") + >>> tmuxp_logger.handlers = [ + ... h for h in tmuxp_logger.handlers if not isinstance(h, logging.FileHandler) + ... ] + >>> os.unlink(f.name) + """ + handler = logging.FileHandler(log_file) + formatter = DebugLogFormatter() if level.upper() == "DEBUG" else LogFormatter() + handler.setFormatter(formatter) + handler_level = getattr(logging, level.upper()) + handler.setLevel(handler_level) + tmuxp_logger = logging.getLogger("tmuxp") + tmuxp_logger.addHandler(handler) + if tmuxp_logger.level == logging.NOTSET or tmuxp_logger.level > handler_level: + tmuxp_logger.setLevel(handler_level) def tmuxp_echo( message: str | None = None, - log_level: str = "INFO", - style_log: bool = False, + file: t.TextIO | None = None, ) -> None: - """Combine logging.log and print for CLI output. + """Print user-facing CLI output. Parameters ---------- message : str | None - Message to log and print. If None, does nothing. - log_level : str - Log level to use (DEBUG, INFO, WARNING, ERROR, CRITICAL). - Default is INFO. - style_log : bool - If True, preserve ANSI styling in log output. - If False, strip ANSI codes from log output. Default is False. + Message to print. If None, does nothing. + file : t.TextIO | None + Output stream. Defaults to sys.stdout. Examples -------- - >>> tmuxp_echo("Session loaded") # doctest: +ELLIPSIS + >>> tmuxp_echo("Session loaded") Session loaded - >>> tmuxp_echo("Warning message", log_level="WARNING") # doctest: +ELLIPSIS + >>> tmuxp_echo("Warning message") Warning message """ if message is None: return - - if style_log: - _echo_logger.log(LOG_LEVELS[log_level], message) - else: - _echo_logger.log(LOG_LEVELS[log_level], unstyle(message)) - - print(message) + print(message, file=file or sys.stdout) diff --git a/src/tmuxp/plugin.py b/src/tmuxp/plugin.py index 84be58d96d..fa153c7771 100644 --- a/src/tmuxp/plugin.py +++ b/src/tmuxp/plugin.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import typing as t import libtmux @@ -11,6 +12,8 @@ from .__about__ import __version__ from .exc import TmuxpPluginException +logger = logging.getLogger(__name__) + #: Minimum version of tmux required to run tmuxp TMUX_MIN_VERSION = "3.2" @@ -181,6 +184,7 @@ def __init__(self, **kwargs: Unpack[PluginConfigSchema]) -> None: def _version_check(self) -> None: """Check all dependency versions for compatibility.""" + logger.debug("checking version constraints for %s", self.plugin_name) for dep, constraints in self.version_constraints.items(): assert isinstance(constraints, dict) try: diff --git a/src/tmuxp/shell.py b/src/tmuxp/shell.py index aea0e92020..3d56655ba5 100644 --- a/src/tmuxp/shell.py +++ b/src/tmuxp/shell.py @@ -106,14 +106,17 @@ def has_bpython() -> bool: def detect_best_shell() -> CLIShellLiteral: """Return the best, most feature-rich shell available.""" if has_ptipython(): - return "ptipython" - if has_ptpython(): - return "ptpython" - if has_ipython(): - return "ipython" - if has_bpython(): - return "bpython" - return "code" + shell: CLIShellLiteral = "ptipython" + elif has_ptpython(): + shell = "ptpython" + elif has_ipython(): + shell = "ipython" + elif has_bpython(): + shell = "bpython" + else: + shell = "code" + logger.debug("detected shell: %s", shell) + return shell def get_bpython( diff --git a/src/tmuxp/types.py b/src/tmuxp/types.py index 4d267ae2ee..4aa5031239 100644 --- a/src/tmuxp/types.py +++ b/src/tmuxp/types.py @@ -9,8 +9,11 @@ from __future__ import annotations +import logging import typing as t +logger = logging.getLogger(__name__) + if t.TYPE_CHECKING: from os import PathLike diff --git a/src/tmuxp/util.py b/src/tmuxp/util.py index 490ee7e940..d63a6c4182 100644 --- a/src/tmuxp/util.py +++ b/src/tmuxp/util.py @@ -10,6 +10,7 @@ import typing as t from . import exc +from .log import tmuxp_echo if t.TYPE_CHECKING: import pathlib @@ -110,7 +111,9 @@ def oh_my_zsh_auto_title() -> None: or os.environ.get("DISABLE_AUTO_TITLE") == "false" ) ): - print( # NOQA: T201 RUF100 + logger.warning("oh-my-zsh DISABLE_AUTO_TITLE not set") + tmuxp_echo( + "oh-my-zsh DISABLE_AUTO_TITLE not set.\n\n" "Please set:\n\n" "\texport DISABLE_AUTO_TITLE='true'\n\n" "in ~/.zshrc or where your zsh profile is stored.\n" @@ -189,8 +192,14 @@ def get_pane(window: Window, current_pane: Pane | None = None) -> Pane: pane = window.panes.get(pane_id=current_pane.pane_id) else: pane = window.active_pane - except exc.TmuxpException as e: - print(e) # NOQA: T201 RUF100 + except Exception as e: + logger.debug( + "pane lookup failed", + extra={"tmux_pane": str(current_pane) if current_pane else ""}, + ) + if current_pane: + raise exc.PaneNotFound(str(current_pane)) from e + raise exc.PaneNotFound from e if pane is None: if current_pane: diff --git a/src/tmuxp/workspace/__init__.py b/src/tmuxp/workspace/__init__.py index ac87e57f62..2b3996b050 100644 --- a/src/tmuxp/workspace/__init__.py +++ b/src/tmuxp/workspace/__init__.py @@ -1 +1,7 @@ """tmuxp workspace functionality.""" + +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 24c93b3c24..57d494c01f 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -15,6 +15,7 @@ from libtmux.window import Window from tmuxp import exc +from tmuxp.log import TmuxpLoggerAdapter from tmuxp.util import get_current_pane, run_before_script if t.TYPE_CHECKING: @@ -332,6 +333,11 @@ def build(self, session: Session | None = None, append: bool = False) -> None: assert session.name is not None self._session = session + _log = TmuxpLoggerAdapter( + logger, + {"tmux_session": self.session_config["session_name"]}, + ) + _log.info("session created") assert session.server is not None @@ -355,8 +361,19 @@ def build(self, session: Session | None = None, append: bool = False) -> None: # session start directory, if it exists. if "start_directory" in self.session_config: cwd = self.session_config["start_directory"] + _log.debug( + "running before script", + ) run_before_script(self.session_config["before_script"], cwd=cwd) except Exception: + _log.error( + "before script failed", + extra={ + "tmux_config_path": str( + self.session_config["before_script"], + ), + }, + ) self.session.kill() raise @@ -400,6 +417,8 @@ def build(self, session: Session | None = None, append: bool = False) -> None: if focus: focus.select() + _log.info("workspace built") + def iter_create_windows( self, session: Session, @@ -469,6 +488,14 @@ def iter_create_windows( environment=environment, ) assert isinstance(window, Window) + window_log = TmuxpLoggerAdapter( + logger, + { + "tmux_session": session.name or "", + "tmux_window": window_name or "", + }, + ) + window_log.debug("window created") if is_first_window_pass: # if first window, use window 1 session.active_window.kill() @@ -563,6 +590,15 @@ def get_pane_shell( ) assert isinstance(pane, Pane) + pane_log = TmuxpLoggerAdapter( + logger, + { + "tmux_session": window.session.name or "", + "tmux_window": window.name or "", + "tmux_pane": pane.pane_id or "", + }, + ) + pane_log.debug("pane created") # Skip readiness wait when a custom shell/command launcher is set. # The shell/window_shell key runs a command (e.g. "top", "sleep 999") @@ -594,6 +630,7 @@ def get_pane_shell( time.sleep(sleep_before) pane.send_keys(cmd["cmd"], suppress_history=suppress, enter=enter) + pane_log.debug("sent command %s", cmd["cmd"]) if sleep_after is not None: time.sleep(sleep_after) diff --git a/src/tmuxp/workspace/constants.py b/src/tmuxp/workspace/constants.py index 48ecc9c1f6..4a39082b6d 100644 --- a/src/tmuxp/workspace/constants.py +++ b/src/tmuxp/workspace/constants.py @@ -2,4 +2,8 @@ from __future__ import annotations +import logging + +logger = logging.getLogger(__name__) + VALID_WORKSPACE_DIR_FILE_EXTENSIONS = [".yaml", ".yml", ".json"] diff --git a/src/tmuxp/workspace/finders.py b/src/tmuxp/workspace/finders.py index da19bcc887..2bc7704c28 100644 --- a/src/tmuxp/workspace/finders.py +++ b/src/tmuxp/workspace/finders.py @@ -142,6 +142,12 @@ def find_local_workspace_files( if start_dir is None: start_dir = os.getcwd() + logger.debug( + "searching for local workspace files from %s", + start_dir, + extra={"tmux_config_path": str(start_dir)}, + ) + current = pathlib.Path(start_dir).resolve() home = pathlib.Path.home().resolve() found: list[pathlib.Path] = [] @@ -361,12 +367,17 @@ def find_workspace_file( ] if len(candidates) > 1: + logger.warning( + "multiple workspace files found, use distinct file names" + " to avoid ambiguity", + extra={"tmux_config_path": workspace_file}, + ) colors = Colors(ColorMode.AUTO) tmuxp_echo( colors.error( - "Multiple .tmuxp.{yml,yaml,json} workspace_files in " - + dirname(workspace_file) - ), + "Multiple .tmuxp.{yaml,yml,json} files found in " + + str(workspace_file) + ) ) tmuxp_echo( "This is undefined behavior, use only one. " @@ -383,6 +394,11 @@ def find_workspace_file( if file_error: raise FileNotFoundError(file_error, workspace_file) + logger.debug( + "resolved workspace file %s", + workspace_file, + extra={"tmux_config_path": workspace_file}, + ) return workspace_file diff --git a/src/tmuxp/workspace/freezer.py b/src/tmuxp/workspace/freezer.py index 8807e9e43e..7ec302494f 100644 --- a/src/tmuxp/workspace/freezer.py +++ b/src/tmuxp/workspace/freezer.py @@ -2,8 +2,11 @@ from __future__ import annotations +import logging import typing as t +logger = logging.getLogger(__name__) + if t.TYPE_CHECKING: from libtmux.pane import Pane from libtmux.session import Session @@ -64,6 +67,8 @@ def freeze(session: Session) -> dict[str, t.Any]: dict tmuxp compatible workspace """ + logger.debug("freezing session", extra={"tmux_session": session.session_name}) + session_config: dict[str, t.Any] = { "session_name": session.session_name, "windows": [], @@ -119,5 +124,12 @@ def filter_interpreters_and_shells(current_cmd: str | None) -> bool: window_config["panes"].append(pane_config) session_config["windows"].append(window_config) + logger.debug( + "frozen window", + extra={ + "tmux_session": session.session_name, + "tmux_window": window.name, + }, + ) return session_config diff --git a/src/tmuxp/workspace/importers.py b/src/tmuxp/workspace/importers.py index fda361ca6f..65184d73a4 100644 --- a/src/tmuxp/workspace/importers.py +++ b/src/tmuxp/workspace/importers.py @@ -2,8 +2,11 @@ from __future__ import annotations +import logging import typing as t +logger = logging.getLogger(__name__) + def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: """Return tmuxp workspace from a `tmuxinator`_ yaml workspace. @@ -19,6 +22,14 @@ def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: ------- dict """ + logger.debug( + "importing tmuxinator workspace", + extra={ + "tmux_session": workspace_dict.get("project_name") + or workspace_dict.get("name", ""), + }, + ) + tmuxp_workspace: dict[str, t.Any] = {} if "project_name" in workspace_dict: @@ -122,6 +133,12 @@ def import_teamocil(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: - clear - cmd_separator """ + _inner = workspace_dict.get("session", workspace_dict) + logger.debug( + "importing teamocil workspace", + extra={"tmux_session": _inner.get("name", "")}, + ) + tmuxp_workspace: dict[str, t.Any] = {} if "session" in workspace_dict: diff --git a/src/tmuxp/workspace/loader.py b/src/tmuxp/workspace/loader.py index 613b2b589d..9efcd05b52 100644 --- a/src/tmuxp/workspace/loader.py +++ b/src/tmuxp/workspace/loader.py @@ -101,6 +101,11 @@ def expand( ------- dict """ + logger.debug( + "expanding workspace config", + extra={"tmux_session": workspace_dict.get("session_name", "")}, + ) + # Note: cli.py will expand workspaces relative to project's workspace directory # for the first cwd argument. cwd = pathlib.Path().cwd() if not cwd else pathlib.Path(cwd) @@ -207,6 +212,11 @@ def trickle(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: ------- dict """ + logger.debug( + "trickling down workspace defaults", + extra={"tmux_session": workspace_dict.get("session_name", "")}, + ) + # prepends a pane's ``shell_command`` list with the window and sessions' # ``shell_command_before``. diff --git a/src/tmuxp/workspace/validation.py b/src/tmuxp/workspace/validation.py index 2573209a5d..e7fe4da743 100644 --- a/src/tmuxp/workspace/validation.py +++ b/src/tmuxp/workspace/validation.py @@ -2,10 +2,13 @@ from __future__ import annotations +import logging import typing as t from tmuxp import exc +logger = logging.getLogger(__name__) + class SchemaValidationError(exc.WorkspaceError): """Tmuxp configuration validation base error.""" @@ -70,6 +73,15 @@ def validate_schema(workspace_dict: t.Any) -> bool: ------- bool """ + logger.debug( + "validating workspace schema", + extra={ + "tmux_session": workspace_dict.get("session_name", "") + if isinstance(workspace_dict, dict) + else "", + }, + ) + # verify session_name if "session_name" not in workspace_dict: raise SessionNameMissingValidationError diff --git a/tests/cli/test_load.py b/tests/cli/test_load.py index 2191b7320c..60c2c446ed 100644 --- a/tests/cli/test_load.py +++ b/tests/cli/test_load.py @@ -17,7 +17,6 @@ from tmuxp import cli from tmuxp._internal.config_reader import ConfigReader from tmuxp._internal.private_path import PrivatePath -from tmuxp.cli._colors import ColorMode, Colors from tmuxp.cli.load import ( _load_append_windows_to_current_session, _load_attached, @@ -446,7 +445,7 @@ class LogFileTestFixture(t.NamedTuple): LOG_FILE_TEST_FIXTURES: list[LogFileTestFixture] = [ LogFileTestFixture( test_id="load_with_log_file", - cli_args=["load", ".", "--log-file", "log.txt", "-d"], + cli_args=["--log-level", "info", "load", ".", "--log-file", "log.txt", "-d"], ), ] @@ -484,10 +483,45 @@ def test_load_log_file( result = capsys.readouterr() log_file_path = tmp_path / "log.txt" - assert "Loading" in log_file_path.open().read() + assert "loading workspace" in log_file_path.open().read() assert result.out is not None +def test_load_log_file_level_filtering( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Log-level filtering: INFO log file should not contain DEBUG messages.""" + tmuxp_config_path = tmp_path / ".tmuxp.yaml" + tmuxp_config_path.write_text( + """ +session_name: hello + - + """, + encoding="utf-8", + ) + oh_my_zsh_path = tmp_path / ".oh-my-zsh" + oh_my_zsh_path.mkdir() + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.chdir(tmp_path) + + with contextlib.suppress(Exception): + cli.cli(["--log-level", "info", "load", ".", "--log-file", "log.txt", "-d"]) + + log_file_path = tmp_path / "log.txt" + log_contents = log_file_path.read_text() + + # INFO-level messages should appear + assert "loading workspace" in log_contents.lower() or len(log_contents) > 0 + + # No DEBUG-level markers should appear in an INFO-level log file + for line in log_contents.splitlines(): + assert "(DEBUG)" not in line, ( + f"DEBUG message leaked into INFO-level log file: {line}" + ) + + def test_load_plugins( monkeypatch_plugin_test_packages: None, ) -> None: @@ -758,18 +792,28 @@ def test_load_append_windows_to_current_session( # Privacy masking in load command -def test_load_masks_home_in_loading_message(monkeypatch: pytest.MonkeyPatch) -> None: - """Load command should mask home directory in [Loading] message.""" +def test_load_no_ansi_in_nontty_stderr( + server: Server, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """No ANSI escape codes in stderr when running in non-TTY context (CI/pipe).""" + monkeypatch.delenv("TMUX", raising=False) + session_file = FIXTURE_PATH / "workspace/builder" / "two_pane.yaml" + + load_workspace(str(session_file), socket_name=server.socket_name, detached=True) + + captured = capsys.readouterr() + assert "\x1b[" not in captured.err, "ANSI codes leaked into non-TTY stderr" + + +def test_load_masks_home_in_spinner_message(monkeypatch: pytest.MonkeyPatch) -> None: + """Spinner message should mask home directory via PrivatePath.""" monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) - monkeypatch.delenv("NO_COLOR", raising=False) - colors = Colors(ColorMode.ALWAYS) workspace_file = pathlib.Path("/home/testuser/work/project/.tmuxp.yaml") - output = ( - colors.info("[Loading]") - + " " - + colors.highlight(str(PrivatePath(workspace_file))) - ) + private_path = str(PrivatePath(workspace_file)) + message = f"Loading workspace: myproject ({private_path})" - assert "~/work/project/.tmuxp.yaml" in output - assert "/home/testuser" not in output + assert "~/work/project/.tmuxp.yaml" in message + assert "/home/testuser" not in message diff --git a/tests/cli/test_ls.py b/tests/cli/test_ls.py index 40e1526839..e6f64e28fc 100644 --- a/tests/cli/test_ls.py +++ b/tests/cli/test_ls.py @@ -161,6 +161,7 @@ def test_ls_json_output( cli.cli(["ls", "--json"]) output = capsys.readouterr().out + assert "\x1b" not in output, "ANSI escapes must not leak into machine output" data = json.loads(output) # JSON output is now an object with workspaces and global_workspace_dirs @@ -200,6 +201,7 @@ def test_ls_ndjson_output( cli.cli(["ls", "--ndjson"]) output = capsys.readouterr().out + assert "\x1b" not in output, "ANSI escapes must not leak into machine output" lines = [line for line in output.strip().split("\n") if line] assert len(lines) == 2 diff --git a/tests/cli/test_output.py b/tests/cli/test_output.py index 3112f60d02..84bee97aa0 100644 --- a/tests/cli/test_output.py +++ b/tests/cli/test_output.py @@ -221,6 +221,54 @@ def test_ndjson_workflow(capsys: pytest.CaptureFixture[str]) -> None: assert captured.out == "" +def test_emit_object_json_writes_immediately( + capsys: pytest.CaptureFixture[str], +) -> None: + """JSON mode emit_object should write indented JSON immediately.""" + formatter = OutputFormatter(OutputMode.JSON) + formatter.emit_object({"status": "ok", "count": 3}) + + captured = capsys.readouterr() + data = json.loads(captured.out) + assert data == {"status": "ok", "count": 3} + # Indented output (indent=2) + assert "\n" in captured.out + + +def test_emit_object_ndjson_writes_compact( + capsys: pytest.CaptureFixture[str], +) -> None: + """NDJSON mode emit_object should write compact single-line JSON.""" + formatter = OutputFormatter(OutputMode.NDJSON) + formatter.emit_object({"status": "ok", "count": 3}) + + captured = capsys.readouterr() + lines = captured.out.strip().split("\n") + assert len(lines) == 1 + assert json.loads(lines[0]) == {"status": "ok", "count": 3} + + +def test_emit_object_human_silent(capsys: pytest.CaptureFixture[str]) -> None: + """HUMAN mode emit_object should produce no output.""" + formatter = OutputFormatter(OutputMode.HUMAN) + formatter.emit_object({"status": "ok"}) + + captured = capsys.readouterr() + assert captured.out == "" + + +def test_emit_object_does_not_buffer() -> None: + """emit_object must not affect _json_buffer.""" + formatter = OutputFormatter(OutputMode.JSON) + old_stdout = sys.stdout + sys.stdout = io.StringIO() + try: + formatter.emit_object({"status": "ok"}) + finally: + sys.stdout = old_stdout + assert formatter._json_buffer == [] + + def test_human_workflow(capsys: pytest.CaptureFixture[str]) -> None: """Test complete HUMAN output workflow.""" formatter = OutputFormatter(OutputMode.HUMAN) diff --git a/tests/cli/test_search.py b/tests/cli/test_search.py index e4140b2645..9e67266b86 100644 --- a/tests/cli/test_search.py +++ b/tests/cli/test_search.py @@ -830,6 +830,7 @@ def test_output_search_results_json(capsys: pytest.CaptureFixture[str]) -> None: formatter.finalize() captured = capsys.readouterr() + assert "\x1b" not in captured.out, "ANSI escapes must not leak into machine output" data = json.loads(captured.out) assert len(data) == 1 assert data[0]["name"] == "dev" @@ -857,6 +858,7 @@ def test_output_search_results_ndjson(capsys: pytest.CaptureFixture[str]) -> Non formatter.finalize() captured = capsys.readouterr() + assert "\x1b" not in captured.out, "ANSI escapes must not leak into machine output" lines = captured.out.strip().split("\n") # Filter out human-readable lines json_lines = [line for line in lines if line.startswith("{")] diff --git a/tests/test_log.py b/tests/test_log.py new file mode 100644 index 0000000000..b916b37b94 --- /dev/null +++ b/tests/test_log.py @@ -0,0 +1,105 @@ +"""Tests for tmuxp.log module.""" + +from __future__ import annotations + +import logging +import sys + +import pytest + +from tmuxp.log import ( + LEVEL_COLORS, + DebugLogFormatter, + LogFormatter, + tmuxp_echo, +) + + +def test_level_colors_no_colorama() -> None: + """LEVEL_COLORS must be raw ANSI escape strings, not colorama objects.""" + for level, code in LEVEL_COLORS.items(): + assert code.startswith("\033["), ( + f"LEVEL_COLORS[{level!r}] should start with ANSI ESC, got {code!r}" + ) + + +def test_log_formatter_format_plain_text() -> None: + """LogFormatter.format() produces plain text without ANSI when unstylized.""" + formatter = LogFormatter() + record = logging.LogRecord( + name="tmuxp", + level=logging.INFO, + pathname="", + lineno=0, + msg="test message", + args=(), + exc_info=None, + ) + output = formatter.format(record) + assert "test message" in output + assert "\033[" not in output + + +def test_debug_log_formatter_format_smoke() -> None: + """DebugLogFormatter.format() runs without error.""" + formatter = DebugLogFormatter() + record = logging.LogRecord( + name="tmuxp", + level=logging.DEBUG, + pathname="", + lineno=42, + msg="debug message", + args=(), + exc_info=None, + ) + output = formatter.format(record) + assert "debug message" in output + + +def test_timestamp_format_has_minutes() -> None: + """Timestamp format must use %M (minutes), not %m (month).""" + formatter = LogFormatter() + record = logging.LogRecord( + name="tmuxp", + level=logging.INFO, + pathname="", + lineno=0, + msg="ts check", + args=(), + exc_info=None, + ) + formatter.format(record) + # asctime is set during format(); if %m were used, seconds portion would + # show month (01-12) instead of minutes (00-59) — we can't easily + # distinguish that directly, so just verify the format string constant. + # Inspect the source: date_format in LogFormatter.format is "%H:%M:%S" + import inspect + + import tmuxp.log as log_module + + src = inspect.getsource(log_module.LogFormatter.format) + assert '"%H:%M:%S"' in src, "Timestamp format must be %H:%M:%S (M = minutes)" + + +def test_tmuxp_echo_default_stdout(capsys: pytest.CaptureFixture[str]) -> None: + """tmuxp_echo writes to stdout by default.""" + tmuxp_echo("hello stdout") + captured = capsys.readouterr() + assert captured.out == "hello stdout\n" + assert captured.err == "" + + +def test_tmuxp_echo_to_stderr(capsys: pytest.CaptureFixture[str]) -> None: + """tmuxp_echo writes to stderr when file=sys.stderr.""" + tmuxp_echo("hello stderr", file=sys.stderr) + captured = capsys.readouterr() + assert captured.err == "hello stderr\n" + assert captured.out == "" + + +def test_tmuxp_echo_none_is_no_op(capsys: pytest.CaptureFixture[str]) -> None: + """tmuxp_echo(None) produces no output.""" + tmuxp_echo(None) + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err == "" diff --git a/tests/test_plugin.py b/tests/test_plugin.py index cf7cfc6371..6d7fda7fd1 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -2,6 +2,8 @@ from __future__ import annotations +import logging + import pytest from tmuxp.exc import TmuxpPluginException @@ -95,3 +97,15 @@ def test_libtmux_version_fail_incompatible() -> None: with pytest.raises(TmuxpPluginException, match=r"Incompatible.*") as exc_info: LibtmuxVersionFailIncompatiblePlugin() assert "libtmux-incompatible-version-fail" in str(exc_info.value) + + +def test_plugin_version_check_logs_debug( + caplog: pytest.LogCaptureFixture, +) -> None: + """_version_check() logs DEBUG with plugin name.""" + with caplog.at_level(logging.DEBUG, logger="tmuxp.plugin"): + AllVersionPassPlugin() + records = [ + r for r in caplog.records if r.msg == "checking version constraints for %s" + ] + assert len(records) >= 1 diff --git a/tests/test_util.py b/tests/test_util.py index baa592e9a8..098c8c212b 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -2,6 +2,9 @@ from __future__ import annotations +import logging +import os +import pathlib import sys import typing as t @@ -9,7 +12,7 @@ from tmuxp import exc from tmuxp.exc import BeforeLoadScriptError, BeforeLoadScriptNotExists -from tmuxp.util import get_session, run_before_script +from tmuxp.util import get_pane, get_session, oh_my_zsh_auto_title, run_before_script from .constants import FIXTURE_PATH @@ -166,3 +169,68 @@ def test_get_session_should_return_first_session_if_no_active_session( server.new_session(session_name="mysecondsession") assert get_session(server) == first_session + + +def test_get_pane_logs_debug_on_failure( + server: Server, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """get_pane() logs DEBUG with tmux_pane extra when pane lookup fails.""" + session = server.new_session(session_name="test_pane_log") + window = session.active_window + + # Make active_pane raise Exception to trigger the logging path + monkeypatch.setattr( + type(window), + "active_pane", + property(lambda self: (_ for _ in ()).throw(Exception("mock pane error"))), + ) + + with ( + caplog.at_level(logging.DEBUG, logger="tmuxp.util"), + pytest.raises(exc.PaneNotFound), + ): + get_pane(window, current_pane=None) + + debug_records = [ + r + for r in caplog.records + if hasattr(r, "tmux_pane") and r.levelno == logging.DEBUG + ] + assert len(debug_records) >= 1 + assert debug_records[0].tmux_pane == "" + + +def test_oh_my_zsh_auto_title_logs_warning( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + tmp_path: t.Any, +) -> None: + """oh_my_zsh_auto_title() logs WARNING when DISABLE_AUTO_TITLE not set.""" + monkeypatch.setenv("SHELL", "/bin/zsh") + monkeypatch.delenv("DISABLE_AUTO_TITLE", raising=False) + + # Create fake ~/.oh-my-zsh directory + fake_home = tmp_path / "home" + fake_home.mkdir() + oh_my_zsh_dir = fake_home / ".oh-my-zsh" + oh_my_zsh_dir.mkdir() + monkeypatch.setenv("HOME", str(fake_home)) + + # Patch os.path.exists to return True for ~/.oh-my-zsh + original_exists = os.path.exists + + def patched_exists(path: str) -> bool: + if path == str(pathlib.Path("~/.oh-my-zsh").expanduser()): + return True + return original_exists(path) + + monkeypatch.setattr(os.path, "exists", patched_exists) + + with caplog.at_level(logging.WARNING, logger="tmuxp.util"): + oh_my_zsh_auto_title() + + warning_records = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warning_records) >= 1 + assert "DISABLE_AUTO_TITLE" in warning_records[0].message diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index 6b78dfcbd3..da95168f46 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -3,6 +3,7 @@ from __future__ import annotations import functools +import logging import os import pathlib import textwrap @@ -697,6 +698,7 @@ def test_window_index( def test_before_script_throw_error_if_retcode_error( server: Server, + caplog: pytest.LogCaptureFixture, ) -> None: """Test tmuxp configuration before_script when command fails.""" config_script_fails = test_utils.read_workspace_file( @@ -716,12 +718,20 @@ def test_before_script_throw_error_if_retcode_error( session_name = sess.name assert session_name is not None - with pytest.raises(exc.BeforeLoadScriptError): + with ( + caplog.at_level(logging.ERROR, logger="tmuxp.workspace.builder"), + pytest.raises(exc.BeforeLoadScriptError), + ): builder.build(session=sess) result = server.has_session(session_name) assert not result, "Kills session if before_script exits with errcode" + error_records = [r for r in caplog.records if r.levelno == logging.ERROR] + assert len(error_records) >= 1 + assert error_records[0].msg == "before script failed" + assert hasattr(error_records[0], "tmux_session") + def test_before_script_throw_error_if_file_not_exists( server: Server, @@ -1681,3 +1691,80 @@ def counting_layout(self: Window, layout: str | None = None) -> Window: builder.build() # 3 panes = 3 layout calls (one per pane in iter_create_panes), not 6 assert call_count == 3 + + +def test_builder_logs_session_created( + server: Server, + caplog: pytest.LogCaptureFixture, +) -> None: + """WorkspaceBuilder.build() logs INFO with tmux_session extra.""" + workspace = { + "session_name": "test_log_session", + "windows": [ + { + "window_name": "main", + "panes": [ + {"shell_command": []}, + ], + }, + ], + } + builder = WorkspaceBuilder(session_config=workspace, server=server) + + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.builder"): + builder.build() + + session_logs = [ + r + for r in caplog.records + if hasattr(r, "tmux_session") and r.msg == "session created" + ] + assert len(session_logs) >= 1 + assert session_logs[0].tmux_session == "test_log_session" + + # Verify workspace built log + built_logs = [r for r in caplog.records if r.msg == "workspace built"] + assert len(built_logs) >= 1 + + builder.session.kill() + + +def test_builder_logs_window_and_pane_creation( + server: Server, + caplog: pytest.LogCaptureFixture, +) -> None: + """WorkspaceBuilder logs DEBUG with tmux_window and tmux_pane extra.""" + workspace = { + "session_name": "test_log_wp", + "windows": [ + { + "window_name": "editor", + "panes": [ + {"shell_command": [{"cmd": "echo hello"}]}, + {"shell_command": []}, + ], + }, + ], + } + builder = WorkspaceBuilder(session_config=workspace, server=server) + + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.builder"): + builder.build() + + window_logs = [ + r + for r in caplog.records + if hasattr(r, "tmux_window") and r.msg == "window created" + ] + assert len(window_logs) >= 1 + assert window_logs[0].tmux_window == "editor" + + pane_logs = [ + r for r in caplog.records if hasattr(r, "tmux_pane") and r.msg == "pane created" + ] + assert len(pane_logs) >= 1 + + cmd_logs = [r for r in caplog.records if r.msg == "sent command %s"] + assert len(cmd_logs) >= 1 + + builder.session.kill() diff --git a/tests/workspace/test_config.py b/tests/workspace/test_config.py index 02ebcf5ffa..fc6d5ccd5b 100644 --- a/tests/workspace/test_config.py +++ b/tests/workspace/test_config.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import pathlib import typing as t @@ -330,3 +331,49 @@ def test_validate_plugins() -> None: with pytest.raises(exc.WorkspaceError) as excinfo: validation.validate_schema(sconfig) assert excinfo.match("only supports list type") + + +def test_expand_logs_debug( + tmp_path: pathlib.Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """expand() logs DEBUG with tmux_session extra.""" + workspace = {"session_name": "test_expand", "windows": [{"window_name": "main"}]} + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.loader"): + loader.expand(workspace, cwd=str(tmp_path)) + records = [r for r in caplog.records if r.msg == "expanding workspace config"] + assert len(records) >= 1 + assert getattr(records[0], "tmux_session", None) == "test_expand" + + +def test_trickle_logs_debug( + tmp_path: pathlib.Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """trickle() logs DEBUG with tmux_session extra.""" + workspace = { + "session_name": "test_trickle", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.loader"): + loader.trickle(workspace) + records = [ + r for r in caplog.records if r.msg == "trickling down workspace defaults" + ] + assert len(records) >= 1 + assert getattr(records[0], "tmux_session", None) == "test_trickle" + + +def test_validate_schema_logs_debug( + caplog: pytest.LogCaptureFixture, +) -> None: + """validate_schema() logs DEBUG with tmux_session extra.""" + workspace = { + "session_name": "test_validate", + "windows": [{"window_name": "main"}], + } + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.validation"): + validation.validate_schema(workspace) + records = [r for r in caplog.records if r.msg == "validating workspace schema"] + assert len(records) >= 1 + assert getattr(records[0], "tmux_session", None) == "test_validate" diff --git a/tests/workspace/test_finder.py b/tests/workspace/test_finder.py index dd0b270bb8..ab9a69dba4 100644 --- a/tests/workspace/test_finder.py +++ b/tests/workspace/test_finder.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import logging import pathlib import typing as t @@ -11,6 +12,7 @@ from tmuxp import cli from tmuxp.cli.utils import tmuxp_echo from tmuxp.workspace.finders import ( + find_local_workspace_files, find_workspace_file, get_workspace_dir, get_workspace_dir_candidates, @@ -514,3 +516,53 @@ def test_get_workspace_dir_candidates_uses_private_path( path = candidate["path"] assert str(home) not in path, f"Path should be masked: {path}" assert path.startswith("~"), f"Path should start with ~: {path}" + + +def test_find_workspace_file_logs_warning_on_multiple( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + capsys: pytest.CaptureFixture[str], +) -> None: + """find_workspace_file() logs WARNING when multiple workspace files found.""" + project = tmp_path / "project" + project.mkdir() + + # Create multiple .tmuxp files in the same directory + (project / ".tmuxp.yaml").write_text("session_name: test") + (project / ".tmuxp.json").write_text('{"session_name": "test"}') + + monkeypatch.chdir(project) + + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.finders"): + find_workspace_file(str(project)) + + warning_records = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warning_records) >= 1 + assert "multiple workspace files found" in warning_records[0].message + assert hasattr(warning_records[0], "tmux_config_path") + + out = capsys.readouterr().out + assert "Multiple .tmuxp." in out + assert "undefined behavior" in out + + +def test_find_local_workspace_files_logs_debug( + tmp_path: pathlib.Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """find_local_workspace_files() logs DEBUG with tmux_config_path extra.""" + project = tmp_path / "project" + project.mkdir() + (project / ".tmuxp.yaml").write_text("session_name: test") + + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.finders"): + find_local_workspace_files(project, stop_at_home=False) + + records = [ + r + for r in caplog.records + if r.msg == "searching for local workspace files from %s" + ] + assert len(records) >= 1 + assert hasattr(records[0], "tmux_config_path") diff --git a/tests/workspace/test_freezer.py b/tests/workspace/test_freezer.py index 42fa6cc581..d42386ecef 100644 --- a/tests/workspace/test_freezer.py +++ b/tests/workspace/test_freezer.py @@ -2,9 +2,12 @@ from __future__ import annotations +import logging import time import typing +import pytest + from tests.fixtures import utils as test_utils from tmuxp._internal.config_reader import ConfigReader from tmuxp.workspace import freezer, validation @@ -106,3 +109,28 @@ def test_export_yaml( new_workspace_data = ConfigReader._from_file(yaml_workspace_file) assert config_fixture.sample_workspace.sample_workspace_dict == new_workspace_data + + +def test_freeze_logs_debug( + session: Session, + caplog: pytest.LogCaptureFixture, +) -> None: + """freeze() logs DEBUG with tmux_session extra.""" + session_config = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/freezer/sample_workspace.yaml"), + ) + builder = WorkspaceBuilder(session_config=session_config, server=session.server) + builder.build(session=session) + + time.sleep(0.50) + + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.freezer"): + freezer.freeze(session) + + freeze_records = [r for r in caplog.records if r.msg == "freezing session"] + assert len(freeze_records) >= 1 + assert hasattr(freeze_records[0], "tmux_session") + + window_records = [r for r in caplog.records if r.msg == "frozen window"] + assert len(window_records) >= 1 + assert hasattr(window_records[0], "tmux_window") diff --git a/tests/workspace/test_import_teamocil.py b/tests/workspace/test_import_teamocil.py index 7de727684b..0ea457e7c6 100644 --- a/tests/workspace/test_import_teamocil.py +++ b/tests/workspace/test_import_teamocil.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import typing as t import pytest @@ -139,3 +140,20 @@ def test_multisession_config( validation.validate_schema( importers.import_teamocil(multisession_config[session_name]), ) + + +def test_import_teamocil_logs_debug( + caplog: pytest.LogCaptureFixture, +) -> None: + """import_teamocil() logs DEBUG record.""" + workspace = { + "session": { + "name": "test", + "windows": [{"name": "main", "panes": [{"cmd": "echo hi"}]}], + }, + } + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.importers"): + importers.import_teamocil(workspace) + records = [r for r in caplog.records if r.msg == "importing teamocil workspace"] + assert len(records) >= 1 + assert getattr(records[0], "tmux_session", None) == "test" diff --git a/tests/workspace/test_import_tmuxinator.py b/tests/workspace/test_import_tmuxinator.py index 23f567ae5d..457605f2ab 100644 --- a/tests/workspace/test_import_tmuxinator.py +++ b/tests/workspace/test_import_tmuxinator.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import typing as t import pytest @@ -60,3 +61,18 @@ def test_config_to_dict( assert importers.import_tmuxinator(tmuxinator_dict) == tmuxp_dict validation.validate_schema(importers.import_tmuxinator(tmuxinator_dict)) + + +def test_import_tmuxinator_logs_debug( + caplog: pytest.LogCaptureFixture, +) -> None: + """import_tmuxinator() logs DEBUG record.""" + workspace = { + "name": "test", + "windows": [{"main": ["echo hi"]}], + } + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.importers"): + importers.import_tmuxinator(workspace) + records = [r for r in caplog.records if r.msg == "importing tmuxinator workspace"] + assert len(records) >= 1 + assert getattr(records[0], "tmux_session", None) == "test" diff --git a/uv.lock b/uv.lock index 0542d5e6f4..03668ca9d4 100644 --- a/uv.lock +++ b/uv.lock @@ -1379,10 +1379,9 @@ wheels = [ [[package]] name = "tmuxp" -version = "1.65.0" +version = "1.66.0" source = { editable = "." } dependencies = [ - { name = "colorama" }, { name = "libtmux" }, { name = "pyyaml" }, ] @@ -1420,7 +1419,6 @@ dev = [ { name = "sphinx-inline-tabs" }, { name = "sphinxext-opengraph" }, { name = "sphinxext-rediraffe" }, - { name = "types-colorama" }, { name = "types-docutils" }, { name = "types-pygments" }, { name = "types-pyyaml" }, @@ -1447,7 +1445,6 @@ docs = [ lint = [ { name = "mypy" }, { name = "ruff" }, - { name = "types-colorama" }, { name = "types-docutils" }, { name = "types-pygments" }, { name = "types-pyyaml" }, @@ -1462,7 +1459,6 @@ testing = [ [package.metadata] requires-dist = [ - { name = "colorama", specifier = ">=0.3.9" }, { name = "libtmux", specifier = "~=0.55.0" }, { name = "pyyaml", specifier = ">=6.0" }, ] @@ -1496,7 +1492,6 @@ dev = [ { name = "sphinx-inline-tabs" }, { name = "sphinxext-opengraph" }, { name = "sphinxext-rediraffe" }, - { name = "types-colorama" }, { name = "types-docutils" }, { name = "types-pygments" }, { name = "types-pyyaml" }, @@ -1519,7 +1514,6 @@ docs = [ lint = [ { name = "mypy" }, { name = "ruff" }, - { name = "types-colorama" }, { name = "types-docutils" }, { name = "types-pygments" }, { name = "types-pyyaml" }, @@ -1586,15 +1580,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, ] -[[package]] -name = "types-colorama" -version = "0.4.15.20250801" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/99/37/af713e7d73ca44738c68814cbacf7a655aa40ddd2e8513d431ba78ace7b3/types_colorama-0.4.15.20250801.tar.gz", hash = "sha256:02565d13d68963d12237d3f330f5ecd622a3179f7b5b14ee7f16146270c357f5", size = 10437, upload-time = "2025-08-01T03:48:22.605Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/95/3a/44ccbbfef6235aeea84c74041dc6dfee6c17ff3ddba782a0250e41687ec7/types_colorama-0.4.15.20250801-py3-none-any.whl", hash = "sha256:b6e89bd3b250fdad13a8b6a465c933f4a5afe485ea2e2f104d739be50b13eea9", size = 10743, upload-time = "2025-08-01T03:48:21.774Z" }, -] - [[package]] name = "types-docutils" version = "0.22.3.20260223"