2024年5月22日 星期三

Python 專案如何在提交時觸發 pytest 自動測試

Python 專案如何在提交時觸發 pytest 自動測試

使用的工具是 pre-commit 和 pytest 這兩個庫

pip install pre-commit pytest pytest-html pytest-metadata



用法其實很容易大概看一下說明就能理解了這邊寫一個範例可以自跑測試

run_tests.py

import os
import subprocess
import pytest
import argparse
from datetime import datetime

# Define the report directory
report_dir = 'reports'

def main():
    parser = argparse.ArgumentParser(description="Run pytest and generate HTML report.")
    parser.add_argument('--hook', action='store_true', help="Run as a pre-commit hook with commit hash in report filename.")
    args = parser.parse_args()

    if not os.path.exists(report_dir):
        os.makedirs(report_dir)

    # Get the current timestamp
    timestamp = datetime.now().strftime('%Y%m%d-%H%M%S')

    if args.hook:
        # Get the current commit hash
        try:
            commit_hash = subprocess.check_output(['git', 'rev-parse', '--short', 'HEAD']).strip().decode('utf-8')
        except subprocess.CalledProcessError as e:
            print(f"Error getting commit hash: {e}")
            return

        # Define the report filename with timestamp and commit hash
        report_file = os.path.join(report_dir, f'test_report_{timestamp}_aftercommit-{commit_hash[:5]}.html')

    else:
        # Define the report filename with timestamp
        report_file = os.path.join(report_dir, f'test_report_{timestamp}.html')

    # Run pytest with the HTML report option
    pytest_args = ['tests/', '--html', report_file]
    print(f"Running pytest with arguments: {pytest_args}")
    pytest.main(pytest_args)

if __name__ == "__main__":
    main()

然後直接運行就可以了,會自動測試存放在 tests 資料夾裡面的測試


比如說這個測試檔案 tests/test_sample.py-

import pytest

def divide(a, b):
    return a / b

# 正常測試
def test_divide_normal():
    assert divide(6, 3) == 2, "測試 divide(6, 3) 失敗,期望結果為 2"
    print("測試 divide(6, 3) 成功,期望結果為 2")

# 異常測試
def test_divide_abnormal():
    with pytest.raises(ZeroDivisionError):
        divide(1, 0)
    print("測試 divide(1, 0) 成功,期望拋出 ZeroDivisionError")

# 邊界測試
def test_divide_boundary():
    assert divide(1e10, 1e5) == 1e5, "測試 divide(1e10, 1e5) 失敗,期望結果為 1e5"
    print("測試 divide(1e10, 1e5) 成功,期望結果為 1e5")


也可以直接呼叫 pytest 進行測試,參數是指定資料夾

pytest tests/

會自己抓 test_ 開頭的檔案中 test_ 開頭的函式測試


使用 --html 將報告生成到 html 上

pytest tests/ --html=report.html




再來是寫 hook 這邊用工具產生的所以只要寫在 .pre-commit-config.yaml 這份檔案裏面就可以了

# .pre-commit-config.yaml
repos:
  - repo: local
    hooks:
      - id: run-tests
        name: Run Tests
        entry: python run_tests.py --hook
        language: system
        stages: [commit]
        pass_filenames: false

然後執行命令安裝到 git 中

pre-commit install

這樣就完成了,再來嘗試提交看看就會自動測試了

Python 如何動態轉發到模組中的同名函式

Python 如何動態轉發到模組中的同名函式

這是上一篇文章的延伸,檔案結構請參考這篇
CHG: Python 如何動態載入模組中的所有的檔案 (charlottehong.blogspot.com)

主要是想把實作分開到 impl 檔案中,這裡是自動導入的寫法一樣追加在該包的初始話文件中


utils\__init__.py

import importlib
from functools import wraps

# 動態轉發與檔案名稱相同的函式或類別
def forward(target):
    # 裝飾器應用於函式時
    if callable(target):
        @wraps(target)
        def wrapper(*args, **kwargs):
            # 先執行原始函數(但不使用其返回值)
            target(*args, **kwargs)
            func_name = target.__name__
            try:
                # 動態導入目標模組
                parent_module = importlib.import_module(f'.{func_name}', package=__package__)
                # 獲取同名的函數
                target_func = getattr(parent_module, func_name, None)
                if target_func is None:
                    raise AttributeError(f"'{parent_module.__name__}' module has no attribute '{func_name}'")
                # 執行轉發函數並返回其結果
                return target_func(*args, **kwargs)
            except ImportError as e:
                raise ImportError(f"Failed to import module for function '{func_name}': {e}")
            except AttributeError as e:
                raise AttributeError(f"Function '{func_name}' not found in the module '{parent_module.__name__}': {e}")
        return wrapper
    # 裝飾器應用於類別時
    elif isinstance(target, type):
        for attr_name, attr_value in target.__dict__.items():
            if callable(attr_value) and not attr_name.startswith('__'):
                decorated_method = forward(attr_value)
                setattr(target, attr_name, decorated_method)
        return target
    else:
        raise TypeError("The @forward decorator can only be applied to functions or classes")


然後就可以在 main 中這樣使用

import utils

@utils.forward
def test_func(name):
    pass

@utils.forward
class test_class:
    pass

if __name__ == "__main__":
    test_func("CHG")
    test_class().method()

如此一來就可以自動轉發到同名的函式或類別上了


其中我有保留了原本函式的執行,這是為了把參數前置處理檢查之類的工作可以一同寫在參數定義上,轉發後的函式只需要專注在業務邏輯上

  • 執行順序是先原函式然後再轉發的函式
  • 原函式的返回值會被丟棄,以轉發的函式的返回值為主


新增在 utils 中有兩個檔案

utils\test_func.py

def test_func(name):
    print(f"[name = {name}]")

utils\test_class.py

class test_class:
    def method(self):
        print("This is a method from test_class in utils.test_class")






2024年5月21日 星期二

Python 如何動態載入模組中的所有的檔案

Python 如何動態載入模組中的所有的檔案

寫了一個自動動態載入所有模組的代碼,免得每次加入新檔案都要在設置一次


這是測試的檔案結構

project/
│
├── utils/
│   ├── __init__.py
│   ├── upload.py
│   ├── info.py
│   └── download.py
└── main.py



目標是對 __init__.py 下手,他可以寫代碼動態載入同層資料夾中所有的模組

import os
import importlib
import inspect
from functools import wraps

# 設定警告訊息和資訊訊息的控制變數
print_warnings = False
print_info = False

# 強制只轉同名的函式或類別
force_promote = False

# 獲取當前目錄
module_dir = os.path.dirname(__file__)

# 獲取當前模組的包名
current_package = __package__

# 動態導入同層資料夾中的所有模組
for module_name in os.listdir(module_dir):
    if module_name.endswith('.py') and module_name != '__init__.py':
        module_name = module_name[:-3]
        try:
            # 若 globals 中已有相同名稱的模組且 print_warnings 為 True,則打印警告訊息
            if module_name in globals() and print_warnings:
                print(f"Warning: '{module_name}' already exists in globals and will be overridden.")

            # 動態導入模組
            module = importlib.import_module(f'.{module_name}', package=current_package)
            globals()[module_name] = module

            # 獲取模組中的屬性,排除內建屬性和非該模組定義的屬性
            module_attrs = [
                attr for attr in module.__dict__
                if not attr.startswith('__')
                and inspect.getmodule(getattr(module, attr)) == module
            ]
            # 找到與模組同名的屬性
            same_name_attr = [attr for attr in module_attrs if attr == module_name]

            # 強制只轉同名的函式或類別
            if force_promote:
                if same_name_attr:
                    # 若 globals 中已有相同名稱的屬性且 print_warnings 為 True,則打印警告訊息
                    if module_name in globals() and print_warnings:
                        print(f"Warning: '{module_name}' in globals will be overridden by '{module.__name__}.{same_name_attr[0]}'.")
                    globals()[module_name] = getattr(module, same_name_attr[0])
                else:
                    if print_warnings:
                        print(f"Warning: No attribute with the same name '{module_name}' found in module '{module.__name__}'.")
            else:
                # 當模組中只有一個與檔名同名的函式或類別時將其提升到父級命名空間
                if len(module_attrs) == 1 and module_attrs[0] == module_name:
                    # 若 globals 中已有相同名稱的屬性且 print_warnings 為 True,則打印警告訊息
                    if module_name in globals() and print_warnings:
                        print(f"Warning: '{module_name}' in globals will be overridden by '{module.__name__}.{module_attrs[0]}'.")
                    globals()[module_name] = getattr(module, module_attrs[0])

            # 若 print_info 為 True,則打印導入的模組及其屬性
            if print_info:
                imported_items = ", ".join(module_attrs)
                print(f"Module '{module_name}' imported with items: \n  [ {imported_items} ]")
        except Exception as e:
            # 打印導入模組時的錯誤訊息
            print(f"Error importing module '{module_name}': {e}")



然後在 main.py 中可以這樣引入

import utils

def main():
    # 調用子命名空間內的函式
    print("========================= utils.info =========================")
    utils.info()               # This will call the info function from utils.info

    # 調用子命名空間內的函式
    print("========================= utils.upload =========================")
    utils.upload.upload()      # This will call the upload function from utils.upload
    utils.upload.info()        # This will call the info from utils.upload
    # 調用類及其方法
    upload_instance = utils.upload.UploadClass()
    upload_instance.method()  # This will call the method from UploadClass in utils.upload

    print("========================= utils.download =========================")
    # 調用子命名空間內的函式
    utils.download.download()  # This will call the download function from utils.download
    utils.download.info()      # This will call the info from utils.download
    # 調用類及其方法
    download_instance = utils.download.DownloadClass()
    download_instance.method()  # This will call the method from DownloadClass in utils.download

if __name__ == "__main__":
    main()



再來是其他檔案 upload.py

def upload():
    print("This is the upload function from utils.upload")

def info():
    print("This is the info function from utils.upload")

class UploadClass:
    def method(self):
        print("This is a method from UploadClass in utils.upload")

下一個 download.py

def download():
    print("This is the download function from utils.download")

def info():
    print("This is the info function from utils.download")

class DownloadClass:
    def method(self):
        print("This is a method from DownloadClass in utils.download")


最後一個 info.py

def info():
    print("This is the info function from utils.info")

其中這個比較特別的我把他設計成
當模組中只有一個與檔名同名的函式或類別時將其提升到父級命名空間
所以可以省略重複的 info 只需要一次就可以了



其他檔案因為存在著第二個函式或類別就不會自動提升了,這是為了避免重名碰撞的問題
雖然無法完全避免,但是至少用當按當索引很容易就會不小心看到了,免得被自己埋坑了

唯一要注意的就是包名不要取的太過容易碰撞了,雖然 import utils 會優先讀取當前目錄,但要是沒有會繼續往環境庫裡面讀取的


最後還有一篇延伸的文章是
CHG: Python 如何動態轉發模組中的函式 (charlottehong.blogspot.com)

在這一篇我們已經做到如何自動加載所有檔案,再來利用裝飾器轉發模組中的所有函式

2024年5月18日 星期六

Python 虛擬機用法

Python 虛擬機用法

新增虛擬機

python -m venv .venv

激活虛擬機

# Linux
source .venv/bin/activate

# Windows
.venv\Scripts\activate

這個在 Vscode 中不需要輸入只要偵測到有環境打開就自動進去了

備份環境依賴

pip freeze > requirements.txt

安裝環境依賴

pip install -r requirements.txt

如果是當前專案的話生成會變成絕對路徑,可以手動改成 -e . 這樣可以動態獲取

安裝當前專案到環境

# 發布用
pip install .

# 開發用引用當前代碼
pip install -e .

用 -e 的話不會複製代碼到環境中,而是直接引用當前的代碼,所以可以即時修改。但要是有框架改變或更名等較大的變異還是需要重新安裝一次才能跑。

退出虛擬環境

deactivate


 

Python 透過 click 仿 PowerShell 自動填充參數

Python 透過 Click 仿 PowerShell 自動填充參數

在 Linux 上很少看到像 PowerShell 那樣依照位置自動填充參數的功能,但透過 Click,我們可以實現大約九成的相似效果。

動態填充的意思是可以給參數設置一個順序,這個順序會自動抓取未加前綴的變數,依序自動填入。舉個例子:

function DemoFunction {
    Param(
        [Parameter(Position = 0, Mandatory)]
        [string]$Id,
        [Parameter(Position = 1, Mandatory)]
        [string]$Name
    )
    return $null
}

你可以通過以下的方式顛倒指定順序:

DemoFunction "CHG" -Id 777

這是一個非常方便的功能。接下來,我們看看如何在 Python 中實現類似的功能。


Python 自動參數填充

我們使用 Click,這是 Python 的一個第三方模組。
官方網站有詳細的文檔:Click Documentation (8.1.x)

以下示例分成兩個檔案:command1.py 和 setup.py

先來看 setup.py,這是 Python 內建的功能,用來啟動代碼。

from setuptools import setup, find_packages

setup(
    name='myapp',
    version='0.1.0',
    # packages=find_packages(),
    py_modules=['command1'],
    install_requires=[
        'click',
    ],
    entry_points={
        'console_scripts': [
            'myappcmd1=command1:pycli1',
        ],
    }
)


接下來是 command1.py

import click

# 自動填入剩餘參數的函數閉包
def auto_fill_argument(required=False):
    def callback(ctx, param, value):
        if 'args' not in ctx.params:
            ctx.params['args'] = ()
        args = list(ctx.params['args'])
        if value is None:
            if args:
                value = args.pop(0)
                ctx.params['args'] = tuple(args)
            elif required:
                raise click.MissingParameter(param_hint=[f"--{param.name}", f"-{param.opts[1]}"])
        ctx.params['args'] = tuple(args)
        return value
    return callback

# 自定義命令類以實現多餘參數檢查
class AddWithExtraArgsCheck(click.Command):
    def invoke(self, ctx):
        if 'args' in ctx.params and ctx.params['args']:
            params_info = ", ".join(f"{param}: {value if value is not None else ''}" for param, value in ctx.params.items() if param != 'args')
            extra_args = " ".join(ctx.params['args'])
            raise click.UsageError(
                f"Got unexpected extra arguments ({extra_args})"
                f", already assigned ({params_info})",
                ctx=ctx
            )
        return super().invoke(ctx)

# 宣告選項群
@click.group()
def pycli1():
    pass

# 選項命令1:: upload
@pycli1.command(cls=AddWithExtraArgsCheck)
@click.option('--identity', '-id', type=str, callback=auto_fill_argument(required=True), help="指定雲端文件夾的 ID")
@click.option('--name', '-n', type=str, callback=auto_fill_argument(required=True), help="指定文件路徑")
@click.option('--type', '-t', type=click.Choice(['any', 'folder', 'file'], case_sensitive=False), default='any', help="指定路徑類型: any (預設), folder, file")
@click.argument('args', nargs=-1)
def upload(identity, name, type, args):
    """上傳文件到指定的雲端文件夾"""
    click.echo(f"雲端文件夾ID:{identity}, 正在上傳文件:{name}, 路徑類型:{type}", err=False)

# 選項命令2:: getmyinfo
@pycli1.command()
def getmyinfo():
    """獲取我的信息"""
    click.echo(f"執行 getmyinfo 動作,目前沒有任何參數", err=False)

if __name__ == '__main__':
    pycli1()


使用範例:

myappcmd1 upload "C:\path\to\your\file.txt" -id 12345

如此一來,就可以實現自動參數填充的功能。


通過指定 callback=auto_fill_argument(required=True) 可以將參數標記為自動填入項目,並且可以進一步標記為必須項目。該參數可以為空,但至少要保留括號。

此外,通過在選項命令處指定 cls=AddWithExtraArgsCheck,可以檢查是否有多餘的參數。這樣做的原因是,啟用 args 後會關閉原生的參數檢查功能,但如果不啟用 args,又無法接收任意參數,所以只能這樣妥協使用。