Project 1: Expense Tracker CLI
প্রথম পূর্ণ project, নিজের হাতে লেখা
দুই মাসে যা শিখেছো, সব এক জায়গায় লাগানোর সময়। এই project টা নিজের হাতে লিখবে, AI কে দিয়ে না। আটকে গেলে অধ্যায় আবার পড়ো, বা AI কে concept জিজ্ঞেস করো, কিন্তু code টা তোমার আঙুল দিয়ে যাবে। এই একটা project শেষ করলে Python নিয়ে আত্মবিশ্বাস অনেক বেড়ে যাবে, আর এটাই পরের মাসগুলোয় database আর API এর ভিত্তি হবে।
#কী বানাবো
একটা command line app, যেখানে দৈনিক খরচ লিখে রাখা যায়:
uv run expense add 250 "দুপুরের খাবার" --category food
uv run expense add 60 "রিকশা" --category transport --date 2026-10-28
uv run expense list --month 2026-10
uv run expense report --month 2026-10
uv run expense delete 3
uv run expense export --month 2026-10 --out october.csv$ uv run expense report --month 2026-10
অক্টোবর ২০২৬ এর খরচ
-----------------------------
food ৳ 4,250.00 (৪৮%)
transport ৳ 1,860.00 (২১%)
bills ৳ 2,700.00 (৩১%)
-----------------------------
মোট ৳ 8,810.00
সবচেয়ে বড় খরচ: বিদ্যুৎ বিল (৳ 1,450.00)#Requirement
যা করবে:
- খরচ যোগ করা: পরিমাণ, বিবরণ, category, তারিখ (না দিলে আজকের)
- মাস অনুযায়ী list দেখা
- মাসের category ভিত্তিক report
- id দিয়ে মুছে ফেলা
- CSV তে export
- Data JSON file এ থাকবে, app বন্ধ করলেও হারাবে না
যেভাবে করবে (non-functional):
- টাকা পয়সায় integer হিসেবে রাখা (float না)
- ভুল input এ পরিষ্কার বাংলা message, traceback না
- Save করার মাঝে crash হলেও data নষ্ট হবে না (atomic save)
- পুরো type hint,
pyrightএ শূন্য error - গুরুত্বপূর্ণ logic এ test, coverage ৮০% এর বেশি
#Structure
expense-tracker/
├── pyproject.toml
├── README.md
├── src/expense_tracker/
│ ├── __init__.py
│ ├── models.py ← Expense, Category (dataclass/enum), Money format
│ ├── errors.py ← ExpenseError পরিবার
│ ├── storage.py ← JsonStorage: load, save (atomic)
│ ├── service.py ← ExpenseService: add, list, delete, report (logic)
│ ├── export.py ← CSV
│ └── cli.py ← argparse, output সাজানো, error ধরা
└── tests/
├── conftest.py
├── test_models.py
├── test_service.py
└── test_storage.py#ধাপে ধাপে
#ধাপ ১: Model
# models.py
from dataclasses import dataclass
from datetime import date
from enum import StrEnum
class Category(StrEnum):
FOOD = "food"
TRANSPORT = "transport"
BILLS = "bills"
SHOPPING = "shopping"
HEALTH = "health"
OTHER = "other"
@dataclass
class Expense:
id: int
amount: int # পয়সায়
title: str
category: Category
spent_on: date
def format_taka(paisa: int) -> str:
return f"৳ {paisa / 100:,.2f}"
def parse_taka(text: str) -> int:
"""'250' বা '250.50' কে পয়সায় বদলায়।"""
... # তুমি লেখো: Decimal ব্যবহার করো, ঋণাত্মক বা শূন্য হলে InvalidAmountError#ধাপ ২: Error
# errors.py
class ExpenseError(Exception):
"""সব expense error এর মূল। cli এটা ধরে সুন্দর message দেখাবে।"""
class InvalidAmountError(ExpenseError): ...
class ExpenseNotFoundError(ExpenseError): ...
class StorageError(ExpenseError): ...#ধাপ ৩: Storage
# storage.py
from pathlib import Path
from typing import Protocol
class Storage(Protocol):
def load(self) -> list[Expense]: ...
def save(self, expenses: list[Expense]) -> None: ...
class JsonStorage:
def __init__(self, path: Path) -> None:
self.path = path
def load(self) -> list[Expense]:
# file না থাকলে খালি list
# JSON ভাঙা থাকলে StorageError (from e)
...
def save(self, expenses: list[Expense]) -> None:
# atomic save: .tmp এ লিখে os.replace
...Data file কোথায় রাখবে? User এর home folder এ: Path.home() / ".expense-tracker" / "data.json"। Project folder এ না।
#ধাপ ৪: Service
# service.py
class ExpenseService:
def __init__(self, storage: Storage) -> None:
self.storage = storage
def add(self, amount: int, title: str, category: Category, spent_on: date) -> Expense: ...
def list_month(self, year: int, month: int) -> list[Expense]: ...
def delete(self, expense_id: int) -> None: ... # না পেলে ExpenseNotFoundError
def report(self, year: int, month: int) -> "MonthlyReport": ...নতুন id কীভাবে দেবে? সবচেয়ে বড় id + ১। (ভাবো: মুছে ফেলার পর কী হবে? পুরনো id আবার ব্যবহার করা কি ঠিক?)
#ধাপ ৫: CLI
Python এর argparse দিয়ে sub-command:
# cli.py
import argparse
import sys
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="expense", description="দৈনিক খরচের হিসাব")
sub = parser.add_subparsers(dest="command", required=True)
add = sub.add_parser("add", help="নতুন খরচ")
add.add_argument("amount")
add.add_argument("title")
add.add_argument("--category", default="other", choices=[c.value for c in Category])
add.add_argument("--date", type=date.fromisoformat, default=date.today())
lst = sub.add_parser("list", help="মাসের খরচ")
lst.add_argument("--month", required=True, help="যেমন 2026-10")
# report, delete, export তুমি যোগ করো
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
service = ExpenseService(JsonStorage(default_path()))
try:
match args.command:
case "add":
exp = service.add(parse_taka(args.amount), args.title, Category(args.category), args.date)
print(f"যোগ হলো #{exp.id}: {exp.title} {format_taka(exp.amount)}")
case "list":
...
except ExpenseError as e:
print(f"সমস্যা: {e}", file=sys.stderr)
return 1
return 0pyproject.toml:
[project.scripts]
expense = "expense_tracker.cli:main"#ধাপ ৬: Test
# tests/conftest.py
import pytest
from expense_tracker.storage import JsonStorage
from expense_tracker.service import ExpenseService
@pytest.fixture
def service(tmp_path):
return ExpenseService(JsonStorage(tmp_path / "data.json"))যেগুলো অবশ্যই test করবে:
parse_taka:"250","250.5","250.55","0","-5","abc","1,250"- মাসের list এ অন্য মাসের খরচ আসছে না
- Report এ শতাংশের যোগফল ১০০ (rounding সাবধান)
- না থাকা id মুছলে
ExpenseNotFoundError - Save করে load করলে একই data ফিরে আসে
- ভাঙা JSON file থাকলে
StorageError main(["add", "abc", "x"])এর return value1
#Bonus চ্যালেঞ্জ
- বাজেট:
expense budget set food 5000আর মাসের ৮০% পার হলে সতর্কবার্তা - রঙিন output:
richpackage দিয়ে সুন্দর table - Undo: শেষ কাজ বাতিল করা
- বাংলা সংখ্যায় output এর option
#শেষ করার checklist
#নিজেকে যাচাই করো
Service কেন সরাসরি JsonStorage ব্যবহার না করে Storage Protocol এর উপর নির্ভর করে?
যাতে storage বদলানো যায় (পরে PostgreSQL) service না ছুঁয়ে, আর test এ চাইলে memory তে চলা নকল storage দেওয়া যায়।
মুছে ফেলা খরচের id আবার নতুন খরচকে দেওয়া কেন ঝুঁকিপূর্ণ?
পুরনো কোনো জায়গায় (export করা CSV, note) সেই id এর উল্লেখ থাকলে সেটা ভুল খরচকে নির্দেশ করবে। Database ও তাই id পুনরায় ব্যবহার করে না। সবসময় এ পর্যন্ত সবচেয়ে বড় id এর পরের সংখ্যা দেওয়া নিরাপদ, বা আলাদা counter রাখা।
Report এ শতাংশ round করলে যোগফল ৯৯% বা ১০১% কেন হতে পারে?
প্রতিটা ভাগ আলাদা করে round হয়, ছোট ছোট rounding এর পার্থক্য জমে। সমাধান: শেষ category তে বাকিটা দেওয়া (১০০ থেকে বাকিগুলোর যোগফল বাদ), বা largest remainder পদ্ধতি।
চিহ্ন দিলে sidebar আর রোডম্যাপে এই অধ্যায়ের station ভরে যাবে।