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,又無法接收任意參數,所以只能這樣妥協使用。

市集中的 python 版本被優先使用,還無法從環境變數中刪除找不到該位置

市集中的 python 版本被優先使用,還無法從環境變數中刪除找不到該位置

Windows中有個隱藏的環境變數,叫做應用程式別名,從市集安裝的似乎預設會從那裏設定,不知道的話真的很頭痛被搞過一次。

位置可以從設定裡面點出來



找到那個元兇關掉就好



Windows11上也大同小異,位置就別找了直接搜尋 "應用程式執行別名" 就好



-


2024年5月17日 星期五

下載 Bats 並安裝到用戶的暫存目錄 (以免安裝的方式啟動)

下載 Bats 並安裝到用戶的暫存目錄 (以免安裝的方式啟動)

完整的流程,從下載Bats到安裝到用戶的暫存目錄,並臨時設置環境變數來使用它。


1. 下載Bats

首先,從GitHub下載Bats v1.11.0的源碼壓縮包:

wget https://github.com/bats-core/bats-core/archive/refs/tags/v1.11.0.tar.gz -O bats-core-1.11.0.tar.gz

解壓縮下載的檔案:

tar -xzf bats-core-1.11.0.tar.gz

進入解壓縮的目錄:

cd bats-core-1.11.0


2. 安裝腳本

原作寫的安裝腳本 bats-core/install.sh 下面是腳本的解析並追加了提示如何添加到環境變數的信息,你也可以直接執行原裝的就好沒有改動。


在解壓縮的目錄中,創建一個名為 install_bats.sh 的文件,並將以下內容粘貼到文件中:-

#!/usr/bin/env bash
set -e

BATS_ROOT="${0%/*}" # 腳本所在目錄的路徑
PREFIX="${1%/}"     # 安裝前綴路徑 (移除路徑結尾的斜線)
LIBDIR="${2:-lib}"  # 庫文件目錄名,默認為 "lib"

# 如果沒有提供安裝路徑,則輸出使用說明到標準錯誤。
if [[ -z "$PREFIX" ]]; then
  printf '%s\n' \
    "usage: $0 <prefix> [base_libdir]" \
    "  e.g. $0 /usr/local" \
    "       $0 /usr/local lib64" >&2
  exit 1
fi

# 使用 install 命令來創建目錄結構並設置適當的權限。
install -d -m 755 "$PREFIX"/{bin,libexec/bats-core,"${LIBDIR}"/bats-core,share/man/man{1,7}}

# 安裝二進制文件、庫和手冊頁。
install -m 755 "$BATS_ROOT/bin"/* "$PREFIX/bin"
install -m 755 "$BATS_ROOT/libexec/bats-core"/* "$PREFIX/libexec/bats-core"
install -m 755 "$BATS_ROOT/lib/bats-core"/* "$PREFIX/${LIBDIR}/bats-core"
install -m 644 "$BATS_ROOT/man/bats.1" "$PREFIX/share/man/man1"
install -m 644 "$BATS_ROOT/man/bats.7" "$PREFIX/share/man/man7"

# 讀取安裝後的 bats 執行文件,修改庫目錄變量後重新寫入。
read -rd '' BATS_EXE_CONTENTS <"$PREFIX/bin/bats" || true
BATS_EXE_CONTENTS=${BATS_EXE_CONTENTS/"BATS_BASE_LIBDIR=lib"/"BATS_BASE_LIBDIR=${LIBDIR}"}
printf "%s" "$BATS_EXE_CONTENTS" > "$PREFIX/bin/bats"

# 輸出安裝完成的訊息
echo "Installed Bats to $PREFIX/bin/bats"

# 提示用戶如何添加 BATS 到環境變數
echo "To temporarily add Bats to your PATH, run:"
echo "export PATH=\"\$PATH:$PREFIX/bin\""


給安裝腳本賦予執行權限:

chmod +x install_bats.sh

執行安裝腳本,將Bats安裝到 ~/tmp/bats 目錄中:

./install_bats.sh ~/tmp/bats


3. 臨時設置環境變數

按照腳本的提示,設置環境變數以臨時使用Bats:

export PATH="~/tmp/bats/bin:$PATH"

這裡的路徑記得要根據安裝的位置輸入,然後這只是臨時的關閉終端機就沒有了


4. 驗證安裝

確認Bats已成功安裝並可用:

bats --version


總結

以上步驟將Bats安裝到用戶的暫存目錄 /tmp/bats,並通過臨時設置環境變數使其可用。這樣可以在不影響系統的情況下使用Bats進行測試。

2024年5月15日 星期三

PowerShell 複製到剪貼簿歷史中

PowerShell 複製到剪貼簿歷史中

預設的 Set-Clipboard 不會顯示在剪貼簿中,這個是他的擴充函式
直接上代碼,自行取用

function Set-ClipboardHistory {
    param(
        [Parameter(Mandatory=$true)]
        [string]$Value,

        [Parameter()] # 記錄在 Windows 的剪貼板歷史
        [switch]$DenyInHistory,

        [Parameter()] # 在用戶的多個設備之間漫遊
        [switch]$Roamable
    )

    # 加載 Windows Runtime 類型
    Add-Type -AssemblyName 'System.Runtime.WindowsRuntime'

    # 創建 DataPackage 和 ClipboardContentOptions 對象
    $dataPackage = New-Object Windows.ApplicationModel.DataTransfer.DataPackage
    $cOptions = New-Object Windows.ApplicationModel.DataTransfer.ClipboardContentOptions

    # 根據參數設定剪貼板內容選項
    $cOptions.IsAllowedInHistory = -not $DenyInHistory
    $cOptions.IsRoamable = $Roamable

    # 設定剪貼板操作為複製
    [int]$RequestedOperationCopy = 1
    $dataPackage.RequestedOperation = $RequestedOperationCopy

    # 使用選項將內容設置到剪貼板
    $dataPackage.SetText($Value)
    [Windows.ApplicationModel.DataTransfer.Clipboard]::SetContentWithOptions($dataPackage, $cOptions) | Out-Null

} # Set-ClipboardHistory "TestString"

-

確保 PowerShell Function 總是返回數組(即便對象是單一物件)

確保 PowerShell Function 總是返回數組(即便對象是單一物件)

這個問題是來自於 PowerShell 在經過函式的時候自動將單一一個的數組把數組拆掉變成一個物件的特性導致的。

要怎麼應付這個自動拆解有兩個方法



同一層級使用 @() 包覆

如果是在同一層級只要使用 @() 打包起來就好了,除了過函數會拆數組的特性之外還有一個是在任何時候總是會拆掉數組中的數組,就是不會讓你包兩層數組的意思,除非這兩層的Count都大於1,不然就會被拆掉只剩一層

利用這個特性就管他是不是數組,總之只要包起來就對了

$array = @((Get-Array))


不用寫判斷式也可以保證他是正確的,如果要寫判斷式的話會長這個樣子

$array = if ((Get-Array) -isnot [array]) { @((Get-Array)) }




函式的返回

函式返回就麻煩了一共套了兩層規則,一個是雙層數組拆解跟返回時拆解所以兩層都要解


對於返回值而言只能用逗號傳遞數組

function MyFunc() {
  return ,$array
}


這個第一次看應該覺得很問號,我給你講個猜想應該就可以馬上記住了。就把他當作加一個空物件組成 Count 大於二的數組 @($null, $array),然後出去的時候那個空物件會被解掉。

雖然不確定實作是不是這樣做的,但總之就是對於函式返回值只有這樣才能解,你用第一種出去還是變成物件。


第二個問題是萬一你那個 $array 不是數組的話逗號的保護模被函式搓破了出去還是變成物件了,這個就把兩層保護組合起來就好了

function MyFunc() {
  return ,@((Get-Array))
}


這樣一來就可以不寫判斷式的保證返回值一定是個數組了