2024年5月25日 星期六

Python 如何使用 Json 通過 JWT 請求獲取 Box Accese Token (不使用boxsdk)

Python 如何使用 Json 通過 JWT 請求獲取 Box Accese Token (不使用boxsdk)

方法是參考自這篇的步驟說明

SDKを使用しないJWT - Box Developerドキュメント

以及這個的官方示例後修改而來
samples-docs-authenticate-with-jwt-api/sample.3.py


如果需要在 PwerShell 上的版本可以參考這個 PsJwt 專案中的範例
PsJwt/Example/Get-BoxToken at main · hunandy14/PsJwt (github.com)


下面讓我們開始正文吧如何一步一步的獲取到令牌



讀取 config.json 文件

這份文件可以通過這個教學一步步取得
SDKを使用しないJWT - Box Developerドキュメント

總之就是需要你在個人的 Box 帳號中註冊一個應用程式,並且在註冊後必須由經由該網域的Admin帳號同意權限請求才能使用。如果該帳號就是 Admin 則可以自己審核自己。

import json
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.serialization import load_pem_private_key

# 讀取 config.json 文件
with open('config.json') as json_file:
    config = json.load(json_file)

# 從配置中加載 PEM 私鑰
key = load_pem_private_key(
    data=config["boxAppSettings"]["appAuth"]["privateKey"].encode('utf8'),
    password=config["boxAppSettings"]["appAuth"]["passphrase"].encode('utf8'),
    backend=default_backend(),
)

這個很簡單就是把東西讀取出來而已,並把加密的私鑰先解密出來。



建立 JWT claims 聲明

建立 JWT claims 聲明

import secrets
import time

# 設定認證 URL
authentication_url = 'https://api.box.com/oauth2/token'

# 建立 JWT claims 用於認證
claims = {
    'iss': config['boxAppSettings']['clientID'],  # 客戶端 ID
    'sub': config['enterpriseID'],                # 企業 ID
    'box_sub_type': 'enterprise',                 # 認證類型
    'aud': authentication_url,                    # 受眾 URL
    'jti': secrets.token_hex(64),                 # JWT ID,用於唯一性
    'exp': round(time.time()) + 45                # 過期時間 (45 秒後)
}

這個聲明最終會變成發送給 Box 伺服器的資訊



建立 JWT assertions

有了 claims 之後,我們需要用這些聲明來生成一個 JWT token。這個 token 將會用來向 Box 的認證服務請求 Access Token。

import jwt

# 建立 JWT assertion
assertion = jwt.encode(
    claims, 
    key, 
    algorithm='RS512',
    headers={
        'kid': config['boxAppSettings']['appAuth']['publicKeyID']
    }
)

這段程式碼使用 pyjwt 庫來對 claims 進行簽名。key 是我們之前解密出的私鑰,而 algorithm 指定了我們使用 RS512 來進行簽名。

JWT token 驗證可以參考這個網站 JSON Web Tokens - jwt.io ,他可以動態驗證你的 JWT 是否正確,如果輸入私鑰也可以動態生成



發送請求以獲取 Access Token

我們現在需要將生成的 JWT token 發送到 Box 的認證伺服器,以換取一個 Access Token。

import requests

# 發送請求以獲取存取權杖
response = requests.post(
    authentication_url, {
        'grant_type': 'urn:ietf:params:oauth:grant-type:jwt-bearer',
        'assertion': assertion,
        'client_id': config['boxAppSettings']['clientID'],
        'client_secret': config['boxAppSettings']['clientSecret']
    }
)
# 解析回應並獲取存取權杖
access_token = response.json()['access_token']
print(f"AccessToken: {access_token}")

這裡使用 requests 庫發送 HTTP POST 請求到 Box 的認證伺服器。回應中包含了我們需要的 Access Token。

這個就是最終的成品了,可以拿來直接對 Box API 伺服器請求操作你的帳號了。



驗證 Access Token 

接下來,我們將使用獲取的 Access Token 來驗證身份,並且可以進一步操作 Box API。這邊就做一個簡單的測試查詢當前帳號是誰。

這部分就直接引用 boxsdk 測試比較快,目的只是測試獲取的令牌是否可用,也可以通過 response 的方式手動獲取請求。

from boxsdk import OAuth2, Client

# 使用 OAuth2 認證
auth = OAuth2(
    client_id=config['boxAppSettings']['clientID'],
    client_secret=config['boxAppSettings']['clientSecret'],
    access_token=access_token,
)

# 創建 Box 客戶端
client = Client(auth)
# 獲取用戶資訊
user = client.user().get()
print('Authenticated as:', user.name)

這段程式碼使用 boxsdk 庫來創建一個 Box 客戶端,並使用 Access Token 進行身份驗證。最後我們打印出已驗證用戶的名稱。



驗證 Access Token 2

這是直接透過 requests 跟 Box 伺服器交互的方法,會稍微麻煩一點,還得自己解構回傳的信息字串。

在這個方法中,我們不使用 boxsdk 庫,而是直接使用 requests 庫來發送 HTTP 請求,並手動處理回應。這樣可以更靈活地控制 API 請求和回應。

import requests

# 發送請求到 Box API 以獲取當前用戶資訊
response = requests.get(
    'https://api.box.com/2.0/users/me',
    headers={
        'Authorization': f'Bearer {access_token}',
    }
)

# 檢查請求是否成功
if response.status_code == 200:
    user_info = response.json()
    print('Authenticated as:', user_info['name'])
else:
    print('Failed to authenticate')
    print('Response:', response.text)

這段程式碼做了以下幾件事:

  1. 設置請求頭:使用獲取的 Access Token 設置請求頭中的 Authorization 字段。
  2. 發送 GET 請求:向 Box API 發送 GET 請求,以獲取當前用戶的資訊。請求的 URL 是 https://api.box.com/2.0/users/me
  3. 處理回應:檢查回應的狀態碼。如果狀態碼是 200,表示請求成功,然後解析回應的 JSON 數據並打印出用戶名稱。如果請求失敗,打印回應的錯誤信息。

通過這種方式,你可以手動處理 Box API 的所有回應,這在需要進行更複雜的錯誤處理或自定義 API 請求時特別有用。




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