فصل ۸: پروژه و حرفه‌ای شدن — پروژه‌ی پایانی، API، تست، دیباگ و کلینیک خطا

پروژه‌ی پایانی (۲): رابط خط فرمان با argparse، گزارش با Counter و تاریخ شمسی

یک برنامه، چند فرمان

برنامه‌های خط فرمان حرفه‌ای (مثل git و pip) یک «فرمان اصلی» و چند «زیرفرمان» دارند. ماژول استاندارد argparse همین را با چند خط می‌سازد و راهنمای --help، بررسی نوع و پیام خطا را هم رایگان تحویل می‌دهد؛ دیگر لازم نیست با input() و منوهای عددی کلنجار بروید.

cli.py

# cli.py — رابط خط فرمان مدیریت سفارش فرش
import argparse
import sys
from collections import Counter
from datetime import datetime
from zoneinfo import ZoneInfo

import jdatetime

from store import STATUSES, Order, load_orders, next_code, save_orders, toman

TEHRAN = ZoneInfo("Asia/Tehran")


def to_jalali(iso: str, fmt: str = "%Y/%m/%d") -> str:
    local = datetime.fromisoformat(iso).astimezone(TEHRAN)
    return jdatetime.datetime.fromgregorian(datetime=local).strftime(fmt)


def cmd_add(args) -> int:
    orders = load_orders()
    order = Order(next_code(orders), args.customer, args.design, args.size, args.price)
    save_orders(orders + [order])
    print(f"سفارش {order.code} ثبت شد.")
    return 0


def cmd_list(args) -> int:
    for o in load_orders():
        if args.status is None or o.status == args.status:
            print(f"{o.code}  {o.customer}  {o.design}  {o.price:,}  {to_jalali(o.created)}  {o.status}")
    return 0


def cmd_status(args) -> int:
    orders = load_orders()
    for o in orders:
        if o.code == args.code.upper():
            o.status = args.status
            save_orders(orders)
            print(f"{o.code}: {o.status}")
            return 0
    print(f"سفارش {args.code} پیدا نشد.", file=sys.stderr)
    return 1


def cmd_report(args) -> int:
    orders = load_orders()
    sales = Counter()
    for o in orders:
        sales[to_jalali(o.created, "%Y/%m")] += o.price
    print("بر اساس وضعیت:", dict(Counter(o.status for o in orders)))
    for design, count in Counter(o.design for o in orders).most_common(3):
        print(f"  طرح {design}: {count} سفارش")
    for month, total in sorted(sales.items()):
        print(f"  {month}: {total:,} تومان")
    return 0


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(prog="orders", description="مدیریت سفارش فرش")
    sub = parser.add_subparsers(dest="command", required=True)
    p = sub.add_parser("add", help="ثبت سفارش")
    p.add_argument("customer")
    p.add_argument("design")
    p.add_argument("size")
    p.add_argument("--price", type=toman, required=True)
    p.set_defaults(func=cmd_add)
    p = sub.add_parser("list", help="فهرست سفارش‌ها")
    p.add_argument("--status", choices=STATUSES)
    p.set_defaults(func=cmd_list)
    p = sub.add_parser("status", help="تغییر وضعیت")
    p.add_argument("code")
    p.add_argument("status", choices=STATUSES)
    p.set_defaults(func=cmd_status)
    sub.add_parser("report", help="گزارش").set_defaults(func=cmd_report)
    return parser


def main(argv: list[str] | None = None) -> int:
    args = build_parser().parse_args(argv)
    try:
        return args.func(args)
    except ValueError as exc:
        print(f"خطا: {exc}", file=sys.stderr)
        return 2


if __name__ == "__main__":
    sys.exit(main())

اجرا در PowerShell

python cli.py add "رضا نراقی" افشان 3x4 --price ۵۷٬۰۰۰٬۰۰۰
python cli.py add "زهرا قمصری" ماهی 2x3 --price 31,500,000
python cli.py status ksh-101 weaving
python cli.py list --status weaving
python cli.py report
python cli.py add --help

سه ایده‌ی کلیدی

  • set_defaults(func=...): هر زیرفرمان تابع خودش را با خود می‌آورد؛ به‌جای زنجیره‌ی بلند if/elif فقط args.func(args) صدا زده می‌شود.
  • Counter سه کار گزارش را انجام می‌دهد: شمارش وضعیت‌ها، پرسفارش‌ترین طرح‌ها با most_common و جمع فروش هر ماه شمسی با += بدون مقداردهی اولیه.
  • main(argv) لیست آرگومان می‌گیرد؛ پس در تست یا REPL می‌نویسید main(["report"]) و دیگر به ترمینال نیازی نیست.

نکته‌هایی که کمتر کسی می‌داند

  • هر تابعی که رشته بگیرد و ValueError بدهد، می‌تواند type= در argparse باشد؛ argparse خطا را به پیام «invalid toman value» تبدیل می‌کند و با کد خروج 2 بیرون می‌رود، بدون این‌که try بنویسید.
  • sys.exit(main()) کد خروج را به سیستم‌عامل می‌دهد؛ در PowerShell متغیر $LASTEXITCODE آن را نشان می‌دهد و در Task Scheduler یا اسکریپت‌های پشتیبان، شکست برنامه تشخیص‌پذیر می‌شود.
  • اگر خروجی را در PowerShell با > به فایل بفرستید، پایتون در ویندوز برای stdout هدایت‌شده کدگذاری ANSI سیستم (cp1252 یا cp1256) را برمی‌دارد و ممکن است UnicodeEncodeError بگیرید؛ $env:PYTHONUTF8 = "1" را تنظیم کنید.
  • تراز ستونی با :16 برای متن فارسی در ترمینال قابل اعتماد نیست، چون ترمینال حروف را متصل و راست‌به‌چپ نمایش می‌دهد؛ برای گزارش جدی CSV با utf-8-sig بسازید و در اکسل باز کنید.
  • Counter.total() (از 3.10) جمع همه‌ی مقادیر را می‌دهد و دو Counter را می‌توان با + جمع کرد؛ مثلاً فروش دو شعبه‌ی کاشان و آران.

برای ذخیره‌ی پیشرفت و شرکت در آزمون، وارد شوید — رایگان است.