Skip to content

CLI passthrough (PTY) mode, standalone snippet output - #256

Open
carli2 wants to merge 4 commits into
pycontribs:mainfrom
launix-de:main
Open

carli2 wants to merge 4 commits into
pycontribs:mainfrom
launix-de:main

Conversation

@carli2

@carli2 carli2 commented Sep 19, 2025

Copy link
Copy Markdown

CLI passthrough (PTY), standalone snippets, and pager safeguards

Summary

  • Add command passthrough mode: run a command in a colored PTY and convert its output to HTML.
  • Introduce --standalone (-S) to emit a minimal inline HTML snippet wrapped in <code>.
  • Disable auto‑pagers in PTY by default (GIT_PAGER=cat, PAGER=cat) to prevent hangs; users can override.
  • Refresh --help, README/docs, and man page; add targeted tests.

Why

Capturing realistic colorized output from CLIs (e.g., git) usually requires a TTY. This PR makes it easy to run such commands, capture their ANSI-colored output, and convert it to HTML
reliably—without getting stuck behind pagers.

What’s Changed

  • PTY passthrough execution with argument parsing and TERM=xterm-256color.
  • --standalone (-S): like --inline but wrapped in <code style='white-space: pre;'>…</code> for convenient embedding.
  • Pager safeguards in PTY mode: set GIT_PAGER=cat and PAGER=cat by default to avoid hangs.
  • Documentation updates and new tests covering passthrough and pager behavior.

Usage

  • Run a command and convert its colored output:

    ansi2html git log -p > git-log.html
    
  • Separate ansi2html options from the child command:

    ansi2html --inline -- git log -p > inline-git-log.html

  • Produce a minimal embeddable snippet:

    echo $'\e[31mRED\e[0m' | ansi2html --standalone

    short form:

    echo $'\e[31mRED\e[0m' | ansi2html -S

Behavior Notes

  • Argument parsing:
    • After --, all args belong to the child command.
    • Without --, the first non-option token starts the child command; all following tokens belong to it.
  • PTY environment:
    • Sets TERM=xterm-256color.
    • Captures stdout and stderr together for conversion.
  • Pagers:
    • In PTY mode, sets GIT_PAGER=cat and PAGER=cat by default.
    • Override explicitly if desired, e.g. GIT_PAGER="less -R".

Docs & Tests

  • Updated: --help, README, dedicated CLI docs, and the man page.
  • Tests added/updated to cover passthrough execution and pager disabling.

Backwards Compatibility

  • Existing pipelines and options continue to work unchanged.
  • Pager behavior only changes in PTY passthrough mode and can be overridden.

Commits

  • baa58eb: cli: add --standalone (-S), disable pagers in PTY, refresh help/docs/man, add tests
  • 3d9e750: add passthrough “wrapper” mode to execute commands in a colored, non‑paged TTY

Checklist

  • PTY passthrough execution
  • --standalone (-S) snippet output
  • Pager safeguards with override
  • Docs/help/man updated
  • Tests added and passing

…e-space: pre;'> wrapper; disable pagers in PTY (GIT_PAGER/PAGER=cat); refresh --help, docs, and man page; add tests
@carli2
carli2 requested a review from ssbarnea as a code owner September 19, 2025 18:11

@Thorsrud22 Thorsrud22 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this. Running a command inside a PTY is a genuinely useful addition, and the happy path works: ansi2html --partial -- printf '\033[31mRED\033[0m' gives <span class="ansi31">RED</span>. I checked the branch out and exercised it on macOS; here is what I found, roughly in order of severity.

Blocking

  1. import pty at module level breaks import ansi2html on Windows. pty imports tty, which does from termios import *, and termios is Unix-only. Simulating a platform without termios (sys.modules['termios'] = None): main imports fine, this branch raises ImportError. ansi2html is a library with ~2M downloads/month, so an import-time failure hits every consumer on Windows even if they never use the CLI. Fix is easy: import pty, select and subprocess inside the passthrough branch (or a helper function) so stdin mode keeps working everywhere.

  2. The "first non-option token starts the command" heuristic breaks every option that takes a separate value. On this branch, all of these fail with expected one argument while working on main:

    ansi2html -s solarized --partial < in.txt
    ansi2html -t Foo --partial < in.txt
    ansi2html -f 12 --partial < in.txt
    ansi2html --input-encoding utf-8 --partial < in.txt
    

    Only the --scheme=solarized form survives. Rather than re-implementing option parsing, let argparse do it: a command positional with nargs=argparse.REMAINDER (or parse_known_args) handles -- and knows which options consume a value. Alternatively require the explicit -- and drop the heuristic entirely.

  3. The branch conflicts with main (19 commits behind, GitHub reports CONFLICTING). The .pre-commit-config.yaml hunk is already on main and should drop out on rebase.

Should fix

  1. Child exit status is discarded. ansi2html sh -c 'exit 3' exits 0. Propagating proc.returncode via sys.exit would make this usable in scripts.
  2. A child that reads stdin hangs forever. The child's stdin is the PTY slave, and nothing ever writes to the master, so ansi2html cat never returns. stdin=subprocess.DEVNULL seems right for an output-capture tool.
  3. --help now prints a doubled usage block. usage=main.__doc__ renders as usage: \n Usage:\n ansi2html ..., and the examples appear again in epilog. Dropping usage= and keeping the examples only in epilog fixes both.
  4. Non-ASCII hyphen in help text. "pseudo‑terminal" in the description (and README) uses U+2011, which can raise on a non-UTF-8 stdout. A plain - is safer.
  5. LESS=-R is set but not documented anywhere (PR body, --help, man page). Either document it alongside GIT_PAGER/PAGER or drop it.

Tests

  1. tests/helpers.py pops every ansi2html* module from sys.modules and prepends src/ to sys.path. tox already installs the package in develop mode, so this isn't needed, and it can make two different module objects coexist so a patch on one doesn't affect the other. from ansi2html import converter is enough. The script parameter of run_with_fake_pty is also unused.
  2. The fake PTY is an os.pipe, so the actual pty.openpty + select + EIO/EOF loop, which is the risky part and differs between Linux and macOS, is never exercised. One real-PTY test (skipped on non-Unix) would cover it.

Scope suggestion

--standalone/-S and the man-page entries for the existing -W, -L, -s, -t options are independent of PTY passthrough and look ready. Splitting them into a small separate PR would let them land now regardless of how the passthrough discussion goes.

Happy to re-test once it's rebased.

import argparse
import io
import os
import pty

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pty -> tty -> from termios import *, and termios doesn't exist on Windows, so this makes import ansi2html itself fail there (verified by blocking termios in sys.modules: main imports, this branch raises ImportError). Suggest importing pty, select and subprocess inside the passthrough branch so stdin mode keeps working on every platform.

# Treat the first non-option token as the start of the command
split_at = None
for i, tok in enumerate(argv):
if not tok.startswith("-"):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This treats the value of any option as the start of the command. On this branch ansi2html -s solarized --partial < in.txt fails with argument -s/--scheme: expected one argument (same for -t, -f, --input-encoding); all work on main. Letting argparse own this, e.g. a command positional with nargs=argparse.REMAINDER, avoids re-implementing option parsing and still supports --.

" to avoid hanging pagers; set them explicitly to override."
),
formatter_class=argparse.RawTextHelpFormatter,
usage=main.__doc__,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With usage= set to the docstring, --help now prints usage: followed by a second Usage: heading, and the examples repeat in epilog. Dropping usage= and keeping examples only in epilog gives a clean help page.

parser = argparse.ArgumentParser(
description=(
"Convert text with ANSI color codes to HTML/LaTeX.\n\n"
"Can also run a command inside a PTY (pseudo‑terminal) and convert\n"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hyphen in "pseudo‑terminal" is U+2011 (non-breaking hyphen), here and in the README. A plain ASCII - avoids encoding errors on a non-UTF-8 stdout.

env.setdefault("GIT_PAGER", "cat")
env.setdefault("PAGER", "cat")
# Keep color passthrough if a pager is still used for some reason
env.setdefault("LESS", "-R")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LESS=-R isn't mentioned in the PR description, --help, or the man page, unlike GIT_PAGER/PAGER. Either document it with the others or drop it.

try:
with subprocess.Popen(
cmd_args,
stdin=slave_fd,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With stdin attached to the PTY slave and nothing writing to the master, a child that reads stdin blocks forever: ansi2html cat never returns. stdin=subprocess.DEVNULL seems like the right default for an output-capture tool.

except OSError:
pass

ansi_text = b"".join(chunks).decode(opts.input_encoding, "replace")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The child's exit status is dropped here: ansi2html sh -c 'exit 3' exits 0. Propagating proc.returncode (e.g. sys.exit(proc.returncode) after printing) would make this usable in scripts and CI.

Comment thread tests/helpers.py
from unittest.mock import patch


def import_local_converter() -> Any:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tox runs with usedevelop = True, so the installed ansi2html already is src/. Popping modules from sys.modules and re-importing can leave two distinct ansi2html.converter module objects alive, so a patch applied to one won't be seen by code holding the other. A plain from ansi2html import converter should be enough.

Comment thread tests/helpers.py
return importlib.import_module("ansi2html.converter")


def run_with_fake_pty(conv: Any, argv: Iterable[str], script: str) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

script is unused in this helper.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants