From 550f763547ff5b1ebcbf354819ff01b42542948d Mon Sep 17 00:00:00 2001 From: AugChao Date: Thu, 28 May 2026 08:02:45 +0800 Subject: [PATCH] feat(week-14): add Cookbook ch.14 in-class examples (R/U Bloom split) Add 5 in_class examples covering testing, debugging, and exceptions, classified by Bloom's taxonomy (R: Remember, U: Understand) to mirror week-04 structure. Update week-14 README to link the new materials. Co-Authored-By: Claude Opus 4.7 (1M context) --- weeks/week-14/README.md | 26 +++- weeks/week-14/in_class/R01-unittest-basics.py | 74 ++++++++++ .../week-14/in_class/R02-exceptions-basic.py | 73 ++++++++++ weeks/week-14/in_class/R03-profile-basic.py | 65 +++++++++ weeks/week-14/in_class/README.md | 55 +++++++ .../week-14/in_class/U01-test-warnings-why.py | 113 +++++++++++++++ .../week-14/in_class/U02-debug-speedup-why.py | 136 ++++++++++++++++++ 7 files changed, 541 insertions(+), 1 deletion(-) create mode 100644 weeks/week-14/in_class/R01-unittest-basics.py create mode 100644 weeks/week-14/in_class/R02-exceptions-basic.py create mode 100644 weeks/week-14/in_class/R03-profile-basic.py create mode 100644 weeks/week-14/in_class/README.md create mode 100644 weeks/week-14/in_class/U01-test-warnings-why.py create mode 100644 weeks/week-14/in_class/U02-debug-speedup-why.py diff --git a/weeks/week-14/README.md b/weeks/week-14/README.md index 9d0b4a035..597300e65 100644 --- a/weeks/week-14/README.md +++ b/weeks/week-14/README.md @@ -1,11 +1,35 @@ # Week 14(115/05/25-115/05/31) -- 主題:綜合練習 +- 主題:測試、除錯與例外 + 綜合練習 +- 課堂範例:[`in_class/`](./in_class/) — Python3 Cookbook 第 14 章,依 Bloom's Taxonomy 分為 R/U 兩層共 5 個範例 - 解題:[11349](./QUESTION-11349.md) | [11417](./QUESTION-11417.md) | [11461](./QUESTION-11461.md) | [12019](./QUESTION-12019.md) - 作業:完成 4 題並提交到 `weeks/week-14/solutions//` --- +## 課堂範例(in_class/) + +涵蓋教材:[Python3 Cookbook 第 14 章:測試、調試和異常](https://python3-cookbook.readthedocs.io/zh-cn/latest/chapters/p14_test_debug_and_exceptions.html) + +### 記憶層(R — Remember) + +| 檔案 | 涵蓋節次 | 主題 | +|------|----------|------| +| [R01](./in_class/R01-unittest-basics.py) | 14.1–14.3 | unittest 基礎:`redirect_stdout` / `mock.patch` / `assertRaises` | +| [R02](./in_class/R02-exceptions-basic.py) | 14.6–14.8 | 例外處理基本:多例外 tuple / `except Exception` / 自定義例外 | +| [R03](./in_class/R03-profile-basic.py) | 14.13 | 效能測量:`timed` 裝飾器 / `timeit` / `cProfile` | + +### 理解層(U — Understand) + +| 檔案 | 涵蓋節次 | 主題 | +|------|----------|------| +| [U01](./in_class/U01-test-warnings-why.py) | 14.4, 14.5, 14.11 | 測試控制與警告:`skipIf` 的報表價值 / `stacklevel=2` / Warning 種類選擇 | +| [U02](./in_class/U02-debug-speedup-why.py) | 14.9, 14.10, 14.12, 14.14 | 例外、除錯與加速:四種 raise 寫法對照 / `print_exc` 必要性 / `LOAD_FAST` vs `LOAD_GLOBAL` | + +詳細說明見 [`in_class/README.md`](./in_class/README.md)。 + +--- + ## 解題清單 | # | 題名 | 難度 | 題目檔 | diff --git a/weeks/week-14/in_class/R01-unittest-basics.py b/weeks/week-14/in_class/R01-unittest-basics.py new file mode 100644 index 000000000..df4c249a6 --- /dev/null +++ b/weeks/week-14/in_class/R01-unittest-basics.py @@ -0,0 +1,74 @@ +""" +R01:unittest 基本用法(記憶層 — 直接複製可執行) + +對應 Cookbook: +- 14.1 測試 stdout 輸出 +- 14.2 在單元測試中給物件打補丁 +- 14.3 在單元測試中測試例外情況 + +執行: + python R01-unittest-basics.py +""" +import io +import unittest +from contextlib import redirect_stdout +from unittest.mock import MagicMock, patch + + +# ---------- 被測函式 ---------- +def url_print(host, domain): + print(f"https://{host}.{domain}") + + +def parse_int(s): + if not s: + raise ValueError("空字串無法轉成整數") + return int(s) + + +def fetch_user(api, user_id): + return api.get(f"/users/{user_id}") + + +# ---------- 14.1 測試 stdout ---------- +class TestStdout(unittest.TestCase): + def test_url_print(self): + buf = io.StringIO() + with redirect_stdout(buf): + url_print("www", "example.com") + self.assertEqual(buf.getvalue().strip(), "https://www.example.com") + + +# ---------- 14.2 mock.patch ---------- +class TestPatch(unittest.TestCase): + def test_fetch_user_with_mock(self): + fake_api = MagicMock() + fake_api.get.return_value = {"id": 1, "name": "Alice"} + + result = fetch_user(fake_api, 1) + + self.assertEqual(result["name"], "Alice") + fake_api.get.assert_called_once_with("/users/1") + + @patch("builtins.print") + def test_url_print_via_patch(self, mock_print): + url_print("api", "example.com") + mock_print.assert_called_once_with("https://api.example.com") + + +# ---------- 14.3 測試例外 ---------- +class TestExceptions(unittest.TestCase): + def test_raises(self): + with self.assertRaises(ValueError): + parse_int("") + + def test_raises_with_message(self): + with self.assertRaisesRegex(ValueError, "空字串"): + parse_int("") + + def test_normal_case(self): + self.assertEqual(parse_int("42"), 42) + + +if __name__ == "__main__": + unittest.main() diff --git a/weeks/week-14/in_class/R02-exceptions-basic.py b/weeks/week-14/in_class/R02-exceptions-basic.py new file mode 100644 index 000000000..db4588953 --- /dev/null +++ b/weeks/week-14/in_class/R02-exceptions-basic.py @@ -0,0 +1,73 @@ +""" +R02:例外處理基本用法(記憶層) + +對應 Cookbook: +- 14.6 處理多個例外 +- 14.7 捕獲所有例外 +- 14.8 建立自定義例外 + +執行: + python R02-exceptions-basic.py +""" +import traceback + + +# ---------- 14.6 多個例外 ---------- +def parse_value(s): + """同一個 except 用 tuple 列出多種例外類別""" + try: + return int(s) + except (ValueError, TypeError) as e: + print(f"[14.6] 解析失敗 {type(e).__name__}: {e}") + return None + + +# ---------- 14.7 捕獲所有例外 ---------- +def safe_run(func, *args): + """except Exception,而不是裸 except:(裸 except 會抓到 KeyboardInterrupt)""" + try: + return func(*args) + except Exception as e: + print(f"[14.7] 發生例外 {type(e).__name__}: {e}") + traceback.print_exc() + + +# ---------- 14.8 自定義例外 ---------- +class NetworkError(Exception): + """所有網路錯誤的基底類別;繼承 Exception 而不是 BaseException""" + + +class HostnameError(NetworkError): + """找不到主機""" + + +class ConnectionTimeout(NetworkError): + """連線逾時,附帶 host / seconds 屬性,方便上層判斷""" + def __init__(self, host, seconds): + super().__init__(f"連線 {host} 超過 {seconds} 秒") + self.host = host + self.seconds = seconds + + +def connect(host, timeout): + if host == "": + raise HostnameError("主機名稱為空") + if timeout < 1: + raise ConnectionTimeout(host, timeout) + return f"connected to {host}" + + +if __name__ == "__main__": + print("--- 14.6 ---") + parse_value("abc") + parse_value(None) + + print("\n--- 14.7 ---") + safe_run(lambda: 1 / 0) + + print("\n--- 14.8 ---") + for host, t in [("example.com", 5), ("", 5), ("slow.com", 0)]: + try: + print(connect(host, t)) + except NetworkError as e: + print(f"接到 {type(e).__name__}: {e}") diff --git a/weeks/week-14/in_class/R03-profile-basic.py b/weeks/week-14/in_class/R03-profile-basic.py new file mode 100644 index 000000000..3cdcb810c --- /dev/null +++ b/weeks/week-14/in_class/R03-profile-basic.py @@ -0,0 +1,65 @@ +""" +R03:效能測量基本用法(記憶層) + +對應 Cookbook: +- 14.13 給程式做效能測試(time / timeit / cProfile) + +執行: + python R03-profile-basic.py +""" +import cProfile +import math +import pstats +import time +import timeit +from functools import wraps + + +# ---------- 計時裝飾器(粗粒度) ---------- +def timed(func): + @wraps(func) + def wrapper(*args, **kwargs): + t0 = time.perf_counter() + result = func(*args, **kwargs) + elapsed = time.perf_counter() - t0 + print(f"[timed] {func.__name__}: {elapsed*1000:.2f} ms") + return result + return wrapper + + +@timed +def sum_of_squares(n): + return sum(i * i for i in range(n)) + + +# ---------- timeit:量微小片段 ---------- +def bench_timeit(): + n = 10_000 + t1 = timeit.timeit("sum(i*i for i in range(n))", + globals={"n": n}, number=1000) + t2 = timeit.timeit("sum(map(lambda i: i*i, range(n)))", + globals={"n": n}, number=1000) + print(f"[timeit] genexp = {t1:.3f}s, map+lambda = {t2:.3f}s") + + +# ---------- cProfile:找熱點 ---------- +def workload(): + total = 0 + for i in range(1, 5000): + total += math.sqrt(i) * math.sin(i) + return total + + +def bench_cprofile(): + pr = cProfile.Profile() + pr.enable() + workload() + pr.disable() + print("[cProfile] 前 5 名:") + pstats.Stats(pr).sort_stats("cumulative").print_stats(5) + + +if __name__ == "__main__": + sum_of_squares(1_000_000) + bench_timeit() + bench_cprofile() diff --git a/weeks/week-14/in_class/README.md b/weeks/week-14/in_class/README.md new file mode 100644 index 000000000..a2a86ced0 --- /dev/null +++ b/weeks/week-14/in_class/README.md @@ -0,0 +1,55 @@ +# Week 14 課堂範例:測試、除錯與例外 + +本目錄涵蓋:[Python3 Cookbook 第 14 章:測試、調試和異常](https://python3-cookbook.readthedocs.io/zh-cn/latest/chapters/p14_test_debug_and_exceptions.html) + +依 **Bloom's Taxonomy** 分為兩層,整理成 5 個範例: + +- **R(Remember 記憶層)**:直接可複製、可執行的基礎用法 +- **U(Understand 理解層)**:為什麼這樣做?陷阱、抉擇與背後原理 + +--- + +## 章節對照表 + +### 記憶層(R) + +| 範例 | 涵蓋節次 | 主題 | 核心 API | +|------|----------|------|----------| +| [R01](./R01-unittest-basics.py) | 14.1–14.3 | unittest 基礎 | `redirect_stdout` / `mock.patch` / `assertRaises` | +| [R02](./R02-exceptions-basic.py) | 14.6–14.8 | 例外處理基本 | 多例外 tuple / `except Exception` / 自定義例外類別 | +| [R03](./R03-profile-basic.py) | 14.13 | 效能測量 | `timed` 裝飾器 / `timeit` / `cProfile` | + +### 理解層(U) + +| 範例 | 涵蓋節次 | 主題 | 關鍵概念 | +|------|----------|------|----------| +| [U01](./U01-test-warnings-why.py) | 14.4, 14.5, 14.11 | 測試控制與警告的「為什麼」 | `skipIf` vs `if return` / `expectedFailure` 的價值 / `stacklevel=2` / Warning 種類選擇 | +| [U02](./U02-debug-speedup-why.py) | 14.9, 14.10, 14.12, 14.14 | 例外、除錯與加速的「為什麼」 | `raise from` vs `raise X` vs bare `raise` vs `raise e` / `print_exc` vs `print(e)` / `LOAD_FAST` vs `LOAD_GLOBAL` | + +--- + +## 執行方式 + +```bash +cd weeks/week-14/in_class + +# 記憶層 +python R01-unittest-basics.py +python R02-exceptions-basic.py +python R03-profile-basic.py + +# 理解層 +python U01-test-warnings-why.py +python U01-test-warnings-why.py --log # 把測試結果寫到 test_result.log +python U02-debug-speedup-why.py +``` + +--- + +## 學習重點(一句話帶走) + +- **R01**:mock 掉外部相依、用 `assertRaises` 驗證例外、用 `redirect_stdout` 驗證輸出。 +- **R02**:多例外用 tuple;`except Exception` 不要寫成 bare `except:`;自定義例外繼承 `Exception`。 +- **R03**:粗測用 `time.perf_counter`,微測用 `timeit`,找瓶頸用 `cProfile`。 +- **U01**:`skipIf` 讓報表能看出「為什麼沒測」;`warnings.warn` 必加 `stacklevel=2` 指向呼叫端。 +- **U02**:包裝底層錯誤用 `raise X from e`;中途記 log 再拋用 bare `raise`;**不要寫 `raise e`**,那會丟失 traceback。 diff --git a/weeks/week-14/in_class/U01-test-warnings-why.py b/weeks/week-14/in_class/U01-test-warnings-why.py new file mode 100644 index 000000000..b74f90a8a --- /dev/null +++ b/weeks/week-14/in_class/U01-test-warnings-why.py @@ -0,0 +1,113 @@ +""" +U01:測試流程與警告的「為什麼」(理解層) + +對應 Cookbook: +- 14.4 將測試輸出寫到日誌檔 +- 14.5 跳過或預期失敗 +- 14.11 輸出警告訊息 + +核心問題: +- 為什麼要 skipIf / skipUnless 而不是 if 包起來? +- expectedFailure 和註解掉測試的差別? +- warnings.warn 的 stacklevel 為什麼幾乎一定要設 2? +- DeprecationWarning vs UserWarning 怎麼選? + +執行: + python U01-test-warnings-why.py + python U01-test-warnings-why.py --log +""" +import sys +import unittest +import warnings + + +# ---------- 14.5 為什麼用裝飾器而不是 if ---------- +class WhySkip(unittest.TestCase): + """ + 用 skipIf 而不是 `if sys.version_info < ...: return` 的理由: + 1. 報表上會明確標 's'(skipped),而不是假裝通過。 + 2. 統計時可以區分「沒測」和「測過了」。 + 3. 不會誤把 setUp 副作用留下來。 + """ + + @unittest.skipIf(sys.version_info < (3, 10), "需要 Python 3.10+") + def test_match_case(self): + x = 1 + match x: + case 1: + self.assertTrue(True) + + @unittest.skipUnless(sys.platform.startswith("darwin"), "只在 macOS") + def test_mac_only(self): + import os + self.assertTrue(os.path.exists("/Users")) + + @unittest.expectedFailure + def test_known_bug(self): + """ + 已知 bug 的測試「留著」而不是刪掉,這樣: + - 真的修好時,會以「unexpected success」提醒你拔掉裝飾器 + - 文件化「這個 case 目前壞掉」這件事 + """ + self.assertEqual(2 + 2, 5) + + +# ---------- 14.4 為什麼要把測試結果寫檔 ---------- +def run_and_log(logfile="test_result.log"): + """ + 場景:CI 環境想保留每次測試的完整輸出,或者在無人監控的 + 背景任務裡跑測試。重點是 TextTestRunner 接受任何 file-like + 物件,不只有 stderr。 + """ + with open(logfile, "w", encoding="utf-8") as f: + runner = unittest.TextTestRunner(stream=f, verbosity=2) + suite = unittest.TestLoader().loadTestsFromTestCase(WhySkip) + runner.run(suite) + print(f"結果寫入 {logfile}") + + +# ---------- 14.11 為什麼 stacklevel=2 ---------- +def old_api_bad(x): + """stacklevel 預設 1 → warning 指向這一行;使用者不知道是誰呼叫的""" + warnings.warn("old_api_bad 已棄用", DeprecationWarning) + return x + + +def old_api_good(x): + """stacklevel=2 → warning 指向「呼叫端」,使用者一眼能定位到自己的程式""" + warnings.warn("old_api_good 已棄用", DeprecationWarning, stacklevel=2) + return x + + +def demo_stacklevel(): + """比較兩種寫法的警告位置(執行時觀察 file:line 訊息)""" + warnings.simplefilter("always") + print("--- stacklevel=1(差):行號指向函式內部 ---") + old_api_bad(1) + print("--- stacklevel=2(好):行號指向呼叫端 ---") + old_api_good(1) + + +# ---------- 14.11 warning 種類選擇 ---------- +def category_guide(): + """ + DeprecationWarning:給「開發者」看的(預設在 __main__ 才顯示) + UserWarning:給「使用者」看的(總是顯示) + RuntimeWarning:執行期奇怪但非錯誤的事(如 0 當除數的某些情況) + + 選錯類別的後果:開發者看不到棄用提醒,或一般使用者被技術細節嚇到。 + """ + warnings.warn("這是給開發者:API 即將移除", DeprecationWarning, stacklevel=2) + warnings.warn("這是給使用者:輸入值偏大,結果可能不準", UserWarning, stacklevel=2) + + +if __name__ == "__main__": + if "--log" in sys.argv: + run_and_log() + else: + demo_stacklevel() + print("\n--- 警告種類選擇 ---") + warnings.simplefilter("default") + category_guide() + print("\n--- 跑 WhySkip 測試 ---") + unittest.main(argv=[sys.argv[0]], exit=False) diff --git a/weeks/week-14/in_class/U02-debug-speedup-why.py b/weeks/week-14/in_class/U02-debug-speedup-why.py new file mode 100644 index 000000000..9ce2769c2 --- /dev/null +++ b/weeks/week-14/in_class/U02-debug-speedup-why.py @@ -0,0 +1,136 @@ +""" +U02:例外鏈接、除錯與加速的「為什麼」(理解層) + +對應 Cookbook: +- 14.9 捕獲例外後拋出另一個例外(raise ... from ...) +- 14.10 重新拋出被捕獲的例外(bare raise) +- 14.12 除錯基本崩潰錯誤 +- 14.14 加速程式運行 + +核心問題: +- `raise X from e` / `raise X` / bare `raise` 三者差在哪? +- 為什麼 `raise e` 比 bare `raise` 差? +- traceback.print_exc() vs print(e) 為什麼一定要用前者? +- 為什麼把 math.sqrt 提升為 local 變數會比較快? + +執行: + python U02-debug-speedup-why.py +""" +import math +import timeit +import traceback + + +# ---------- 14.9 / 14.10 三種拋法的差別 ---------- +class AppError(Exception): + pass + + +def low_level(): + raise ValueError("低階:值不對") + + +def variant_a(): + """raise X from e:明確標示「因為 e 所以 X」(推薦用於包裝底層錯誤)""" + try: + low_level() + except ValueError as e: + raise AppError("應用層失敗") from e + + +def variant_b(): + """raise X:context 隱式保留,traceback 會顯示『During handling...』""" + try: + low_level() + except ValueError: + raise AppError("應用層失敗") + + +def variant_c_good(): + """bare raise:保留原 traceback;想在中途記 log 又原封不動往上拋時用""" + try: + low_level() + except ValueError: + print(" [中途記 log]") + raise + + +def variant_c_bad(): + """`raise e`:traceback 從這一行重新開始,丟失「真正出事的位置」""" + try: + low_level() + except ValueError as e: + raise e + + +def demo_raise_styles(): + for name, fn in [("A: raise X from e", variant_a), + ("B: raise X (隱式)", variant_b), + ("C-good: bare raise", variant_c_good), + ("C-bad : raise e", variant_c_bad)]: + print(f"\n=== {name} ===") + try: + fn() + except Exception: + traceback.print_exc() + + +# ---------- 14.12 為什麼一定要 print_exc 而不是 print(e) ---------- +def demo_print_exc_vs_str(): + """ + print(e) 只給訊息,看不出在哪一行、呼叫鏈是什麼。 + print_exc / format_exc 才有完整 traceback——除錯成本差數十倍。 + """ + def will_crash(): + data = {"a": 1} + return data["missing"] + + try: + will_crash() + except Exception as e: + print("【壞示範】print(e):") + print(f" {e}") + print("【好示範】traceback.print_exc():") + traceback.print_exc() + + +# ---------- 14.14 local 變數為什麼比較快 ---------- +def slow_version(items): + """每次都要做 LOAD_GLOBAL(math)+ LOAD_ATTR(sqrt)""" + result = [] + for x in items: + result.append(math.sqrt(x)) + return result + + +def fast_version(items): + """ + sqrt = math.sqrt → LOAD_FAST,比 LOAD_GLOBAL 快 + list comprehension 比 append 少一次 method 查找與呼叫 + """ + sqrt = math.sqrt + return [sqrt(x) for x in items] + + +def demo_speedup(): + """ + 重點: + 1. 先 cProfile 找瓶頸再優化,不要憑感覺。 + 2. 微優化(local var、list comp)只在「熱迴圈」有用, + 一般程式可讀性比快幾 ms 重要。 + """ + data = list(range(1, 100_000)) + t1 = timeit.timeit(lambda: slow_version(data), number=10) + t2 = timeit.timeit(lambda: fast_version(data), number=10) + print(f"slow = {t1:.3f}s, fast = {t2:.3f}s, speedup = {t1/t2:.2f}x") + + +if __name__ == "__main__": + print("########## 14.9 / 14.10 三種拋法 ##########") + demo_raise_styles() + + print("\n########## 14.12 print_exc vs print(e) ##########") + demo_print_exc_vs_str() + + print("\n########## 14.14 local 變數加速 ##########") + demo_speedup()