ข้ามไปยังเนื้อหา

The AI bug catalog

pattern ทุกอันด้านล่างถูกเขียนให้เหมือน Python ที่ AI assistant ผลิตออกมาจริง ๆ แล้วรันบน python3.14 -X dev -W error::RuntimeWarning, เช็คด้วย ruff/mypy --strict/pyright/bandit ในจุดที่เกี่ยวข้อง และคัดลอกมาตรง ๆ จากการรันจริง — ไม่ได้เขียนจากความจำ แต่ละหัวข้อจบด้วย Caught by ระบุเครื่องมือตัวเดียวที่จับมันได้จริง หรือบอกตรง ๆ ว่าไม่มีเครื่องมือไหนจับได้เลย

24 pattern ถูกจัดกลุ่มแบบเดียวกับ Review Checklist ที่ตามมาหลังบทนี้: mutability, errors, async/concurrency, typing, resources & security และ tooling/style

def add_item(item: str, cart: list[str] = []) -> list[str]:
cart.append(item)
return cart
first = add_item("apple")
second = add_item("banana")
print(first)
print(second is first)

pattern แรกนี้คือตัวที่ควรลองรันเองก่อนอ่านต่อ — playground ด้านบนพิมพ์ ['apple', 'banana'] สำหรับ first ไม่ใช่ ['apple'] และพิมพ์ True สำหรับ identity check ทุกอย่างด้านล่างเป็นไปตามกฎเดียวกัน: โค้ดจริง output จริง ไม่มีข้อยกเว้น

cart: list[str] = [] ถูก evaluate ครั้งเดียวเป๊ะ ตอนที่ add_item ถูก define — ไม่ใช่ครั้งละ call ทุก call ที่ไม่ได้ส่ง cart ของตัวเองมาจะแชร์ list object เดียวกันนั้นตลอดไป

อาการ — output จริง (ตรงกับ playground ด้านบน):

['apple', 'banana']
True

second = add_item("banana") ควรจะเริ่ม cart ใหม่ แต่กลับไป append ต่อบน cart ของ call แรก และ second is first พิสูจน์ว่ามันเป็น object เดียวกัน

ทางแก้: ใช้ default เป็น None แล้วสร้าง list ข้างใน function body:

def add_item(item: str, cart: list[str] | None = None) -> list[str]:
if cart is None:
cart = []
cart.append(item)
return cart

Caught by: ruff’s B006 ตรง ๆ ไม่ต้องตั้งค่าอะไรเพิ่ม:

B006 Do not use mutable data structures for argument defaults
help: Replace with `None`; initialize within function

ดู Functions

🐛 หา bug ในโค้ดนี้

def log_event(event: str, history: list[str] = []) -> list[str]:
history.append(event)
return history
first_run = log_event("startup")
second_run = log_event("startup")
เฉลย

bug เดียวกัน ชื่อต่างกัน second_run ไม่ใช่ log ใหม่ — มันคือ list เดียวกัน กับ first_run ตอนนี้มี ["startup", "startup"] เพราะ history = [] ถูก evaluate ครั้งเดียวเป๊ะ ตอน function-definition time ทางแก้: history: list[str] | None = None แล้ว if history is None: history = [] ข้างใน body

def make_multipliers() -> list:
return [lambda x: x * i for i in range(3)]
fns = make_multipliers()
print([f(10) for f in fns])

แต่ละ lambda close over ตัวแปร i ไม่ใช่ค่าที่มันมีตอน lambda ถูกสร้าง พอถึงเวลาเรียก lambda ตัวไหนก็ตาม comprehension ทำงานเสร็จไปแล้ว และ i มีค่าสุดท้าย คือ 2 — ทุก lambda เห็น i ตัวสุดท้ายเดียวกัน

อาการ — output จริง:

[20, 20, 20]

ไม่ใช่ [0, 10, 20] ซึ่งเป็นสิ่งที่ผู้อ่านที่อ่านผ่าน ๆ “แต่ละ lambda คูณด้วย i ของตัวเอง” จะคาดหวัง

ทางแก้: bind ค่าปัจจุบันเป็น default argument ซึ่ง ถูก evaluate ครั้งเดียวตอนสร้าง lambda แต่ละตัว:

def make_multipliers() -> list:
return [lambda x, i=i: x * i for i in range(3)]

Caught by: ruff’s B023 ตรง ๆ:

B023 Function definition does not bind loop variable `i`

ดู Closures

def normalize(scores: list[int]) -> list[int]:
scores.sort()
for i in range(len(scores)):
scores[i] = max(scores[i], 0)
return scores
original = [3, -1, 2]
result = normalize(original)
print("caller's list after normalize:", original)
print("result is original:", result is original)

normalize ดูเหมือน pure function — รับ list แล้วคืน list — แต่ scores.sort() และ scores[i] = ... ทั้งคู่ mutate list object เอง original ของผู้เรียกเปลี่ยนไปทั้งที่ไม่มีอะไรตรงจุดที่เรียก (normalize(original)) บ่งบอกแบบนั้นเลย

อาการ — output จริง:

caller's list after normalize: [0, 2, 3]
result is original: True

ผู้เรียกน่าจะคาดหวังว่า original จะยังเป็น [3, -1, 2] และ result เป็น copy ที่ normalize แล้ว

ทางแก้: copy ก่อน mutate ถ้า function ตั้งใจให้ non-destructive: scores = list(scores) เป็นบรรทัดแรก หรือใช้ sorted(scores) แทน scores.sort()

Caught by: ไม่มี นี่เป็นคำถามเชิง semantic/API-contract ล้วน ๆ — ไม่มี linter ตัวไหนนับว่า docstring หรือชื่อ function สัญญาไว้ว่าจะไม่ mutate argument ของมัน ดู Lists & Tuples

grid = [[0] * 3] * 3
grid[0][0] = 1
print(grid)

[[0] * 3] * 3 สร้าง inner list หนึ่งตัว คือ [0, 0, 0] แล้วทำซ้ำ reference ไปยัง list ตัวเดียวกันนั้นสามครั้ง — ไม่ได้สร้างสามแถวที่เป็นอิสระต่อกัน

อาการ — output จริง:

[[1, 0, 0], [1, 0, 0], [1, 0, 0]]

การเขียนไปที่ grid[0][0] เปลี่ยนทุกแถว เพราะทั้งสามแถวเป็น object เดียวกัน

ทางแก้: สร้างแต่ละแถวแยกกันด้วย comprehension เพื่อให้ [0] * 3 แต่ละครั้งสร้าง list ใหม่:

grid = [[0] * 3 for _ in range(3)]

Caught by: ไม่มี — ทั้งเวอร์ชันที่ผิดและที่แก้แล้วเป็น Python ปกติที่ valid ทั้งคู่ นี่เป็นกับดักเชิง semantic ล้วน ๆ ในวิธีที่ * บน list ทำซ้ำ reference ไม่ใช่ค่า ดู Lists & Tuples

a = "hello"
b = "".join(["h", "e", "l", "l", "o"])
print(a == b)
print(a is b)
x = 1000
y = int("1000")
print(x == y)
print(x is y)

is เช็ค object identity ไม่ใช่ equality CPython intern integer เล็ก ๆ บางตัว (-5 ถึง 256) และ string literal บางตัวที่ compile พร้อมกัน ซึ่งทำให้ is ดูเหมือน “ใช้ได้” สำหรับค่าในช่วงนั้น — แต่ b ถูกสร้างตอน runtime ผ่าน .join() และ y ผ่าน int(str) ดังนั้นทั้งคู่ไม่ใช่ object เดียวกันกับคู่ที่เทียบเท่ากันด้วย ==

อาการ — output จริง:

True
False
True
False

ทั้งสองคู่เท่ากัน (== เป็น True) แต่ไม่มีคู่ไหนเป็น object เดียวกัน (is เป็น False) — is ให้คำตอบผิดสำหรับ “ค่าเหล่านี้เหมือนกันไหม” ทันทีที่ operand ตัวใดตัวหนึ่งไม่ใช่ integer เล็ก ๆ ที่ถูก cache หรือ literal ที่ถูก deduplicate ตอน compile

ทางแก้: ใช้ == สำหรับเทียบค่า สงวน is ไว้สำหรับเช็ค singleton (is None, is True, is False) ที่ identity คือ contract จริง ๆ

Caught by: ruff’s F632 แต่จับได้เฉพาะรูปแบบ literal ตรง ๆ (x is 5, s is "foo"):

F632 Use `==` to compare constant literals

รูปแบบตัวแปรเทียบกับตัวแปรที่แสดงข้างบน — ตัวที่กัด demo นี้จริง ๆ — ไม่ถูกจับโดย F632 หรือเครื่องมือไหนใน list ของคอร์สนี้เลย ความบังเอิญเรื่อง object identity ต้องจับด้วยตาเท่านั้น ดู Variables & Types

6. except ที่กว้างเกินไปกลืนสิ่งที่ผิดแบบเงียบ ๆ

หัวข้อที่มีชื่อว่า “6. except ที่กว้างเกินไปกลืนสิ่งที่ผิดแบบเงียบ ๆ”
def risky_lookup(data: dict, key: str) -> int:
try:
return data[key]
except:
pass
return -1
print(risky_lookup({}, "x"))
print(risky_lookup(None, "x"))

อาการ — output จริง:

-1
-1

key ที่ไม่มีอยู่คืน -1 ถูกต้อง แต่ risky_lookup(None, "x") raise TypeError: 'NoneType' object is not subscriptable — เป็น bug จริง (argument type ผิด) ไม่ใช่ key ที่หาย — และ bare except: กลืนมันแบบเดียวกันเป๊ะ คืน -1 ราวกับไม่มีอะไรเกิดขึ้น

เวอร์ชัน async ของ bug นี้อันตรายกว่า: การจับกว้างเกินไปคาบเกี่ยว await สามารถกลืน cancellation ของ task เองได้

async def worker(n: int) -> None:
try:
await asyncio.sleep(n)
print(f"worker {n} finished normally")
except BaseException as e:
print(f"worker swallowed {type(e).__name__}: {e!r} -- keeps running")
async def main() -> None:
task = asyncio.create_task(worker(5))
await asyncio.sleep(0.05)
task.cancel()
await task
print("main: task.cancelled() =", task.cancelled())

อาการ — output จริง:

worker swallowed CancelledError: CancelledError() -- keeps running
main: task.cancelled() = False

task.cancel() ควรจะทำให้ task หยุด แต่ except BaseException กลับจับ asyncio.CancelledError ที่ raise ข้างใน await asyncio.sleep(n) พิมพ์ข้อความ แล้ว return ตามปกติ — task.cancelled() เป็น False แปลว่า cancellation request ถูกกลืนไปแบบเงียบ ๆ และ task รายงานตัวเองว่าจบตามปกติ

ทางแก้: ระบุ exception เฉพาะเจาะจงที่คาดไว้ (except KeyError: สำหรับ sync case) ใน async code ห้ามจับ BaseException (หรือ bare except:) คาบเกี่ยว await เว้นแต่จะ re-raise asyncio.CancelledError ทันที การจับแค่ Exception ปลอดภัยกว่าเป็น default เพราะ CancelledError สืบทอดจาก BaseException ไม่ใช่ Exception โดยตั้งใจ เพื่อไม่ให้ error handler ทั่วไปดักมันไป

Caught by: ruff’s E722 สำหรับ bare except: และ S110 สำหรับรูปแบบ try/except/pass:

E722 Do not use bare `except`
S110 `try`-`except`-`pass` detected, consider logging the exception

สำหรับรูปแบบ async except BaseException ruff’s BLE001 (blind-except) จับได้ตรง ๆ:

BLE001 Do not catch blind exception: `BaseException`

BLE001 ไม่ ทำงานกับรูปแบบ bare except: / except: pass ข้างบน — อันนั้นถูกจับโดย E722/S110 เท่านั้น ดู Exceptions

7. raise e แทนที่จะเป็น bare raise เพิ่ม frame ที่ไม่จำเป็น — และโค้ด AI เขียนแบบนี้บ่อยมาก

หัวข้อที่มีชื่อว่า “7. raise e แทนที่จะเป็น bare raise เพิ่ม frame ที่ไม่จำเป็น — และโค้ด AI เขียนแบบนี้บ่อยมาก”
def a():
b()
def b():
raise ValueError("deep failure")
def reraise_bare():
try:
a()
except ValueError:
raise
def reraise_e():
try:
a()
except ValueError as e:
raise e

อาการ — output จริง นับ traceback frame ด้วย traceback.extract_tb:

bare raise frames: 4
raise e frames: 5

ทั้งคู่ re-raise “exception เดียวกัน” แต่ raise e เพิ่ม frame มาอีกหนึ่ง — บรรทัด raise e เอง — เข้าไปใน traceback บน call stack ที่ลึก มันทำให้ traceback ยาวขึ้นและอ่านสับสนขึ้นเล็กน้อย โดยไม่ได้ประโยชน์อะไรเลย

ทางแก้: ใช้ bare raise เพื่อ re-raise exception แบบไม่เปลี่ยนแปลง

Caught by: ruff’s TRY201 ตรง ๆ:

TRY201 Use `raise` without specifying exception name
help: Remove exception name

ดู Exceptions

def withdraw(balance: int, amount: int) -> int:
assert amount <= balance, "insufficient funds"
return balance - amount
print(withdraw(100, 30))
print(withdraw(100, 999))

อาการ — output จริง รันปกติ:

70
Traceback (most recent call last):
...
AssertionError: insufficient funds

อาการ — output จริง python3.14 -O:

70
-899

ภายใต้ -O statement assert ถูกตัดออกจาก bytecode ที่ compile แล้วทั้งหมด — เงื่อนไขไม่ถูก evaluate เลย และการถอนเงินเกินยอดจะให้เลขติดลบแบบเงียบ ๆ แทนที่จะ fail

ทางแก้: ใช้ if/raise สำหรับอะไรก็ตามที่ต้องถูกเช็คเสมอ:

def withdraw(balance: int, amount: int) -> int:
if amount > balance:
raise ValueError("insufficient funds")
return balance - amount

Caught by: ruff’s S101 (และ bandit’s B101) ตรง ๆ:

S101 Use of `assert` detected

ดู Exceptions

async def greet(name: str) -> None:
await asyncio.sleep(0.01)
print(f"hello, {name}")
async def main() -> None:
greet("Ada") # missing await -- creates a coroutine object, never runs it
await asyncio.sleep(0.02)
print("main done")

การเรียก greet("Ada") โดยไม่มี await ไม่รัน body ของ function เลย — มันสร้าง coroutine object แล้วทิ้งมันทันที "hello, Ada" ไม่เคยพิมพ์เลย

อาการ — output จริง (จับด้วยการครอบการเรียกด้วย warnings.catch_warnings(record=True) เพื่อให้ demo ยังรันได้ภายใต้ verifier ของคอร์สนี้ ที่เปลี่ยน RuntimeWarning ให้เป็น error):

caught: RuntimeWarning: coroutine 'greet' was never awaited
main done

มีแค่ "main done" ที่พิมพ์ — greeting ไม่เคยเกิดขึ้นแบบเงียบ ๆ ถ้าไม่ครอบด้วย catch_warnings Python ยังรายงานสิ่งนี้ทันทีที่ coroutine object ที่ถูกทิ้งไว้ถูก garbage-collect:

Exception ignored while finalizing coroutine <coroutine object greet at 0x...>:
RuntimeWarning: coroutine 'greet' was never awaited

ทางแก้: เพิ่ม await greet("Ada") ที่ขาดไป หรือถ้าการเรียกนั้นตั้งใจให้รันเบื้องหลังจริง ๆ ใช้ asyncio.create_task(greet("Ada")) แล้วเก็บ reference ของ task ไว้ (ดู pattern 10)

Caught by: ไม่มีอะไรแบบ static — ไม่มี linter ตัวไหนพิสูจน์ได้โดยทั่วไปว่าการเรียกที่คืน coroutine ขาด await มีแค่การรันโค้ดจริง (python3.14 -X dev) เท่านั้นที่เผย RuntimeWarning และเผยได้ก็ต่อเมื่อ coroutine ที่ถูกทิ้งไว้ถูก garbage-collect ระหว่างการรันจริง ๆ ดู asyncio basics

async def send_metric(name: str) -> None:
await asyncio.sleep(0.2)
print(f"metric sent: {name}") # this line may never run
async def handle_request(n: int) -> str:
asyncio.create_task(send_metric(f"request-{n}")) # no reference kept anywhere
return f"response-{n}"
async def main() -> None:
results = [await handle_request(i) for i in range(3)]
print(results)
# main returns immediately -- nothing is waiting for the background tasks

อาการ — output จริง:

['response-0', 'response-1', 'response-2']

ไม่มีบรรทัด "metric sent: ..." ทั้งสามบรรทัดพิมพ์ออกมาเลย asyncio.run(main()) ปิด event loop ทันทีที่ main return ยกเลิกทุก task ที่ไม่มีใคร await อยู่ — แบบเงียบ ๆ ไม่มี error ไม่มี warning ในการรันนี้

ทางแก้: เก็บ reference ของทุก background task ไว้ใน set ที่เป็นของสิ่งที่มี lifetime ยาวกว่า function ที่สร้างมัน แล้วเอาออกเมื่อเสร็จ — pattern ที่ official docs ของ asyncio แนะนำ:

background_tasks: set[asyncio.Task] = set()
def spawn(coro) -> None:
task = asyncio.create_task(coro)
background_tasks.add(task)
task.add_done_callback(background_tasks.discard)

Caught by: ruff’s RUF006 ตรง ๆ:

RUF006 Store a reference to the return value of `asyncio.create_task`

ดู Tasks & gather

async def slow_task(n: int) -> None:
print(f"task {n} start")
time.sleep(0.3) # blocks the WHOLE event loop, not just this task
print(f"task {n} done")
async def main() -> None:
start = time.perf_counter()
await asyncio.gather(slow_task(1), slow_task(2), slow_task(3))
elapsed = time.perf_counter() - start
print(f"elapsed: {elapsed:.2f}s (should be ~0.3s if truly concurrent)")

time.sleep เป็น blocking synchronous call — มันไม่คืน control กลับให้ event loop แบบที่ await asyncio.sleep(...) ทำ ระหว่างที่ task หนึ่งกำลัง sleep ไม่มีอะไรอื่นในกระบวนการทำงานได้เลย รวมถึงอีกสอง task ที่ “concurrent” ด้วย

อาการ — output จริง:

task 1 start
task 1 done
task 2 start
task 2 done
task 3 start
task 3 done
elapsed: 0.93s (should be ~0.3s if truly concurrent)

ทั้งสาม task รันเรียงกันทีละตัว ใช้เวลารวมประมาณ 3 เท่าของที่ควรจะเป็นถ้ามันซ้อนกันจริง ๆ — asyncio.gather ดูเหมือน concurrent ใน source แต่ถูก serialize เต็มรูปแบบตอน runtime การรันด้วย -X dev (ซึ่งเปิด asyncio debug mode ด้วย) ยัง log slow-callback warning สำหรับแต่ละ task ที่ถูก block:

Executing <Task finished name='Task-2' coro=<slow_task() done, ...> took 0.311 seconds

ทางแก้: ใช้ await asyncio.sleep(...) สำหรับ delay หรือ await loop.run_in_executor(None, blocking_fn) สำหรับ blocking call ที่หลีกเลี่ยงไม่ได้จริง ๆ

Caught by: ruff’s ASYNC251 (จาก flake8-async rule set) ตรง ๆ:

ASYNC251 Async functions should not call `time.sleep`

asyncio debug mode ของ python -X dev ยัง log slow callback ตอน runtime เพิ่มเติมตามที่แสดงข้างบน ดู GIL & threads

class Account:
def __init__(self, balance: int) -> None:
self.balance = balance
async def withdraw(self, amount: int) -> bool:
if amount > self.balance:
return False
await asyncio.sleep(0) # simulates an awaited I/O call mid-operation
self.balance -= amount # another task can run between the check and this line
return True
async def main() -> None:
acct = Account(balance=100)
results = await asyncio.gather(
acct.withdraw(80),
acct.withdraw(80),
)
print("withdraw results:", results)
print("final balance:", acct.balance)

การเรียก withdraw ทั้งสองครั้งเช็ค amount > self.balance (80 > 100 เป็น False ทั้งคู่) ก่อน ที่ทั้งคู่จะไปถึง await — event loop สามารถสลับระหว่างทั้งสองที่จุด await นั้นได้ และทั้งคู่ก็ดำเนินการลบ 80 ต่อ ทั้งที่ account มี 100 เท่านั้น

อาการ — output จริง:

withdraw results: [True, True]
final balance: -60

การถอนทั้งสองครั้งรายงานว่าสำเร็จ และ balance ติดลบ — race แบบ time-of-check-to-time-of-use แบบคลาสสิก เกิดขึ้นได้จาก await ใด ๆ ระหว่างการเช็คกับการ mutate ไม่ใช่จาก OS-thread parallelism จริง

ทางแก้: ครอบทั้งลำดับ check-then-mutate ด้วย asyncio.Lock:

def __init__(self, balance: int) -> None:
self.balance = balance
self._lock = asyncio.Lock()
async def withdraw(self, amount: int) -> bool:
async with self._lock:
if amount > self.balance:
return False
await asyncio.sleep(0)
self.balance -= amount
return True

Caught by: ไม่มี นี่คือ race condition จริง — การเกิดขึ้นของมันขึ้นอยู่กับลำดับการสลับที่ event loop เลือกใช้จริง ๆ และไม่มีเครื่องมือ static ตัวไหนพิสูจน์หรือหักล้างมันได้ ดู Tasks & gather

from multiprocessing import Pool
def square(n: int) -> int:
return n * n
pool = Pool(processes=4) # module-level -- no __main__ guard
print(pool.map(square, [1, 2, 3, 4]))

บน macOS และ Windows start method default คือ spawn (Linux ตั้งแต่ 3.14 default เป็น forkserver นอก main interpreter — ดู multiprocessing) worker ของ spawn/forkserver re-import module ทั้งหมด ที่เริ่มมัน ถ้า Pool(...) รันระดับ module โดยไม่มี guard ทุกการ re-import จะสร้าง Pool อีกตัว ซึ่ง spawn worker เพิ่ม ซึ่ง re-import module อีกครั้ง — การสร้าง process แบบวนซ้ำไม่มีที่สิ้นสุด

อาการ — output จริง (การรันนี้ถูก kill หลังจาก 5 วินาที มันไม่หยุดเองและต้อง kill ด้วยมือ — ห้ามรันแบบไม่มี guard บนเครื่องของคุณเอง):

Traceback (most recent call last):
Traceback (most recent call last):
Traceback (most recent call last):
File "<string>", line 1, in <module>
from multiprocessing.spawn import spawn_main; spawn_main(tracker_fd=7, pipe_handle=18)
File ".../multiprocessing/spawn.py", line 122, in spawn_main
exitcode = _main(fd, parent_sentinel)
...

28,569 บรรทัดของ traceback แบบวนซ้ำถูกจับไว้ในห้าวินาทีก่อนที่ process tree จะถูก force-kill — แต่ละ worker spawn worker เพิ่ม แต่ละตัว re-import module แล้วเจอ Pool(...) อีกครั้ง

ทางแก้: guard entry point และใช้ context-manager form เพื่อให้ pool ถูกปิดเสมอ:

if __name__ == "__main__":
with Pool(processes=4) as pool:
print(pool.map(square, [1, 2, 3, 4]))

output จริง เวอร์ชันที่แก้แล้ว:

[1, 4, 9, 16]

Caught by: ไม่มีอะไรแบบ static — ไม่มี linter ตัวไหนนับว่าโค้ดระดับ module ที่ spawn process ถูก guard หรือเปล่า ความล้มเหลวเป็นคุณสมบัติเชิง runtime ล้วน ๆ ของ start method ที่เลือกใช้ ดู multiprocessing

14. __eq__ ถูกนิยามโดยไม่มี __hash__ ทำให้ instance กลายเป็น unhashable

หัวข้อที่มีชื่อว่า “14. __eq__ ถูกนิยามโดยไม่มี __hash__ ทำให้ instance กลายเป็น unhashable”
class Point:
def __init__(self, x: int, y: int) -> None:
self.x = x
self.y = y
def __eq__(self, other: object) -> bool:
if not isinstance(other, Point):
return NotImplemented
return self.x == other.x and self.y == other.y
p = Point(1, 2)
print(p == Point(1, 2))
print({p})

การนิยาม __eq__ บน class ทำให้ Python ตั้ง __hash__ เป็น None อัตโนมัติ เว้นแต่คุณจะนิยาม __hash__ เองด้วย — สอง object ที่เทียบเท่ากันต้อง hash เท่ากัน และ Python ไม่สามารถรับประกันสิ่งนั้นให้ class ที่มันไม่ได้ควบคุมการ hash ของ

อาการ — output จริง:

True
Traceback (most recent call last):
...
TypeError: cannot use 'Point' as a set element (unhashable type: 'Point')

p == Point(1, 2) ทำงานได้ปกติ การใส่ Point ลงใน set (หรือใช้เป็น dict key) พังทันที

ทางแก้: นิยาม __hash__ คู่กับ __eq__ ถ้า instance จำเป็นต้อง hashable (และ immutable โดยพฤตินัย) หรือยอมรับว่า class นี้ตั้งใจให้ unhashable และเอกสารไว้:

def __hash__(self) -> int:
return hash((self.x, self.y))

Caught by: ruff’s PLW1641 ตรง ๆ:

PLW1641 Object does not implement `__hash__` method

ดู Dunder methods

class ShoppingCart:
items: list[str] = [] # class attribute, shared across every instance
def add(self, item: str) -> None:
self.items.append(item)
cart_a = ShoppingCart()
cart_b = ShoppingCart()
cart_a.add("apple")
cart_b.add("banana")
print("cart_a.items:", cart_a.items)
print("cart_b.items:", cart_b.items)
print("same list object:", cart_a.items is cart_b.items)

items: list[str] = [] ที่ประกาศระดับ class เป็น list เพียงตัวเดียวเป๊ะ ที่ class เป็นเจ้าของ — self.items.append(...) หา instance attribute ชื่อ items ไม่เจอ เลย mutate class attribute ที่แชร์กันแทน นี่คือ sibling ของ mutable default argument ใน pattern 1 แต่อยู่ใน class body

อาการ — output จริง:

cart_a.items: ['apple', 'banana']
cart_b.items: ['apple', 'banana']
same list object: True

สอง cart ที่สร้างแยกกันโดยอิสระแชร์ list เดียวกัน — การเพิ่มลงในตัวไหนก็ตามส่งผลกับทั้งคู่

ทางแก้: initialize mutable state ต่อ instance ใน __init__ (หรือ dataclass field(default_factory=list)):

def __init__(self) -> None:
self.items: list[str] = []

Caught by: ruff’s RUF012 ตรง ๆ:

RUF012 Mutable default value for class attribute
help: Consider initializing in `__init__` or annotating with `typing.ClassVar`

ดู Classes

16. Any บวกกับ # type: ignore แบบเหมารวมปิดการเช็คทั้งหมด

หัวข้อที่มีชื่อว่า “16. Any บวกกับ # type: ignore แบบเหมารวมปิดการเช็คทั้งหมด”
from typing import Any
def process_config(config: Any) -> int:
return config.retries + 1 # type: ignore
print(process_config({"retries": "3"}))

Any บอกทุก checker ให้หยุดตรวจสอบอะไรก็ตามเกี่ยวกับ config — attribute access, arithmetic ทุกอย่าง # type: ignore ด้านบนปิดเสียงสิ่งที่ควรจะถูก flag รวมกันแล้วไม่มีอะไรถูกเช็คเลย และ crash โผล่มาแค่ตอน runtime เท่านั้น

อาการ — output จริง:

Traceback (most recent call last):
...
AttributeError: 'dict' object has no attribute 'retries'

mypy —strict output — จริง ไม่ได้แก้ไข:

Success: no issues found in 1 source file

pyright output — จริง ไม่ได้แก้ไข:

0 errors, 0 warnings, 0 informations

ทั้งสอง checker รันในโหมดที่เข้มงวดที่สุด รายงานว่าไฟล์นี้สะอาดหมดจด — Any และ ignore แบบเหมารวมทำให้ AttributeError ข้างบนมองไม่เห็นสำหรับ static analysis เลย

ทางแก้: ให้ config มี type จริง (TypedDict, dataclass หรือ Pydantic model) แทน Any และเอา ignore แบบเหมารวมออก ถ้าบรรทัดไหนต้องการ ignore จริง ๆ ให้ระบุ error code ที่มันปิดเสียงให้ชัดเจน

Caught by: ไม่มีอะไรที่เช็ค type — mypy --strict และ pyright เงียบทั้งคู่ ซึ่งเป็นประเด็นของหัวข้อนี้ ruff’s ANN401 flag parameter Any เอง และ PGH003 flag ignore comment ที่ไม่มี code:

ANN401 Dynamically typed expressions (typing.Any) are disallowed in `config`
PGH003 Use specific rule codes when ignoring type issues

ดู Type hints

def greeting(name: str | None) -> str:
return "Hello, " + name.upper()
print(greeting("ada"))
print(greeting(None))

parameter ถูก type ถูกต้องเป็น str | None แต่ body ไม่เคยเช็ค None ก่อนเรียก .upper() — type hint บันทึกความเป็นไปได้ไว้แต่โค้ดไม่ได้จัดการมันจริง

อาการ — output จริง:

Hello, ADA
Traceback (most recent call last):
...
AttributeError: 'NoneType' object has no attribute 'upper'

mypy —strict output — จริง:

error: Item "None" of "str | None" has no attribute "upper" [union-attr]
Found 1 error in 1 file (checked 1 source file)

pyright output — จริง:

error: "upper" is not a known attribute of "None" (reportOptionalMemberAccess)
1 error, 0 warnings, 0 informations

checker ทั้งสองเห็นตรงกันในกรณีนี้ และทั้งคู่จับได้ ก่อน ที่โค้ดจะถูกรันเลยด้วยซ้ำ

ทางแก้: narrow ก่อนใช้งาน:

def greeting(name: str | None) -> str:
if name is None:
return "Hello, stranger"
return "Hello, " + name.upper()

Caught by: ทั้ง mypy --strict (union-attr) และ pyright (reportOptionalMemberAccess) ตรง ๆ ไม่ต้องตั้งค่าอะไรเพิ่ม — pattern นี้ไม่มีข้ออ้างที่ชอบธรรมให้ผ่านเข้ามาถึงขั้น code review ดู Type hints

class Animal:
def speak(self) -> str:
return "..."
class Dog(Animal):
def speak(self) -> str:
return "Woof"
class Cat(Animal):
def speak(self) -> str:
return "Meow"
def add_a_cat(animals: list[Animal]) -> None:
animals.append(Cat()) # perfectly legal for list[Animal]
dogs: list[Dog] = [Dog(), Dog()]
add_a_cat(dogs)
for d in dogs:
print(d.speak())

Dog เป็น Animal แต่ list[Dog] ไม่ใช่ list[Animal] ในสายตาของ type system — list เป็น invariant ใน element type ของมัน เพราะ add_a_cat ได้รับอนุญาตให้ append Animal อะไรก็ได้ (รวมถึง Cat) เข้าไปใน list ที่มันได้รับมา ถ้า call นี้ถูกอนุญาต dogs จะจบลงด้วยการมี Cat อยู่ข้างใน ทำลาย assumption ในอนาคตทุกอันที่ว่าทุก element ของ dogs เป็น Dog แบบเงียบ ๆ

อาการ — output จริง (interpreter เองไม่ได้หยุดสิ่งนี้ type checker ต่างหากที่หยุด ก่อนที่มันจะรันเลยด้วยซ้ำ):

Woof
Woof
Meow

ไม่มีอะไร crash ตอน runtime — นั่นคืออันตรายพอดี: list ที่เสียหายทำงานได้ปกติจนกว่าจะมีอะไรเรียก method ที่เป็นของ Dog เท่านั้นบนสิ่งที่กลายเป็น Cat

mypy —strict output — จริง:

error: Argument 1 to "add_a_cat" has incompatible type "list[Dog]"; expected "list[Animal]" [arg-type]
note: "list" is invariant -- see https://mypy.readthedocs.io/en/stable/common_issues.html#variance
note: Consider using "Sequence" instead, which is covariant

pyright output — จริง:

error: Argument of type "list[Dog]" cannot be assigned to parameter "animals" of type "list[Animal]" in function "add_a_cat"
"list[Dog]" is not assignable to "list[Animal]"
Type parameter "_T@list" is invariant, but "Dog" is not the same as "Animal"
Consider switching from "list" to "Sequence" which is covariant (reportArgumentType)

checker ทั้งสองเห็นตรงกัน และแนะนำทางแก้เดียวกัน

ทางแก้: ถ้า add_a_cat แค่ต้อง อ่าน list ให้รับ Sequence[Animal] (covariant) แทน list[Animal] ถ้ามันต้องได้รับอนุญาตให้ mutate list ของ Animal แบบผสมจริง ๆ ผู้เรียกก็ไม่ควรส่ง list[Dog] ที่เจาะจงกว่าเข้ามาตั้งแต่แรก

Caught by: ทั้ง mypy --strict และ pyright ตรง ๆ ไม่ต้องตั้งค่าอะไรเพิ่ม ดู Type hints

from datetime import datetime, timezone
def is_expired(expires_at: datetime) -> bool:
return datetime.utcnow() > expires_at # naive datetime -- no tzinfo
aware_deadline = datetime.now(timezone.utc)
print(is_expired(aware_deadline))

datetime.utcnow() คืนเวลา wall-clock UTC แต่ ไม่มี timezone ผูกอยู่ — มัน “เป็น” UTC แค่ตามธรรมเนียม ไม่ใช่ตาม type การเทียบมันกับ aware datetime (ที่มี tzinfo แบบ datetime.now(timezone.utc)) ไม่ใช่แค่ผิด แต่เป็น TypeError แข็ง ๆ เลย

อาการ — output จริง:

.../19_naive_datetime.py:5: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC).
Traceback (most recent call last):
...
TypeError: can't compare offset-naive and offset-aware datetimes

utcnow() ยังถูก deprecate อย่างเป็นทางการตั้งแต่ Python 3.12 โดย interpreter เองชี้ทางแก้ให้เลย

ทางแก้: ใช้ datetime.now(timezone.utc) (หรือ datetime.now(UTC) ตั้งแต่ 3.11) ทุกที่ที่ต้องการ UTC timestamp:

def is_expired(expires_at: datetime) -> bool:
return datetime.now(timezone.utc) > expires_at

Caught by: ruff’s DTZ003 ตรง ๆ:

DTZ003 `datetime.datetime.utcnow()` used
help: Use `datetime.datetime.now(tz=...)` instead

ดู stdlib tour

def read_report(path: str) -> str:
f = open(path) # no `with`, no encoding= -- leaks the fd, platform-dependent decoding
data = f.read()
f.close()
return data

สองปัญหาแยกกันในบรรทัดเดียว: ไม่มี with แปลว่า file descriptor ยังเปิดค้างอยู่ถ้า f.read() raise ก่อนที่ f.close() จะรัน ไม่มี encoding= แปลว่า text ถูก decode ด้วย locale encoding default ของ platform ซึ่งเป็น UTF-8 บน Linux/macOS ส่วนใหญ่ทุกวันนี้ แต่ ไม่รับประกัน — โค้ดเดียวกันเป๊ะสามารถ decode ต่างกันบนเครื่องอื่น

อาการ — output จริง (บนเครื่อง Unix นี้ ด้วย UTF-8 locale สิ่งนี้ round-trip สะอาดพอดี — อันตรายจริงมองเห็นได้เฉพาะข้าม platform เท่านั้น ซึ่ง environment นี้ไม่สามารถจำลองได้อย่างซื่อสัตย์):

'héllo\n'

ทางแก้: ใช้ with เสมอ และส่ง encoding= อย่างชัดเจนเสมอ:

def read_report(path: str) -> str:
with open(path, encoding="utf-8") as f:
return f.read()

Caught by: ruff’s SIM115 สำหรับ context manager ที่ขาดหายไป ตรง ๆ:

SIM115 Use a context manager for opening files

การเช็ค encoding ที่ขาดหายไป (PLW1514) มีอยู่ใน ruff แต่เป็น rule แบบ preview ไม่เปิดใช้งาน default:

warning: Selection `PLW1514` has no effect because preview is not enabled.

รัน ruff check --preview เพื่อรวมมันด้วย ดู stdlib tour

import subprocess
def list_files(directory: str) -> str:
result = subprocess.run(f"ls {directory}", shell=True, capture_output=True, text=True)
return result.stdout
print(list_files(". ; echo INJECTED"))

shell=True รัน string ทั้งหมดผ่าน /bin/sh ซึ่งตีความ ;, &&, backtick และ $() — attacker (หรือแค่ input ที่ไม่เรียบร้อย) สามารถต่อคำสั่งอื่นเข้าไปได้

อาการ — output จริง: directory listing ตามด้วยบรรทัดที่ไม่เคยเป็นส่วนหนึ่งของชื่อไฟล์ใดเลย:

... (directory listing) ...
INJECTED

; echo INJECTED ใน argument รันเป็นคำสั่ง shell ที่สองที่แยกออกมาต่างหากโดยสมบูรณ์

ทางแก้: ส่ง list ของ argument และห้ามตั้ง shell=True เลยสำหรับอะไรก็ตามที่สร้างจาก external input:

def list_files(directory: str) -> str:
result = subprocess.run(["ls", directory], capture_output=True, text=True)
return result.stdout

Caught by: ruff’s S602 (และ bandit’s B602 บวก B404 สำหรับการ import subprocess เลย) ตรง ๆ:

S602 `subprocess` call with `shell=True` identified, security issue

ดู stdlib tour

weather_from_import.py
from requests import get
def get_temperature(city: str) -> float:
response = get(f"https://api.example.com/weather/{city}")
return response.json()["temp"]
test_weather_from_import_wrong.py
from unittest.mock import patch
import weather_from_import
@patch("requests.get") # WRONG: weather_from_import already bound its own name `get`
def test_get_temperature(mock_get):
mock_get.return_value.json.return_value = {"temp": 72.0}
result = weather_from_import.get_temperature("bangkok") # calls the REAL requests.get
assert result == 72.0

unittest.mock.patch("requests.get") แทนที่ attribute get บน module requests แต่ weather_from_import.py ทำ from requests import get — นั่นสร้างชื่อ get ของตัวเอง แยกต่างหากข้างใน module weather_from_import ที่ผูกกับ function เดิมตอน import time การ patch requests.get ทีหลังไม่แตะ weather_from_import.get เลย

อาการ — pytest output จริง:

requests.exceptions.ConnectionError: HTTPSConnectionPool(host='api.example.com', port=443): Max retries exceeded with url: /weather/bangkok (Caused by NameResolutionError("...Failed to resolve 'api.example.com'..."))
1 failed in 0.14s

mock มีผลเป็นศูนย์ — test เรียก network จริงไปยัง domain ที่ไม่มีอยู่จริง และ fail ด้วย DNS error ไม่ใช่ด้วยค่า mocked ใน CI environment ที่สภาพ network ต่างออกไป bug เดียวกันนี้สามารถทำให้ test ผ่าน แบบผิด ๆ ได้เช่นกัน ถ้า code path บังเอิญทนต่อ call จริงได้ — ดู Worked Review 4 สำหรับ variant นั้น

ทางแก้: patch ชื่อ ตามที่ถูก lookup จาก module ที่ถูกทดสอบ เสมอ — @patch("weather_from_import.get") ไม่ใช่ @patch("requests.get") เพราะนั่นคือ attribute จริงที่โค้ดที่ถูกทดสอบจะเรียก

Caught by: ไม่มี ไม่มีเครื่องมือไหนตรวจว่า target string ของ @patch ตรงกับวิธีที่โค้ดที่ถูกทดสอบ import ชื่อนั้นมาจริง ๆ หรือเปล่า test สามารถ fail ดัง ๆ (แบบนี้) หรือผ่านแบบเงียบ ๆ ด้วยเหตุผลผิด ๆ ก็ได้ มีแค่คนที่อ่านทั้งสองไฟล์เทียบกันเท่านั้นที่จับได้ ดู pytest

requests
urllib3==1.26.4

dependency ที่ไม่ pin (requests ไม่มี version) จะ resolve เป็น version ล่าสุดที่ compatible ในวันที่ใครสักคนรัน pip install — requirements.txt เดียวกันเป๊ะสามารถติดตั้งโค้ดที่ต่างกัน อาจมีช่องโหว่ ห่างกันแค่หนึ่งเดือน sudo pip install ... ติดตั้งเข้าไปใน system Python ที่ตัว operating system เองพึ่งพาอยู่ ซึ่งสามารถทำลาย OS tooling อื่นที่ไม่เกี่ยวข้องแบบเงียบ ๆ และต้องการสิทธิ์ root สำหรับสิ่งที่ควรเป็น operation ระดับ user หรือ project เท่านั้น

อาการ — pip-audit output จริง รันกับบรรทัดที่ pin ไว้ข้างบนพอดี:

Found 18 known vulnerabilities in 1 package
Name Version ID Fix Versions
------- ------- --------------- -------------
urllib3 1.26.4 PYSEC-2021-108 1.26.5
urllib3 1.26.4 PYSEC-2023-192 1.26.17,2.0.6
urllib3 1.26.4 PYSEC-2023-212 1.26.18,2.0.7
...

การ pin จำเป็นต่อ reproducibility แต่ไม่พอในตัวมันเอง — การ pin สามารถ freeze version ที่ มีช่องโหว่ที่รู้อยู่แล้ว ไว้ตลอดไปได้เช่นกัน ซึ่งเป็นสิ่งที่ tooling audit ของ pip-audit/uv มีไว้จับอย่างต่อเนื่องพอดี

ทางแก้: pin ทุก dependency (uv lock สร้างและดูแลสิ่งนี้ให้อัตโนมัติ) รัน pip-audit (หรือเครื่องมือ audit เทียบเท่าของ uv) ใน CI และห้ามติดตั้งเข้าไปใน Python ที่ system จัดการ — ใช้ virtual environment (uv venv) หรือ pipx สำหรับ standalone tool

Caught by: เครื่องมือ pip-audit/uv audit-เทียบเท่าจับ version ที่ pin ไว้แล้วแต่มีช่องโหว่ได้ ตามที่แสดงข้างบน ไม่มีอะไร automated จับ requirement ที่ ไม่ pin เอง และไม่มีอะไร automated หยุด sudo pip install — ทั้งสองอย่างเป็นเรื่อง process discipline ไม่ใช่สิ่งที่ linter มองเห็นได้ ดู venv, pip & uv

def process_batch(items: list[int]) -> list[int]:
results = []
for item in items:
if item < 0:
print(f"skipping invalid item: {item}") # library code -- caller can't filter/redirect this
continue
results.append(item * 2)
return results
print(process_batch([1, -2, 3]))

อาการ — output จริง:

skipping invalid item: -2
[2, 6]

output ดูไม่เป็นอันตรายตรงนี้ แต่ print ข้างในโค้ดที่เป็น library (ต่างจาก output ของ CLI ของ script เอง) ไม่สามารถถูก filter ตาม level, redirect ไปไฟล์หรือ log aggregator, ปิดใน production หรือ correlate กับ timestamp และชื่อ logger ได้เลย — ทุกอย่างที่ logging ให้ฟรี

ทางแก้:

import logging
logger = logging.getLogger(__name__)
def process_batch(items: list[int]) -> list[int]:
results = []
for item in items:
if item < 0:
logger.warning("skipping invalid item: %s", item)
continue
results.append(item * 2)
return results

Caught by: ruff’s T201 ตรง ๆ บนทุกการเรียก print ในไฟล์:

T201 `print` found
help: Remove `print`

ดู Logging

flowchart LR
T1["ruff (default rule sets)"] --> P1["1 (B006), 2 (B023), 7 (TRY201), 8 (S101), 10 (RUF006), 11 (ASYNC251), 14 (PLW1641), 15 (RUF012), 16 (ANN401/PGH003), 19 (DTZ003), 20 (SIM115), 21 (S602), 24 (T201)"]
T2["ruff, partially"] --> P2["5 (F632, literal form only), 6 (E722/S110 sync; BLE001 async, two different rules for two shapes of the same mistake)"]
T3["bandit"] --> P3["8 (B101), 21 (B602/B404), overlaps ruff's S-rules"]
T4["mypy --strict + pyright"] --> P4["17 (union-attr / reportOptionalMemberAccess), 18 (arg-type / reportArgumentType), both agree"]
T5["mypy --strict, silent"] --> P5["16, Any + blanket ignore defeats strict mode entirely"]
T6["pyright, silent"] --> P6["16, same file, same silence"]
T7["python -X dev (runtime)"] --> P7["9 (RuntimeWarning), 19 (DeprecationWarning)"]
T8["pip-audit / uv audit"] --> P8["23, but only the pinned-and-vulnerable half of it"]
T9["Nothing"] --> P9["3, 4, 12, 13, 22, and the unpinned/sudo half of 23, a full third of the catalog"]
24 รูปแบบ เรียงตามเครื่องมือที่จับได้จริง
second มีค่าอะไร และทำไม?
def add_item(item, cart=[]):
    cart.append(item)
    return cart

first = add_item("apple")
second = add_item("banana")
ทำไม except BaseException ข้างใน try/await block ของ async function ถึงเป็นอันตรายต่อ cancellation โดยเฉพาะ ในแบบที่ except Exception ไม่เป็น?
จาก mypy --strict และ pyright ที่รายงาน 'no issues found' เหมือนกันบน function ที่ type เป็น def process_config(config: Any) -> int: ซึ่ง crash ตอน runtime ด้วย AttributeError สิ่งนี้บอกอะไร?
ทำไม ruff's F632 ถึงจับ x is 5 ได้ แต่จับ a is b ที่ a กับ b เป็น string ที่สร้างแยกกันแต่บังเอิญเท่ากันไม่ได้?
pytest test หนึ่งใช้ @patch("requests.get") เพื่อ mock function ข้างใน weather.py ซึ่งทำ from requests import get ที่ด้านบนของไฟล์ เกิดอะไรขึ้น?
async task สองตัวเรียก Account.withdraw(80) บน account ที่มี balance=100 พร้อมกัน และการเช็คเกิดขึ้นก่อน await asyncio.sleep(0) กลาง function ผลลัพธ์จริงที่สังเกตได้คืออะไร?
ทำไมการเรียก Pool() โดยไม่มี if __name__ == "__main__": guard ถึงอันตรายเป็นพิเศษบน macOS และ Windows มากกว่าการไม่มี guard บน Linux แบบ fork คลาสสิก?
ทั้ง mypy --strict และ pyright ปฏิเสธ add_a_cat(dogs) ที่ dogs: list[Dog] และ add_a_cat คาดหวัง list[Animal] ทั้งที่ Dog เป็น Animal ทำไม?