Merge pull request 'fix(logging): restore AUR progress bars and render clean colored logs' (#44) from fix/aur-wrapper-pty-progress-bars into main
Generate documentation / build-docs (push) Successful in 37s
Generate documentation / build-docs (push) Successful in 37s
Reviewed-on: #44
This commit was merged in pull request #44.
This commit is contained in:
+178
@@ -110,3 +110,181 @@ fish_variables
|
|||||||
config.local.fish
|
config.local.fish
|
||||||
|
|
||||||
# End of https://www.toptal.com/developers/gitignore/api/fish
|
# End of https://www.toptal.com/developers/gitignore/api/fish
|
||||||
|
|
||||||
|
# id: gi-patterns-e390e9c720b3dda36906a2b4cb76ffdd
|
||||||
|
# Created by https://www.toptal.com/developers/gitignore/api/python
|
||||||
|
# Edit at https://www.toptal.com/developers/gitignore?templates=python
|
||||||
|
|
||||||
|
### Python ###
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
# For a library or package, you might want to ignore these files since the code is
|
||||||
|
# intended to run in multiple environments; otherwise, check them in:
|
||||||
|
# .python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||||
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||||
|
# install all needed dependencies.
|
||||||
|
#Pipfile.lock
|
||||||
|
|
||||||
|
# poetry
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||||
|
#poetry.lock
|
||||||
|
|
||||||
|
# pdm
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||||
|
#pdm.lock
|
||||||
|
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||||
|
# in version control.
|
||||||
|
# https://pdm.fming.dev/#use-with-ide
|
||||||
|
.pdm.toml
|
||||||
|
|
||||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
|
||||||
|
# PyCharm
|
||||||
|
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||||
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
|
#.idea/
|
||||||
|
|
||||||
|
### Python Patch ###
|
||||||
|
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
|
||||||
|
poetry.toml
|
||||||
|
|
||||||
|
# ruff
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# LSP config files
|
||||||
|
pyrightconfig.json
|
||||||
|
|
||||||
|
# End of https://www.toptal.com/developers/gitignore/api/python
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
#
|
#
|
||||||
# Generates ~/.local/bin/paru on first run (and on version bump) when
|
# Generates ~/.local/bin/paru on first run (and on version bump) when
|
||||||
# /usr/bin/paru is installed. The wrapper tees paru output to a
|
# /usr/bin/paru is installed. The wrapper runs paru in a PTY so progress
|
||||||
# timestamped log file and prunes old logs, mirroring smart_exit behavior.
|
# bars are preserved, renders the captured animation to a clean static log
|
||||||
|
# (via scripts/clean_progress_log.py), and prunes old logs.
|
||||||
|
|
||||||
# Auto-generating a wrapper in ~/.local/bin is opinionated (C2 auto-exec).
|
# Auto-generating a wrapper in ~/.local/bin is opinionated (C2 auto-exec).
|
||||||
# Wrapper generation is also gated by C5 (Logging & Capture).
|
# Wrapper generation is also gated by C5 (Logging & Capture).
|
||||||
@@ -20,7 +21,7 @@ end
|
|||||||
|
|
||||||
set -l _paru_real /usr/bin/paru
|
set -l _paru_real /usr/bin/paru
|
||||||
set -l _paru_wrapper "$HOME/.local/bin/paru"
|
set -l _paru_wrapper "$HOME/.local/bin/paru"
|
||||||
set -l _paru_wrapper_version 1
|
set -l _paru_wrapper_version 5
|
||||||
|
|
||||||
# Skip entirely if the real paru binary isn't present
|
# Skip entirely if the real paru binary isn't present
|
||||||
test -x $_paru_real; or return
|
test -x $_paru_real; or return
|
||||||
@@ -38,14 +39,31 @@ printf '%s\n' \
|
|||||||
'#!/usr/bin/env bash' \
|
'#!/usr/bin/env bash' \
|
||||||
"# paru-wrapper-version: $_paru_wrapper_version" \
|
"# paru-wrapper-version: $_paru_wrapper_version" \
|
||||||
'# Auto-generated by conf.d/paru-wrapper.fish — do not edit by hand.' \
|
'# Auto-generated by conf.d/paru-wrapper.fish — do not edit by hand.' \
|
||||||
'# Tees paru output to a timestamped log file and prunes old ones.' \
|
'# Runs paru in a PTY via script(1) so progress bars are preserved on screen,' \
|
||||||
'set -o pipefail' \
|
'# then renders the captured terminal animation to a clean static log.' \
|
||||||
'' \
|
'' \
|
||||||
'log_dir="${SCROLLBACK_HISTORY_DIR:-$HOME/.terminal_history}"' \
|
'log_dir="${SCROLLBACK_HISTORY_DIR:-$HOME/.terminal_history}"' \
|
||||||
'mkdir -p "$log_dir"' \
|
'mkdir -p "$log_dir"' \
|
||||||
'log_file="$log_dir/paru_$(date +%Y-%m-%d_%H-%M-%S).log"' \
|
'log_file="$log_dir/paru_$(date +%Y-%m-%d_%H-%M-%S).log"' \
|
||||||
'' \
|
'' \
|
||||||
'/usr/bin/paru "$@" 2>&1 | tee "$log_file"' \
|
'# Build a safely-quoted command string for script(1).' \
|
||||||
|
'# script(1) allocates a PTY so paru detects a real terminal and shows progress.' \
|
||||||
|
'cmd_str="/usr/bin/paru"' \
|
||||||
|
'for arg in "$@"; do' \
|
||||||
|
' cmd_str+=" $(printf '"'"'%q'"'"' "$arg")"' \
|
||||||
|
'done' \
|
||||||
|
'script -q -e -c "$cmd_str" "$log_file"' \
|
||||||
|
'exit_code=$?' \
|
||||||
|
'' \
|
||||||
|
'# Render the captured terminal animation (progress bars repaint in place via' \
|
||||||
|
'# cursor moves) down to its final static frame, preserving ANSI color. Falls' \
|
||||||
|
'# back to dropping only the script(1) header/footer when python3 is missing.' \
|
||||||
|
'cleaner="${XDG_CONFIG_HOME:-$HOME/.config}/fish/scripts/clean_progress_log.py"' \
|
||||||
|
'if command -v python3 >/dev/null 2>&1 && [[ -f "$cleaner" ]]; then' \
|
||||||
|
' python3 "$cleaner" < "$log_file" > "${log_file}.tmp" 2>/dev/null && mv "${log_file}.tmp" "$log_file" || rm -f "${log_file}.tmp"' \
|
||||||
|
'else' \
|
||||||
|
' sed -i "/^Script \(started\|done\) on /d" "$log_file" 2>/dev/null || true' \
|
||||||
|
'fi' \
|
||||||
'' \
|
'' \
|
||||||
'max_files="${SCROLLBACK_HISTORY_MAX_FILES:-100}"' \
|
'max_files="${SCROLLBACK_HISTORY_MAX_FILES:-100}"' \
|
||||||
'mapfile -t logs < <(ls -1t "$log_dir"/paru_*.log 2>/dev/null)' \
|
'mapfile -t logs < <(ls -1t "$log_dir"/paru_*.log 2>/dev/null)' \
|
||||||
@@ -53,6 +71,8 @@ printf '%s\n' \
|
|||||||
'for (( i = ${#logs[@]} - 1; i >= ${#logs[@]} - excess && i >= 0; i-- )); do' \
|
'for (( i = ${#logs[@]} - 1; i >= ${#logs[@]} - excess && i >= 0; i-- )); do' \
|
||||||
' rm -f "${logs[$i]}"' \
|
' rm -f "${logs[$i]}"' \
|
||||||
'done' \
|
'done' \
|
||||||
|
'' \
|
||||||
|
'exit $exit_code' \
|
||||||
> $_paru_wrapper
|
> $_paru_wrapper
|
||||||
|
|
||||||
chmod +x $_paru_wrapper
|
chmod +x $_paru_wrapper
|
||||||
|
|||||||
+26
-6
@@ -2,8 +2,9 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
#
|
#
|
||||||
# Generates ~/.local/bin/yay on first run (and on version bump) when
|
# Generates ~/.local/bin/yay on first run (and on version bump) when
|
||||||
# /usr/bin/yay is installed. The wrapper tees yay output to a
|
# /usr/bin/yay is installed. The wrapper runs yay in a PTY so progress
|
||||||
# timestamped log file and prunes old logs, mirroring smart_exit behavior.
|
# bars are preserved, renders the captured animation to a clean static log
|
||||||
|
# (via scripts/clean_progress_log.py), and prunes old logs.
|
||||||
|
|
||||||
# Auto-generating a wrapper in ~/.local/bin is opinionated (C2 auto-exec).
|
# Auto-generating a wrapper in ~/.local/bin is opinionated (C2 auto-exec).
|
||||||
# Wrapper generation is also gated by C5 (Logging & Capture).
|
# Wrapper generation is also gated by C5 (Logging & Capture).
|
||||||
@@ -20,7 +21,7 @@ end
|
|||||||
|
|
||||||
set -l _yay_real /usr/bin/yay
|
set -l _yay_real /usr/bin/yay
|
||||||
set -l _yay_wrapper "$HOME/.local/bin/yay"
|
set -l _yay_wrapper "$HOME/.local/bin/yay"
|
||||||
set -l _yay_wrapper_version 1
|
set -l _yay_wrapper_version 5
|
||||||
|
|
||||||
# Skip entirely if the real yay binary isn't present
|
# Skip entirely if the real yay binary isn't present
|
||||||
test -x $_yay_real; or return
|
test -x $_yay_real; or return
|
||||||
@@ -38,14 +39,31 @@ printf '%s\n' \
|
|||||||
'#!/usr/bin/env bash' \
|
'#!/usr/bin/env bash' \
|
||||||
"# yay-wrapper-version: $_yay_wrapper_version" \
|
"# yay-wrapper-version: $_yay_wrapper_version" \
|
||||||
'# Auto-generated by conf.d/yay-wrapper.fish — do not edit by hand.' \
|
'# Auto-generated by conf.d/yay-wrapper.fish — do not edit by hand.' \
|
||||||
'# Tees yay output to a timestamped log file and prunes old ones.' \
|
'# Runs yay in a PTY via script(1) so progress bars are preserved on screen,' \
|
||||||
'set -o pipefail' \
|
'# then renders the captured terminal animation to a clean static log.' \
|
||||||
'' \
|
'' \
|
||||||
'log_dir="${SCROLLBACK_HISTORY_DIR:-$HOME/.terminal_history}"' \
|
'log_dir="${SCROLLBACK_HISTORY_DIR:-$HOME/.terminal_history}"' \
|
||||||
'mkdir -p "$log_dir"' \
|
'mkdir -p "$log_dir"' \
|
||||||
'log_file="$log_dir/yay_$(date +%Y-%m-%d_%H-%M-%S).log"' \
|
'log_file="$log_dir/yay_$(date +%Y-%m-%d_%H-%M-%S).log"' \
|
||||||
'' \
|
'' \
|
||||||
'/usr/bin/yay "$@" 2>&1 | tee "$log_file"' \
|
'# Build a safely-quoted command string for script(1).' \
|
||||||
|
'# script(1) allocates a PTY so yay detects a real terminal and shows progress.' \
|
||||||
|
'cmd_str="/usr/bin/yay"' \
|
||||||
|
'for arg in "$@"; do' \
|
||||||
|
' cmd_str+=" $(printf '"'"'%q'"'"' "$arg")"' \
|
||||||
|
'done' \
|
||||||
|
'script -q -e -c "$cmd_str" "$log_file"' \
|
||||||
|
'exit_code=$?' \
|
||||||
|
'' \
|
||||||
|
'# Render the captured terminal animation (progress bars repaint in place via' \
|
||||||
|
'# cursor moves) down to its final static frame, preserving ANSI color. Falls' \
|
||||||
|
'# back to dropping only the script(1) header/footer when python3 is missing.' \
|
||||||
|
'cleaner="${XDG_CONFIG_HOME:-$HOME/.config}/fish/scripts/clean_progress_log.py"' \
|
||||||
|
'if command -v python3 >/dev/null 2>&1 && [[ -f "$cleaner" ]]; then' \
|
||||||
|
' python3 "$cleaner" < "$log_file" > "${log_file}.tmp" 2>/dev/null && mv "${log_file}.tmp" "$log_file" || rm -f "${log_file}.tmp"' \
|
||||||
|
'else' \
|
||||||
|
' sed -i "/^Script \(started\|done\) on /d" "$log_file" 2>/dev/null || true' \
|
||||||
|
'fi' \
|
||||||
'' \
|
'' \
|
||||||
'max_files="${SCROLLBACK_HISTORY_MAX_FILES:-100}"' \
|
'max_files="${SCROLLBACK_HISTORY_MAX_FILES:-100}"' \
|
||||||
'mapfile -t logs < <(ls -1t "$log_dir"/yay_*.log 2>/dev/null)' \
|
'mapfile -t logs < <(ls -1t "$log_dir"/yay_*.log 2>/dev/null)' \
|
||||||
@@ -53,6 +71,8 @@ printf '%s\n' \
|
|||||||
'for (( i = ${#logs[@]} - 1; i >= ${#logs[@]} - excess && i >= 0; i-- )); do' \
|
'for (( i = ${#logs[@]} - 1; i >= ${#logs[@]} - excess && i >= 0; i-- )); do' \
|
||||||
' rm -f "${logs[$i]}"' \
|
' rm -f "${logs[$i]}"' \
|
||||||
'done' \
|
'done' \
|
||||||
|
'' \
|
||||||
|
'exit $exit_code' \
|
||||||
> $_yay_wrapper
|
> $_yay_wrapper
|
||||||
|
|
||||||
chmod +x $_yay_wrapper
|
chmod +x $_yay_wrapper
|
||||||
|
|||||||
+12
-3
@@ -60,6 +60,9 @@ The configuration is split across:
|
|||||||
completions/ Tab completion scripts
|
completions/ Tab completion scripts
|
||||||
integrations/
|
integrations/
|
||||||
fzf.fish FZF Catppuccin theme and key binding config
|
fzf.fish FZF Catppuccin theme and key binding config
|
||||||
|
scripts/
|
||||||
|
clean_progress_log.py Renders script(1) typescripts (paru/yay progress
|
||||||
|
animations) to clean static logs, preserving color
|
||||||
docs/ Offline documentation and compiled man page
|
docs/ Offline documentation and compiled man page
|
||||||
fish-config.md Primary source manual (terminal-readable)
|
fish-config.md Primary source manual (terminal-readable)
|
||||||
fish-config.1 Compiled man page (auto-generated by CI)
|
fish-config.1 Compiled man page (auto-generated by CI)
|
||||||
@@ -216,7 +219,13 @@ scrollback snapshot to SCROLLBACK_HISTORY_DIR. Files are named:
|
|||||||
|
|
||||||
scrollback_YYYY-MM-DD_HH-MM-SS.log
|
scrollback_YYYY-MM-DD_HH-MM-SS.log
|
||||||
|
|
||||||
The paru and yay wrappers (auto-generated in ~/.local/bin/) tee all output to:
|
The paru and yay wrappers (auto-generated in ~/.local/bin/) run the command
|
||||||
|
inside a PTY via script(1) so download progress bars are preserved on screen,
|
||||||
|
then render the captured terminal animation down to a clean static log via
|
||||||
|
scripts/clean_progress_log.py (a small terminal-screen emulator that replays
|
||||||
|
cursor movements, collapses repainted progress frames to their final state,
|
||||||
|
and preserves ANSI color). If python3 is unavailable the wrapper falls back to
|
||||||
|
dropping only the script(1) header/footer. Output is saved to:
|
||||||
|
|
||||||
paru_YYYY-MM-DD_HH-MM-SS.log
|
paru_YYYY-MM-DD_HH-MM-SS.log
|
||||||
yay_YYYY-MM-DD_HH-MM-SS.log
|
yay_YYYY-MM-DD_HH-MM-SS.log
|
||||||
@@ -1615,9 +1624,9 @@ __fish_config_op_logging skips all capture and removes the logging wrappers.
|
|||||||
───────────────────────────────────────────────────────────────────────────
|
───────────────────────────────────────────────────────────────────────────
|
||||||
Scrollback capture Terminal session output saved to:
|
Scrollback capture Terminal session output saved to:
|
||||||
~/.terminal_history/scrollback_YYYY-MM-DD_HH-MM-SS.log
|
~/.terminal_history/scrollback_YYYY-MM-DD_HH-MM-SS.log
|
||||||
paru wrapper All paru/AUR output teed to:
|
paru wrapper All paru/AUR output captured to:
|
||||||
~/.terminal_history/paru_YYYY-MM-DD_HH-MM-SS.log
|
~/.terminal_history/paru_YYYY-MM-DD_HH-MM-SS.log
|
||||||
yay wrapper All yay/AUR output teed to:
|
yay wrapper All yay/AUR output captured to:
|
||||||
~/.terminal_history/yay_YYYY-MM-DD_HH-MM-SS.log
|
~/.terminal_history/yay_YYYY-MM-DD_HH-MM-SS.log
|
||||||
Kitty watcher watcher.py captures scrollback when Kitty closes
|
Kitty watcher watcher.py captures scrollback when Kitty closes
|
||||||
|
|
||||||
|
|||||||
Executable
+225
@@ -0,0 +1,225 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Copyright (C) 2026 Rootiest
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
#
|
||||||
|
# SYNOPSIS
|
||||||
|
# clean_progress_log.py < typescript.raw > clean.log
|
||||||
|
#
|
||||||
|
# DESCRIPTION
|
||||||
|
# Renders a script(1) typescript containing terminal animations (pacman/paru
|
||||||
|
# download progress bars, which repaint in place using carriage returns and
|
||||||
|
# ANSI cursor-movement/erase sequences) down to a clean, static log that shows
|
||||||
|
# only the final state of each line. SGR color sequences are preserved so the
|
||||||
|
# output still renders with color in pagers such as ov, bat, or less -R.
|
||||||
|
#
|
||||||
|
# It implements a minimal terminal screen-buffer emulator: it replays the
|
||||||
|
# cursor movements against an in-memory grid of cells, so the concatenated
|
||||||
|
# redraw frames collapse to the final frame exactly as a real terminal would
|
||||||
|
# display them. script(1) "Script started/done" header and footer lines are
|
||||||
|
# dropped.
|
||||||
|
#
|
||||||
|
# RETURNS
|
||||||
|
# 0 Always (best-effort; unparseable bytes are passed through as text).
|
||||||
|
#
|
||||||
|
# EXAMPLE
|
||||||
|
# script -q -e -c 'paru -Syu' raw.log
|
||||||
|
# clean_progress_log.py < raw.log > clean.log
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# One CSI sequence: ESC [ <params> <final>. Covers SGR (m), cursor moves
|
||||||
|
# (A/B/C/D/E/F/G/H/f), erases (J/K), and private modes (?25l/h) alike.
|
||||||
|
_CSI = re.compile(r"\x1b\[([0-9;?]*)([A-Za-z])")
|
||||||
|
# OSC sequence (e.g. window title): ESC ] ... BEL or ESC ] ... ESC \
|
||||||
|
_OSC = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)")
|
||||||
|
# script(1) header/footer lines to drop from the final render.
|
||||||
|
_SCRIPT_LINE = re.compile(r"^Script (started|done) on ")
|
||||||
|
|
||||||
|
|
||||||
|
class Screen:
|
||||||
|
"""A minimal, unbounded-height terminal emulator.
|
||||||
|
|
||||||
|
Each cell is a [sgr, char] pair, where sgr is the full active SGR escape
|
||||||
|
string in effect when the character was written ('' means default). The
|
||||||
|
grid grows downward as needed; relative cursor moves index into it, so
|
||||||
|
in-place repaints overwrite earlier frames just like a real terminal.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.rows = [[]]
|
||||||
|
self.row = 0
|
||||||
|
self.col = 0
|
||||||
|
self.sgr = ""
|
||||||
|
|
||||||
|
def _ensure_row(self, r):
|
||||||
|
while len(self.rows) <= r:
|
||||||
|
self.rows.append([])
|
||||||
|
|
||||||
|
def _ensure_col(self, c):
|
||||||
|
row = self.rows[self.row]
|
||||||
|
while len(row) <= c:
|
||||||
|
row.append(["", " "])
|
||||||
|
|
||||||
|
def write_char(self, ch):
|
||||||
|
self._ensure_row(self.row)
|
||||||
|
self._ensure_col(self.col)
|
||||||
|
self.rows[self.row][self.col] = [self.sgr, ch]
|
||||||
|
self.col += 1
|
||||||
|
|
||||||
|
def newline(self):
|
||||||
|
# script(1) typescripts always emit CRLF (the PTY's ONLCR translates
|
||||||
|
# LF to CRLF on output), so a bare LF starts a fresh line. Reset the
|
||||||
|
# column to avoid "staircase" artifacts if a bare LF ever appears.
|
||||||
|
self.row += 1
|
||||||
|
self.col = 0
|
||||||
|
self._ensure_row(self.row)
|
||||||
|
|
||||||
|
def carriage_return(self):
|
||||||
|
self.col = 0
|
||||||
|
|
||||||
|
def backspace(self):
|
||||||
|
if self.col > 0:
|
||||||
|
self.col -= 1
|
||||||
|
|
||||||
|
def tab(self):
|
||||||
|
self.col = (self.col // 8 + 1) * 8
|
||||||
|
|
||||||
|
def set_sgr(self, params):
|
||||||
|
# Treat a bare reset (ESC[0m / ESC[m) as clearing all attributes;
|
||||||
|
# otherwise adopt the new full sequence. pacman always emits complete
|
||||||
|
# attribute sets, so replacement (rather than merging) is exact here.
|
||||||
|
if params in ("", "0"):
|
||||||
|
self.sgr = ""
|
||||||
|
else:
|
||||||
|
self.sgr = "\x1b[" + params + "m"
|
||||||
|
|
||||||
|
def csi(self, params, final):
|
||||||
|
if final == "m":
|
||||||
|
self.set_sgr(params)
|
||||||
|
return
|
||||||
|
# Numeric argument (default 1 for moves, 0 for erases).
|
||||||
|
nums = [int(p) for p in params.split(";") if p.isdigit()]
|
||||||
|
n = nums[0] if nums else None
|
||||||
|
if final == "A":
|
||||||
|
self.row = max(0, self.row - (n or 1))
|
||||||
|
elif final == "B" or final == "E":
|
||||||
|
self.row += (n or 1)
|
||||||
|
self._ensure_row(self.row)
|
||||||
|
if final == "E":
|
||||||
|
self.col = 0
|
||||||
|
elif final == "F":
|
||||||
|
self.row = max(0, self.row - (n or 1))
|
||||||
|
self.col = 0
|
||||||
|
elif final == "C":
|
||||||
|
self.col += (n or 1)
|
||||||
|
elif final == "D":
|
||||||
|
self.col = max(0, self.col - (n or 1))
|
||||||
|
elif final == "G":
|
||||||
|
self.col = (n or 1) - 1
|
||||||
|
elif final in ("H", "f"):
|
||||||
|
self.row = (nums[0] - 1) if len(nums) >= 1 else 0
|
||||||
|
self.col = (nums[1] - 1) if len(nums) >= 2 else 0
|
||||||
|
self._ensure_row(self.row)
|
||||||
|
elif final == "K":
|
||||||
|
self._erase_line(n or 0)
|
||||||
|
elif final == "J":
|
||||||
|
self._erase_display(n or 0)
|
||||||
|
# Any other final (private modes like ?25l/h, etc.) is ignored.
|
||||||
|
|
||||||
|
def _erase_line(self, mode):
|
||||||
|
self._ensure_row(self.row)
|
||||||
|
row = self.rows[self.row]
|
||||||
|
if mode == 0: # cursor to end of line
|
||||||
|
del row[self.col:]
|
||||||
|
elif mode == 1: # start of line to cursor
|
||||||
|
for c in range(min(self.col + 1, len(row))):
|
||||||
|
row[c] = ["", " "]
|
||||||
|
elif mode == 2: # whole line
|
||||||
|
row.clear()
|
||||||
|
|
||||||
|
def _erase_display(self, mode):
|
||||||
|
if mode == 2: # whole screen
|
||||||
|
self.rows = [[]]
|
||||||
|
self.row = 0
|
||||||
|
self.col = 0
|
||||||
|
elif mode == 0: # cursor to end of screen
|
||||||
|
self._erase_line(0)
|
||||||
|
del self.rows[self.row + 1:]
|
||||||
|
|
||||||
|
def feed(self, data):
|
||||||
|
i = 0
|
||||||
|
n = len(data)
|
||||||
|
while i < n:
|
||||||
|
ch = data[i]
|
||||||
|
if ch == "\x1b":
|
||||||
|
m = _CSI.match(data, i)
|
||||||
|
if m:
|
||||||
|
self.csi(m.group(1), m.group(2))
|
||||||
|
i = m.end()
|
||||||
|
continue
|
||||||
|
m = _OSC.match(data, i)
|
||||||
|
if m:
|
||||||
|
i = m.end()
|
||||||
|
continue
|
||||||
|
# Charset designators (ESC ( B etc.) and other 2-char escapes.
|
||||||
|
if i + 1 < n and data[i + 1] in "()":
|
||||||
|
i += 3
|
||||||
|
else:
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
if ch == "\n":
|
||||||
|
self.newline()
|
||||||
|
elif ch == "\r":
|
||||||
|
self.carriage_return()
|
||||||
|
elif ch == "\b":
|
||||||
|
self.backspace()
|
||||||
|
elif ch == "\t":
|
||||||
|
self.tab()
|
||||||
|
elif ch == "\x07": # bell
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
self.write_char(ch)
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
def render(self):
|
||||||
|
out_lines = []
|
||||||
|
for row in self.rows:
|
||||||
|
last_sgr = ""
|
||||||
|
buf = []
|
||||||
|
for sgr, ch in row:
|
||||||
|
if sgr != last_sgr:
|
||||||
|
if sgr == "":
|
||||||
|
buf.append("\x1b[0m")
|
||||||
|
else:
|
||||||
|
buf.append(sgr)
|
||||||
|
last_sgr = sgr
|
||||||
|
buf.append(ch)
|
||||||
|
if last_sgr != "":
|
||||||
|
buf.append("\x1b[0m")
|
||||||
|
line = "".join(buf)
|
||||||
|
# Strip trailing whitespace (and any trailing reset that follows it).
|
||||||
|
line = re.sub(r"[ \t]+(\x1b\[0m)?$", r"\1", line)
|
||||||
|
out_lines.append(line)
|
||||||
|
# Drop script(1) header/footer lines (compare against de-escaped text).
|
||||||
|
kept = []
|
||||||
|
for line in out_lines:
|
||||||
|
plain = _CSI.sub("", line)
|
||||||
|
if _SCRIPT_LINE.match(plain):
|
||||||
|
continue
|
||||||
|
kept.append(line)
|
||||||
|
# Trim trailing blank lines.
|
||||||
|
while kept and kept[-1].strip() == "":
|
||||||
|
kept.pop()
|
||||||
|
return "\n".join(kept) + ("\n" if kept else "")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
data = sys.stdin.buffer.read().decode("utf-8", "replace")
|
||||||
|
screen = Screen()
|
||||||
|
screen.feed(data)
|
||||||
|
sys.stdout.write(screen.render())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user