2025年8月19日 星期二

Python 學習筆記 : 將 K 線圖模組 kbar.py 打包上傳 PyPI (一)

去年 (2024) 8 月時我把使用 mplfinance 套件繪製 K 線圖的方法寫成一個自訂模組 kbar.py 以簡化繪圖程序, 打算將其打包成一個 Python 套件上傳到 PyPI 網站, 這樣就能夠方便地用 pip install 來安裝. 這幾天抽空準備打包發佈事宜, 但是在 Colab 上運行 kbar.py 卻發現無法順利顯示中文, 於是花了兩天利用 AI 進行改版作業, 終於得到一個全平台版的 kbar.py, 過程參考 :


以下的打包發佈便是以此全平台版的 kbar.py 為對象. 由於我過去未曾製作過這樣可供安裝的 Python 套件, 便搜尋谷歌找到下面這篇文章 : 


但看完覺得似乎有點小複雜, 所以就轉而詢問 ChatGPT, 它給出的建議感覺比較好懂. 另外, 我從母校借到下面這本碁峰的書, 書末也有介紹將模組上傳 PyPI 的方法 :

Python Bible 實戰聖經 (碁峰 2021) 第 16 章

我又問了 ChatGPT 如何發佈套見到 PyPI 網站, 它的作法與網路教學以及書裡面的介紹有點出入, 經過來回問 ChatGPT 發現現在 PyPI 有新版打包發佈方式 (PEP621), 書上與網路上的都是較舊的版本 (但也還能用). 以下的做法基本上是參考 ChatGPT 給出的建議, 依照最新的的 PEP621 打包標準來做 (用 pyproject.toml 完全取代 setup.py). 


1. 註冊 PyPI 帳號 :  

PyPI 是一個 Python 套件儲存庫網站, 用來發佈與管理 Python 第三方開放原始碼套件. 開發者可以將套件發布到 PyPI 供使用者利用 pip 指令下載並安裝到本機的 Python 執行環境中. 此外 PyPI 的套件管理功能可讓使用者查詢套件之版本歷史, 功能描述, 與相依套件等資訊. 

按官網首頁右上方的 Signup 鈕 : 



 
填寫 Email 與帳號等資料後按 Create Account 鈕即完成帳戶之創建 :




有了 PyPI 帳號後就可以進行套件打包前的前置作業了. 


2. 編輯 kbar.py 模組添加說明字串 (docstring) :  

撰寫說明字串只要提供原始碼請 ChatGPT 代勞即可, 不用自己花時間去寫, 提示詞如下 : 

請為下面的 kbar.py 寫一個標準的 Python doc 註解範例, 也就是 help(kbar) 的標準輸出 : 

# kbar.py
import mplfinance as mpf
import matplotlib as mpl
from matplotlib import font_manager
import os
import sys
import subprocess

def install_noto_cjk_linux():  # 在 Linux (Ubuntu/樹莓派) 安裝 Noto CJK 字型
    try:
        print('偵測到 Linux 環境,嘗試安裝 Noto CJK 字型...')
        result=subprocess.run(
            ['sudo', 'apt-get', 'install', '-y', 'fonts-noto-cjk'],
            capture_output=True,
            text=True
            )
        if result.returncode != 0:
            print(f'字型安裝失敗: {result.stderr}')
            return None
        # 更新 font cache
        subprocess.run(['fc-cache', '-fv'], capture_output=True)
        # 強制刷新 matplotlib 字型快取
        font_manager._load_fontmanager(try_read_cache=False)
        print('Noto CJK 字型安裝完成')
        return True
    except Exception as e:
        print(f'安裝過程出錯: {e}')
        return None

def download_noto_cjk_colab():  # 下載中文字型到 Colab 環境
    font_path='/content/NotoSansCJK-Regular.ttc'
    if not os.path.exists(font_path):
        print("正在下載中文字型...")
        url='https://github.com/googlefonts/noto-cjk/raw/main/' +\
            'Sans/OTC/NotoSansCJK-Regular.ttc'
        try:            
            result=subprocess.run(  # 使用 wget 下載字型
                ['wget', '-O', font_path, url],
                capture_output=True,
                text=True
                )            
            if result.returncode != 0:
                print(f'下載失敗: {result.stderr}')
                return None            
            print('中文字型下載完成')
            return font_path
        except Exception as e:
            print(f'字型下載失敗: {e}')
            return None
    else:
        print('字型檔案已存在')
        return font_path

def register_font_colab(font_path):  # 註冊字型並回傳可用的字型名稱
    if not font_path or not os.path.exists(font_path):
        return None    
    try:  # 註冊字型       
        font_manager.fontManager.addfont(font_path)        
        # 重建字型管理器快取
        font_manager._load_fontmanager(try_read_cache=False)        
        # 檢查註冊後可用的字型名稱
        fonts={f.name for f in font_manager.fontManager.ttflist}        
        # Noto Sans CJK 可能的名稱變化
        possible_names=[
            'Noto Sans CJK TC',
            'Noto Sans CJK JP', 
            'Noto Sans CJK SC',
            'Noto Sans CJK KR',
            'Noto Sans CJK'
            ]
        for name in possible_names:
            if name in fonts:
                print(f'成功註冊字型: {name}')
                return name
        print(f"字型註冊後可用名稱: {[f for f in fonts if 'Noto' in f or 'CJK' in f]}")
        return None
    except Exception as e:
        print(f"字型註冊失敗: {e}")
        return None

def detect_font():  # 偵測系統中是否有常見的中文字型 
    fonts={f.name for f in font_manager.fontManager.ttflist}
    # 檢查常見中文字型
    linux_chinese_fonts=['Noto Sans CJK TC','Noto Sans CJK JP','Noto Sans CJK SC']
    if 'Microsoft JhengHei' in fonts:
        return 'Microsoft JhengHei'
    elif 'PingFang TC' in fonts:
        return 'PingFang TC'
    elif any(name in fonts for name in linux_chinese_fonts):
        return next(name for name in linux_chinese_fonts if name in fonts)
    else:  # 檢測環境        
        in_colab='google.colab' in sys.modules
        in_linux=sys.platform.startswith('linux')
        if in_colab:
            print('Colab 環境偵測到,正在下載並設定中文字型...')
            font_path=download_noto_cjk_colab()
            if font_path:
                return register_font_colab(font_path)
        elif in_linux:
            print('Linux 環境偵測到,嘗試安裝 Noto CJK 字型...')
            if install_noto_cjk_linux():
                # 再檢查一次
                fonts={f.name for f in font_manager.fontManager.ttflist}
                for name in linux_chinese_fonts:
                    if name in fonts:
                        return name
        return None

def check_font(font):  # 檢查指定字型是否存在並回傳候選清單
    fonts={f.name for f in font_manager.fontManager.ttflist}
    candidates=[]    
    if font and font in fonts:
        candidates.append(font)
        print(f'使用指定字型: {font}')
    elif font:
        print(f"[警告] 找不到字型 '{font}',將使用 fallback 字型")
    # 通用 fallback 清單
    fallbacks=[  
        'Microsoft JhengHei',
        'PingFang TC',
        'Noto Sans CJK TC',
        'Noto Sans CJK JP',
        'Noto Sans CJK SC',
        'DejaVu Sans',
        'Liberation Sans',
        'Arial',
        'sans-serif'
        ]    
    for fallback in fallbacks:
        if fallback in fonts and fallback not in candidates:
            candidates.append(fallback)
    if candidates:
        print(f'字型候選清單: {candidates[:3]}')  # 只顯示前3個
        return candidates
    else:
        print('警告: 沒有找到合適的字型')
        return ['DejaVu Sans']

class KBar():
    def __init__(self, df, font=None):
        self.df=df
        self.addplots=[]
        self.font=font or detect_font()
        # 設定 matplotlib 全域參數
        if self.font:
            # 清除舊的字型設定
            if 'font.sans-serif' in mpl.rcParams:
                current_fonts=mpl.rcParams['font.sans-serif'].copy()
                # 移除可能造成問題的字型
                current_fonts=[f for f in current_fonts if f not in ['SimHei']]
                mpl.rcParams['font.sans-serif']=[self.font] + current_fonts
            else:
                mpl.rcParams['font.sans-serif']=[self.font, 'DejaVu Sans']
            mpl.rcParams['axes.unicode_minus']=False
            print(f'設定字型為: {self.font}')
        else:
            print('警告: 未找到中文字型,中文可能無法正常顯示')
    
    def addplot(self, data, **kwargs):
        plot=mpf.make_addplot(data, **kwargs)
        self.addplots.append(plot)
    
    def plot(self, embedding=False, **kwargs):
        color=mpf.make_marketcolors(
            up='red',
            down='green',
            inherit=True
            )   
        # 取得字型候選清單
        font_candidates=check_font(self.font)
        style=mpf.make_mpf_style(
            base_mpf_style='default',
            marketcolors=color,
            rc={
                'font.family': font_candidates,
                'font.sans-serif': font_candidates,
                'axes.unicode_minus': False
                }
            )  
        kwargs['type']='candle'
        kwargs['style']=style
        kwargs['addplot']=self.addplots
        result=mpf.plot(self.df, **kwargs)
        if kwargs.get('returnfig', False):
            return result

生成結果如下 :

# kbar.py
"""
kbar - K 線圖繪製工具
======================

本模組提供簡單的介面,在 matplotlib 與 mplfinance 上繪製中文化的 K 線圖。
支援自動偵測、安裝中文字型,確保在 Linux、Colab 與 Windows 環境下
皆能正常顯示中文。
"""

import mplfinance as mpf
import matplotlib as mpl
from matplotlib import font_manager
import os
import sys
import subprocess

def install_noto_cjk_linux():
    """
    在 Linux (Ubuntu/樹莓派) 安裝 Noto CJK 字型。
    
    Returns
    -------
    bool or None
        成功安裝回傳 True;失敗或例外回傳 None。
    """
    try:
        print('偵測到 Linux 環境,嘗試安裝 Noto CJK 字型...')
        result = subprocess.run(
            ['sudo', 'apt-get', 'install', '-y', 'fonts-noto-cjk'],
            capture_output=True,
            text=True
        )
        if result.returncode != 0:
            print(f'字型安裝失敗: {result.stderr}')
            return None
        subprocess.run(['fc-cache', '-fv'], capture_output=True)
        font_manager._load_fontmanager(try_read_cache=False)
        print('Noto CJK 字型安裝完成')
        return True
    except Exception as e:
        print(f'安裝過程出錯: {e}')
        return None

def download_noto_cjk_colab():
    """
    在 Google Colab 下載 NotoSansCJK-Regular.ttc 字型。
    
    Returns
    -------
    str or None
        成功下載或已存在回傳字型路徑;失敗回傳 None。
    """
    font_path = '/content/NotoSansCJK-Regular.ttc'
    if not os.path.exists(font_path):
        print("正在下載中文字型...")
        url = 'https://github.com/googlefonts/noto-cjk/raw/main/' + \
              'Sans/OTC/NotoSansCJK-Regular.ttc'
        try:
            result = subprocess.run(
                ['wget', '-O', font_path, url],
                capture_output=True,
                text=True
            )
            if result.returncode != 0:
                print(f'下載失敗: {result.stderr}')
                return None
            print('中文字型下載完成')
            return font_path
        except Exception as e:
            print(f'字型下載失敗: {e}')
            return None
    else:
        print('字型檔案已存在')
        return font_path

def register_font_colab(font_path):
    """
    註冊 Colab 下載的字型,回傳可用的字型名稱。
    
    Parameters
    ----------
    font_path : str
        字型檔案的路徑。
    
    Returns
    -------
    str or None
        註冊成功回傳字型名稱;失敗回傳 None。
    """
    if not font_path or not os.path.exists(font_path):
        return None
    try:
        font_manager.fontManager.addfont(font_path)
        font_manager._load_fontmanager(try_read_cache=False)
        fonts = {f.name for f in font_manager.fontManager.ttflist}
        possible_names = [
            'Noto Sans CJK TC',
            'Noto Sans CJK JP', 
            'Noto Sans CJK SC',
            'Noto Sans CJK KR',
            'Noto Sans CJK'
        ]
        for name in possible_names:
            if name in fonts:
                print(f'成功註冊字型: {name}')
                return name
        print(f"字型註冊後可用名稱: {[f for f in fonts if 'Noto' in f or 'CJK' in f]}")
        return None
    except Exception as e:
        print(f"字型註冊失敗: {e}")
        return None

def detect_font():
    """
    偵測系統中文字型,並在需要時自動安裝或註冊。
    
    Returns
    -------
    str or None
        偵測或安裝成功回傳字型名稱;未找到回傳 None。
    """
    fonts = {f.name for f in font_manager.fontManager.ttflist}
    linux_chinese_fonts = ['Noto Sans CJK TC','Noto Sans CJK JP','Noto Sans CJK SC']
    if 'Microsoft JhengHei' in fonts:
        return 'Microsoft JhengHei'
    elif 'PingFang TC' in fonts:
        return 'PingFang TC'
    elif any(name in fonts for name in linux_chinese_fonts):
        return next(name for name in linux_chinese_fonts if name in fonts)
    else:
        in_colab = 'google.colab' in sys.modules
        in_linux = sys.platform.startswith('linux')
        if in_colab:
            print('Colab 環境偵測到,正在下載並設定中文字型...')
            font_path = download_noto_cjk_colab()
            if font_path:
                return register_font_colab(font_path)
        elif in_linux:
            print('Linux 環境偵測到,嘗試安裝 Noto CJK 字型...')
            if install_noto_cjk_linux():
                fonts = {f.name for f in font_manager.fontManager.ttflist}
                for name in linux_chinese_fonts:
                    if name in fonts:
                        return name
        return None

def check_font(font):
    """
    檢查指定字型是否存在,建立候選字型清單。
    
    Parameters
    ----------
    font : str
        指定字型名稱。
    
    Returns
    -------
    list[str]
        可用的字型候選清單。
    """
    fonts = {f.name for f in font_manager.fontManager.ttflist}
    candidates = []
    if font and font in fonts:
        candidates.append(font)
        print(f'使用指定字型: {font}')
    elif font:
        print(f"[警告] 找不到字型 '{font}',將使用 fallback 字型")
    fallbacks = [
        'Microsoft JhengHei',
        'PingFang TC',
        'Noto Sans CJK TC',
        'Noto Sans CJK JP',
        'Noto Sans CJK SC',
        'DejaVu Sans',
        'Liberation Sans',
        'Arial',
        'sans-serif'
    ]
    for fallback in fallbacks:
        if fallback in fonts and fallback not in candidates:
            candidates.append(fallback)
    if candidates:
        print(f'字型候選清單: {candidates[:3]}')
        return candidates
    else:
        print('警告: 沒有找到合適的字型')
        return ['DejaVu Sans']

class KBar:
    """
    K 線圖繪製工具 (基於 mplfinance)

    Parameters
    ----------
    df : pandas.DataFrame
        股票或資產的 OHLCV 資料。
    font : str, optional
        指定字型名稱;若 None 則自動偵測中文字型。

    Examples
    --------
    >>> import pandas as pd
    >>> from kbar import KBar
    >>> df = pd.read_csv('stock.csv', index_col=0, parse_dates=True)
    >>> k = KBar(df)
    >>> k.addplot(df['Close'].rolling(5).mean(), color='blue', panel=0)
    >>> k.plot(volume=True, mav=(5,10,20))
    """

    def __init__(self, df, font=None):
        self.df = df
        self.addplots = []
        self.font = font or detect_font()
        if self.font:
            if 'font.sans-serif' in mpl.rcParams:
                current_fonts = mpl.rcParams['font.sans-serif'].copy()
                current_fonts = [f for f in current_fonts if f not in ['SimHei']]
                mpl.rcParams['font.sans-serif'] = [self.font] + current_fonts
            else:
                mpl.rcParams['font.sans-serif'] = [self.font, 'DejaVu Sans']
            mpl.rcParams['axes.unicode_minus'] = False
            print(f'設定字型為: {self.font}')
        else:
            print('警告: 未找到中文字型,中文可能無法正常顯示')

    def addplot(self, data, **kwargs):
        """
        新增技術指標圖層。

        Parameters
        ----------
        data : pandas.Series or numpy.ndarray
            要繪製的數據。
        **kwargs : dict
            傳遞給 mplfinance.make_addplot 的其他參數。
        """
        plot = mpf.make_addplot(data, **kwargs)
        self.addplots.append(plot)

    def plot(self, embedding=False, **kwargs):
        """
        繪製 K 線圖。

        Parameters
        ----------
        embedding : bool, optional
            是否內嵌顯示,預設 False。
        **kwargs : dict
            傳遞給 mplfinance.plot 的參數,例如 volume, mav, returnfig。

        Returns
        -------
        None 或 (fig, axes)
            若 returnfig=True 則回傳圖表物件。
        """
        color = mpf.make_marketcolors(up='red', down='green', inherit=True)
        font_candidates = check_font(self.font)
        style = mpf.make_mpf_style(
            base_mpf_style='default',
            marketcolors=color,
            rc={
                'font.family': font_candidates,
                'font.sans-serif': font_candidates,
                'axes.unicode_minus': False
            }
        )
        kwargs['type'] = 'candle'
        kwargs['style'] = style
        kwargs['addplot'] = self.addplots
        result = mpf.plot(self.df, **kwargs)
        if kwargs.get('returnfig', False):
            return result

重新載入 kbar.py 用 help() 來檢視說明文件 :

>>> import kbar   

將整個模組傳入 help() 會輸出模組的全部說明 : 

>>> help(kbar)   
Help on module kbar:

NAME
    kbar

DESCRIPTION
    kbar - K 線圖繪製工具
    ======================

    本模組提供簡單的介面,在 matplotlib 與 mplfinance 上繪製中文化的 K 線圖。
    支援自動偵測、安裝中文字型,確保在 Linux、Colab 與 Windows 環境下
    皆能正常顯示中文。

CLASSES
    builtins.object
        KBar

    class KBar(builtins.object)
     |  KBar(df, font=None)
     |
     |  K 線圖繪製工具 (基於 mplfinance)
     |
     |  Parameters
     |  ----------
     |  df : pandas.DataFrame
     |      股票或資產的 OHLCV 資料。
     |  font : str, optional
     |      指定字型名稱;若 None 則自動偵測中文字型。
     |
     |  Examples
     |  --------
     |  >>> import pandas as pd
     |  >>> from kbar import KBar
     |  >>> df = pd.read_csv('stock.csv', index_col=0, parse_dates=True)
     |  >>> k = KBar(df)
     |  >>> k.addplot(df['Close'].rolling(5).mean(), color='blue', panel=0)
     |  >>> k.plot(volume=True, mav=(5,10,20))
     |
     |  Methods defined here:
     |
     |  __init__(self, df, font=None)
     |      Initialize self.  See help(type(self)) for accurate signature.
     |
     |  addplot(self, data, **kwargs)
     |      新增技術指標圖層。
     |
     |      Parameters
     |      ----------
     |      data : pandas.Series or numpy.ndarray
     |          要繪製的數據。
     |      **kwargs : dict
     |          傳遞給 mplfinance.make_addplot 的其他參數。
     |
     |  plot(self, embedding=False, **kwargs)
     |      繪製 K 線圖。
     |
     |      Parameters
     |      ----------
     |      embedding : bool, optional
     |          是否內嵌顯示,預設 False。
     |      **kwargs : dict
     |          傳遞給 mplfinance.plot 的參數,例如 volume, mav, returnfig。
     |
     |      Returns
     |      -------
     |      None 或 (fig, axes)
     |          若 returnfig=True 則回傳圖表物件。
     |
     |  ----------------------------------------------------------------------
     |  Data descriptors defined here:
     |
     |  __dict__
     |      dictionary for instance variables
     |
     |  __weakref__
     |      list of weak references to the object

FUNCTIONS
    check_font(font)
        檢查指定字型是否存在,建立候選字型清單。

        Parameters
        ----------
        font : str
            指定字型名稱。

        Returns
        -------
        list[str]
            可用的字型候選清單。

    detect_font()
        偵測系統中文字型,並在需要時自動安裝或註冊。

        Returns
        -------
        str or None
            偵測或安裝成功回傳字型名稱;未找到回傳 None。

    download_noto_cjk_colab()
        在 Google Colab 下載 NotoSansCJK-Regular.ttc 字型。

        Returns
        -------
        str or None
            成功下載或已存在回傳字型路徑;失敗回傳 None。

    install_noto_cjk_linux()
        在 Linux (Ubuntu/樹莓派) 安裝 Noto CJK 字型。

        Returns
        -------
        bool or None
            成功安裝回傳 True;失敗或例外回傳 None。

    register_font_colab(font_path)
        註冊 Colab 下載的字型,回傳可用的字型名稱。

        Parameters
        ----------
        font_path : str
            字型檔案的路徑。

        Returns
        -------
        str or None
            註冊成功回傳字型名稱;失敗回傳 None。

顯示函式 addplot() 的說明 : 

>>> help(kbar.KBar.addplot)    
Help on function addplot in module kbar:

addplot(self, data, **kwargs)
    新增技術指標圖層。

    Parameters
    ----------
    data : pandas.Series or numpy.ndarray
        要繪製的數據。
    **kwargs : dict
        傳遞給 mplfinance.make_addplot 的其他參數。

顯示函式 plot() 的說明 : 

>>> help(kbar.KBar.plot)  
Help on function plot in module kbar:

plot(self, embedding=False, **kwargs)
    繪製 K 線圖。

    Parameters
    ----------
    embedding : bool, optional
        是否內嵌顯示,預設 False。
    **kwargs : dict
        傳遞給 mplfinance.plot 的參數,例如 volume, mav, returnfig。

    Returns
    -------
    None 或 (fig, axes)
        若 returnfig=True 則回傳圖表物件。

這樣有說明文件的 kbar.py 就準備好了.


3. 配置檔案目錄結構 :  

首先建立一個 PyPI 目錄來存放所有 PyPI 專案,  我的套件名稱是 kbar, 所以就先在 PyPI 下建立一個 kbar 專案目錄, 然後建立下面三個空檔案 (後續會編輯這三個檔案之內容) :
  • README.md : 讀我檔
  • LICENCE : 授權聲明 (注意無副檔名)
  • pyproject.toml : 打包設定檔 
接下來在專案目錄 kbar 下建立一個 src 子目錄與一個 kbar 孫目錄, 把上面已經加上說明字串的 kbar.py 複製到此 kbar 孫目錄下, 並且建立一個 __init__.py 檔案以符合 Python 類別的要求 (讓 kbar 孫目錄被視為一個套件), 編輯此 __init__.py 檔, 輸入如下內容後存檔 :

from .kbar import KBar, detect_font

__all__ = ["KBar", "detect_font"]

這主要是為了讓使用者能夠直接寫 from kbar import KBar 匯入類別 (即直接把 kbar.py 裡的 KBar 類別匯入到套件的命名空間內), 而不用管模組的內部結構 (否則就必須寫 from kbar.kbar import KBar 了), 如此不僅可讓套件的 API 更簡潔, 也可以讓 unittest 正常執行. 設定 __all__ 則是限定了套件之公開 API, 避免外部使用者直接 import 內部工具函式以維持乾淨的介面. 其中 detect_font 是為了 unittest 而添加的. 

最後在 kbar 專案目錄下建立一個 tests 子目錄, 底下先建立兩個空檔案  __init__.py 與 test_kbar.py (後續要編輯內容), 這樣就完成套件檔案目錄結構之配置了. 用 tree 指令列出目前結構如下 : 

D:\pypi>tree kbar /f     
列出磁碟區 新增磁碟區 的資料夾 PATH
磁碟區序號為 1258-16B8
D:\PYPI\KBAR
│  LICENSE
│  pyproject.toml
│  README.md
├─src
│  └─kbar
│       │   kbar.py
│       └─ __init__.py
└─tests
    │   test_kbar.py
    └─__init__.py

目前除了 kbar.py 以外, 其他均為空檔案. 

關於 tree 指令用法參考 :


沒有留言 :