前言


前一陣子用python寫了一支 Windows 的 Console 程式: 因為需要為程式的輸出訊息加一點顏色而小小的 '卡' 了一陣子; 上個月底終於把它解決了, 特別記錄一下免得日後給忘了.

為 Console 輸出上色


這個 Console 上色的問題, 因為不是主要的功能, 所以一開始只解決了一半, 就一直放著: 輸出 ANSI Terminal 的控制碼可以在 git for windows 的bash視窗上更改輸出訊息的顏色; 但是一換到 Windows CMD Console 執行時這些 ANSI Terminal 的控制碼就完全沒有效果, 只是一五一十的印出來.

上個月底總算把程式的主要功能寫完測完, 因此回頭解決它. 但是, 在 Google 上爬了許久 (主要都是 StackOverflow 網站), 大部是說需換掉 Windows CMD 讓它可以支援 ANSI Terminal 的控制碼就可以了. 可是這並不是我要的答案. 其中也有試著下載一些其他的模組, 大部份試不下去的原因都是因為在 python for windows 環境下並沒有 termios 這個模組可用. 直到看到有人回覆說用colorama模組就可以解決, 不過也試了幾次才順利把問題解決.

一開始我是直接像colorama 0.4.1的說明頁面上說的加了以下的 code:

from colorama import init
init()

結果並不像說明頁面上說的這樣就可以了, 而是剛好和我原本狀況相反: Windows CMD Console 正常了, 但是在 git for windows 的 bash 視窗中原本有上色的部份卻不見了.

還好之前為了暫時把 Windows CMD Console 的 ANSI Terminal 的控制碼取消掉, 找到了如何判別系統環境的程式片段:

if 'SHELL' not in os.environ:
    ...

於是二者結合, 哇哈哈… 結果正確了.

if 'SHELL' not in os.environ:
    from colorama import init
    init()

class bColors:
    ENDC      = '\033[0m'
    BOLD      = '\033[1m'
    UNDERLINE = '\033[4m'
    Dk_BLACK  = '\033[30m'
    Dk_RED    = '\033[31m'
    Dk_GREEN  = '\033[32m'
    Dk_YELLOW = '\033[33m'
    Dk_BLUE   = '\033[34m'
    Dk_VIOLET = '\033[35m'
    Dk_BEIGE  = '\033[36m'
    Dk_WHITE  = '\033[37m'
    Li_RED    = '\033[91m'
    Li_GREEN  = '\033[92m'
    Li_YELLOW = '\033[93m'
    Li_BLUE   = '\033[94m'
    Li_VIOLET = '\033[95m'

class colorMessage(bColors):
    def ok(self, msg):
        return self.Li_GREEN + msg + self.ENDC
    def err(self, msg):
        return self.Li_RED + msg + self.ENDC
    def warn(self, msg):
        return self.Dk_YELLOW + msg + self.ENDC
    def bold(self, msg):
        return self.BOLD + msg + self.ENDC
    def erPrint(self, msg, hmsg='Error: '):
        print(self.err(hmsg)+msg, file=sys.stderr)
    def wrnPrint(self, msg, hmsg='Warning: '):
        print(self.warn(hmsg)+msg, file=sys.stderr)

cMsg = colorMessage()
...

def usage2(appName):
    print(cMsg.ok("\nCommon Arguments:"))
    print(cMsg.bold(" -H")+", "+cMsg.bold("--full-help"))
    print("     full help messages.")
    print(cMsg.bold(" -h")+", "+cMsg.bold("--help"))
    print("     brief help messages.")
...

cMsg.erPrint(msg)
...

說明:

  • 首先 (也是最重要的), 前三行是: 只有系統是 Windows 才需要載入colorama, 並執行init(). 之所以會這樣子和colorama說明文件不一致, 我猜應該是我使用的環境的問題: 我開發程式時用的是 git for windows 的 bash 環境, 而正式要執行的環境是 windows CMD console. 問題應該是因為 git for windows 的系統底層用的是 MSYS2 所造成的, (其實要在 windows 環境下搞出一個和 linux/unix 完全相容的環境本來就有相當的難度). 改天有空裝 cent OS 時, 再來試一下是否真如colorama的說明頁面說的, 只要importinit()就可以; 也順便測試是否我的程式移到 linux/unix 也可以正常運作?
  • 再來的 class bColors: 是定義不同顏色的 ANSI Terminal 控制碼.
  • 再來的 class colorMessage(bColors): 是定義一些基本的上色功能 (前面加上色控制碼, 後面加回復控制碼), 以及二個列印到 stderr 的 function.
  • 再來的 cMsg = colorMessage() 則是產生一個 colorMessage() 的 instance.
  • 然後就可以使用定義好的功能了

補充修正2019/04/09


哈, 真是狗屎運啊, 沒想到之前的 Google 搜尋都是作白工!!??

今天在 Google 搜尋 msys2 bash python for windows msvcrt getch 結果找到了 StackExchange SuperUser 的 這一遍, 發現它說 node, python, ipython, php, php5, psql 等這幾支程式已知都需要 Win32 Console 才能正確執行, 建議使用者在 git for windows 的 bash 裡下指令時, 前面要多帶一個winpty. 意即是在winpty模擬出來的 pty (Pseudo tty) 環境中執行這些程式.
例如: winpty /path/to/python.exe ....
於是就順手試了一下, 結果…前面真的都是作白工了: colorama真的只要二行就好, 不需要多判斷.

不過這樣寫在 windows CMD Console 及加了 winptybash 環境下是正確的, 但是直接使用bash執行卻沒有上色. 所以呢, 為了在直接使用bash執行時也可以輸出有上色的提示訊息我還是附加載入colorama的條件式.

if ('SHELL' not in os.environ) or not re.search(r'/dev/pty\d+', os.popen('tty').read()):
    from colorama import init
    init()

註一: 由原本 git bash 視窗輸入指令: tty, 指令輸出的 tty 名稱會是 /dev/pty0, /dev/pty1… 但如果是輸入 winpty tty, 則指令輸出的 tty 名稱會是 /dev/cons0, /dev/cons1

Console 讀取按鍵2019/04/09


上面的 '補充修正' 還有一個更棒的效應: 連我之前一直測試不出來的: 使用msvcrt模組裡的getch()kbhit()來直接讀取鍵盤的問題也一併解決了.

這個問題的狀況如下: python程式碼由msvcrt模組中匯入getch()kbhit()二個函數, 然後在無窮迴圏中getch()讀取按鍵輸入, 然後執行對應的功能, 一般遊戲程式都會需要這種功能 (或者參看下面這一段副程式). 結果, 在 windows CMD Console 中執行都正常. 但是, 在 git for windows 的bash環境執行時只要執行到getch()就一直卡在裡面, 幾乎是不論按什麼鍵都是一動也不動, 無法跳出來. 即使改用 linux/unix 的寫法 sys.stdin.read(1) 結果在 git for windows 的bash環境中就是失敗.

def readPSWD(prompt):
    sys.stdout.write(prompt)
    sys.stdout.flush()
    str = ""
    while True:
#       if not kbhit():
#          sleep(0.1)
#          continue
        c = getch()
        if c in (b'\r', b'\n'):
            return str
        elif c == b'\b':
            if len(str) > 0:
                str = str[0:len(str)-1]
                sys.stdout.write('\b \b')
                sys.stdout.flush()
                continue
        str += c.decode()
        sys.stdout.write('*')
        sys.stdout.flush()

改用winpty /path/to/python.exe ...來執行, 上面的問題就都迎刃而解了. 所以, 以下的程式碼也都可以順利執行了. (先判斷是 windows 還是 linux/unix, 如果是 linux/unix 就定義替代的getch()kbhit()二個函數)

try:
    from msvcrt import getch, kbhit
except ImportError:
    import tty
    import termios
    def getch():
        ...
    def kbhit():
        ...

不過, 執行時 (git for windows 的bash) python 程式認為自己是在 windows 環境裡執行, 使用的是msvcrt模組的功能而不是 linux/unix 的替代函數.

編譯 python 程式2019/04/13


python 是一種直譯的手稿語言 (scripting language), 在程式的開發/除錯上相對容易. 但是總不能叫程式的未端使用者也安裝個 python 和需要的模組套件吧? 因此有了編譯 python 程式的想法.

其實要編譯 python 程式並不難, 上網 Google 一查就有了: (參考這一篇 Compiling Python Code)

我選用了第一個, 也就是 pyInstaller. 雖然功能參數不少, 第一次接觸有點嚇到. 不過因為我的需求簡單, 所以也很快就找到了對應的參數 (它也很簡單).
pyinstaller -F myApps.py

這樣就可以順利在子目錄 dist 中產生 myApp.exe 了. (當然還有其他的子錄目和很多檔案, 但這些都是我們修正程式錯誤後, 協助加快重新編譯用的, 無需給末端用戶).

改變 console 程式執行時的字型大小2019/04/13


其實上一段只是為了鋪陳, 這一段才是重點. 編譯成執行檔給 user 後, 第一回應是: 字太小, 可不可以改大一點? 我一整個儍掉… (PS. 心裡的惡魔說: 到底是公尛, 真是電腦小白到一個不行)

好的, 沒問題, 改給你. 還好, 從上網 Google, 到改完程式, 重新編譯, 15 分鐘搞定. (Good Luck! 因為查到 StackOverflow 的這一篇 )

先用把 Windwos CMD Console 視窗的字型名稱字型大小調整好, 再執行以下的程式, 查出應該設定的數值.

import sys
from ctypes import POINTER, WinDLL, Structure, sizeof, byref
from ctypes.wintypes import BOOL, SHORT, WCHAR, UINT, ULONG, DWORD, HANDLE

LF_FACESIZE = 32
STD_OUTPUT_HANDLE = -11

class COORD(Structure):
    _fields_ = [
        ("X", SHORT),
        ("Y", SHORT),
    ]

class CONSOLE_FONT_INFOEX(Structure):
    _fields_ = [
        ("cbSize", ULONG),
        ("nFont", DWORD),
        ("dwFontSize", COORD),
        ("FontFamily", UINT),
        ("FontWeight", UINT),
        ("FaceName", WCHAR * LF_FACESIZE)
    ]

kernel32_dll = WinDLL("kernel32.dll")

get_last_error_func = kernel32_dll.GetLastError
get_last_error_func.argtypes = []
get_last_error_func.restype = DWORD

get_std_handle_func = kernel32_dll.GetStdHandle
get_std_handle_func.argtypes = [DWORD]
get_std_handle_func.restype = HANDLE

get_current_console_font_ex_func = kernel32_dll.GetCurrentConsoleFontEx
get_current_console_font_ex_func.argtypes = [HANDLE, BOOL, POINTER(CONSOLE_FONT_INFOEX)]
get_current_console_font_ex_func.restype = BOOL

set_current_console_font_ex_func = kernel32_dll.SetCurrentConsoleFontEx
set_current_console_font_ex_func.argtypes = [HANDLE, BOOL, POINTER(CONSOLE_FONT_INFOEX)]
set_current_console_font_ex_func.restype = BOOL


def main():
    # Get stdout handle
    stdout = get_std_handle_func(STD_OUTPUT_HANDLE)
    if not stdout:
        print("{:s} error: {:d}".format(get_std_handle_func.__name__, get_last_error_func()))
        return
    # Get current font characteristics
    font = CONSOLE_FONT_INFOEX()
    font.cbSize = sizeof(CONSOLE_FONT_INFOEX)
    res = get_current_console_font_ex_func(stdout, False, byref(font))
    if not res:
        print("{:s} error: {:d}".format(get_current_console_font_ex_func.__name__, get_last_error_func()))
        return
    # Display font information
    print("Console information for {:}".format(font))
    for field_name, _ in font._fields_:
        field_data = getattr(font, field_name)
        if field_name == "dwFontSize":
            print("    {:s}: {{X: {:d}, Y: {:d}}}".format(field_name, field_data.X, field_data.Y))
        else:
            print("    {:s}: {:}".format(field_name, field_data))
    while 1:
        try:
            height = int(input("\nEnter font height (invalid to exit): "))
        except:
            break
        # Alter font height
        font.dwFontSize.X = 10  # Changing X has no effect (at least on my machine)
        font.dwFontSize.Y = height
        # Apply changes
        res = set_current_console_font_ex_func(stdout, False, byref(font))
        if not res:
            print("{:s} error: {:d}".format(set_current_console_font_ex_func.__name__, get_last_error_func()))
            return
        print("OMG! The window changed :)")
        # Get current font characteristics again and display font size
        res = get_current_console_font_ex_func(stdout, False, byref(font))
        if not res:
            print("{:s} error: {:d}".format(get_current_console_font_ex_func.__name__, get_last_error_func()))
            return
        print("\nNew sizes    X: {:d}, Y: {:d}".format(font.dwFontSize.X, font.dwFontSize.Y))


if __name__ == "__main__":
    print("Python {:s} on {:s}\n".format(sys.version, sys.platform))
    main()
python_console_font

Windows CMD Console 字型的設定輸出

再接著把你要的數值 (查到的數值) 填到以下的程式. (上一段的精簡版, 只有設定的功能)

import ctypes

LF_FACESIZE = 32
STD_OUTPUT_HANDLE = -11

class COORD(ctypes.Structure):
    _fields_ = [("X", ctypes.c_short), ("Y", ctypes.c_short)]

class CONSOLE_FONT_INFOEX(ctypes.Structure):
    _fields_ = [("cbSize", ctypes.c_ulong),
                ("nFont", ctypes.c_ulong),
                ("dwFontSize", COORD),
                ("FontFamily", ctypes.c_uint),
                ("FontWeight", ctypes.c_uint),
                ("FaceName", ctypes.c_wchar * LF_FACESIZE)]

font = CONSOLE_FONT_INFOEX()
font.cbSize = ctypes.sizeof(CONSOLE_FONT_INFOEX)
font.nFont = 16
font.dwFontSize.X = 11
font.dwFontSize.Y = 24
font.FontFamily = 54
font.FontWeight = 400
font.FaceName = "Consolas"

handle = ctypes.windll.kernel32.GetStdHandle(STD_OUTPUT_HANDLE)
ctypes.windll.kernel32.SetCurrentConsoleFontEx(
        handle, ctypes.c_long(False), ctypes.pointer(font))

接著就是修改主程式: 在 windows 環境下才修改視窗設定. (假設上一段程式儲存成 set_font.py)

if 'SHELL' not in os.environ:
    import set_font

搞定: 無論 User 的 Windows CMD Console 怎麼設定, 我的程式一定會以固定字型 Consolas, 固定大小的字體開始. 程式結束後, 也不會影響到 User 原本的設定.

錯誤結束時暫停一下2019/04/13


編譯成執行檔後, 附帶的多出了一個問題: 如果 User 直接在視窗環境滑鼠點二下執行, 然後直接出現錯誤 (可能他的環境少了個什麼東西而你沒考慮到…), 結果是執行視窗直接關掉, 什麼錯誤訊息也沒有看到. 怎麼辦啊?

原本以為這個問題會有點小棘手小障礙, 結果也是出奇的順利. (多謝 StackOverflow 這一篇)

以下是我修改後的程式.

import atexit
import sys, os

class ExitHooks(object):
    def __init__(self):
        self.exit_code = None
        self.exception = None

    def hook(self):
        self._orig_exit = sys.exit
        sys.exit = self.exit
        sys.excepthook = self.exc_handler

    def exit(self, code=0):
        self.exit_code = code
        self._orig_exit(code)

    def exc_handler(self, exc_type, exc, *args):
        self.exception = exc

hooks = ExitHooks()
hooks.hook()

def goodbye():
    if not (hooks.exit_code is None and hooks.exception is None):
        os.system('pause')
#       input("\nPress Enter key to exit.")

atexit.register(goodbye)

連結


    MagicJackTing 發表在 痞客邦 留言(0) 人氣()