Python Windows10 において Console 出力に色を付ける

# はじめに

Windows10においてPythonでコンソールにログを出力すると、文字の色が単色なので非常に見づらいです。
最近は、開発環境等で色を付けるのに慣れてしまっているのでこれは避けたいです。
何とか手間をかけずに色付きで表示する方法を探してみました

追記：Linuxでも動くようです。

# 環境

Windows 10 Pro, version 1909, build 18363.959
Python 3.8.3
Powershell 7.0.2

# 方法

簡単に言うとVirtual Terminal Sequences(ANSI escape sequences)を使います。
Windows10でこれを使うには、はじめに有効化する必要があります。
以下のようにします。

```python:python
from ctypes import windll, wintypes, byref

def enable():
  INVALID_HANDLE_VALUE = -1
  STD_INPUT_HANDLE = -10
  STD_OUTPUT_HANDLE = -11
  STD_ERROR_HANDLE = -12
  ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
  ENABLE_LVB_GRID_WORLDWIDE = 0x0010

  hOut = windll.kernel32.GetStdHandle(STD_OUTPUT_HANDLE)
  if hOut == INVALID_HANDLE_VALUE:
    return False
  dwMode = wintypes.DWORD()
  if windll.kernel32.GetConsoleMode(hOut, byref(dwMode)) == 0:
    return False
  dwMode.value |= ENABLE_VIRTUAL_TERMINAL_PROCESSING
  # dwMode.value |= ENABLE_LVB_GRID_WORLDWIDE
  if windll.kernel32.SetConsoleMode(hOut, dwMode) == 0:
    return False
  return True

enable()
```

これが成功すれば、Virtual terminal sequences が使えるようになります。
これ以降で、例えば、赤で出力したい場合は、

```python:python
print("\x1b[31m")
```

と出力すると以降の文字は全て赤で表示されるようになります。
`\x1b`は Asciiコードの制御文字の`ESC`です。
それに続く`[31m`がコマンドのようなものです。
もっとも全て赤で出力してしまったら本末転倒なので、

```python:python
print("\x1b[31mRed, \x1b[34mBlue\x1b[0m") # 表示されるのは"Red, Blue"
```

などと使います。
詳細は、[Microsoft Docs: Console Virtual Terminal Sequences](https://docs.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences
 "console-virtual-terminal-sequences")などをご覧ください。

# コード

これをクラスにまとめてみました。
このコードを"VirtualTerminalSequences.py"としてモジュールとして使用できるようにします。
色々定義していますが、

- `VTS.enable()`
- `VTS.printColored(msg, fc, bc)`
- `VTS.getColorMessage(msg, fc, bc)`

を使えばよいです。

```python:VirtualTerminalSequences.py
import os
from ctypes import wintypes, byref
if os.name == "nt":
  from ctypes import windll

####################################################################################################
# VirtualTerminalSequences
####################################################################################################


class VTS:
  RESET = "\x1b[0m"  # reset
  BOLD = "\x1b[1m"
  UNDERLINE = "\x1b[4m"
  REVERSE = "\x1b[07m"  # reverse foreground and background colors
  UNDERLINE_OFF = "\x1b[24m"
  REVERSE_OFF = "\x1b[27m"
  FOREGROUND_COLORS = {
    "BLACK": "\x1b[30m",
    "RED": "\x1b[31m",
    "GREEN": "\x1b[32m",
    "YELLOW": "\x1b[33m",
    "BLUE": "\x1b[34m",
    "MAGENTA": "\x1b[35m",
    "CYAN": "\x1b[36m",
    "WHITE": "\x1b[37m",
    "DEFAULT_COLOR": "\x1b[39m",
    "GRAY": "\x1b[90m",
    "BRIGHT_RED": "\x1b[91m",
    "BRIGHT_GREEN": "\x1b[92m",
    "BRIGHT_YELLOW": "\x1b[93m",
    "BRIGHT_BLUE": "\x1b[94m",
    "BRIGHT_MAGENTA": "\x1b[95m",
    "BRIGHT_CYAN": "\x1b[96m",
    "BRIGHT_WHITE": "\x1b[97m",
  }
  BACKGROUND_COLORS = {
    "BLACK": "\x1b[40m",
    "RED": "\x1b[41m",
    "GREEN": "\x1b[42m",
    "YELLOW": "\x1b[43m",
    "BLUE": "\x1b[44m",
    "MAGENTA": "\x1b[45m",
    "CYAN": "\x1b[46m",
    "WHITE": "\x1b[47m",
    "DEFAULT_COLOR": "\x1b[49m",
    "GRAY": "\x1b[100m",
    "BRIGHT_RED": "\x1b[101m",
    "BRIGHT_GREEN": "\x1b[102m",
    "BRIGHT_YELLOW": "\x1b[103m",
    "BRIGHT_BLUE": "\x1b[104m",
    "BRIGHT_MAGENTA": "\x1b[105m",
    "BRIGHT_CYAN": "\x1b[106m",
    "BRIGHT_WHITE": "\x1b[107m",
  }

  INVALID_HANDLE_VALUE = -1
  STD_OUTPUT_HANDLE = -11
  STD_ERROR_HANDLE = -12
  ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
  ENABLE_LVB_GRID_WORLDWIDE = 0x0010

  @staticmethod
  def enable():
    if os.name != "nt":
      return True
    hOut = windll.kernel32.GetStdHandle(VTS.STD_OUTPUT_HANDLE)
    if hOut == VTS.INVALID_HANDLE_VALUE:
      return False
    dwMode = wintypes.DWORD()
    if windll.kernel32.GetConsoleMode(hOut, byref(dwMode)) == 0:
      return False
    dwMode.value |= VTS.ENABLE_VIRTUAL_TERMINAL_PROCESSING
    # dwMode.value |= ENABLE_LVB_GRID_WORLDWIDE
    if windll.kernel32.SetConsoleMode(hOut, dwMode) == 0:
      return False
    return True

  @staticmethod
  def reset():
    print(VTS.RESET)

  @staticmethod
  def isTupleOrList(x):
    return isinstance(x, tuple) or isinstance(x, list)

  @staticmethod
  def printColored(msg, fc="DEFAULT_COLOR", bc="DEFAULT_COLOR"):
    print(VTS.getColorMessage(msg, fc, bc))

  @staticmethod
  def getForegroundColor(value="DEFAULT_COLOR"):
    if isinstance(value, str):
      return VTS._getForegroundColorName(value)
    elif isinstance(value, int):
      return VTS._getForegroundColorNumber(value)
    elif VTS.isTupleOrList(value) and len(value) == 3:
      return VTS._getForegroundColorRGB(*value)
    else:
      return VTS.FOREGROUND_COLORS["DEFAULT_COLOR"]

  @staticmethod
  def getBackgroundColor(value="DEFAULT_COLOR"):
    if isinstance(value, str):
      return VTS._getBackgroundColorName(value)
    elif isinstance(value, int):
      return VTS._getBackgroundColorNumber(value)
    elif VTS.isTupleOrList(value) and len(value) == 3:
      return VTS._getBackgroundColorRGB(*value)
    else:
      return VTS.FOREGROUND_COLORS["DEFAULT_COLOR"]

  @staticmethod
  def _getForegroundColorName(name="DEFAULT_COLOR"):
    name = name.upper()
    return VTS.FOREGROUND_COLORS.get(name, VTS.FOREGROUND_COLORS["DEFAULT_COLOR"])

  @staticmethod
  def _getBackgroundColorName(name="DEFAULT_COLOR"):
    name = name.upper()
    return VTS.BACKGROUND_COLORS.get(name, VTS.BACKGROUND_COLORS["DEFAULT_COLOR"])

  @staticmethod
  def _getForegroundColorNumber(n):
    if n >= 0 and n <= 255:
      return f"\x1b[38;5;{n}m"
    return VTS.FOREGROUND_COLORS["DEFAULT_COLOR"]

  @staticmethod
  def _getForegroundColorRGB(r, g, b):
    if r >= 0 and r <= 255 and g >= 0 and g <= 255 and b >= 0 and b <= 255:
      return f"\x1b[38;2;{r};{g};{b}m"
    return VTS.FOREGROUND_COLORS["DEFAULT_COLOR"]

  @staticmethod
  def _getBackgroundColorNumber(n):
    if n >= 0 and n <= 255:
      return f"\x1b[48;5;{n}m"
    return VTS.BACKGROUND_COLORS["DEFAULT_COLOR"]

  @staticmethod
  def _getBackgroundColorRGB(r, g, b):
    if r >= 0 and r <= 255 and g >= 0 and g <= 255 and b >= 0 and b <= 255:
      return f"\x1b[48;2;{r};{g};{b}m"
    return VTS.BACKGROUND_COLORS["DEFAULT_COLOR"]

  @staticmethod
  def getColorMessage(msg, fc="DEFAULT_COLOR", bc="DEFAULT_COLOR", reset=True):
    msg = f"{VTS.getForegroundColor(fc)}{VTS.getBackgroundColor(bc)}{msg}"
    if not reset:
      return msg
    return f"{msg}{VTS.RESET}"

  @staticmethod
  def getBoldMessage(msg, reset=True):
    msg = f"{VTS.BOLD}{msg}"
    if not reset:
      return msg
    return f"{msg}{VTS.RESET}"

  @staticmethod
  def getUnderlineMessage(msg, reset=True):
    msg = f"{VTS.UNDERLINE}{msg}"
    if not reset:
      return msg
    return f"{msg}{VTS.RESET}"

  @staticmethod
  def getReverseMessage(msg, reset=True):
    msg = f"{VTS.REVERSE}{msg}"
    if not reset:
      return msg
    return f"{msg}{VTS.RESET}"

  @staticmethod
  def testColor():
    for kBc, vBc in VTS.BACKGROUND_COLORS.items():
      for kFc, vFc in VTS.FOREGROUND_COLORS.items():
        string = VTS.getColorMessage(f"{kFc:<14}, {kBc:<14}", kFc, kBc)
        print(string)
    VTS.reset()
```

## テストコード

```python:python
import os

from VirtualTerminalSequences import VTS

os.system("CLS")  # 画面をクリア
print(f"NORMAL")
print(f"{VTS.BOLD}BOLD{VTS.RESET}")
print(f"{VTS.UNDERLINE}UNDERLINE")
print(f"{VTS.UNDERLINE_OFF}UNDERLINE_OFF")
print(f"{VTS.REVERSE}REVERSE")
print(f"{VTS.REVERSE_OFF}REVERSE_OFF")

[print(f"{VTS._getForegroundColorName(key)}$", end="") for key in VTS.FOREGROUND_COLORS.keys()]
VTS.reset()
[print(f"{VTS._getForegroundColorNumber(n)}$", end="") for n in range(256)]
VTS.reset()
[print(f"{VTS._getForegroundColorRGB(r,0,0)}$", end="") for r in range(256)]
VTS.reset()
[print(f"{VTS._getForegroundColorRGB(0,g,0)}$", end="") for g in range(256)]
VTS.reset()
[print(f"{VTS._getForegroundColorRGB(0,0,b)}$", end="") for b in range(256)]
VTS.reset()

[print(f"{VTS._getBackgroundColorName(key)}_", end="") for key in VTS.BACKGROUND_COLORS.keys()]
VTS.reset()
[print(f"{VTS._getBackgroundColorNumber(n)}_", end="") for n in range(256)]
VTS.reset()
[print(f"{VTS._getBackgroundColorRGB(r,0,0)}_", end="") for r in range(256)]
VTS.reset()
[print(f"{VTS._getBackgroundColorRGB(0,g,0)}_", end="") for g in range(256)]
VTS.reset()
[print(f"{VTS._getBackgroundColorRGB(0,0,b)}_", end="") for b in range(256)]
VTS.reset()

print(VTS.getColorMessage("色々な色", "red", "blue"))
print(VTS.getColorMessage("色々な色", 3, 6))
print(VTS.getColorMessage("色々な色", (200, 200, 200), (100, 100, 100), False))
print(VTS.getUnderlineMessage("河川に下線"))
print(VTS.getBoldMessage("太い痩せ猫", False))
print(VTS.getReverseMessage("裏返る裏切り"))
print(VTS.getReverseMessage("裏返る裏切り"))
string1 = VTS.getColorMessage("abc", "red", "green")
string2 = VTS.getColorMessage("def", "green", "blue")
string3 = VTS.getColorMessage("ghi", "blue", "red")
print(string1 + string2 + string3)

```

# Loggingと組み合わせる

Loggingモジュールを使ってログを出力している場合は、

- `Formatter`で情報毎に色分けする
- `Filter`で条件ごとに色分けする
- `Logger.info()`などによる出力段階で色分けする

等が考えられます。

## サンプル例

ここでは、

- `Filter`でログレベルごとに色分けする
- `Formatter`で時刻情報を色分けする

を実装してみました。

```python:
import logging
import logging.handlers
import logging.config
from VirtualTerminalSequences import VTS

class ColorFilter(logging.Filter):

  def __init__(self):
    super().__init__()
    VTS.enable()

  def filter(self, record):
    if record.levelno == logging.DEBUG:
      record.msg = VTS.getColorMessage(record.msg, "green")
    if record.levelno == logging.INFO:
      record.msg = VTS.getColorMessage(record.msg, "cyan")
    if record.levelno == logging.WARNING:
      record.msg = VTS.getColorMessage(record.msg, "yellow")
    if record.levelno == logging.ERROR:
      record.msg = VTS.getColorMessage(record.msg, "magenta")
    if record.levelno == logging.CRITICAL:
      record.msg = VTS.getColorMessage(record.msg, "red")
    return True

config = {
    "version": 1,
    "formatters": {
        "normalColor": {
            "class":
                "logging.Formatter",
            "format":
                f"{VTS.FOREGROUND_COLORS['BLACK']}{VTS.BACKGROUND_COLORS['WHITE']}" + "{asctime}" + f"{VTS.RESET}" +
                " - {message}",
            "datefmt":
                "%H:%M:%S",
            "style":
                "{",
        },
        "detail": {
            "class": "logging.Formatter",
            "format": "{asctime}.{msecs:03.0f} - {name:<15} - {levelname:<8} - {message}",
            "datefmt": "%Y-%m-%d-%H:%M:%S",
            "style": "{",
        },
    },
    "filters": {
        "color": {
            "()": "ColorFilter",
        },
    },
    "handlers": {
        "null": {
            "class": "logging.NullHandler",
            "level": "DEBUG",  # logging.DEBUG も可能
        },
        "console": {
            "class": "logging.StreamHandler",
            "level": "INFO",
            "formatter": "normalColor",
            "filters": ["color"],
        },
        "file": {
            "class": "logging.handlers.RotatingFileHandler",
            "level": "DEBUG",
            "formatter": "detail",
            "filename": "log.log",
            "maxBytes": 1024 * 1024,
            "backupCount": 2,
            "encoding": "utf-8",
        },
    },
    "loggers": {
        "console": {
            "handlers": ["console"],
            "level": logging.INFO,
        },
        "__main__": {
            "handlers": ["console", "file"],
            "level": logging.DEBUG,
        },
    },
}

logging.config.dictConfig(config)
```




# 参考

[Microsoft Docs: Console Virtual Terminal Sequences](https://docs.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences
 "console-virtual-terminal-sequences")
[Microsoft Docs: getStdHandle](https://docs.microsoft.com/en-us/windows/console/getstdhandle 
"getStdHandle")
[Microsoft Docs: getConsoleMode](https://docs.microsoft.com/en-us/windows/console/getconsolemode "getConsoleMode"
)
[Microsoft Docs: setConsoleMode](https://docs.microsoft.com/en-us/windows/console/setconsolemode
 "setConsoleMode")