Chương 14 · Dự án thực chiến

Dự án thực chiến

Mười ba chương trước dạy từng mảnh: cú pháp, cấu trúc dữ liệu, ngoại lệ, module, tệp tin, OOP, generator, decorator, async, typing, test. Chương cuối này ghép tất cả lại qua bốn dự án hoàn chỉnh — CLI tự động hóa, thu thập dữ liệu web, gọi REST API, và một công cụ chạy định kỳ có lưu trữ. Mỗi dự án đều đi từ phiên bản "chạy được" tới phiên bản đủ chất lượng để giao cho người khác dùng: có kiểm tra đầu vào, xử lý lỗi tử tế, ghi log, có test, và đóng gói được.

Độ dài: ~1500 dòng Dự án: 4 + 5 bài tập mở rộng Quiz: 8 Prerequisites: Chương 1-13
🎯 Mục tiêu chương
  • Phân biệt script và sản phẩm — biết cần thêm gì để vượt ranh giới đó.
  • Xây CLI hoàn chỉnh bằng argparse: sub-command, cờ, giá trị mặc định, --dry-run.
  • Thu thập dữ liệu web đúng kỹ thuật và đúng mực: robots.txt, giới hạn tần suất, User-Agent trung thực.
  • Gọi REST API có phân trang, retry với backoff, và tôn trọng rate limit.
  • Lưu trữ bằng sqlite3 — database không cần server.
  • Chạy định kỳ bằng cron / launchd; ghi log để biết đêm qua có chuyện gì.
  • Áp dụng checklist chất lượng 12 mục trước khi gọi một dự án là "xong".
Now is better than never.
Although never is often better than right now.

1. Từ script tới sản phẩm

Một script là đoạn code giải quyết vấn đề của bạn, hôm nay, trên máy bạn. Một sản phẩm là đoạn code giải quyết vấn đề của người khác, trong nhiều tháng, trên máy họ. Khoảng cách giữa hai thứ đó là những hạng mục sau:

Khía cạnhScriptSản phẩmHọc ở
Cấu hìnhSửa hằng số trong codeTham số dòng lệnh / tệp cấu hình / biến môi trườngCh. 14
Đầu vào saiSập với tracebackThông điệp rõ ràng + mã thoát khác 0Ch. 5
Lỗi mạngSậpRetry có backoff, timeout, thông báoCh. 5, 14
Đường dẫnCứng, tuyệt đốipathlib tương đối với __file__Ch. 7
Theo dõiprintlogging có cấp độ, ghi ra tệpCh. 5
Cấu trúcMột tệpPackage, src layoutCh. 6
Kiểu dữ liệuKhông annotateType hint đầy đủ, mypy sạchCh. 12
Đúng đắn"Chạy thử thấy được"Test tự động, chạy trong CICh. 13
Cài đặtCopy tệppip install, có lệnh trong PATHCh. 6, 13
Thao tác nguy hiểmLàm luôn--dry-run mặc định, xác nhận trước khi xoáCh. 14
🧠 Nguyên tắc xuyên suốt chương này

Mọi thao tác không thể hoàn tác phải khó thực hiện hơn thao tác an toàn. Xoá tệp, ghi đè dữ liệu, gửi email hàng loạt — mặc định phải là "chỉ xem trước", còn thực thi thật thì cần cờ tường minh (--apply, --yes). Người dùng sẽ gõ sai lệnh; thiết kế của bạn quyết định lần gõ sai đó tốn 3 giây hay mất một ngày dữ liệu.

2. argparse — xương sống của mọi CLI

sys.argv đủ cho script một tham số. Từ hai tham số trở lên, hoặc khi cần cờ tuỳ chọn, hãy dùng argparse — nó tự sinh --help, tự kiểm tra kiểu, tự báo lỗi và thoát với mã 2.

cli_co_ban.py
import argparse
from pathlib import Path

def tao_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(
        prog="don-thu-muc",
        description="Sắp xếp tệp trong thư mục theo phần mở rộng.",
        epilog="Ví dụ: don-thu-muc ~/Downloads --apply",
    )
    # Tham số bắt buộc (positional)
    p.add_argument("thu_muc", type=Path, help="Thư mục cần dọn")

    # Cờ bật/tắt
    p.add_argument("--apply", action="store_true",
                   help="Thực thi thật (mặc định chỉ xem trước)")
    p.add_argument("-r", "--de-quy", action="store_true",
                   help="Xử lý cả thư mục con")

    # Tham số có giá trị, có mặc định
    p.add_argument("--kich-thuoc-toi-thieu", type=int, default=0,
                   metavar="BYTE", help="Bỏ qua tệp nhỏ hơn (mặc định: 0)")

    # Chỉ cho phép vài giá trị
    p.add_argument("--theo", choices=["duoi", "ngay", "kich-thuoc"],
                   default="duoi", help="Tiêu chí nhóm (mặc định: duoi)")

    # Nhận nhiều giá trị
    p.add_argument("--bo-qua", nargs="*", default=[],
                   metavar="DUOI", help="Các đuôi tệp bỏ qua, vd: .tmp .log")

    # Nhóm loại trừ lẫn nhau
    nhom = p.add_mutually_exclusive_group()
    nhom.add_argument("-v", "--chi-tiet", action="store_true")
    nhom.add_argument("-q", "--im-lang", action="store_true")

    p.add_argument("--version", action="version", version="%(prog)s 1.0")
    return p
terminal — argparse tự sinh help
$ don-thu-muc --help
usage: don-thu-muc [-h] [--apply] [-r] [--kich-thuoc-toi-thieu BYTE]
                   [--theo {duoi,ngay,kich-thuoc}] [--bo-qua [DUOI ...]]
                   [-v | -q] [--version] thu_muc

Sắp xếp tệp trong thư mục theo phần mở rộng.

positional arguments:
  thu_muc               Thư mục cần dọn

options:
  -h, --help            show this help message and exit
  --apply               Thực thi thật (mặc định chỉ xem trước)
  ...

Ví dụ: don-thu-muc ~/Downloads --apply

2.1. Sub-command — CLI nhiều lệnh con

Công cụ lớn thường có nhiều lệnh con, như git commit / git push hay uv add / uv run. argparse làm được điều đó với add_subparsers.

cli_sub.py
import argparse, sys
from pathlib import Path

def lenh_dong_bo(args: argparse.Namespace) -> int:
    print(f"Đồng bộ {args.nguon} → {args.dich}")
    return 0

def lenh_bao_cao(args: argparse.Namespace) -> int:
    print(f"Báo cáo dạng {args.dinh_dang}")
    return 0

def main(argv: list[str] | None = None) -> int:
    p = argparse.ArgumentParser(prog="cong-cu")
    sub = p.add_subparsers(dest="lenh", required=True)

    p_db = sub.add_parser("dong-bo", help="Đồng bộ hai thư mục")
    p_db.add_argument("nguon", type=Path)
    p_db.add_argument("dich", type=Path)
    p_db.set_defaults(ham=lenh_dong_bo)      # ⭐ gắn hàm xử lý vào parser con

    p_bc = sub.add_parser("bao-cao", help="Sinh báo cáo")
    p_bc.add_argument("--dinh-dang", choices=["json", "csv", "md"], default="md")
    p_bc.set_defaults(ham=lenh_bao_cao)

    args = p.parse_args(argv)
    return args.ham(args)                    # điều phối không cần if/elif

if __name__ == "__main__":
    sys.exit(main())
💡 Mẫu set_defaults(ham=...)

Thay vì viết chuỗi if args.lenh == "dong-bo": ... elif ..., hãy gắn thẳng hàm xử lý vào từng parser con. Thêm lệnh mới chỉ cần thêm một khối 3 dòng, không phải sửa hàm điều phối — đúng nguyên tắc "mở để mở rộng, đóng để sửa đổi".

3. Dự án 1 — CLI dọn dẹp thư mục

Một công cụ sắp xếp thư mục Downloads lộn xộn: gom tệp vào các thư mục con theo loại (anh/, tai_lieu/, nen/…). Đây là bài toán đơn giản nhưng đủ chỗ để thực hành mọi nguyên tắc: pathlib, xử lý xung đột tên, chế độ xem trước, ghi log.

src/don_thu_muc/core.py
"""Logic thuần — không biết gì về dòng lệnh, nên dễ test."""
from __future__ import annotations

import logging
from collections import Counter
from dataclasses import dataclass
from pathlib import Path

log = logging.getLogger(__name__)

NHOM: dict[str, frozenset[str]] = {
    "anh":      frozenset({".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic"}),
    "tai_lieu": frozenset({".pdf", ".docx", ".xlsx", ".pptx", ".txt", ".md"}),
    "nen":      frozenset({".zip", ".tar", ".gz", ".rar", ".7z"}),
    "video":    frozenset({".mp4", ".mkv", ".mov", ".avi"}),
    "code":     frozenset({".py", ".js", ".ts", ".java", ".go", ".rs"}),
}

@dataclass(frozen=True)
class KeHoach:
    """Một thao tác di chuyển dự kiến."""
    nguon: Path
    dich: Path

def phan_loai(duoi: str) -> str:
    duoi = duoi.lower()
    for ten_nhom, tap_duoi in NHOM.items():
        if duoi in tap_duoi:
            return ten_nhom
    return "khac"

def ten_khong_trung(dich: Path) -> Path:
    """bao_cao.pdf → bao_cao (1).pdf → bao_cao (2).pdf ..."""
    if not dich.exists():
        return dich
    for i in range(1, 1000):
        ung_vien = dich.with_name(f"{dich.stem} ({i}){dich.suffix}")
        if not ung_vien.exists():
            return ung_vien
    raise FileExistsError(f"Quá nhiều bản trùng tên với {dich.name}")

def lap_ke_hoach(
    thu_muc: Path,
    *,
    de_quy: bool = False,
    bo_qua: frozenset[str] = frozenset(),
    kich_thuoc_toi_thieu: int = 0,
) -> list[KeHoach]:
    """Tính TOÀN BỘ việc cần làm mà không đụng vào đĩa."""
    if not thu_muc.is_dir():
        raise NotADirectoryError(f"Không phải thư mục: {thu_muc}")

    ke_hoach: list[KeHoach] = []
    da_dat: set[Path] = set()          # tên đã "đặt gạch" trong lần chạy này
    nguon_iter = thu_muc.rglob("*") if de_quy else thu_muc.iterdir()

    for f in sorted(nguon_iter):
        if not f.is_file() or f.suffix.lower() in bo_qua:
            continue
        if f.stat().st_size < kich_thuoc_toi_thieu:
            continue
        nhom = phan_loai(f.suffix)
        if f.parent.name == nhom:      # đã đúng chỗ rồi
            continue

        dich = ten_khong_trung(thu_muc / nhom / f.name)
        while dich in da_dat:            # tránh 2 tệp cùng nhắm 1 đích
            dich = ten_khong_trung(dich)
        da_dat.add(dich)
        ke_hoach.append(KeHoach(f, dich))

    return ke_hoach

def thuc_thi(ke_hoach: list[KeHoach]) -> Counter[str]:
    """Thực hiện kế hoạch. Một tệp lỗi không làm dừng cả lô."""
    thong_ke: Counter[str] = Counter()
    for kh in ke_hoach:
        try:
            kh.dich.parent.mkdir(parents=True, exist_ok=True)
            kh.nguon.rename(kh.dich)
            thong_ke[kh.dich.parent.name] += 1
            log.debug("đã chuyển %s → %s", kh.nguon.name, kh.dich)
        except OSError as e:
            thong_ke["loi"] += 1
            log.warning("bỏ qua %s: %s", kh.nguon.name, e)
    return thong_ke

Tách "lập kế hoạch" khỏi "thực thi"

Đây là mẫu thiết kế đáng nhớ nhất của chương. lap_ke_hoach() là hàm thuần — đọc trạng thái, trả về danh sách việc cần làm, không đụng gì vào đĩa. thuc_thi() chỉ làm theo danh sách đó. Ba lợi ích lớn: (1) --dry-run chỉ là "chạy phần lập kế hoạch rồi in ra" — không cần một nhánh code riêng dễ lệch; (2) test được toàn bộ logic phân loại mà không cần tệp thật; (3) có thể hiển thị kế hoạch cho người dùng xác nhận trước khi làm.

src/don_thu_muc/__main__.py
"""Lớp CLI — chỉ lo phân tích tham số, in ấn, và mã thoát."""
import argparse, logging, sys
from pathlib import Path

from don_thu_muc.core import lap_ke_hoach, thuc_thi

def cau_hinh_log(chi_tiet: bool, im_lang: bool) -> None:
    muc = logging.DEBUG if chi_tiet else (logging.ERROR if im_lang else logging.INFO)
    logging.basicConfig(level=muc, format="%(levelname)s %(message)s")

def main(argv: list[str] | None = None) -> int:
    args = tao_parser().parse_args(argv)
    cau_hinh_log(args.chi_tiet, args.im_lang)

    try:
        ke_hoach = lap_ke_hoach(
            args.thu_muc,
            de_quy=args.de_quy,
            bo_qua=frozenset(d.lower() for d in args.bo_qua),
            kich_thuoc_toi_thieu=args.kich_thuoc_toi_thieu,
        )
    except (NotADirectoryError, PermissionError) as e:
        print(f"Lỗi: {e}", file=sys.stderr)
        return 2                     # mã 2 = sai cách dùng

    if not ke_hoach:
        print("Không có gì để dọn.")
        return 0

    if not args.apply:
        print(f"[XEM TRƯỚC] {len(ke_hoach)} tệp sẽ được chuyển:\n")
        for kh in ke_hoach[:20]:
            print(f"  {kh.nguon.name}  →  {kh.dich.parent.name}/{kh.dich.name}")
        if len(ke_hoach) > 20:
            print(f"  ... và {len(ke_hoach) - 20} tệp nữa")
        print("\nThêm --apply để thực hiện.")
        return 0

    thong_ke = thuc_thi(ke_hoach)
    for nhom, n in sorted(thong_ke.items()):
        print(f"  {nhom}: {n}")
    return 1 if thong_ke["loi"] else 0

if __name__ == "__main__":
    sys.exit(main())
📌 Quy ước mã thoát
MãÝ nghĩa
0Thành công
1Lỗi chung khi chạy (một phần công việc thất bại)
2Sai cách dùng — tham số không hợp lệ (argparse tự dùng mã này)
130Người dùng bấm Ctrl+C (128 + SIGINT 2)

Shell script và CI đọc mã thoát để quyết định chạy tiếp hay dừng. Một CLI luôn trả 0 là CLI không dùng được trong pipeline.

4. Dự án 2 — Thu thập dữ liệu web

⚠️ Trước khi viết dòng code đầu tiên
  1. Kiểm tra xem có API chính thức không. Nếu có, luôn dùng API — ổn định hơn, nhanh hơn, và được phép.
  2. Đọc robots.txt tại https://ten-mien/robots.txt và tôn trọng nó.
  3. Đọc điều khoản sử dụng. Nhiều trang cấm thu thập tự động; vi phạm có thể dẫn tới hậu quả pháp lý.
  4. Giới hạn tần suất. Nghỉ giữa các request. Không có ai cảm ơn bạn vì làm sập server của họ.
  5. Khai báo trung thực. Đặt User-Agent có tên công cụ và cách liên hệ, đừng giả mạo trình duyệt.
  6. Không thu thập dữ liệu cá nhân nếu không có cơ sở pháp lý rõ ràng.
terminal — cài thư viện
$ uv add httpx beautifulsoup4 lxml
# httpx: HTTP client hiện đại (đồng bộ + bất đồng bộ, HTTP/2)
# beautifulsoup4: phân tích HTML
# lxml: bộ phân tích nhanh cho BeautifulSoup
src/thu_thap/scraper.py
from __future__ import annotations

import logging, time
from dataclasses import dataclass
from urllib.parse import urljoin, urlparse
from urllib.robotparser import RobotFileParser

import httpx
from bs4 import BeautifulSoup

log = logging.getLogger(__name__)

USER_AGENT = "HocPythonBot/1.0 (+https://vidu.vn/lien-he)"

@dataclass(frozen=True)
class BaiViet:
    tieu_de: str
    lien_ket: str
    tom_tat: str


class ThuThapLoi(Exception):
    """Lỗi gốc của module thu thập."""

class BiCam(ThuThapLoi):
    """robots.txt không cho phép truy cập URL này."""


def duoc_phep(url: str) -> bool:
    """Hỏi robots.txt trước khi lấy bất kỳ trang nào."""
    goc = urlparse(url)
    rp = RobotFileParser()
    rp.set_url(f"{goc.scheme}://{goc.netloc}/robots.txt")
    try:
        rp.read()
    except OSError:
        log.warning("Không đọc được robots.txt — mặc định KHÔNG cho phép")
        return False          # mặc định an toàn
    return rp.can_fetch(USER_AGENT, url)


def tai_trang(client: httpx.Client, url: str, *, so_lan: int = 3) -> str:
    """Tải HTML với retry + backoff luỹ thừa, tôn trọng Retry-After."""
    if not duoc_phep(url):
        raise BiCam(f"robots.txt chặn: {url}")

    loi: list[Exception] = []
    for lan in range(so_lan):
        try:
            r = client.get(url, timeout=10.0)
            if r.status_code == 429:              # quá nhiều request
                cho = float(r.headers.get("Retry-After", 5))
                log.info("Bị giới hạn, chờ %.1fs", cho)
                time.sleep(cho)
                continue
            r.raise_for_status()                  # ném với 4xx/5xx
            return r.text
        except (httpx.TimeoutException, httpx.HTTPStatusError) as e:
            loi.append(e)
            cho = 2 ** lan                        # 1s, 2s, 4s
            log.warning("Lần %d thất bại (%s), thử lại sau %ds", lan + 1, e, cho)
            time.sleep(cho)

    raise ThuThapLoi(f"Không tải được {url} sau {so_lan} lần") from ExceptionGroup(
        "mọi lần thử đều hỏng", loi
    )


def phan_tich(html: str, url_goc: str) -> list[BaiViet]:
    """Tách dữ liệu — hàm THUẦN, test được bằng HTML mẫu offline."""
    soup = BeautifulSoup(html, "lxml")
    ket_qua: list[BaiViet] = []

    for the in soup.select("article.post"):        # CSS selector
        a = the.select_one("h2 a")
        p = the.select_one("p.summary")
        if a is None:                          # HTML có thể thiếu — đừng giả định
            continue
        ket_qua.append(BaiViet(
            tieu_de=a.get_text(strip=True),
            lien_ket=urljoin(url_goc, a["href"]),    # biến /bai/1 thành URL đầy đủ
            tom_tat=p.get_text(strip=True) if p else "",
        ))
    return ket_qua


def thu_thap(urls: list[str], *, nghi: float = 1.0) -> list[BaiViet]:
    tat_ca: list[BaiViet] = []
    with httpx.Client(headers={"User-Agent": USER_AGENT},
                     follow_redirects=True) as client:
        for i, url in enumerate(urls):
            if i:
                time.sleep(nghi)               # ⭐ lịch sự với server
            try:
                tat_ca.extend(phan_tich(tai_trang(client, url), url))
            except ThuThapLoi as e:
                log.error("Bỏ qua %s: %s", url, e)
    return tat_ca
💡 Vì sao tách tai_trang và phan_tich

phan_tich() chỉ nhận chuỗi HTML và trả về dữ liệu — không mạng, không I/O. Nhờ đó bạn lưu một trang HTML thật vào tests/du_lieu/trang_mau.html và viết test chạy trong mili-giây, không phụ thuộc internet, không làm phiền server của người ta. Đây là ứng dụng trực tiếp của nguyên tắc "tách phần thuần khỏi phần có tác dụng phụ" mà bạn đã thấy ở Dự án 1 và sẽ thấy lại ở Chương 13.

5. Dự án 3 — Gọi REST API

Gọi API là công việc phổ biến hơn scraping rất nhiều, và có nhiều thứ phải làm đúng: xác thực, phân trang, giới hạn tần suất, retry chỉ với lỗi tạm thời, và không bao giờ để lộ khoá bí mật.

src/api_client/github.py
from __future__ import annotations

import logging, os, time
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import datetime

import httpx

log = logging.getLogger(__name__)

@dataclass(frozen=True, slots=True)
class Repo:
    ten: str
    sao: int
    ngon_ngu: str | None
    cap_nhat: datetime

    @classmethod
    def tu_json(cls, d: dict) -> Repo:
        """Alternative constructor — biên giới giữa JSON tuỳ tiện và kiểu chặt chẽ."""
        return cls(
            ten=d["full_name"],
            sao=d["stargazers_count"],
            ngon_ngu=d.get("language"),
            cap_nhat=datetime.fromisoformat(d["updated_at"]),
        )


class GitHubClient:
    GOC = "https://api.github.com"

    def __init__(self, token: str | None = None) -> None:
        # ⭐ Khoá bí mật đọc từ biến môi trường, KHÔNG BAO GIỜ ghi trong code
        token = token or os.getenv("GITHUB_TOKEN")
        headers = {
            "Accept": "application/vnd.github+json",
            "User-Agent": "HocPython/1.0",
        }
        if token:
            headers["Authorization"] = f"Bearer {token}"
        self._client = httpx.Client(headers=headers, timeout=15.0)

    # --- Context manager: dùng được với `with` ---
    def __enter__(self) -> GitHubClient:
        return self

    def __exit__(self, *_) -> bool:
        self._client.close()
        return False          # không nuốt exception

    def _get(self, duong_dan: str, **tham_so) -> httpx.Response:
        """GET có retry — CHỈ retry lỗi tạm thời."""
        for lan in range(4):
            r = self._client.get(f"{self.GOC}{duong_dan}", params=tham_so)

            # Hết hạn mức → chờ tới lúc reset
            if r.status_code == 403 and r.headers.get("X-RateLimit-Remaining") == "0":
                reset = int(r.headers["X-RateLimit-Reset"])
                cho = max(reset - time.time(), 0) + 1
                log.warning("Hết hạn mức, chờ %.0fs", cho)
                time.sleep(cho)
                continue

            # 5xx = lỗi phía server → đáng retry
            if r.status_code >= 500:
                time.sleep(2 ** lan)
                continue

            # 4xx khác = lỗi phía TA → retry vô nghĩa, ném ngay
            r.raise_for_status()
            return r

        raise RuntimeError(f"GET {duong_dan} thất bại sau 4 lần")

    def tim_repo(self, tu_khoa: str, *, gioi_han: int = 100) -> Iterator[Repo]:
        """Generator — tự lo phân trang, người dùng chỉ việc lặp."""
        trang, da_lay = 1, 0
        while da_lay < gioi_han:
            r = self._get("/search/repositories",
                          q=tu_khoa, sort="stars", per_page=30, page=trang)
            muc = r.json()["items"]
            if not muc:
                return                       # hết dữ liệu
            for m in muc:
                if da_lay >= gioi_han:
                    return
                yield Repo.tu_json(m)
                da_lay += 1
            trang += 1


# --- Dùng ---
with GitHubClient() as gh:
    for repo in gh.tim_repo("language:python topic:cli", gioi_han=50):
        print(f"{repo.sao:6,}  {repo.ten}")
🧠 Retry cái gì và không retry cái gì
Tình huốngRetry?Vì sao
Timeout, lỗi kết nối✅ CóSự cố mạng nhất thời
429 Too Many Requests✅ Có, sau Retry-AfterServer bảo ta chờ
500, 502, 503, 504✅ Có, backoff luỹ thừaServer đang gặp vấn đề
400 Bad Request❌ KhôngRequest của ta sai — gửi lại vẫn sai
401, 403❌ KhôngThiếu quyền — retry chỉ tốn thời gian
404 Not Found❌ KhôngTài nguyên không tồn tại
POST tạo dữ liệu⚠️ Cẩn thậnCó thể tạo bản ghi trùng — cần khoá idempotency

Backoff luỹ thừa (1s, 2s, 4s, 8s) quan trọng hơn người ta tưởng: nếu 1000 client cùng retry sau đúng 1 giây, server vừa hồi phục lại bị đánh sập lần nữa. Hệ thống thật còn thêm jitter — cộng một lượng ngẫu nhiên nhỏ để các client không đồng bộ với nhau.

⚠️ Không bao giờ ghi khoá bí mật vào mã nguồn
bi_mat.py
# ❌ TUYỆT ĐỐI KHÔNG — lộ ngay khi push lên GitHub
TOKEN = "ghp_abcd1234..."

# ✅ Đọc từ biến môi trường
import os
TOKEN = os.environ["GITHUB_TOKEN"]        # KeyError nếu thiếu — fail fast
TOKEN = os.getenv("GITHUB_TOKEN")          # None nếu thiếu — dùng khi tuỳ chọn

# ✅ Hoặc từ tệp .env (nhớ thêm .env vào .gitignore!)
from dotenv import load_dotenv       # uv add python-dotenv
load_dotenv()

Khoá đã push lên GitHub coi như đã lộ vĩnh viễn — xoá commit không đủ, vì nó còn trong lịch sử và trong bản sao của người khác. Cách xử lý duy nhất đúng là thu hồi khoá đó và tạo khoá mới. Bật gitleaks hoặc GitHub secret scanning để phát hiện sớm.

6. Dự án 4 — Tự động hóa có lưu trữ

Ghép Dự án 3 với sqlite3 để tạo một công cụ chạy hằng ngày: lấy dữ liệu, lưu lại, so sánh với lần trước, và báo cáo những gì thay đổi.

src/theo_doi/kho.py
"""Lưu trữ bằng sqlite3 — database trong một tệp, có sẵn trong stdlib."""
from __future__ import annotations

import sqlite3
from collections.abc import Iterator
from contextlib import contextmanager
from datetime import date
from pathlib import Path

LUOC_DO = """
CREATE TABLE IF NOT EXISTS anh_chup (
    ngay      TEXT NOT NULL,
    ten_repo  TEXT NOT NULL,
    sao       INTEGER NOT NULL,
    PRIMARY KEY (ngay, ten_repo)
);
CREATE INDEX IF NOT EXISTS idx_repo ON anh_chup(ten_repo);
"""

@contextmanager
def mo_kho(duong_dan: Path) -> Iterator[sqlite3.Connection]:
    """Context manager: tự commit khi xong, rollback khi lỗi, luôn đóng."""
    duong_dan.parent.mkdir(parents=True, exist_ok=True)
    conn = sqlite3.connect(duong_dan)
    conn.row_factory = sqlite3.Row       # truy cập cột theo TÊN thay vì chỉ số
    try:
        conn.executescript(LUOC_DO)
        yield conn
        conn.commit()
    except Exception:
        conn.rollback()
        raise                            # ném lại — đừng nuốt
    finally:
        conn.close()

def luu_anh_chup(conn: sqlite3.Connection, ngay: date,
                 du_lieu: list[tuple[str, int]]) -> int:
    cur = conn.executemany(
        "INSERT OR REPLACE INTO anh_chup (ngay, ten_repo, sao) VALUES (?, ?, ?)",
        [(ngay.isoformat(), ten, sao) for ten, sao in du_lieu],
    )                                    # ⭐ (?, ?, ?) — KHÔNG BAO GIỜ nối chuỗi SQL
    return cur.rowcount

def thay_doi(conn: sqlite3.Connection, hom_nay: date,
              hom_truoc: date) -> list[sqlite3.Row]:
    """So sánh hai ngày, trả về repo tăng sao nhiều nhất."""
    return conn.execute(
        """
        SELECT a.ten_repo, b.sao AS truoc, a.sao AS nay, a.sao - b.sao AS tang
        FROM   anh_chup a
        JOIN   anh_chup b ON a.ten_repo = b.ten_repo
        WHERE  a.ngay = ? AND b.ngay = ? AND a.sao > b.sao
        ORDER  BY tang DESC
        LIMIT  20
        """,
        (hom_nay.isoformat(), hom_truoc.isoformat()),
    ).fetchall()
⚠️ SQL injection — luôn dùng tham số ?
sql_injection.py
ten = nguoi_dung_nhap   # giả sử: "x'; DROP TABLE anh_chup; --"

# ❌ THẢM HOẠ — chuỗi người dùng trở thành câu lệnh SQL
conn.execute(f"SELECT * FROM anh_chup WHERE ten_repo = '{ten}'")

# ✅ AN TOÀN — driver tự escape, dữ liệu luôn là dữ liệu
conn.execute("SELECT * FROM anh_chup WHERE ten_repo = ?", (ten,))
#                                                        ↑ tuple 1 phần tử: nhớ dấu phẩy!

Quy tắc không có ngoại lệ: giá trị luôn đi qua tham số ?, không bao giờ qua f-string hay +. Điều này đúng với mọi cơ sở dữ liệu, mọi ngôn ngữ. (Tên bảng và tên cột không tham số hoá được — nếu chúng đến từ người dùng, phải kiểm tra bằng danh sách trắng.)

src/theo_doi/__main__.py — ghép mọi thứ lại
import logging, sys
from datetime import date, timedelta
from logging.handlers import RotatingFileHandler
from pathlib import Path

from api_client.github import GitHubClient
from theo_doi.kho import mo_kho, luu_anh_chup, thay_doi

THU_MUC = Path(__file__).resolve().parent
KHO     = THU_MUC / "du_lieu" / "theo_doi.db"
NHAT_KY = THU_MUC / "nhat_ky" / "chay.log"

def cau_hinh_log() -> None:
    """Log ra CẢ tệp lẫn màn hình — cron không có màn hình để đọc."""
    NHAT_KY.parent.mkdir(parents=True, exist_ok=True)
    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
        handlers=[
            RotatingFileHandler(NHAT_KY, maxBytes=5*1024*1024, backupCount=3),
            logging.StreamHandler(sys.stderr),
        ],
    )

def main() -> int:
    cau_hinh_log()
    log = logging.getLogger("theo_doi")
    hom_nay = date.today()

    try:
        with GitHubClient() as gh:
            du_lieu = [(r.ten, r.sao)
                       for r in gh.tim_repo("language:python", gioi_han=100)]
        log.info("Lấy được %d repo", len(du_lieu))

        with mo_kho(KHO) as conn:
            luu_anh_chup(conn, hom_nay, du_lieu)
            tang = thay_doi(conn, hom_nay, hom_nay - timedelta(days=1))

        if tang:
            print(f"\n📈 Tăng sao nhiều nhất ({hom_nay}):")
            for h in tang[:10]:
                print(f"  +{h['tang']:<5} {h['ten_repo']}")
        else:
            print("Chưa có dữ liệu hôm trước để so sánh.")
        return 0

    except KeyboardInterrupt:
        log.info("Người dùng dừng")
        return 130
    except Exception:
        log.exception("Chạy thất bại")     # ⭐ log.exception giữ traceback
        return 1

if __name__ == "__main__":
    sys.exit(main())
terminal — lên lịch chạy hằng ngày
# Linux / macOS — cron
$ crontab -e

# Chạy 8:00 sáng mỗi ngày. LƯU Ý 3 ĐIỂM:
#  1. Đường dẫn TUYỆT ĐỐI tới python trong venv — cron không biết venv
#  2. cd vào thư mục dự án trước — cron chạy ở $HOME
#  3. Chuyển hướng output — cron gửi mail nếu có stdout/stderr
0 8 * * * cd /home/anh/du-an && /home/anh/du-an/.venv/bin/python -m theo_doi >> /home/anh/du-an/cron.log 2>&1

# Kiểm tra trước khi tin: chạy thủ công với môi trường TRỐNG
$ env -i /home/anh/du-an/.venv/bin/python -m theo_doi
🕳️ Vì sao script chạy tay được mà cron thì không
  1. PATH khác nhau. Cron có PATH tối thiểu — python có thể không tồn tại. Luôn dùng đường dẫn tuyệt đối tới interpreter trong venv.
  2. Thư mục làm việc là $HOME. Mọi đường dẫn tương đối đều sai. Đây là lý do dùng Path(__file__).resolve().parent.
  3. Không có biến môi trường từ shell. GITHUB_TOKEN đặt trong .zshrc sẽ không có trong cron — phải khai báo trong crontab hoặc đọc từ tệp .env.
  4. Không có terminal. Chương trình chờ input() sẽ treo mãi mãi.

Mẹo chẩn đoán: chạy env -i /duong/dan/python -m goi — cờ -i xoá sạch môi trường, mô phỏng điều kiện cron ngay tại terminal của bạn.

7. Checklist chất lượng — 12 mục

Trước khi coi một dự án là "xong", đối chiếu với danh sách sau:

#Hạng mụcKiểm tra bằngChương
1Cấu trúc src layout, có pyproject.tomluv pip install -e . chạy được6
2Có __main__.py, main() trả intpython -m goi; echo $?6
3Mọi đường dẫn dùng pathlib, neo theo __file__Chạy từ thư mục khác vẫn đúng7
4Mọi tệp văn bản có encoding="utf-8"python -X warn_default_encoding -m goi7
5Không có bare except, không nuốt lỗiruff check (rule E722, BLE)5
6Thao tác nguy hiểm có --dry-run mặc địnhTự thử gõ sai lệnh14
7Dùng logging, không print để debuggrep -rn "print(" src/5
8Không có khoá bí mật trong mã nguồngitleaks detect14
9Type hint đầy đủ, mypy sạchmypy --strict src/12
10Có test cho phần logic thuần, độ phủ > 70%pytest --cov=src13
11Lint + format tự độngruff check . && ruff format --check .13
12README có: cài đặt, ví dụ dùng, cấu hìnhĐưa cho người khác đọc và làm theo—
pyproject.toml — cấu hình đầy đủ cho một dự án CLI
[project]
name = "theo-doi"
version = "1.0.0"
description = "Theo dõi repo GitHub hằng ngày"
requires-python = ">=3.12"
dependencies = ["httpx>=0.27", "python-dotenv>=1.0"]

[project.optional-dependencies]
dev = ["pytest>=8", "pytest-cov", "mypy>=1.11", "ruff>=0.6", "respx"]

[project.scripts]
theo-doi = "theo_doi.__main__:main"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.ruff]
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP", "SIM", "BLE", "C4", "PTH"]
# BLE = blind-except, PTH = ép dùng pathlib thay os.path

[tool.mypy]
python_version = "3.12"
strict = true

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "--cov=src --cov-report=term-missing"

Bài tập — 5 dự án mở rộng

Dự án A — Công cụ tìm tệp trùng lặp

CLI tim-trung [thu_muc] [--apply] [--giu {cu-nhat,moi-nhat,ngan-nhat}]:

  • Quét đệ quy, nhóm tệp trùng nội dung (không phải trùng tên).
  • Tối ưu ba tầng: lọc theo kích thước → băm 4KB đầu → băm toàn bộ. Chỉ băm đầy đủ khi thật cần.
  • Mặc định chỉ báo cáo; --apply mới xoá, giữ lại bản theo tiêu chí --giu.
  • In tổng dung lượng tiết kiệm được.
Gợi ý

Dùng hashlib.blake2b (nhanh hơn md5 và an toàn hơn). Đọc theo khối 8192 byte để không nạp tệp lớn vào RAM. Cấu trúc: defaultdict[int, list[Path]] theo kích thước → defaultdict[bytes, list[Path]] theo băm. Tầng lọc theo kích thước loại bỏ >90% ứng viên mà gần như không tốn gì.

Dự án B — Theo dõi giá và cảnh báo

Công cụ chạy mỗi giờ, theo dõi giá sản phẩm từ một API công khai, lưu vào SQLite và cảnh báo khi giá giảm quá ngưỡng.

  • Cấu hình bằng tệp theo_doi.toml (đọc bằng tomllib có sẵn từ Python 3.11).
  • Lưu lịch sử giá; tính giá trung bình 7 ngày.
  • Cảnh báo khi giá hiện tại thấp hơn trung bình 7 ngày quá X%.
  • Gửi thông báo qua webhook (Discord/Slack) — dùng httpx.post.
  • Chống spam: không cảnh báo lại cùng một sản phẩm trong 24 giờ.
Gợi ý

Bảng canh_bao(san_pham, gui_luc) để chống spam; trước khi gửi thì SELECT MAX(gui_luc) WHERE san_pham = ? và so với thời điểm hiện tại. Đưa URL webhook vào biến môi trường, đừng để trong tệp TOML nếu tệp đó được commit. Test phần tính toán bằng dữ liệu giá giả lập — hoàn toàn không cần mạng.

Dự án C — Trích xuất dữ liệu từ trang tin

Thu thập tiêu đề bài viết từ một trang tin công khai cho phép thu thập, xuất ra JSON và CSV.

  • Kiểm tra robots.txt trước mỗi URL; nghỉ ít nhất 1 giây giữa các request.
  • Tách tai_trang() (có mạng) khỏi phan_tich() (thuần) để test được offline.
  • Lưu HTML thô vào cache/ — chạy lại lần hai không gọi mạng nữa.
  • Xử lý HTML thiếu trường: không được ném AttributeError vì None.
  • Viết test dùng HTML mẫu lưu sẵn trong tests/du_lieu/.
Gợi ý

Cache đơn giản: tên tệp là hashlib.sha256(url.encode()).hexdigest()[:16] + ".html". Kiểm tra cache_path.exists() trước khi gọi mạng. Thêm cờ --lam-moi để bỏ qua cache. Với BeautifulSoup, select_one trả None khi không thấy — luôn kiểm tra trước khi .get_text().

Dự án D — Sao lưu tăng dần

sao-luu <nguon> <dich> [--apply] [--giu-lai N]:

  • Chỉ sao chép tệp mới hoặc đã đổi (so sánh st_mtime và kích thước; xác nhận bằng băm nếu nghi ngờ).
  • Ghi manifest.json chứa danh sách tệp + băm + thời điểm sao lưu.
  • Giữ lại N bản sao lưu gần nhất, xoá bản cũ hơn.
  • Có lệnh --khoi-phuc <ngay> để phục hồi về một thời điểm.
  • Báo tiến độ khi xử lý nhiều tệp.
Gợi ý

Dùng shutil.copy2 (giữ metadata) thay shutil.copy. Tiết kiệm dung lượng bằng hard link cho tệp không đổi: os.link(ban_cu, ban_moi) — đây chính là cách Time Machine của macOS hoạt động. Kiểm tra dich không nằm bên trong nguon, nếu không sẽ sao lưu đệ quy vô hạn.

Dự án E — Nâng cấp một dự án lên chuẩn 12 mục

Chọn một trong bốn dự án trên (hoặc một script cũ của bạn) và đưa nó qua đủ 12 mục trong bảng checklist. Ghi lại nhật ký từng bước:

  • Mục nào tốn nhiều công nhất? Vì sao?
  • Mục nào phát hiện ra bug thật? Bug gì?
  • Sau khi mypy --strict sạch, bạn phải sửa những chỗ nào?
  • Độ phủ test đạt bao nhiêu? Phần nào khó test nhất và bạn giải quyết ra sao?

Kết quả cuối: một repo có README, cài được bằng pip install, và CI xanh.

Gợi ý

Thứ tự làm hiệu quả nhất: (1) src layout + pyproject.toml trước — mọi thứ khác dựa vào nó; (2) ruff — sửa nhanh, phát hiện nhiều; (3) test cho phần logic thuần; (4) mypy --strict cuối cùng vì nó đòi hỏi nhiều nhất.
Phần khó test nhất luôn là phần chạm I/O — đó là tín hiệu cho thấy nên tách nó ra khỏi logic, đúng như mẫu "lập kế hoạch / thực thi" ở Dự án 1.

Quiz

Q1

Vì sao nên tách "lập kế hoạch" khỏi "thực thi" trong công cụ CLI?

Xem đáp án
✓ Đáp án

Ba lý do. (1) --dry-run trở thành miễn phí — chỉ cần chạy phần lập kế hoạch rồi in ra, thay vì duy trì một nhánh code song song luôn có nguy cơ lệch khỏi nhánh thật. (2) Test dễ hơn hẳn — hàm lập kế hoạch là hàm thuần, nhận trạng thái và trả về danh sách việc; test nó không cần tệp thật, không cần mạng, chạy trong mili-giây. (3) Kiểm tra được trước khi làm — phát hiện xung đột tên, thiếu quyền, hoặc kế hoạch rỗng trước khi thay đổi bất cứ thứ gì trên đĩa, nên không bao giờ để lại trạng thái nửa vời.

Q2

Vì sao main() nên nhận argv và trả về int?

Xem đáp án
✓ Đáp án

Nhận argv để test được: gọi thẳng main(["--apply", "/tmp/x"]) trong pytest, không cần giả lập sys.argv hay chạy tiến trình con. Mặc định argv=None rồi argv if argv is not None else sys.argv[1:] để dùng bình thường vẫn tiện.
Trả int vì đó là mã thoát: sys.exit(main()). Shell script và CI đọc $? để quyết định chạy tiếp hay dừng. Một CLI luôn trả 0 dù thất bại là CLI không dùng được trong pipeline — và tệ hơn, nó khiến CI báo xanh trong khi công việc đã hỏng.

Q3

Lỗi nào nên retry và lỗi nào không? Vì sao cần backoff luỹ thừa?

Xem đáp án
✓ Đáp án

Nên retry — lỗi tạm thời, tức lỗi mà việc gửi lại y nguyên request có cơ hội thành công: timeout, lỗi kết nối, 429 (chờ theo Retry-After), và 5xx (server đang gặp vấn đề).
Không retry — lỗi vĩnh viễn do phía ta: 400 (request sai), 401/403 (thiếu quyền), 404 (không tồn tại). Gửi lại vẫn hỏng, chỉ tốn thời gian và làm phiền server.
Backoff luỹ thừa (1s, 2s, 4s, 8s) cần thiết vì nếu hàng nghìn client cùng retry sau đúng một giây, server vừa hồi phục lại bị đánh sập ngay — hiện tượng "thundering herd". Hệ thống thật còn thêm jitter (một lượng ngẫu nhiên nhỏ) để các client không đồng bộ với nhau. Riêng POST tạo dữ liệu cần cẩn trọng: retry có thể tạo bản ghi trùng nếu API không hỗ trợ khoá idempotency.

Q4

Vì sao script chạy tay được nhưng cron thì không?

Xem đáp án
✓ Đáp án

Vì cron chạy trong môi trường tối thiểu, khác hẳn shell tương tác của bạn:

  • PATH ngắn — python có thể không tìm thấy, và chắc chắn không phải python trong venv. Phải dùng đường dẫn tuyệt đối /duong/dan/.venv/bin/python.
  • Thư mục làm việc là $HOME — mọi đường dẫn tương đối đều sai. Đây là lý do neo theo Path(__file__).resolve().parent.
  • Không có biến môi trường từ .zshrc/.bashrc — API_TOKEN biến mất. Phải khai báo trong crontab hoặc đọc từ tệp .env.
  • Không có terminal — input() treo mãi mãi; output không chuyển hướng sẽ bị cron gửi thành email.

Cách chẩn đoán nhanh: env -i /duong/dan/.venv/bin/python -m goi — cờ -i xoá sạch môi trường, tái hiện điều kiện cron ngay tại terminal.

Q5

Vì sao conn.execute(f"... WHERE ten = '{x}'") là lỗi nghiêm trọng?

Xem đáp án
✓ Đáp án

Đó là lỗ hổng SQL injection. Chuỗi do người dùng cung cấp được ghép thẳng vào câu lệnh SQL, nên nó không còn là dữ liệu mà trở thành mã lệnh. Với x = "'; DROP TABLE users; --", câu lệnh chạy ra thành hai lệnh, lệnh thứ hai xoá bảng. Kẻ tấn công cũng có thể dùng UNION SELECT để đọc dữ liệu ngoài phạm vi cho phép.

Cách đúng duy nhất là dùng tham số: conn.execute("... WHERE ten = ?", (x,)). Driver gửi câu lệnh và dữ liệu riêng biệt tới database, nên dữ liệu không bao giờ được diễn dịch thành mã. Lưu ý dấu phẩy trong (x,) — thiếu nó thì đó không phải tuple.
Tên bảng và tên cột không tham số hoá được; nếu chúng đến từ người dùng, phải kiểm tra bằng danh sách trắng cố định.

Q6

Trước khi thu thập dữ liệu từ một trang web, cần kiểm tra những gì?

Xem đáp án
✓ Đáp án

Theo thứ tự ưu tiên: (1) Có API chính thức không? Nếu có thì luôn dùng API — ổn định hơn, nhanh hơn, và được phép rõ ràng. (2) robots.txt tại https://ten-mien/robots.txt — dùng urllib.robotparser để kiểm tra bằng code, và khi không đọc được thì mặc định là không cho phép. (3) Điều khoản sử dụng — nhiều trang cấm thu thập tự động, vi phạm có thể dẫn tới hậu quả pháp lý. (4) Tần suất — nghỉ giữa các request, tôn trọng Crawl-delay nếu có. (5) User-Agent trung thực — nêu tên công cụ và cách liên hệ, đừng giả mạo trình duyệt. (6) Dữ liệu cá nhân — không thu thập nếu không có cơ sở pháp lý.

Q7

Vì sao dùng logging thay vì print trong sản phẩm?

Xem đáp án
✓ Đáp án

Năm lý do: (1) Cấp độ — người dùng bật -v để xem DEBUG, mặc định chỉ INFO trở lên; với print bạn phải tự viết cơ chế đó. (2) Nhiều đích đến — cùng lúc ghi ra tệp (có xoay vòng theo dung lượng) và ra màn hình; cron cần tệp vì không ai ngồi nhìn terminal lúc 3 giờ sáng. (3) Ngữ cảnh tự động — thời gian, tên module, cấp độ được thêm vào mọi dòng mà không phải gõ tay. (4) Traceback — log.exception() đính kèm nguyên vẹn vết ngăn xếp, trong khi print(f"lỗi: {e}") vứt bỏ nó. (5) Tắt được theo module — thư viện dùng logging.getLogger(__name__) để ứng dụng chủ quyết định hiển thị gì.

Ngoại lệ hợp lý: print vẫn đúng cho kết quả mà người dùng cần đọc hoặc chuyển qua ống lệnh — logging dành cho thông tin chẩn đoán, không phải đầu ra chính của chương trình.

Q8

Bạn lỡ commit một API token lên GitHub. Xử lý thế nào?

Xem đáp án
✓ Đáp án

Thu hồi token ngay lập tức và tạo token mới. Đây là bước đầu tiên và bắt buộc — mọi việc khác chỉ là dọn dẹp.

Xoá commit không đủ, vì: token vẫn nằm trong lịch sử git; đã có trong bản sao của bất kỳ ai từng clone/fork; các bot quét GitHub công khai thường tìm thấy khoá mới trong vài giây; và GitHub còn giữ commit qua API một thời gian kể cả sau khi force-push.

Sau khi đã thu hồi, mới dọn lịch sử bằng git filter-repo hoặc BFG (lưu ý điều này viết lại lịch sử, cần phối hợp với cả nhóm). Phòng ngừa về sau: đọc khoá từ biến môi trường, thêm .env vào .gitignore, cài gitleaks làm pre-commit hook (xem Chương 13), và bật secret scanning của GitHub.

Tổng kết

Sau chương 14 — chương cuối của Python sub-pillar — bạn đã master:

  • Ranh giới script/sản phẩm: 10 khía cạnh phải nâng cấp, và nguyên tắc "thao tác không hoàn tác được phải khó thực hiện hơn".
  • argparse: positional, cờ, choices, nargs, nhóm loại trừ, sub-command với set_defaults(ham=...).
  • Mẫu lập kế hoạch / thực thi — cho --dry-run miễn phí và test dễ.
  • Thu thập dữ liệu web: robots.txt, giới hạn tần suất, User-Agent trung thực, tách hàm thuần khỏi mạng.
  • REST API: xác thực qua biến môi trường, phân trang bằng generator, retry đúng loại lỗi, backoff luỹ thừa.
  • sqlite3: context manager tự commit/rollback, row_factory, và tham số ? chống SQL injection.
  • Chạy định kỳ: cron và bốn cái bẫy môi trường; logging có xoay vòng tệp.
  • Checklist 12 mục — thước đo khách quan cho "dự án đã xong".

Hết sub-pillar Python — đi đâu tiếp?

Bạn đã đi qua 14 chương: từ cài đặt, cú pháp, cấu trúc dữ liệu, hàm, ngoại lệ, module, tệp tin, OOP, generator, decorator, async, typing, testing, tới dự án hoàn chỉnh. Ba hướng đi tiếp tự nhiên:

  • Web backend — FastAPI (tận dụng trực tiếp type hint ở Chương 12 và async ở Chương 11) hoặc Django (nếu cần ORM và admin sẵn có).
  • Dữ liệu — polars hoặc pandas, numpy, và các thư viện trực quan hoá.
  • Đi sâu vào ngôn ngữ — metaclass, descriptor, __slots__, mở rộng bằng C/Rust, và profiling hiệu năng.

Dù chọn hướng nào, nền tảng ở 14 chương này đều dùng được nguyên vẹn — framework thay đổi, ngôn ngữ thì không.

Kết nối

  • Chương 5 (Exceptions) — ExceptionGroup gom lỗi retry; log.exception; mã thoát theo loại lỗi.
  • Chương 6 (Modules) — src layout, __main__.py, [project.scripts] tạo lệnh terminal.
  • Chương 7 (File I/O) — pathlib, JSON, CSV, encoding: nền tảng của cả bốn dự án.
  • Chương 8 (OOP) — @dataclass(frozen=True) cho model, @classmethod làm alternative constructor, __enter__/__exit__ cho client.
  • Chương 9 (Generators) — phân trang API bằng generator, xử lý tệp lớn theo dòng.
  • Chương 10 (Decorators & CM) — @contextmanager cho kết nối database, decorator @retry.
  • Chương 11 (Async) — nâng cấp: thu thập 100 URL song song bằng asyncio.TaskGroup + httpx.AsyncClient.
  • Chương 12 (Typing) — mypy --strict là mục 9 của checklist.
  • Chương 13 (Testing) — pytest, respx để giả lập HTTP, tmp_path fixture cho test tệp.
  • CLI pillar — cron, biến môi trường, mã thoát, chuyển hướng output.
  • Database pillar — SQL, chỉ mục, giao dịch: nền cho phần sqlite3.
  • DevOps pillar — đóng gói Docker, CI/CD cho chính dự án bạn vừa xây.