সরাসরি পর্বে যান
পর্ব ৫১ · ৫৫-এর মধ্যে · মডিউল ৭

ক্যাপস্টোন — দোকানের বিক্রি ও স্টকের একটি আসল Python সিস্টেম

Capstone — A Real Python System for a Small Shop's Sales, Stock and Daily Report

Taught in Bangla, this capstone builds a real Python system for shop sales and stock: requirements and business rules, a four-layer CLI, service, domain and storage architecture, dataclasses, money in integer paisa and custom exceptions, a storage Protocol with memory and SQLite implementations, transactions, six tested milestones, and turning it into a portfolio project.

এই পর্বে যা শিখবেন

  • একটি বাস্তব সমস্যা থেকে কমান্ডের টেবিল, ব্যবসার নিয়ম আর MVP-র সীমারেখা লেখা
  • চার স্তরের নকশা — CLI, service, domain, storage — আর কেন নির্ভরতা শুধু ভেতরের দিকে যায়
  • dataclass দিয়ে নিজেকে যাচাই করা domain, পয়সায় টাকা, আর custom exception-এর বংশতালিকা
  • Protocol-এর পেছনে দুটি storage — SQLite (parameterized query, transaction) আর memory — এবং একই টেস্ট দুটোতেই
  • ছয় মাইলস্টোনে বানানো, pytest, প্রজেক্টের কাঠামো (pyproject.toml, python -m shop), AI-কে দায়িত্বের সাথে ব্যবহার, আর portfolio সাজানো

১ · প্রোগ্রাম থেকে সিস্টেম — অনেক ছোট অংশ, প্রতিটির একটি কাজ

পঞ্চাশটি পর্বে আপনি Python-এর প্রায় পুরো ভাণ্ডার দেখেছেন: মূল ভাষা, ডেটা স্ট্রাকচার, module আর package, exception, ফাইল, class, dataclass, Protocol, type hint, টেস্টিং, logging, asyncio, পারফরম্যান্স, ওয়েব আর নিরাপত্তা। পর্ব ২১-এর মাঝপথের প্রজেক্টে বানিয়েছিলেন একটি ছোট টুল — কয়েকটি ফাংশন আর একটি লুপ, সব এক ফাইলে, বন্ধ করলেই সব ডেটা মুছে যায়। এবার তার বড় ভাই: এমন একটি সিস্টেম, যা ডেটা স্থায়ীভাবে রাখে, ভুলে ভাঙে না, টেস্ট দিয়ে প্রমাণিত, আর অন্য কেউ পড়ে বুঝতে ও বাড়াতে পারে।

মিরপুরের একটি মুদি দোকানের মালিক রহমান সাহেব। চাল, ডাল, তেল, ডিম — প্রায় দুশো রকম পণ্য। বিক্রি লেখা হয় খাতায়, স্টক মাথায়। মাস শেষে তিনি টের পান কোনো পণ্য ফুরিয়ে গেছে তিন দিন আগে, অথচ খদ্দের ফিরে গেছেন। কোন পণ্য সবচেয়ে বেশি বিক্রি হয়, আজ মোট কত টাকা এল — হাতে যোগ করতে আধা ঘণ্টা, তাও প্রায়ই মেলে না। তাঁর ছেলে কম্পিউটারে একটু-আধটু কাজ জানে। তাঁদের দরকার একটি ছোট সফটওয়্যার: বিক্রি লিখলেই স্টক কমবে, স্টক না থাকলে বিক্রি আটকাবে, আর দিনের শেষে একটি রিপোর্ট।

এই পর্বে আমরা সেই সিস্টেমটি নকশা করব আর ধাপে ধাপে বানানোর পথ দেখব। পুরো প্রজেক্ট প্রায় ৫০০–৭০০ লাইনের — একটি পর্বে প্রতিটি লাইন দেখানো সম্ভবও নয়, দরকারও নয়। আমরা দেখব সিদ্ধান্তগুলো: কোন অংশ কী করবে, কেন এভাবে, আর প্রতিটি সিদ্ধান্তের দাম কী। মূল কোডের গুরুত্বপূর্ণ অংশগুলো দেখাব, আর অংশ ৬-এ পুরো সিস্টেমের একটি সংক্ষিপ্ত এক-ফাইলের রূপ এই পাতাতেই চালিয়ে দেখবেন।

মূল ধারণা

একটি বড় সফটওয়্যার আসলে অনেক ছোট অংশ, যার প্রত্যেকটির একটিই কাজ, আর অংশগুলো একে অপরের সাথে কথা বলে স্পষ্ট, ছোট দরজা দিয়ে। একটি রেস্তোরাঁর কথা ভাবুন: ওয়েটার অর্ডার নেন, ম্যানেজার ঠিক করেন কোন অর্ডার নেওয়া যাবে (মাংস শেষ হলে না), রাঁধুনি রাঁধেন, ভাঁড়ারঘরের লোক জিনিস রাখেন আর বের করে দেন। ভাঁড়ারঘর তাক বদলালে ওয়েটারের কিছু যায় আসে না। সফটওয়্যারেও ঠিক তাই।

মাঝপথের প্রজেক্টের তুলনায় নতুন কী আসছে, এক নজরে দেখুন:

দিকপর্ব ২১ · ফলাফলের টুলপর্ব ৫১ · দোকানের সিস্টেম
ডেটা কোথায়একটি dict, শুধু মেমরিতেSQLite ডেটাবেস ফাইলে; টেস্টে memory store
ভুল সামলানোNone ফেরত আর শর্তcustom exception-এর বংশতালিকা
ডেটার রূপdict-এর ভেতরে dictনিজেকে যাচাই করা frozen dataclass
গঠনএক ফাইল, তিন স্তরএকটি package, চার স্তর, Protocol দিয়ে জোড়া
প্রমাণকয়েকটি assertpytest, দুই storage-এ একই টেস্ট, ruff আর mypy
চালানোpython result_tool.pypython -m shop sell rice 3, কমান্ড-লাইন subcommand

বিষয় বাছাইয়েও একটি সিদ্ধান্ত আছে। C++ কোর্সের ক্যাপস্টোন একটি লাইব্রেরির বই ধার দেওয়ার ব্যবস্থা; এখানে আমরা নিয়েছি এমন বিষয়, যেখানে টাকা আর স্টক — দুটি সংখ্যা, যেগুলো কখনো ভুল হতে পারবে না। আপনি চাইলে একই নকশায় কোচিং সেন্টারের ফি ও উপস্থিতি, ফার্মেসির ওষুধের মেয়াদ বা ছাত্রাবাসের মিলের হিসাব বানাতে পারেন; অংশ ১০-এ সে পরামর্শও আছে।

২ · চাহিদা — কমান্ডের টেবিল আর ব্যবসার নিয়ম

রহমান সাহেব আর তাঁর ছেলের সাথে বসে আমরা requirement লিখলাম। প্রথম সংস্করণ চলবে terminal-এ, একটি CLI হিসেবে। প্রতিটি কাজ একটি subcommand — git commit বা pip install-এর মতো, প্রোগ্রামের নামের পরে কাজের নাম:

কমান্ডকী করেকখন “ঠিক আছে” বলব
python -m shop add rice Chinigura 140.50 --stock 20নতুন পণ্য যোগ — SKU, নাম, দাম, শুরুর স্টকদাম ধনাত্মক, দশমিকের পরে সর্বোচ্চ ২ ঘর; একই SKU আবার দিলে error
python -m shop sell rice 3বিক্রি লেখাস্টক কমে, বিক্রি জমা হয়, মোট দাম দেখায়; স্টক কম, অজানা SKU বা ০/ঋণাত্মক পরিমাণে কিছুই বদলায় না
python -m shop restock oil 24নতুন মাল আসাস্টক বাড়ে; পরিমাণ ধনাত্মক
python -m shop stock --low 5স্টকের তালিকা--low দিলে শুধু ৫-এর কম থাকা পণ্য
python -m shop report --date 2026-10-18দৈনিক রিপোর্টপণ্যভিত্তিক বিক্রি টাকার ক্রমে, মোট, সবচেয়ে বেশি বিক্রি হওয়া ৩টি পণ্য; তারিখ না দিলে আজ
python -m shop export --format csv --out oct.csvবিক্রির তালিকা ফাইলেCSV (Excel-এ খোলা যায়) বা JSON; বাংলা নাম ঠিকঠাক থাকে

এখানে SKU মানে প্রতিটি পণ্যের ছোট, অনন্য কোড (rice, oil) — নাম বদলালেও কোড একই থাকে। টেবিলের শেষ কলাম আবারও আমাদের specification: প্রতিটি সারি একেকটি ভবিষ্যৎ টেস্ট। এর সাথে কয়েকটি ব্যবসার নিয়ম লিখে রাখলাম, যা কোনো একটি কমান্ডের নয়, পুরো সিস্টেমের:

  • স্টক কখনো ঋণাত্মক হবে না। এটি সিস্টেমের সবচেয়ে গুরুত্বপূর্ণ invariant — সবসময় সত্য থাকা শর্ত।
  • প্রতিটি বিক্রি হয় পুরোপুরি হবে, নয় একেবারেই না। স্টক কমল অথচ বিক্রি জমা হলো না — এমন অর্ধেক অবস্থা অসম্ভব।
  • টাকার হিসাব পয়সা পর্যন্ত নির্ভুল। দিনের মোট আর প্রতিটি বিক্রির যোগফল হুবহু মিলবে।
  • প্রতিটি বিক্রি আর প্রতিটি বাতিল হওয়া চেষ্টা log-এ থাকবে — পরে “কাল বিকেলে তেলের স্টক কীভাবে শূন্য হলো?” প্রশ্নের উত্তর দিতে।

✅ প্রথম সংস্করণে (MVP)

উপরের ছয়টি কমান্ড, চারটি নিয়ম, SQLite-এ স্থায়ী ডেটা, log ফাইল, pytest-এর টেস্ট।

MVP

⏳ পরে (লিখে রাখা আছে)

বাকির খাতা, একাধিক কর্মী ও লগইন, ওয়েব বা মোবাইল থেকে বিক্রি, স্টক কমলে SMS, মাসিক বিশ্লেষণ, বারকোড।

পরের সংস্করণ

পর্ব ২১-এর মতোই, MVP-র সীমারেখা লিখিত থাকায় “বাকির খাতাটাও একটু দিয়ে দাও না” শুনে আমরা “না” বলি না — বলি “হ্যাঁ, তালিকায় আছে, পরের ধাপে।” অংশ ১০-এ দেখবেন, আমাদের নকশা এই প্রতিটি সম্প্রসারণের জন্য আগে থেকেই জায়গা রেখেছে।

৩ · স্থাপত্য — চার স্তর: CLI → service → domain → storage

পর্ব ২১-এ আমরা কোডকে তিন ভাগে রেখেছিলাম: কথা বলা, হিসাব, ডেটা। ক্যাপস্টোনে সেই ভাবনাটিই একটি পূর্ণাঙ্গ layered architecture — প্রতিটি স্তর আলাদা module, প্রতিটির একটি দায়িত্ব। এটিই পর্ব ২১-এর separation of concerns, এবার পুরো প্রজেক্টের মাপে।

দোকানের সিস্টেম — চার স্তর আর একটি চুক্তি

তীর মানে “এটি ওটাকে চেনে ও ডাকে” — সব তীর ভেতরের দিকে

cli.pyargparse: লেখা → কমান্ড, ফল → লেখা
__main__.pypython -m shop-এর দরজা
service.py · Shopব্যবসার নিয়ম: sell, restock, report
report.pyCounter, groupby, CSV/JSON
domain.pyItem, Sale, ShopError — শুধু Python, কোনো I/O নেই
storage.pyStore Protocol · SqliteStore · MemoryStore
ছবি ৫১.১ — CLI চেনে service-কে; service চেনে domain আর Store নামের একটি Protocol-কে; কোন storage আসলে বসবে, তা ঠিক করে একেবারে বাইরের স্তর। domain কাউকে চেনে না।
স্তরকী করেকী কখনো করে না
CLIকমান্ড-লাইনের লেখা পড়ে, service ডাকে, ফল বা error ছাপে, exit code দেয়দাম হিসাব, স্টক পরীক্ষা, SQL
Serviceএকটি কাজের পুরো নিয়ম চালায়: খোঁজা → যাচাই → জমা রাখা → log লেখাprint, input, SQL লেখা
Domainব্যবসার ধারণাগুলো (পণ্য, বিক্রি) আর তাদের নিজস্ব নিয়ম (পরিমাণ ধনাত্মক)ফাইল, ডেটাবেস, নেটওয়ার্ক, এমনকি logging
Storageডেটা রাখা আর ফেরত দেওয়া; transactionকোনো ব্যবসার সিদ্ধান্ত (“স্টক কম, তাই বাতিল”)

সবচেয়ে গুরুত্বপূর্ণ নিয়মটি তীরের দিকে: ভেতরের স্তর বাইরের স্তরকে চেনে না। domain জানে না CLI আছে কি না; service জানে না ডেটা SQLite-এ না অন্য কোথাও। এজন্যই কাল যদি একটি ওয়েব পাতা (পর্ব ৪৮-এর FastAPI) লাগে, শুধু CLI-র পাশে আরেকটি বাইরের স্তর বসবে — service আর domain-এর একটি লাইনও বদলাবে না। এই ধারণার একটি জনপ্রিয় নাম hexagonal architecture বা “ports and adapters” — ভাবনার প্রশ্ন ২-এ এর ইতিহাস।

এবার দেখুন একটি বিক্রির কমান্ড চার স্তর কীভাবে পেরোয়। “পরে” চাপুন:

  1. CLIargparseলেখা → কমান্ড
  2. ServiceShop.sellনিয়ম
  3. DomainSale(...)নিজে যাচাই
  4. Storagerecord_saleএক transaction
  5. Loglog.infoপ্রমাণ
  6. উত্তরprintCLI-তে ফেরা
cli.py$ python -m shop sell rice 3
args = parser.parse_args(["sell", "rice", "3"])
args.cmd == "sell" · args.sku == "rice" · args.qty == 3
type=int-এর জন্য "3" ইতিমধ্যে int; "three" দিলে argparse নিজেই থামাত
service.pyShop.sell("rice", 3)
item, stock = store.get_item("rice")
→ Item(sku='rice', name='Chinigura', price=14050), 20
service জানে না store SQLite না memory — শুধু Protocol-এর method ডাকে
domain.pySale.__post_init__
sale = Sale("rice", qty=3, unit_price=14050, day=date(2026, 10, 18))
qty > 0 ✓  ·  sale.total == 42150 পয়সা
if 3 > 20: raise OutOfStock(...)   ← মিথ্যা, তাই এগিয়ে যায়
storage.pywith conn: (BEGIN … COMMIT)
UPDATE item SET stock = stock - ? WHERE sku = ?   (3, "rice")
INSERT INTO sale VALUES (?, ?, ?, ?)             ("rice", 3, 42150, "2026-10-18")
দুটোই সফল → COMMIT · rice-এর স্টক এখন 17
shop.logফাইলে, পর্দায় নয়
2026-10-18 19:42:07 INFO shop.service: sold 3 x rice, stock now 17
terminalexit code 0
ok: 3 x Chinigura = Tk 421.50
$ echo $?  →  0
  1. CLI পড়ে। argparse কমান্ড-লাইনের শব্দগুলো ভাগ করে, type=int দিয়ে পরিমাণকে সংখ্যায় বদলায়, ভুল হলে নিজেই usage দেখিয়ে থামে। CLI-র কাজ এটুকুই: লেখা থেকে Python-এর মান বানানো, তারপর service ডাকা।
  2. Service নিয়ম চালায়। Shop.sell প্রথমে storage থেকে পণ্য আর বর্তমান স্টক চায়। পণ্য না থাকলে এখানেই UnknownItem. service-এর হাতে আছে শুধু Store Protocol — কোন class আসলে বসানো, সে জানে না।
  3. Domain নিজেকে যাচাই করে। Sale তৈরির মুহূর্তে তার __post_init__ পরিমাণ পরীক্ষা করে। ভুল Sale তৈরিই হতে পারে না — তাই পরের কোনো স্তরকে আর পরীক্ষা করতে হয় না। তারপর service স্টকের নিয়ম মেলায়: ৩ ≤ ২০, ঠিক আছে।
  4. Storage জমা রাখে, এক transaction-এ। স্টক কমানো আর বিক্রি লেখা — দুটি SQL একসাথে। মাঝপথে বিদ্যুৎ গেলেও ডেটাবেস হয় আগের অবস্থায়, নয় নতুন অবস্থায় — অর্ধেক কখনো নয়। মান যায় ? দিয়ে, SQL-এর লেখায় জোড়া দিয়ে নয়।
  5. Log প্রমাণ রাখে। logging (পর্ব ৪০) সময়, স্তর আর ঘটনা লিখে রাখে একটি ফাইলে। ব্যবহারকারী এটি দেখেন না; দেখেন আপনি, যখন কিছু গোলমাল হয়।
  6. উত্তর ফেরে। service একটি string ফেরত দেয়, ছাপে CLI. সফল হলে exit code ০ — যাতে অন্য script জানতে পারে কাজটি হয়েছে। প্রতিটি স্তর তার এক কাজ করে পরের জনের হাতে দিল।

৪ · Domain — dataclass, পয়সায় টাকা আর custom exception

domain স্তর হলো সিস্টেমের হৃদয়: “পণ্য” আর “বিক্রি” বলতে আমরা কী বুঝি, আর তাদের কোন নিয়ম কখনো ভাঙা যাবে না। এখানে কোনো ফাইল, ডেটাবেস বা print নেই — শুধু সাধারণ Python. তাই এটি সবচেয়ে সহজে টেস্ট করা যায়, আর সবচেয়ে কম বদলায়।

টাকা কেন পূর্ণসংখ্যার পয়সায়

পর্ব ০৭-এ দেখেছেন, 0.1 + 0.2 Python-এ 0.30000000000000004. কারণ IEEE 754 float দশমিক ভগ্নাংশকে দুইয়ের ঘাতে রাখে, আর ০.১ সেখানে হুবহু লেখা যায় না। একটি বিক্রিতে এই ভুল চোখে পড়ে না, কিন্তু হাজার বিক্রির যোগফলে পয়সা এদিক-ওদিক হয়, আর == দিয়ে মেলানো ব্যর্থ হয়। সমাধান দুটি:

  • পূর্ণসংখ্যায় সবচেয়ে ছোট একক। টাকার minor unit পয়সা (১ টাকা = ১০০ পয়সা)। ৳১৪০.৫০ রাখা হয় 14050 হিসেবে। int-এর যোগ-গুণ সবসময় নির্ভুল, আর Python-এর int যত বড় দরকার তত বড় হয়।
  • Decimal — দশমিক পদ্ধতিতেই হিসাব করা সংখ্যা। ব্যবহারকারীর লেখা “140.50” পড়তে আমরা Decimal ব্যবহার করি, যাচাই করি দশমিকের পরে দুই ঘরের বেশি নেই, তারপর পয়সায় বদলাই। ভেতরে সবখানে int.
from decimal import Decimal, InvalidOperation
from shop.domain import InvalidInput

def parse_taka(text: str) -> int:
    """'140.50' → 14050 পয়সা। ঋণাত্মক, শূন্য, তিন ঘর দশমিক বা অক্ষর হলে InvalidInput."""
    try:
        d = Decimal(text)
    except InvalidOperation:
        raise InvalidInput(f"bad price {text!r}") from None
    if not d.is_finite() or d <= 0 or d != d.quantize(Decimal("0.01")):
        raise InvalidInput(f"bad price {text!r}")
    return int(d * 100)

def taka(poisha: int) -> str:
    return f"Tk {poisha // 100:,}.{poisha % 100:02d}"      # 104250 → 'Tk 1,042.50'

লক্ষ করুন, টাকাকে কখনো float-এ নেওয়া হয় না — Decimal("140.50") * 100 হুবহু 14050. d.is_finite() পরীক্ষাটি “NaN” বা “Infinity” লেখাকে আটকায়, যেগুলো Decimal বৈধ সংখ্যা হিসেবে পড়ে ফেলে। আর ছাপার সময় // আর % দিয়ে টাকা ও পয়সা আলাদা। ভ্যাট বা ছাড়ের মতো ভাগের হিসাব এলে Decimal-এর quantize দিয়ে কোন নিয়মে পূর্ণ করবেন তা স্পষ্ট বলে দিন — এটি ব্যবসার সিদ্ধান্ত, কোডের নয়।

নিজেকে পাহারা দেওয়া dataclass

পর্ব ৩২-এর frozen dataclass এখানে আদর্শ। @dataclass নিজেই __init__, __repr__, __eq__ লিখে দেয়; frozen=True তৈরির পরে বদলানো আটকায়; আর __post_init__ তৈরির মুহূর্তে নিয়ম পরীক্ষা করে। ফল: ভুল অবস্থার একটি Sale object পৃথিবীতে থাকতেই পারে না।

from dataclasses import dataclass
from datetime import date

class ShopError(Exception):
    """সব ব্যবসায়িক ভুলের মূল class — CLI শুধু এটাকেই ধরে।"""

class InvalidInput(ShopError): ...
class UnknownItem(ShopError): ...
class DuplicateItem(ShopError): ...

class OutOfStock(ShopError):
    def __init__(self, sku: str, have: int, want: int) -> None:
        super().__init__(f"only {have} {sku} left, asked {want}")
        self.sku, self.have, self.want = sku, have, want     # পরে কাজে লাগবে

@dataclass(frozen=True)
class Item:
    sku: str
    name: str
    price: int                          # পয়সা

    def __post_init__(self) -> None:
        if not (self.sku.isascii() and self.sku.isidentifier()):
            raise InvalidInput(f"SKU must be a short code like 'rice', got {self.sku!r}")
        if self.price <= 0:
            raise InvalidInput("price must be positive")

@dataclass(frozen=True)
class Sale:
    sku: str
    qty: int
    unit_price: int                     # বিক্রির মুহূর্তের দাম, পয়সায়
    day: date

    def __post_init__(self) -> None:
        if self.qty <= 0:
            raise InvalidInput(f"quantity must be positive, got {self.qty}")

    @property
    def total(self) -> int:
        return self.qty * self.unit_price

তিনটি নকশার সিদ্ধান্ত লক্ষ করুন। এক: Sale নিজের মধ্যে unit_price রাখে। কাল তেলের দাম বাড়লেও আজকের বিক্রির টাকা বদলাবে না — হিসাবের খাতায় অতীত কখনো বদলায় না। দুই: total একটি property, জমা রাখা মান নয় — তাই qty আর total-এর অমিল অসম্ভব। তিন: exception-গুলো একটি exception hierarchy: সবাই ShopError-এর সন্তান।

কেন আলাদা আলাদা custom exception, শুধু ValueError নয়? কারণ এগুলো ব্যবসার ঘটনা, প্রোগ্রামের বাগ নয়। CLI একটি মাত্র except ShopError দিয়ে সব ব্যবসায়িক ভুল ধরে সুন্দর বার্তা দেয়; কিন্তু একটি সত্যিকারের বাগ — ধরুন TypeError — সেই জালে ধরা পড়ে না, traceback দিয়ে জোরে চিৎকার করে, যাতে আপনি জানতে পারেন। আর OutOfStock নিজের মধ্যে have আর want বহন করে — ভবিষ্যতের ওয়েব স্তর এগুলো দিয়ে বলতে পারবে “মাত্র ৫টি আছে, ৫টিই নেবেন?”

এবার দেখুন একটি অবৈধ বিক্রি — স্টক ৫, চাওয়া ৬ — কীভাবে বাতিল হয়, আর কোথাও কোনো দাগ রেখে যায় না:

1print(shop.sell("oil", 6))           # cli.py2item, stock = store.get_item("oil")  # service3sale = Sale("oil", 6, item.price, today)4if 6 > stock: raise OutOfStock(...)5store.record_sale(sale)   # চলেই না6except ShopError as e: log.warning(...)7print(f"error: {e}"); return 1
এই মুহূর্তের অবস্থা
oil-এর স্টক (DB)
sale টেবিলে সারি
sale
exception
  1. CLI ডাকল। shop.sell("oil", 6) — CLI জানে না এটি সফল হবে কি না; সে শুধু একটি try-এর ভেতরে ডেকেছে।
  2. পড়া হলো, বদলানো নয়। service storage থেকে পণ্য আর স্টক (৫) পড়ল। পড়া নিরাপদ — এখনো কিছু বদলায়নি।
  3. Domain রাজি। পরিমাণ ৬ ধনাত্মক, তাই Sale তৈরি হলো। domain জানে না স্টক কত — সেটি তার কাজ নয়; স্টক মেলানো service-এর নিয়ম, কারণ তার জন্য storage থেকে পড়া লাগে।
  4. নিয়ম ভাঙল — exception. ৬ > ৫, তাই raise OutOfStock. raise হওয়ামাত্র ফাংশনের বাকি অংশ চলে না — পরের লাইনের record_sale কখনো ডাকা হয় না। যাচাই আগে, বদল পরে — পর্ব ২১-এর নীতি, এবার exception দিয়ে।
  5. ধরা পড়ল বাইরের স্তরে। exception call stack বেয়ে উঠে CLI-র except ShopError-এ ধরা পড়ল (পর্ব ২৪)। CLI একটি WARNING log লিখল — বাতিল হওয়া চেষ্টাও প্রমাণ।
  6. পরিষ্কার উত্তর, অক্ষত ডেটা। ব্যবহারকারী পেলেন এক লাইনের বার্তা, traceback নয়; exit code ১, যাতে script বুঝতে পারে ব্যর্থ হয়েছে। স্টক এখনো ৫, বিক্রির সারি এখনো ২ — সিস্টেম ঠিক আগের মতো।

৫ · Storage — একটি Protocol, দুটি রূপ

service-কে ডেটা রাখতে হবে, কিন্তু আমরা চাই না সে SQLite-এর সাথে বাঁধা থাকুক। পর্ব ৩৩-এর Protocol ঠিক এই কাজের যন্ত্র: আমরা লিখে দিই service-এর কোন কোন method লাগবে — একটি চুক্তি। যে class-এ এই method-গুলো আছে, সে-ই চলবে, উত্তরাধিকার ছাড়াই। একে বলে structural typing; mypy চুক্তি মেলে কি না পরীক্ষা করে দেয়।

from datetime import date
from typing import Protocol
from shop.domain import Item, Sale

class Store(Protocol):
    def add_item(self, item: Item, stock: int) -> None: ...
    def get_item(self, sku: str) -> tuple[Item, int] | None: ...
    def record_sale(self, sale: Sale) -> None: ...
    def restock(self, sku: str, qty: int) -> None: ...
    def sales_between(self, start: date, end: date) -> list[Sale]: ...

এই চুক্তির দুটি বাস্তবায়ন। MemoryStore — একটি dict আর একটি list, প্রায় ২৫ লাইন; টেস্টে ব্যবহারের জন্য, মিলিসেকেন্ডে চলে, কোনো ফাইল তৈরি করে না। এটি একধরনের fake object. আর SqliteStore — আসল কাজের জন্য, পর্ব ৫০-এর নিরাপত্তার নিয়ম মেনে:

import sqlite3
from pathlib import Path

SCHEMA = """
CREATE TABLE IF NOT EXISTS item (
    sku   TEXT PRIMARY KEY,
    name  TEXT NOT NULL,
    price INTEGER NOT NULL CHECK (price > 0),
    stock INTEGER NOT NULL CHECK (stock >= 0)
);
CREATE TABLE IF NOT EXISTS sale (
    id INTEGER PRIMARY KEY, sku TEXT NOT NULL REFERENCES item(sku),
    qty INTEGER NOT NULL, unit_price INTEGER NOT NULL, day TEXT NOT NULL
);
"""

class SqliteStore:
    def __init__(self, path: Path | str = ":memory:") -> None:
        self.conn = sqlite3.connect(path)
        self.conn.executescript(SCHEMA)

    def record_sale(self, sale: Sale) -> None:
        with self.conn:                     # সফল হলে COMMIT, exception হলে ROLLBACK
            self.conn.execute("UPDATE item SET stock = stock - ? WHERE sku = ?",
                              (sale.qty, sale.sku))
            self.conn.execute("INSERT INTO sale (sku, qty, unit_price, day) VALUES (?, ?, ?, ?)",
                              (sale.sku, sale.qty, sale.unit_price, sale.day.isoformat()))

চারটি জিনিস খেয়াল করুন:

  • Parameterized query: প্রতিটি মান যায় ? দিয়ে, আলাদা tuple-এ। কখনো f-string দিয়ে SQL বানানো নয় — নইলে কেউ SKU-র জায়গায় SQL লিখে দিলেই SQL injection (পর্ব ৫০)।
  • Transaction: with self.conn: ব্লকের দুটি লেখা একসাথে commit হয়; মাঝে কিছু ব্যর্থ হলে rollback. মনে রাখবেন, এই with connection বন্ধ করে না — শুধু transaction সামলায়।
  • ডেটাবেসেও পাহারা: CHECK (stock >= 0) — service-এর নিয়মে কোনো বাগ থাকলেও ডেটাবেস ঋণাত্মক স্টক জমা নেবে না, sqlite3.IntegrityError তুলবে, আর transaction ফিরে যাবে। দুই স্তরে পাহারা, একটি ভুললে অন্যটি ধরে।
  • ":memory:" default: path না দিলে পুরো ডেটাবেস থাকে RAM-এ — একটি in-memory database. টেস্টে আর এই পাতার চালানো উদাহরণে ঠিক এটিই ব্যবহার করব; আসল দোকানে shop.db ফাইল।

Protocol-এর আসল শক্তি দেখুন: কোন storage বসবে, তা ঠিক হয় এক জায়গায়, আর বাকি সব অপরিবর্তিত।

Shop(store)service — চেনে শুধু Store
tests/test_shop.pyএকই টেস্ট
Store (Protocol)add_item · get_item · record_sale · restock · sales_between
MemoryStoredict + list · টেস্টে
SqliteStoreshop.db · আসল দোকানে
ভবিষ্যৎ: PostgresStoreঅনেক দোকান, একটি সার্ভার
  1. চুক্তি। Shop.__init__(self, store: Store) — service শুধু Protocol-এর নাম জানে। বাইরে থেকে store ঢুকিয়ে দেওয়ার এই কৌশলের নাম dependency injection: Shop নিজে store বানায় না, কেউ তাকে দেয়।
  2. টেস্টে memory. Shop(MemoryStore()) — কোনো ফাইল নেই, কোনো পরিষ্কার করা নেই, শত শত টেস্ট এক সেকেন্ডের কম সময়ে। Shop টের পায় না যে আসল ডেটাবেস নেই।
  3. আসল কাজে SQLite. Shop(SqliteStore("shop.db")) — এই একটি লাইন থাকে cli.py-তে। service, domain, রিপোর্ট — কোথাও কোনো বদল নেই।
  4. একই টেস্ট, দুই store-এ। pytest-এর parametrized fixture প্রতিটি টেস্ট দুবার চালায় — একবার memory, একবার SQLite (একটি অস্থায়ী ফাইলে)। দুটোই পাস মানে দুই বাস্তবায়ন সত্যিই একই আচরণ করে; SQLite-এর ধাপটি একটি integration test.
  5. ভবিষ্যৎ। রহমান সাহেব দ্বিতীয় দোকান খুললে দুই দোকানের ডেটা এক সার্ভারে লাগতে পারে। তখন একটি নতুন class লিখবেন যা একই পাঁচটি method মানে, আর আগের সব টেস্ট তার ওপরও চালাবেন। চুক্তি স্থির থাকলে বাস্তবায়ন বদলানো নিরাপদ।

Repository-র মতো, কিন্তু সরল। বড় প্রজেক্টে এই storage স্তরকে প্রায়ই “repository” বলে — domain object রাখা ও খোঁজার একটি স্তর, যার পেছনে ডেটাবেস লুকানো। আমরা ইচ্ছা করে কোনো ORM (যেমন SQLAlchemy) নিইনি: দুটি টেবিলের জন্য সরাসরি SQL পড়তে সহজ, standard library-তেই চলে, আর আপনি দেখতে পান ডেটাবেসে আসলে কী যাচ্ছে।

৬ · এক ফাইলে পুরো সিস্টেম — চালিয়ে দেখুন

আসল প্রজেক্ট কয়েকটি ফাইলে ভাগ করা, কিন্তু এই পাতার runner একটি ফাইলই চালাতে পারে। তাই নিচে চার স্তরের একটি সংক্ষিপ্ত রূপ, এক ফাইলে: domain (Sale, exception), storage (Store Protocol আর :memory: SQLite), service (Shop), আর CLI. প্রজেক্ট বলে এটি অন্য উদাহরণের চেয়ে অনেক লম্বা — প্রায় ৮০ লাইন — কিন্তু প্রতিটি অংশ আপনি এই পর্বে আলাদা করে দেখেছেন। কয়েকটি সরলীকরণ: পণ্যগুলো শুরুতেই কোডে যোগ করা, তারিখ নেই, আর argparse-এর বদলে একটি কমান্ড-লুপ, কারণ runner শুধু ইনপুট-লাইন দিতে পারে।

স্ক্রিপ্ট করা সেশনে বৈধ বিক্রি, স্টকের চেয়ে বেশি চাওয়া, শূন্য পরিমাণ, অজানা পণ্য আর ভুল লেখা — সবই আছে। চালানোর আগে অনুমান করুন রিপোর্টের মোট কত হবে:

import logging, sqlite3, sys
from collections import Counter
from dataclasses import dataclass
from typing import Protocol

logging.basicConfig(stream=sys.stdout, format="  log %(levelname)s: %(message)s", level=logging.INFO)
log = logging.getLogger("shop")
class ShopError(Exception): pass                   # base of all business errors
class OutOfStock(ShopError): pass
class UnknownItem(ShopError): pass
def taka(poisha: int) -> str:
    return f"Tk {poisha // 100:,}.{poisha % 100:02d}"
@dataclass(frozen=True)
class Sale:                                        # domain: guards its own rules
    sku: str
    qty: int
    unit_price: int                                # poisha, never float
    def __post_init__(self) -> None:
        if self.qty <= 0:
            raise ShopError(f"quantity must be positive, got {self.qty}")
    @property
    def total(self) -> int:
        return self.qty * self.unit_price
class Store(Protocol):                             # what the service needs
    def get(self, sku: str) -> tuple[str, int, int] | None: ...
    def record_sale(self, sale: Sale) -> None: ...
    def sales(self) -> list[tuple[str, int, int]]: ...
class SqliteStore:
    def __init__(self) -> None:
        self.db = sqlite3.connect(":memory:")
        self.db.executescript("CREATE TABLE item(sku TEXT PRIMARY KEY, name TEXT, price INT, stock INT);"
                              "CREATE TABLE sale(sku TEXT, qty INT, total INT);")
    def add_item(self, sku: str, name: str, price: int, stock: int) -> None:
        self.db.execute("INSERT INTO item VALUES (?, ?, ?, ?)", (sku, name, price, stock))
    def get(self, sku: str) -> tuple[str, int, int] | None:
        return self.db.execute("SELECT name, price, stock FROM item WHERE sku = ?", (sku,)).fetchone()
    def record_sale(self, sale: Sale) -> None:
        with self.db:                              # one transaction: both or neither
            self.db.execute("UPDATE item SET stock = stock - ? WHERE sku = ?", (sale.qty, sale.sku))
            self.db.execute("INSERT INTO sale VALUES (?, ?, ?)", (sale.sku, sale.qty, sale.total))
    def sales(self) -> list[tuple[str, int, int]]:
        return self.db.execute("SELECT sku, qty, total FROM sale").fetchall()
class Shop:                                        # service: business rules, no print/input
    def __init__(self, store: Store) -> None:
        self.store = store
    def sell(self, sku: str, qty: int) -> str:
        if (item := self.store.get(sku)) is None:
            raise UnknownItem(f"no item '{sku}'")
        name, price, stock = item
        sale = Sale(sku, qty, price)
        if qty > stock:
            raise OutOfStock(f"only {stock} {name} left, asked {qty}")
        self.store.record_sale(sale)
        log.info("sold %d x %s, stock now %d", qty, sku, stock - qty)
        return f"ok: {qty} x {name} = {taka(sale.total)}"
    def report(self) -> list[str]:
        pcs, money = Counter(), Counter()
        for sku, qty, total in self.store.sales():
            pcs[sku] += qty
            money[sku] += total
        rows = [f"{s:<5}{pcs[s]:>3} pcs {taka(money[s]):>11}" for s in sorted(money, key=lambda s: (-money[s], s))]
        return rows + [f"TOTAL {taka(sum(money.values())):>18}"]

store = SqliteStore()
for row in [("rice", "Chinigura", 14050, 20), ("egg", "Egg", 1350, 30), ("oil", "Soyabean", 18900, 5)]:
    store.add_item(*row)                           # Tk 140.50 = 14050 poisha
shop = Shop(store)
for line in sys.stdin:                             # CLI: read, call the service, print
    print(">", line.strip())
    cmd, *args = line.split() or [""]
    try:
        match cmd, args:
            case "sell", [sku, qty] if qty.lstrip("-").isdecimal():
                print("  " + shop.sell(sku, int(qty)))
            case "report", []:
                print("\n".join("  " + row for row in shop.report()))
            case _:
                print("  usage: sell SKU QTY | report")
    except ShopError as e:
        log.warning("%s: %s", type(e).__name__, e)
        print(f"  error: {e}")

ইনপুট: data-stdin-এ ৯টি লাইন — sell rice 3, sell egg 12, sell oil 6, sell oil 0, sell sugar 1, sell oil 2, sell egg 6, sell egg two, report. শুরুর স্টক: চাল ২০ (৳১৪০.৫০), ডিম ৩০ (৳১৩.৫০), তেল ৫ (৳১৮৯). আউটপুট মিলিয়ে নিন: চাল ৩টি = Tk 421.50, স্টক ১৭; ডিম ১২টি = Tk 162.00, স্টক ১৮. তারপর তিনটি বাতিল: তেল ৬টি — OutOfStock; তেল ০টি — domain-এর Sale.__post_init__ থেকে ShopError; চিনি — UnknownItem. প্রতিটির জন্য একটি log WARNING আর একটি error: লাইন। এরপর তেল ২টি সফল, আর log বলছে stock now 3 — অর্থাৎ ৫ থেকে ২ কমেছে; আগের বাতিল হওয়া ৬টির চেষ্টা স্টকে কোনো দাগ রাখেনি। ডিম আরও ৬টি = Tk 81.00. sell egg two-এ “two” সংখ্যা নয়, তাই match-এর guard মেলে না, usage বার্তা আসে। রিপোর্ট টাকার ক্রমে: rice ৳৪২১.৫০, oil ৳৩৭৮.০০, egg ১৮টি ৳২৪৩.০০; মোট Tk 1,042.50 — হাতে যোগ করে দেখুন, এক পয়সাও এদিক-ওদিক নেই। কয়েকটি খুঁটিনাটি: (১) logging সাধারণত stderr-এ লেখে; এখানে stream=sys.stdout দিয়েছি যাতে log আর উত্তর পাশাপাশি দেখা যায় — আসল প্রজেক্টে log যায় ফাইলে। (২) Shop-এ কোনো print নেই; সে string ফেরত দেয় বা exception তোলে। (৩) sorted(..., key=lambda s: (-money[s], s)) — পর্ব ২১-এর সেই tuple-কৌশল: টাকা বেশি থেকে কম, সমান হলে SKU-র বর্ণক্রমে। (৪) for line in sys.stdin ইনপুট শেষ হলে নিজেই থামে, তাই quit বা EOFError সামলানোর দরকার হয়নি।

C/C++ জানলে

C++ কোর্সের ক্যাপস্টোনেও একই চার স্তর — CLI → service → domain → storage — কিন্তু সেখানে storage-এর চুক্তি একটি abstract base class (pure virtual function), টাকার জন্য নিজের Money class, আর ভুল ফেরত দিতে std::expected. Python-এ চুক্তি Protocol (উত্তরাধিকার লাগে না, mypy পরীক্ষা করে), ভুলের জন্য exception, আর domain-এর নিয়ম __post_init__-এ। C++-এ অনেক ভুল compile-এর সময়ই ধরা পড়ে; Python-এ সেই দায় নেয় type checker আর টেস্ট। পাশাপাশি পড়তে C++ কোর্সের ক্যাপস্টোন প্রজেক্ট দেখুন।

৭ · ধাপে ধাপে বানানো — ছয় মাইলস্টোন আর প্রজেক্টের কাঠামো

পর্ব ২১-এ পাঁচটি milestone ছিল; এবার ছয়টি, আর ক্রমটি গুরুত্বপূর্ণ: ভেতর থেকে বাইরে। প্রথমে domain, কারণ সেটি কাউকে চেনে না আর সবচেয়ে সহজে টেস্ট হয়; সবশেষে বাইরের জিনিস — ডেটাবেস আর ফাইল। প্রতিটি মাইলস্টোনের শেষে টেস্ট পাস, একটি git commit, আর একটি প্রশ্ন: “আজ থামতে হলে হাতে কী আছে?”

  1. M1domainধারণা ও নিয়ম
  2. M2servicememory-তে
  3. M3cliargparse + log
  4. M4sqliteস্থায়ী ডেটা
  5. M5reportCSV/JSON
  6. M6polishগোছানো
M1 · domain.py, money.pyকোনো I/O নেই
parse_taka("140.50") == 14050 · parse_taka("1.005") → InvalidInput
Sale("oil", 0, 18900, d) → InvalidInput
টেস্ট: টাকার সীমা · NaN · ০ পরিমাণ · frozen-এ বদলাতে গেলে FrozenInstanceError
M2 · service.py + MemoryStoreসব নিয়ম, ডেটাবেস ছাড়া
shop = Shop(MemoryStore(), today=lambda: date(2026, 10, 18))
shop.sell("oil", 6) → OutOfStock · stock অপরিবর্তিত
টেস্ট: বিক্রি · স্টক কম · অজানা SKU · ডুপ্লিকেট SKU · restock
M3 · cli.py, __main__.pyপ্রথমবার মানুষ ব্যবহার করতে পারে
$ python -m shop sell oil 6
error: only 5 oil left, asked 6        (exit code 1)
টেস্ট: main(["sell", "oil", "6"]) == 1 · capsys দিয়ে আউটপুট মেলানো
M4 · SqliteStoreবন্ধ করলেও ডেটা থাকে
@pytest.fixture(params=["memory", "sqlite"])
M2-এর সব টেস্ট এবার দুই store-এ · তার সাথে: ঋণাত্মক স্টকে IntegrityError
নতুন কোনো service টেস্ট লিখতে হলো না
M5 · report.pyএখানে MVP সম্পূর্ণ
$ python -m shop report --date 2026-10-18
rice  3 pcs  Tk 421.50 ... TOTAL Tk 1,042.50 · top: rice, oil, egg
টেস্ট: খালি দিন · সমান টাকায় ক্রম · CSV-তে বাংলা নাম round trip
M6 · pyproject, ruff, mypy, READMEঅন্য কেউ চালাতে পারে
$ ruff check . && mypy src && pytest
All checks passed! · Success: no issues found · 24 passed
GitHub-এ প্রতিটি push-এ এই তিনটি স্বয়ংক্রিয়ভাবে (CI)
  1. M1 — domain. Item, Sale, exception-এর বংশতালিকা, parse_taka. কোনো ফাইল বা ডেটাবেস নেই, তাই টেস্ট লেখা সবচেয়ে সহজ। এই ধাপের টেস্টগুলো বেশিরভাগই সীমানা আর ভুল ইনপুট।
  2. M2 — service, memory-তে। Shop আর MemoryStore. ব্যবসার সব নিয়ম এখন চলে, যদিও কোনো ডেটাবেস নেই। আজকের তারিখও বাইরে থেকে দেওয়া (today=...), যাতে টেস্টের ফল প্রতিদিন একই থাকে।
  3. M3 — CLI. argparse-এর subcommand, __main__.py, আর logging.basicConfig একবার, শুধু এখানে। main(argv) একটি list নেয় আর exit code ফেরত দেয় — তাই terminal ছাড়াই টেস্ট করা যায়।
  4. M4 — SQLite. এখন বড় পরীক্ষা: M2-এর টেস্টগুলো fixture বদলে SQLite-এর ওপর চালান। সব পাস হলে প্রমাণ হলো, storage বদলানোয় আচরণ বদলায়নি। এখানেই Protocol-এর বিনিয়োগ ফেরত আসে।
  5. M5 — রিপোর্ট আর export. Counter, sorted, groupby, csv, json. স্পেকের সব কমান্ড তৈরি — এটাই MVP, রহমান সাহেবকে দেখানো যায়।
  6. M6 — গোছানো। pyproject.toml, ruff আর mypy পরিষ্কার, README, আর GitHub-এ continuous integration. আচরণ একই; কিন্তু এখন অন্য একজন প্রোগ্রামার — বা ছয় মাস পরের আপনি — নির্ভয়ে এতে হাত দিতে পারবেন।

প্রজেক্টের কাঠামো আর pyproject.toml

পর্ব ২২-২৩-এ শেখা src layout:

shop-ledger/
├── pyproject.toml
├── README.md
├── src/shop/
│   ├── __init__.py
│   ├── __main__.py      # python -m shop
│   ├── cli.py           # argparse, logging config, exit code
│   ├── service.py       # Shop
│   ├── domain.py        # Item, Sale, ShopError…
│   ├── money.py         # parse_taka, taka
│   ├── storage.py       # Store, MemoryStore, SqliteStore
│   └── report.py        # daily_report, export_csv, export_json
└── tests/
    ├── conftest.py      # shop fixture (memory + sqlite)
    ├── test_domain.py
    ├── test_service.py
    └── test_cli.py
[project]
name = "shop-ledger"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = []                       # শুধু standard library

[project.optional-dependencies]
dev = ["pytest", "ruff", "mypy"]

[project.scripts]
shop = "shop.cli:main"                  # pip install করলে "shop sell rice 3"

[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"

dependencies = [] একটি সচেতন সিদ্ধান্ত: sqlite3, argparse, logging, csv, json, decimal — সবই standard library-তে। দোকানের পুরোনো কম্পিউটারে শুধু Python থাকলেই চলবে, আর পর্ব ৫০-এর supply-chain ঝুঁকিও প্রায় শূন্য। [project.scripts] একটি entry point বানায়: pip install -e . করলে shop নামে একটি কমান্ড তৈরি হয়। আর python -m shop কাজ করে __main__.py-এর জন্য:

def main(argv: list[str] | None = None) -> int:
    args = build_parser().parse_args(argv)          # subcommand: add, sell, restock…
    logging.basicConfig(filename=args.log, level=logging.INFO,
                        format="%(asctime)s %(levelname)s %(name)s: %(message)s")
    shop = Shop(SqliteStore(args.db))               # এখানেই, শুধু এখানেই, storage বাছাই
    try:
        match args.cmd:
            case "sell":
                print(shop.sell(args.sku, args.qty))
            case "report":
                print(render(shop.daily_report(args.date)))
            # … add, restock, stock, export
    except ShopError as e:
        log.warning("%s: %s", type(e).__name__, e)
        print(f"error: {e}", file=sys.stderr)
        return 1
    return 0

# __main__.py — পুরো ফাইল
from shop.cli import main
raise SystemExit(main())

রিপোর্ট আর export — Counter, groupby, CSV, JSON

দৈনিক রিপোর্ট মূলত তিনটি প্রশ্নের উত্তর: কোন পণ্যে কত টাকা, মোট কত, আর সেরা তিনটি কোনগুলো। পর্ব ২৭-এর Counter গোনার জন্য তৈরি, আর তার most_common() সেরা তালিকা দেয়। কয়েক দিনের রিপোর্ট একসাথে লাগলে পর্ব ৩৮-এর groupby — তবে মনে রাখবেন, groupby শুধু পাশাপাশি থাকা সমান key-কে এক দলে আনে, তাই আগে সাজাতে হয়:

from collections import Counter
from itertools import groupby
from operator import attrgetter

def daily_report(sales: list[Sale]) -> Report:
    money: Counter[str] = Counter()
    for s in sales:
        money[s.sku] += s.total
    rows = sorted(money.items(), key=lambda kv: (-kv[1], kv[0]))   # টাকা ↓, তারপর SKU
    return Report(rows=rows, total=sum(money.values()),
                  top=[sku for sku, _ in rows[:3]])

def by_day(sales: list[Sale]) -> dict[date, int]:
    ordered = sorted(sales, key=attrgetter("day"))                  # groupby-র আগে sort!
    return {day: sum(s.total for s in grp)
            for day, grp in groupby(ordered, key=attrgetter("day"))}

def export_csv(sales: list[Sale], path: Path) -> None:
    with path.open("w", newline="", encoding="utf-8-sig") as f:     # BOM: Excel-এ বাংলা ঠিক
        w = csv.writer(f)
        w.writerow(["day", "sku", "qty", "unit_price_taka", "total_taka"])
        w.writerows([s.day.isoformat(), s.sku, s.qty, taka_plain(s.unit_price),
                     taka_plain(s.total)] for s in sales)

লক্ষ করুন, most_common()-এর বদলে আমরা নিজেরা সাজিয়েছি: most_common সমান সংখ্যার ক্ষেত্রে প্রথম দেখার ক্রম রাখে, যা দিনভেদে বদলাতে পারে; আমাদের tuple-চাবি ফলকে পুরোপুরি নির্দিষ্ট করে। CSV-তে newline="" (পর্ব ২৬) আর utf-8-sig — শেষেরটি ফাইলের শুরুতে একটি BOM বসায়, যাতে Excel বাংলা নাম ঠিকঠাক দেখায়। JSON export-এ json.dumps(..., ensure_ascii=False, indent=2), যাতে “চিনিগুঁড়া” চ... হয়ে না যায়।

৮ · টেস্ট আর নকশার সিদ্ধান্ত

পর্ব ৩৯-এর pytest এখানে পুরো শক্তিতে। সবচেয়ে মূল্যবান কৌশলটি conftest.py-এর একটি fixture, যা প্রতিটি টেস্টকে দুটি store-এ চালায়:

import pytest
from datetime import date
from shop.domain import InvalidInput, OutOfStock
from shop.service import Shop
from shop.storage import MemoryStore, SqliteStore

@pytest.fixture(params=["memory", "sqlite"])
def shop(request, tmp_path):
    store = MemoryStore() if request.param == "memory" else SqliteStore(tmp_path / "t.db")
    s = Shop(store, today=lambda: date(2026, 10, 18))
    s.add_item("oil", "Soyabean", "189", stock=5)
    return s

def test_sell_reduces_stock(shop):
    assert shop.sell("oil", 2) == "ok: 2 x Soyabean = Tk 378.00"
    assert shop.stock("oil") == 3

def test_out_of_stock_changes_nothing(shop):
    with pytest.raises(OutOfStock) as exc:
        shop.sell("oil", 6)
    assert exc.value.have == 5
    assert shop.stock("oil") == 5 and shop.daily_report().total == 0

@pytest.mark.parametrize("qty", [0, -1])
def test_bad_quantity_is_rejected(shop, qty):
    with pytest.raises(InvalidInput):
        shop.sell("oil", qty)

তিনটি টেস্ট ফাংশন, কিন্তু চলবে আটটি টেস্ট: প্রথম দুটি দুই store-এ (২ × ২ = ৪), আর শেষটি দুই store × দুই পরিমাণ (৪)। tmp_path pytest-এর দেওয়া একটি অস্থায়ী ফোল্ডার, প্রতিটি টেস্টের জন্য নতুন — টেস্টগুলো একে অপরের ডেটা দেখে না। চালালে মোটামুটি এমন দেখবেন (সময় আর version আপনার কম্পিউটারে আলাদা হবে):

$ pytest tests/test_service.py
============================= test session starts ==============================
collected 8 items

tests/test_service.py ........                                           [100%]

============================== 8 passed in 0.09s ===============================

pytest এই পাতায় চালানো যায় না, তাই নিচে একই ভাবনা সাধারণ assert দিয়ে: টাকার নির্ভুলতা, সফল বিক্রি, আর বাতিল হওয়া বিক্রিতে অবস্থার অপরিবর্তন — একটি MemoryStore-এর ওপর। এখানেও লম্বা, কারণ টেস্টের জন্য domain আর store-এর ছোট রূপ ফাইলের ভেতরেই রাখতে হয়েছে:

import copy
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation

class ShopError(Exception): pass
class OutOfStock(ShopError): pass
def parse_taka(text: str) -> int:                  # "140.50" -> 14050 poisha
    try:
        d = Decimal(text)
    except InvalidOperation:
        raise ShopError(f"bad price {text!r}") from None
    if not d.is_finite() or d <= 0 or d != d.quantize(Decimal("0.01")):
        raise ShopError(f"bad price {text!r}")
    return int(d * 100)
@dataclass(frozen=True)
class Sale:
    sku: str
    qty: int
    unit_price: int
    def __post_init__(self) -> None:
        if self.qty <= 0:
            raise ShopError("quantity must be positive")
class MemoryStore:                                 # same methods as SqliteStore
    def __init__(self) -> None:
        self.items = {"oil": ["Soyabean", 18900, 5]}
        self.sold: list[Sale] = []
    def get(self, sku: str) -> tuple[str, int, int] | None:
        return tuple(self.items[sku]) if sku in self.items else None
    def record_sale(self, sale: Sale) -> None:
        self.items[sale.sku][2] -= sale.qty
        self.sold.append(sale)
def sell(store: MemoryStore, sku: str, qty: int) -> int:   # the service rule, trimmed
    name, price, stock = store.get(sku)
    sale = Sale(sku, qty, price)
    if qty > stock:
        raise OutOfStock(f"only {stock} {name} left")
    store.record_sale(sale)
    return sale.qty * price
def rejects(fn, *args) -> bool:                    # True if fn raises a ShopError
    try:
        fn(*args)
    except ShopError:
        return True
    return False

assert 0.10 + 0.20 != 0.30 and parse_taka("0.10") + parse_taka("0.20") == parse_taka("0.30")
assert all(rejects(parse_taka, t) for t in ["abc", "-5", "0", "1.005", "NaN"])
print("PASS money: integer poisha adds exactly, 5 bad prices rejected")
s = MemoryStore()
assert sell(s, "oil", 2) == 37800 and s.get("oil")[2] == 3 and len(s.sold) == 1
print("PASS sell: stock goes down and the sale is recorded")
before = copy.deepcopy(s.items), list(s.sold)
assert rejects(sell, s, "oil", 4) and rejects(sell, s, "oil", 0)
assert (s.items, s.sold) == before and issubclass(OutOfStock, ShopError)
print("PASS rejected sales (out of stock, qty 0) change nothing")

আউটপুট তিনটি লাইন: PASS money: integer poisha adds exactly, 5 bad prices rejected, PASS sell: stock goes down and the sale is recorded, PASS rejected sales (out of stock, qty 0) change nothing. প্রথম লাইনের আগের assert একসাথে দুটি কথা প্রমাণ করে: float-এ 0.10 + 0.20 != 0.30, অথচ পয়সায় ১০ + ২০ হুবহু ৩০. তারপর পাঁচটি ভুল দাম — অক্ষর, ঋণাত্মক, শূন্য, তিন ঘর দশমিক (1.005), আর NaN — সবগুলো ShopError তোলে। দ্বিতীয় অংশ: ২টি তেল = ৩৭৮০০ পয়সা, স্টক ৫ থেকে ৩, একটি বিক্রি জমা। তৃতীয় অংশটি সবচেয়ে গুরুত্বপূর্ণ: copy.deepcopy দিয়ে আগের অবস্থার একটি ছবি রাখা হলো (পর্ব ১৯ — শুধু dict(s.items) করলে ভেতরের list-গুলো alias থাকত আর তুলনা অর্থহীন হতো), তারপর দুটি অবৈধ বিক্রি, তারপর মেলানো — অবস্থা হুবহু এক। rejects একটি ছোট সাহায্যকারী, যা pytest-এর pytest.raises-এর কাজটুকু করে। শেষে issubclass(OutOfStock, ShopError) নিশ্চিত করে যে CLI-র একটি except সব ব্যবসায়িক ভুল ধরবে।

নকশার সিদ্ধান্ত — কেন এভাবে, আর দাম কী

প্রতিটি বড় সিদ্ধান্ত লিখে রাখুন: কী বেছে নিলেন, কী বাদ দিলেন, কেন। পেশাদার দলে একে বলে architecture decision record. আমাদের সংক্ষিপ্ত তালিকা:

সিদ্ধান্তকেনবিকল্প ও দাম
SQLiteএকটি ফাইল, কোনো সার্ভার নেই, standard library-তে আছে, transaction আর CHECK দেয়; দোকানের এক কম্পিউটারের জন্য যথেষ্টJSON ফাইল: সরল, কিন্তু transaction নেই, মাঝপথে বন্ধ হলে ফাইল নষ্ট হতে পারে। PostgreSQL: অনেক ব্যবহারকারীর জন্য ভালো, কিন্তু সার্ভার চালাতে ও দেখভাল করতে হয়
Protocol-এর পেছনে storageদ্রুত টেস্ট (memory), পরে storage বদলানো সহজ, service ডেটাবেসের খুঁটিনাটি জানে নাসরাসরি SQLite ডাকা: কম কোড, কিন্তু প্রতিটি টেস্টে ডেটাবেস লাগে আর বদল কঠিন। দাম: একটি বাড়তি চুক্তি আর দ্বিতীয় বাস্তবায়ন রক্ষণাবেক্ষণ
পয়সায় int, ইনপুটে Decimalযোগ-গুণ নির্ভুল, তুলনা == দিয়ে চলে, SQLite-এর INTEGER-এ সরাসরি যায়float: সহজ, কিন্তু পয়সার গরমিল। সবখানে Decimal: নির্ভুল, কিন্তু SQLite-এ রাখতে রূপান্তর লাগে। দাম: ছাপার আর পড়ার সময় রূপান্তর মনে রাখা
Custom exceptionব্যবসার ভুল আর প্রোগ্রামের বাগ আলাদা থাকে; একটি except ShopError; exception নিজের তথ্য (have, want) বহন করেNone বা (ok, message) ফেরত: ডাকা জায়গা ভুলে পরীক্ষা না করলে ভুল চুপচাপ এগিয়ে যায়
frozen dataclassতৈরির পরে বদলানো যায় না, তাই একবার যাচাই মানে চিরকাল বৈধ; hash করা যায়সাধারণ class বা dict: নমনীয়, কিন্তু যেকোনো জায়গা থেকে ভুল মান বসানো সম্ভব
ORM নয়, সরাসরি SQLদুটি টেবিল; শেখার জন্য স্বচ্ছ; কোনো dependency নেইSQLAlchemy: বড় schema-তে সুবিধা, কিন্তু শেখার আরেকটি স্তর
পরীক্ষা ও ইন্টারভিউতে
  • টাকার হিসাবে float কেন বিপজ্জনক? — float দুইয়ের ঘাতে রাখে, তাই ০.১-এর মতো দশমিক হুবহু থাকে না; যোগফলে পয়সা গরমিল হয়। সমাধান: পয়সায় int, বা Decimal.
  • SQL-এ ব্যবহারকারীর মান নিরাপদে কীভাবে পাঠাবেন? — parameterized query: execute("... WHERE sku = ?", (sku,)); কখনো f-string বা + দিয়ে SQL বানানো নয়।
  • sqlite3-এ with conn: কী করে? — ব্লক সফল হলে commit, exception হলে rollback; connection বন্ধ করে না।
  • ABC আর Protocol-এর পার্থক্য? — ABC-তে class-কে উত্তরাধিকার নিতে হয় (nominal); Protocol-এ ঠিক method থাকলেই চলে (structural), পরীক্ষা করে type checker.
  • Layered architecture-এর মূল নিয়ম কী? — প্রতিটি স্তরের একটি দায়িত্ব, আর নির্ভরতা শুধু ভেতরের দিকে; domain কোনো I/O জানে না।

৯ · AI-এর যুগে — ক্যাপস্টোনে AI আপনার pair programmer, মালিক আপনি

“Python-এ দোকানের স্টক ম্যানেজমেন্ট সিস্টেম লেখো” — এক লাইনের এই অনুরোধে AI কয়েক সেকেন্ডে ৩০০ লাইন দেবে, আর সেটি চলবেও। কিন্তু একটু খুঁটিয়ে দেখলে প্রায়ই পাবেন: দাম float-এ, SQL f-string দিয়ে বানানো, স্টক পরীক্ষা আর বিক্রি লেখা আলাদা transaction-এ, সব কাজ এক বিশাল class-এ, কোনো টেস্ট নেই, আর এমন একটি library যা আসলে নেইই। সবচেয়ে বড় সমস্যা হলো, ইন্টারভিউতে কেউ জিজ্ঞেস করলে “এই অংশটা এভাবে কেন?” — আপনার কাছে উত্তর থাকবে না।

ক্যাপস্টোনের উদ্দেশ্য কোড পাওয়া নয়, সিদ্ধান্ত নেওয়ার ক্ষমতা দেখানো। তাই AI-কে ব্যবহার করুন pair programming-এর সঙ্গী হিসেবে, চারটি নিয়মে: (১) পরিকল্পনা আপনার — স্পেক, স্তর, ডেটা মডেল আগে নিজে লিখুন, তারপর AI-কে সমালোচনা করতে বলুন। (২) ছোট টুকরোয় চান — একবারে একটি মাইলস্টোন, একটি ফাইল। (৩) কোডের আগে টেস্ট চান, আর টেস্টের প্রত্যাশিত মান নিজে হিসাব করে মেলান। (৪) যন্ত্র দিয়ে যাচাই — AI-এর প্রতিটি কোডে ruff, mypy আর pytest চালান; আর যা ব্যাখ্যা করতে পারেন না, তা paste করবেন না।

পরিকল্পনা নিজে লিখে তারপর AI-কে সমালোচক বানান। নিচের কাঠামোটি কপি করে নিজের প্রজেক্ট বসিয়ে নিন:

আমি Python-এ একটি ছোট দোকানের বিক্রি ও স্টকের CLI সিস্টেম বানাচ্ছি (শুধু standard library: sqlite3, argparse, logging, dataclasses, decimal). আমার নকশা: - স্তর: cli.py → service.py (Shop) → domain.py (Item, Sale frozen dataclass, ShopError-এর সন্তান OutOfStock/UnknownItem/InvalidInput) → storage.py (Store Protocol; SqliteStore আর MemoryStore) - টাকা পূর্ণসংখ্যার পয়সায়, ইনপুট Decimal দিয়ে পড়া - বিক্রিতে স্টক কমানো আর বিক্রি লেখা একটি transaction-এ এখনই কোড লিখবে না। আগে দাও: ১. আমার নকশায় ৫টি দুর্বলতা বা ঝুঁকি (যেমন দুটি বিক্রি একসাথে এলে কী হবে), প্রতিটির সংক্ষিপ্ত সমাধান। ২. M2 (service + MemoryStore)-এর জন্য ১০টি pytest টেস্টের নাম আর এক লাইনে কী যাচাই করবে — সীমার মান আর ভুল ইনপুটসহ। ৩. তুমি কোথায় কোনো অনুমান করছ, তার তালিকা। কোড লেখার সময় কোনো float টাকা, f-string দিয়ে SQL, বা তৃতীয় পক্ষের package ব্যবহার করবে না। আমি অনুমোদন দিলে একবারে একটি ফাইল লিখবে, প্রতিটি ফাংশনে type hint দিয়ে।

AI-এর লেখা কোড প্রজেক্টে ঢোকানোর আগে — চেকলিস্ট

১০ · এরপর কোথায় — বাড়ানোর দিক, portfolio আর কোর্সের মানচিত্র

বাড়ানোর দিক — প্রতিটি একটি নতুন স্তর, পুরোনো কোড অক্ষত

সম্প্রসারণকী যোগ হবেকী বদলাবে নাপর্ব
ওয়েব API — মোবাইল থেকে বিক্রিapi.py: FastAPI-র route, যা Shop.sell ডাকে আর OutOfStock-কে HTTP 409-এ বদলায়service, domain, storage৪৮
স্টক কমলে বার্তাএকটি async notifier; service শুধু একটি “low stock” ঘটনা জানায়domain, storage৪২
মাসিক বিশ্লেষণSQLite থেকে pandas.read_sql, কোন বারে বিক্রি বেশি, কোন পণ্য ধীরপুরো মূল সিস্টেম — বিশ্লেষণ শুধু পড়ে৪৬
অন্যের কম্পিউটারে ইনস্টলwheel বানানো, pipx install, অথবা ছোট একটি Docker imageকোড২৩
বাকির খাতাdomain-এ Customer, Credit; storage-এ একটি টেবিল; নতুন subcommandবিক্রি আর স্টকের পুরোনো নিয়ম ও টেস্ট—
from fastapi import FastAPI, HTTPException

app = FastAPI()
shop = Shop(SqliteStore("shop.db"))

@app.post("/sales")
def sell(sku: str, qty: int) -> dict[str, str]:
    try:
        return {"message": shop.sell(sku, qty)}
    except OutOfStock as e:
        raise HTTPException(status_code=409, detail=str(e))
    except ShopError as e:
        raise HTTPException(status_code=400, detail=str(e))

দেখুন, ওয়েব স্তরটি CLI-র প্রায় যমজ: লেখা থেকে মান বানায়, service ডাকে, exception-কে নিজের ভাষায় (HTTP status code) বদলায়। চার স্তরের নকশার সবচেয়ে বড় প্রমাণ এটাই।

Portfolio — প্রজেক্টকে আপনার পরিচয়পত্র বানানো

চাকরির বাজারে, ফ্রিল্যান্সিংয়ে বা উচ্চশিক্ষার আবেদনে “Python জানি” বলার চেয়ে অনেক জোরালো একটি লিংক, যেখানে একটি গোছানো প্রজেক্ট আছে। কয়েকটি পরামর্শ:

  • README প্রথম পাতা: এক বাক্যে কী সমস্যা সমাধান করে; ৩০ সেকেন্ডের একটি টার্মিনাল-সেশন (লেখা বা GIF); ইনস্টল আর চালানোর তিনটি কমান্ড; স্থাপত্যের একটি ছবি; “নকশার সিদ্ধান্ত” অংশ (উপরের টেবিলের মতো); আর জানা সীমাবদ্ধতা। শেষেরটি অনেকে বাদ দেন — অথচ সীমাবদ্ধতা স্বীকার করা পরিণত প্রকৌশলীর লক্ষণ।
  • GitHub-এ ইতিহাসসহ: একটি বিশাল “final code” commit নয় — মাইলস্টোন ধরে অর্থবহ commit (“M4: SqliteStore passes all service tests”). প্রতিটি push-এ টেস্ট চালানোর CI ব্যাজ।
  • ২–৩ মিনিটের ডেমো ভিডিও: একটি বিক্রি, একটি বাতিল হওয়া বিক্রি, রিপোর্ট, আর টেস্ট চালানো। বাংলায় বা ইংরেজিতে, যেটিতে আপনি স্বচ্ছন্দ — মুখে ব্যাখ্যা করতে পারাটাই আসল দেখানোর জিনিস।
  • নিজের বিষয় বেছে নিন: হুবহু এই দোকান না বানিয়ে একই নকশায় কোচিং সেন্টারের ফি ও উপস্থিতি, ফার্মেসির ওষুধের মেয়াদ, বা আপনার এলাকার কোনো আসল সমস্যা। একজন আসল ব্যবহারকারী থাকলে আরও ভালো — তাঁর প্রতিক্রিয়া README-তে লিখুন।

ইন্টারভিউয়ে যিনি প্রজেক্ট দেখেন, তিনি সাধারণত লাইনের সংখ্যা গোনেন না। তিনি দেখেন: আপনি কি নিজের সিদ্ধান্ত ব্যাখ্যা করতে পারেন? ভুল কোথায় হতে পারে জানেন? টেস্ট আছে কি, আর কী পরীক্ষা করে? টাকার মতো সংবেদনশীল জিনিস ঠিকভাবে সামলেছেন কি? কোড পড়তে সহজ কি? প্রায়ই তাঁরা বলবেন, “এখানে একটা নতুন নিয়ম যোগ করুন” — তখন বোঝা যায় নকশাটি সত্যিই আপনার কি না।

যা শিখলেন — কোর্সের মানচিত্রে ক্যাপস্টোন

মডিউলক্যাপস্টোনে কোথায়
১ · ভিত্তিসমস্যা ভাঙা, কোডের আগে চাহিদা ও পরিকল্পনা (পর্ব ০২)
২ · মূল ভাষাপয়সায় int আর Decimal (পর্ব ০৭), match (পর্ব ১১), f-string ফরম্যাট (পর্ব ১০)
৩ · ডেটা স্ট্রাকচারdict, tuple-চাবিতে সাজানো, alias এড়িয়ে deepcopy (পর্ব ১৬, ১৯), মাঝপথের প্রজেক্টের পদ্ধতি (পর্ব ২১)
৪ · বড় প্রোগ্রাম গোছানোpackage আর python -m (পর্ব ২২), pyproject.toml (পর্ব ২৩), custom exception (পর্ব ২৪), pathlib, CSV/JSON (পর্ব ২৫–২৬), Counter (পর্ব ২৭)
৫ · অবজেক্ট-ওরিয়েন্টেডcomposition (Shop-এর ভেতরে store, পর্ব ৩১), frozen dataclass (পর্ব ৩২), Protocol (পর্ব ৩৩)
৬ · আধুনিক Pythonwith দিয়ে transaction (পর্ব ৩৬), type hint আর mypy (পর্ব ৩৭), groupby, lambda (পর্ব ৩৮), pytest (পর্ব ৩৯), logging (পর্ব ৪০)
৭ · বাস্তব প্রয়োগruff (পর্ব ৪৪), pandas (পর্ব ৪৬), FastAPI (পর্ব ৪৮), argparse (পর্ব ৪৯), parameterized query ও নিরাপত্তা (পর্ব ৫০)
ভাষা শেখা শেষ হয় কোর্সে; সিস্টেম বানানো শুরু হয় এখান থেকে।
ছোট অংশ, স্পষ্ট চুক্তি, প্রমাণসহ টেস্ট — বাকিটা অভ্যাস।

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। এগুলো আপনাকে কোড-লেখক থেকে সিস্টেম-চিন্তক বানাবে।

প্র ০১পর্ব ২১-এ Gall’s law দেখেছিলাম। ক্যাপস্টোনে কি এর মানে বদলায়? চলতে থাকা একটি সিস্টেমকে একদিনে ফেলে নতুন করে লেখা কেন প্রায়ই সর্বনাশ ডেকে আনে?

১৯৭৫ সালের “General Systemantics” বইয়ে John Gall লিখেছিলেন, কাজ করে এমন প্রতিটি জটিল ব্যবস্থা দেখা যায় কাজ করা একটি সরল ব্যবস্থা থেকে বেড়ে উঠেছে; শূন্য থেকে জটিল করে নকশা করা ব্যবস্থা কাজ করে না। মাঝপথের প্রজেক্টে এর অর্থ ছিল “ছোট শুরু করুন”। ক্যাপস্টোনে এর দ্বিতীয় অর্থটি সামনে আসে: যা ইতিমধ্যে চলছে, তাকে বাঁচিয়ে রেখে বাড়ান। আমাদের ছয় মাইলস্টোনের প্রতিটির শেষে একটি চলা সিস্টেম; M4-এ storage বদলালাম, কিন্তু M2-এর টেস্টগুলো একটিও বদলাল না — চলা অবস্থা থেকে চলা অবস্থায় গেলাম।

উল্টো পথের সবচেয়ে বিখ্যাত সতর্কবাণী ২০০০ সালের এপ্রিলে Joel Spolsky-র প্রবন্ধ “Things You Should Never Do, Part I”. তিনি লিখেছিলেন, Netscape তাদের browser-এর কোড শূন্য থেকে নতুন করে লেখার যে সিদ্ধান্ত নিয়েছিল, তা একটি সফটওয়্যার কোম্পানির পক্ষে সম্ভাব্য সবচেয়ে খারাপ কৌশলগত ভুল। নতুন সংস্করণ আসতে বছরের পর বছর লেগেছিল, আর সেই সময়ে বাজার প্রতিদ্বন্দ্বীর হাতে চলে যায়। Spolsky-র যুক্তি: পুরোনো কোড দেখতে কুৎসিত লাগে, কারণ তার ভেতরে বছরের পর বছর ধরে ধরা পড়া বাগের সমাধান জমে আছে — প্রতিটি অদ্ভুত শর্ত হয়তো কোনো একজন ব্যবহারকারীর আসল সমস্যার উত্তর। নতুন করে লিখলে সেই জ্ঞান হারায়, আর পুরোনো বাগগুলো নতুন করে ফিরে আসে।

তাহলে খারাপ নকশা কি চিরকাল বয়ে বেড়াতে হবে? না — উপায় হলো ধাপে ধাপে বদল। Martin Fowler ২০০৪ সালে “strangler fig” নামে একটি পদ্ধতির কথা লেখেন: গাছের চারপাশে লতানো ডুমুরগাছের মতো, পুরোনো সিস্টেমের পাশে নতুন অংশ একটি একটি করে বসান, প্রতিবার একটি কাজ নতুনে সরান, যতদিন না পুরোনোটি আপনিই অপ্রয়োজনীয় হয়ে যায়। আমাদের নকশায় এটি সহজ: Protocol-এর পেছনে নতুন store বসাতে পুরো সিস্টেম থামাতে হয় না।

এখানে টেস্টের ভূমিকা কেন্দ্রীয়। একটি চলা সিস্টেমকে নিরাপদে বদলানোর একমাত্র প্রমাণ হলো, বদলের আগে আর পরে একই টেস্টগুলো পাস করছে। যে সিস্টেমে টেস্ট নেই, সেখানে প্রতিটি বদল একটি জুয়া — আর তখনই মানুষ “সব ফেলে নতুন করে লিখি” ভাবতে শুরু করে।

মূল উপলব্ধি: জটিল সিস্টেম জন্মায় না, বেড়ে ওঠে — আর বেড়ে ওঠার প্রতিটি ধাপে তাকে বেঁচে থাকতে হয়। টেস্ট আর স্পষ্ট চুক্তি হলো সেই জীবনরক্ষা, যা ছাড়া প্রতিটি বড় বদল একটি পুনর্জন্মের ঝুঁকি।

প্র ০২“Separation of concerns” কথাটি কোথা থেকে এল? Dijkstra-র স্তরে ভাগ করা সিস্টেম থেকে Alistair Cockburn-এর hexagonal architecture পর্যন্ত — ধারণাটি কীভাবে বিকশিত হলো, আর এর দাম কী?

কথাটি প্রথম লিখিত রূপ পায় ১৯৭৪ সালে, Edsger W. Dijkstra-র একটি প্রবন্ধে — “On the role of scientific thought” (তাঁর হাতে লেখা নোটের সংগ্রহে EWD447). তিনি লেখেন, বুদ্ধিমান চিন্তার মূল কৌশল হলো একটি বিষয়ের একটি দিক নিয়ে একবারে মনোযোগ দেওয়া — অন্য দিকগুলো ভুলে গিয়ে নয়, বরং জেনেশুনে সাময়িকভাবে পাশে সরিয়ে রেখে। প্রোগ্রাম সঠিক কি না আর দক্ষ কি না, দুটি আলাদা প্রশ্ন; একসাথে দুটি ভাবলে কোনোটিই ভালো হয় না। তিনি এর নাম দেন “separation of concerns”.

ধারণাটি অবশ্য তার আগেই কাজে লেগেছিল। ১৯৬৮ সালে Dijkstra আর তাঁর দল নেদারল্যান্ডসের Eindhoven-এ “THE multiprogramming system” নামে একটি অপারেটিং সিস্টেমের বর্ণনা প্রকাশ করেন, যা কয়েকটি স্তরে সাজানো ছিল — প্রতিটি স্তর শুধু নিচের স্তরের সেবা ব্যবহার করে। এতে প্রতিটি স্তর আলাদাভাবে যাচাই করা যেত। ১৯৭২ সালে David Parnas-এর বিখ্যাত প্রবন্ধ “On the Criteria To Be Used in Decomposing Systems into Modules” আরেক ধাপ এগিয়ে বলে: module ভাগ করুন সিদ্ধান্ত লুকানোর ভিত্তিতে — যে সিদ্ধান্ত বদলাতে পারে (যেমন ডেটা কোথায় থাকবে), তা একটি module-এর ভেতরে লুকিয়ে রাখুন। আমাদের Store Protocol ঠিক এই information hiding.

২০০৫ সালে Alistair Cockburn “Hexagonal Architecture” প্রবন্ধে প্রচলিত স্তর-ভাবনার একটি দুর্বলতা দেখান: অনেক প্রজেক্টে ব্যবসার যুক্তি ধীরে ধীরে ইউজার ইন্টারফেস বা ডেটাবেসের কোডে চুঁইয়ে ঢুকে যায়। তাঁর প্রস্তাব — অ্যাপ্লিকেশনের কেন্দ্রে ব্যবসার যুক্তি, চারপাশে “port” (চুক্তি), আর port-এ লাগানো “adapter” (CLI, ওয়েব, টেস্ট, ডেটাবেস)। কেন্দ্র কোনো adapter চেনে না। এর আরেক নাম তাই “ports and adapters”. আমাদের সিস্টেমে Store একটি port; SqliteStore আর MemoryStore দুটি adapter; CLI আর ভবিষ্যতের FastAPI আরও দুটি। পরে Robert C. Martin-এর “Clean Architecture” একই ভাবনাকে আরেক রূপে জনপ্রিয় করে।

তবে এর দাম আছে। প্রতিটি স্তর মানে বাড়তি ফাইল, বাড়তি চুক্তি, আর একটি কাজ বুঝতে কয়েকটি ফাইল ঘোরা। দশ লাইনের script-এ চার স্তর বসানো হাস্যকর বাড়াবাড়ি। নিয়মটি তাই: যে অংশ সত্যিই বদলাতে পারে বা আলাদা করে টেস্ট করা দরকার, শুধু সেখানে সীমানা টানুন। আমাদের ক্ষেত্রে storage বদলানোর সম্ভাবনা বাস্তব, আর টেস্টের গতি জরুরি — তাই সীমানাটি দাম দেওয়ার যোগ্য।

মূল উপলব্ধি: Separation of concerns কোনো ফোল্ডার সাজানোর নিয়ম নয়, একটি চিন্তার কৌশল — একবারে একটি প্রশ্ন। আর সীমানা টানুন সেখানে, যেখানে ভবিষ্যতের বদল আসবে বলে আপনি সত্যিই আশা করেন।

প্র ০৩“Make it work, make it right, make it fast” — Kent Beck-এর নামে প্রচলিত এই কথার ক্রমটি কেন গুরুত্বপূর্ণ? আমাদের মাইলস্টোনে এই তিন ধাপ কোথায়?

এই তিন ধাপের উপদেশটি সাধারণত Kent Beck-এর নামে উদ্ধৃত হয় — যিনি Extreme Programming আর test-driven development-এর অন্যতম প্রবর্তক; যদিও কথাটির প্রথম উৎস নিয়ে নিশ্চিত তথ্য নেই, আর এর রূপভেদ আরও আগে থেকেই প্রোগ্রামারদের মুখে ঘুরত। এর শক্তি ক্রমে। Make it work — আগে কাজটি কোনোভাবে হোক, যাতে বোঝা যায় সমস্যাটি আসলে কী। Make it right — তারপর কোড পরিষ্কার করুন, ঠিক নকশায় আনুন, টেস্ট দিয়ে সুরক্ষিত করুন। Make it fast — সবশেষে, আর শুধু যদি মেপে দেখা যায় দরকার, তবেই দ্রুত করুন।

ক্রম উল্টালে কী হয়? প্রথমেই দ্রুততার চিন্তা করলে এমন অংশ দ্রুত করবেন যা হয়তো শেষে থাকবেই না, আর কোড জটিল হবে এমন জায়গায় যেখানে দরকার ছিল না। Donald Knuth ১৯৭৪ সালের প্রবন্ধ “Structured Programming with go to Statements”-এ লেখেন, প্রায় ৯৭% ক্ষেত্রে ছোটখাটো দক্ষতার কথা ভুলে যাওয়া উচিত — অকাল অপ্টিমাইজেশন সব অনিষ্টের মূল। একই বাক্যের বাকি অংশটি প্রায়ই বাদ পড়ে: বাকি ৩% গুরুত্বপূর্ণ জায়গার সুযোগ ছাড়া উচিত নয় — আর সেই জায়গা খুঁজতে হয় মেপে (পর্ব ৪৩-এর profiler).

“work” আর “right”-এর মাঝের সীমাটিও শিক্ষণীয়। আমাদের এক-ফাইলের shop_mini.py হলো “make it work” — চলে, সব নিয়ম মানে, কিন্তু সব এক জায়গায়, পণ্য কোডে বসানো, log পর্দায়। প্যাকেজ, আলাদা module, Protocol, fixture-এর পেছনে দুই store — এগুলো “make it right”. আর “fast”? রহমান সাহেবের দোকানে দিনে কয়েকশো বিক্রি; SQLite এক সেকেন্ডে এর বহুগুণ সামলায়। তাই আমাদের মাইলস্টোনে “fast”-এর কোনো ধাপই নেই — এবং সেটাই সঠিক সিদ্ধান্ত। দশ বছরের বিক্রির রিপোর্ট ধীর হলে তখন মেপে দেখবেন; হয়তো একটি index-ই যথেষ্ট।

লক্ষ করুন, “right” ধাপটি টেস্ট ছাড়া অসম্ভব। কোড গোছানো (refactoring) মানে আচরণ না বদলে গঠন বদলানো — আর আচরণ বদলায়নি, তা জানার একমাত্র উপায় টেস্ট। এজন্যই TDD-তে এই চক্রকে বলা হয় “red, green, refactor”: ব্যর্থ টেস্ট, পাস করানোর মতো সরল কোড, তারপর নির্ভয়ে গোছানো।

মূল উপলব্ধি: আগে প্রমাণ করুন যে সমাধান সম্ভব, তারপর তাকে টিকে থাকার মতো করুন, আর গতি নিয়ে ভাবুন শুধু মাপজোকের পরে। ক্রম ভাঙলে আপনি ভুল সমস্যার নিখুঁত সমাধান বানাবেন।

প্র ০৪ব্যাংক আর হিসাবের সফটওয়্যার টাকা কেন পূর্ণসংখ্যায় বা Decimal-এ রাখে? float-এর ছোট্ট ভুল সত্যিকারের জগতে কত বড় হতে পারে?

১৯৮৫ সালে প্রকাশিত IEEE 754 মান আজকের প্রায় সব কম্পিউটারের float-এর ভিত্তি। এটি বিজ্ঞান আর প্রকৌশলের জন্য চমৎকার: বিশাল পরিসর, দ্রুত হার্ডওয়্যার। কিন্তু এটি সংখ্যা রাখে দুইয়ের ঘাতে, আর দশমিক ০.১ দুইয়ের ঘাতে একটি অসীম পৌনঃপুনিক ভগ্নাংশ — ঠিক যেমন ১/৩ দশমিকে ০.৩৩৩…। তাই প্রতিটি দশমিক দাম ঢোকার মুহূর্তেই সামান্য ভুল নিয়ে ঢোকে, আর প্রতিটি যোগে ভুল জমে। বিজ্ঞানীর কাছে দশমিকের পনেরো ঘর পরের ভুল অর্থহীন; হিসাবরক্ষকের কাছে এক পয়সার অমিল মানে খাতা মেলে না — আর খাতা না মিললে নিরীক্ষা, সন্দেহ, ঘণ্টার পর ঘণ্টা খোঁজা।

ছোট ভুল কত বড় হতে পারে, তার একটি বিখ্যাত উদাহরণ কানাডার Vancouver Stock Exchange-এর সূচক। ১৯৮২ সালের জানুয়ারিতে সূচকটি ১০০০ দিয়ে শুরু হয়। প্রতিটি লেনদেনের পর সূচক নতুন করে হিসাব হতো আর দশমিকের তিন ঘরের পরের অংশ পূর্ণ না করে কেটে ফেলা হতো। দিনে হাজার হাজার বার সামান্য নিচের দিকে কাটা — প্রায় দুই বছর পরে, ১৯৮৩ সালের শেষ দিকে, সূচক দেখাচ্ছিল ৫২০-এর কাছাকাছি, অথচ সঠিক হিসাবে হওয়ার কথা ছিল ১১০০-র কাছাকাছি। ভুলটি float-এর নয়, পূর্ণ করার নিয়মের — কিন্তু শিক্ষা একই: সংখ্যার প্রতিনিধিত্ব আর পূর্ণ করার নিয়ম ব্যবসার সিদ্ধান্ত, আর পুনরাবৃত্তিতে ছোট ভুল বিশাল হয়।

সমাধানের দুটি ঐতিহ্য। প্রথমটি সবচেয়ে ছোট একক পূর্ণসংখ্যায় — পয়সা, সেন্ট। ISO 4217 মান প্রতিটি মুদ্রার জন্য দশমিকের ঘর নির্দিষ্ট করে (বাংলাদেশি টাকা BDT-র ২), আর অনেক পেমেন্ট API টাকার পরিমাণ এভাবেই পূর্ণসংখ্যায় চায়। দ্বিতীয়টি দশমিক গণিত: COBOL-এর যুগ থেকে ব্যবসায়িক সফটওয়্যারে দশমিক সংখ্যা প্রচলিত, আর Python-এর decimal module (Python 2.4 থেকে) IBM-এর Mike Cowlishaw-এর “General Decimal Arithmetic” নির্দেশিকা অনুসরণ করে। ২০০৮ সালে IEEE 754-এর সংশোধনে দশমিক float-ও যোগ হয়।

তবে Decimal-ও জাদু নয়। ১০০ টাকা তিনজনে ভাগ করলে ৩৩.৩৩ + ৩৩.৩৩ + ৩৩.৩৩ = ৯৯.৯৯ — এক পয়সা কোথায় যাবে, তা কোনো সংখ্যা-পদ্ধতি ঠিক করে দেয় না; ব্যবসাকে নিয়ম বানাতে হয়। Decimal-এর default পূর্ণ করার নিয়ম ROUND_HALF_EVEN (“banker’s rounding”), যা দীর্ঘমেয়াদে উপরে-নিচে পক্ষপাত কমায় — কিন্তু আপনার দোকান বা দেশের আইন অন্য নিয়ম চাইতে পারে। তাই আমাদের নকশায় ভাগের হিসাব এলে quantize-এ নিয়মটি স্পষ্ট লেখা থাকবে।

মূল উপলব্ধি: টাকা একটি “প্রায় ঠিক” সংখ্যা নয়, একটি হুবহু গণনা। তাকে রাখুন এমন রূপে যেখানে যোগ নির্ভুল — আর পূর্ণ করার প্রতিটি নিয়ম কোডে নয়, ব্যবসার কাছ থেকে আসুক।

প্র ০৫SQLite-কে বলা হয় “fopen()-এর প্রতিদ্বন্দ্বী”, কোনো ডেটাবেস সার্ভারের নয়। “application file format” হিসেবে SQLite ব্যবহারের মানে কী, আর কখন এটি ভুল পছন্দ?

SQLite তৈরি করেন D. Richard Hipp, ২০০০ সালে, একটি মার্কিন নৌবাহিনীর প্রকল্পের সফটওয়্যারের জন্য — এমন একটি ডেটাবেস দরকার ছিল যা আলাদা কোনো সার্ভার বা প্রশাসক ছাড়াই চলে। ফল: একটি ছোট C library, যা পুরো ডেটাবেস একটি সাধারণ ফাইলে রাখে, আর প্রোগ্রামের ভেতরেই চলে। এর কোড public domain-এ দেওয়া — যে কেউ যেকোনো কাজে বিনা শর্তে ব্যবহার করতে পারে। Python-এ sqlite3 module আছে Python 2.5 (২০০৬) থেকে। আজ এটি প্রতিটি Android আর iPhone-এ, প্রধান browser-গুলোতে, অসংখ্য desktop অ্যাপে চলে; SQLite-এর নিজস্ব ওয়েবসাইটের অনুমান, এক ট্রিলিয়নের বেশি SQLite ডেটাবেস সক্রিয় ব্যবহারে আছে — সম্ভবত পৃথিবীর সবচেয়ে বেশি ব্যবহৃত ডেটাবেস ইঞ্জিন।

SQLite-এর ওয়েবসাইট নিজেই বলে, SQLite ক্লায়েন্ট-সার্ভার ডেটাবেসের সাথে প্রতিযোগিতা করে না; প্রতিযোগিতা করে fopen()-এর সাথে — অর্থাৎ নিজের বানানো ফাইল-ফরম্যাটের সাথে। ধরুন আপনার অ্যাপ নিজের ডেটা একটি JSON বা নিজস্ব ফরম্যাটের ফাইলে রাখে। তখন আপনাকেই ভাবতে হয়: মাঝপথে বিদ্যুৎ গেলে ফাইল কি অর্ধেক লেখা থাকবে? পুরো ফাইল না পড়ে একটি রেকর্ড কীভাবে খুঁজব? নতুন সংস্করণে একটি ঘর যোগ হলে পুরোনো ফাইল কী হবে? SQLite এগুলোর উত্তর দেয় transaction, index আর SQL দিয়ে — এজন্যই একে “application file format” হিসেবে ব্যবহার করার পরামর্শ, আর আমাদের shop.db ঠিক তাই: রহমান সাহেব পুরো দোকানের ইতিহাস একটি ফাইল কপি করেই ব্যাকআপ রাখতে পারেন। মার্কিন Library of Congress দীর্ঘমেয়াদে ডেটাসেট সংরক্ষণের জন্য যে ফরম্যাটগুলো সুপারিশ করে, SQLite তাদের একটি।

কখন ভুল পছন্দ? যখন অনেক ব্যবহারকারী নেটওয়ার্কে একসাথে লিখবেন। SQLite একসাথে অনেক পাঠক সামলায়, কিন্তু একটি ডেটাবেসে একসময়ে একজনই লিখতে পারে (WAL মোডে পাঠক আর লেখক একসাথে চলে, তবু লেখক একজন)। নেটওয়ার্ক-শেয়ার করা ফোল্ডারে SQLite ফাইল রাখাও SQLite-এর নির্দেশিকায় নিরুৎসাহিত, কারণ নেটওয়ার্ক ফাইল-সিস্টেমের লক নির্ভরযোগ্য নাও হতে পারে। রহমান সাহেবের পাঁচটি শাখা হলে, আর প্রতিটি থেকে একসাথে বিক্রি এলে, তখন PostgreSQL-এর মতো সার্ভার-ডেটাবেস — আর আমাদের Protocol সেই বদলের দরজা খোলা রেখেছে।

মূল উপলব্ধি: প্রতিটি ডেটা-সমস্যায় সার্ভার লাগে না। একজন ব্যবহারকারী, একটি যন্ত্র, নির্ভরযোগ্যতা চাই — এমন ক্ষেত্রে SQLite নিজের বানানো ফাইলের চেয়ে নিরাপদ আর বড় ডেটাবেসের চেয়ে সরল; সীমা জেনে বেছে নেওয়াই প্রকৌশল।

অনুশীলন

বেশিরভাগ কাজে নতুন কোড লিখতে হবে না — অনুমান করা, নকশা নিয়ে ভাবা আর ভুল খোঁজাই আসল কাজ।

  1. আউটপুট অনুমান: অংশ ৬-এর চালানো প্রোগ্রামের ইনপুটের শেষে (report-এর আগে) এই তিনটি লাইন যোগ করলে কী কী ছাপা হবে, আর রিপোর্ট কেমন দাঁড়াবে? sell oil 3, sell oil 1, sell rice -2.

    তেলের স্টক তখন ৩ (৫ − ২)। sell oil 3 সফল: log INFO: sold 3 x oil, stock now 0, তারপর ok: 3 x Soyabean = Tk 567.00 (৩ × ১৮৯). sell oil 1: স্টক ০, তাই OutOfStock — error: only 0 Soyabean left, asked 1. sell rice -2: guard-এ qty.lstrip("-").isdecimal() সত্য, তাই int("-2") service-এ যায়; Sale.__post_init__ ধরে — error: quantity must be positive, got -2. রিপোর্ট: oil এখন ৫টি, ৳৯৪৫.০০ (৩৭৮ + ৫৬৭) — তাই প্রথমে oil, তারপর rice ৳৪২১.৫০, তারপর egg ৳২৪৩.০০; মোট Tk 1,609.50. লক্ষ করুন, ঋণাত্মক সংখ্যাকে CLI ইচ্ছা করে ঢুকতে দিয়েছে, যাতে নিয়মটি এক জায়গায় — domain-এ — থাকে।

  2. নকশার প্রশ্ন: restock SKU QTY কমান্ড যোগ করতে হবে। কোন কোন ফাইলে কী বদলাবে, আর কোন ফাইলে কিছুই বদলাবে না? কোন টেস্টগুলো লিখবেন?

    storage.py: Store Protocol-এ restock(sku, qty), আর দুই বাস্তবায়নেই তা — SQLite-এ UPDATE item SET stock = stock + ? WHERE sku = ?, parameterized. service.py: Shop.restock — পণ্য আছে কি না (UnknownItem), পরিমাণ ধনাত্মক কি না (InvalidInput), তারপর storage ডাকা আর log. cli.py: একটি নতুন subparser আর একটি case. domain.py: সম্ভবত কিছুই না — চাইলে একটি Restock dataclass, যদি স্টক আসার ইতিহাসও রাখতে চান (রাখাই ভালো: কোন দিন কত মাল এল, সেটিও হিসাবের অংশ)। report.py বদলায় না। টেস্ট (fixture-এর কারণে দুই store-এই চলবে): restock-এ স্টক বাড়ে; অজানা SKU-তে error; ০ ও ঋণাত্মক পরিমাণে error আর স্টক অপরিবর্তিত; restock-এর পরে আগে আটকে যাওয়া বিক্রি এখন সফল। প্রতিটি বদল কোন স্তরে, তা আগে থেকেই বলতে পারা মানে নকশাটি কাজ করছে।

  3. ভুল খুঁজুন: একজন সহকর্মী (বা AI) SqliteStore-এ এই method লিখেছেন। এতে অন্তত তিনটি সমস্যা আছে — খুঁজে বের করুন আর ঠিক করুন।
    def record_sale(self, sale):
        self.conn.execute(f"UPDATE item SET stock = stock - {sale.qty} WHERE sku = '{sale.sku}'")
        self.conn.commit()
        self.conn.execute(f"INSERT INTO sale VALUES ('{sale.sku}', {sale.qty}, {sale.total / 100})")
        self.conn.commit()

    (১) SQL injection: f-string দিয়ে SQL বানানো। কোনোভাবে SKU-তে ' OR '1'='1 ঢুকলে প্রথম লাইনটি সব পণ্যের স্টক কমাবে। সমাধান: ? placeholder আর মানের tuple. (২) দুটি আলাদা transaction: প্রথম commit-এর পরে দ্বিতীয় লাইনে কিছু ব্যর্থ হলে স্টক কমে গেল অথচ বিক্রি জমা হলো না — ঠিক যে অর্ধেক অবস্থা আমাদের নিয়মে নিষিদ্ধ। সমাধান: দুটি লেখা একটি with self.conn: ব্লকে, মাঝে কোনো commit নয়। (৩) টাকা float-এ: sale.total / 100 সত্যিকারের ভাগ, ফল float — ডেটাবেসে পয়সার নির্ভুলতা হারাল। সমাধান: পয়সায় int রাখুন, ছাপার সময় রূপান্তর। বাড়তি: type hint নেই (sale: Sale, -> None), আর INSERT-এ কলামের নাম না লেখায় টেবিলে কলাম যোগ হলে কোডটি ভাঙবে। এই অনুশীলনটিই AI-এর কোড পর্যালোচনার আসল রূপ।

  4. কোড লিখুন: MemoryStore লিখুন, যা অংশ ৫-এর Store Protocol-এর পাঁচটি method মানে (add_item, get_item, record_sale, restock, sales_between). একই SKU দুবার যোগ করলে DuplicateItem তুলুন। SQLite-এর CHECK (stock >= 0)-এর মতো পাহারাও রাখুন।

    একটি সম্ভাব্য সমাধান:

    class MemoryStore:
        def __init__(self) -> None:
            self._items: dict[str, Item] = {}
            self._stock: dict[str, int] = {}
            self._sales: list[Sale] = []
    
        def add_item(self, item: Item, stock: int) -> None:
            if item.sku in self._items:
                raise DuplicateItem(item.sku)
            self._items[item.sku], self._stock[item.sku] = item, stock
    
        def get_item(self, sku: str) -> tuple[Item, int] | None:
            return (self._items[sku], self._stock[sku]) if sku in self._items else None
    
        def record_sale(self, sale: Sale) -> None:
            new = self._stock[sale.sku] - sale.qty
            if new < 0:                                  # ডেটাবেসের CHECK-এর সমতুল্য
                raise RuntimeError("stock would go negative")   # এটি বাগ, ব্যবসার ভুল নয়
            self._stock[sale.sku] = new                  # যাচাইয়ের পরে, দুটি বদল পাশাপাশি
            self._sales.append(sale)
    
        def restock(self, sku: str, qty: int) -> None:
            self._stock[sku] += qty
    
        def sales_between(self, start: date, end: date) -> list[Sale]:
            return [s for s in self._sales if start <= s.day <= end]

    খেয়াল করুন: Store-কে উত্তরাধিকার নিতে হয়নি — Protocol structural, method মিললেই mypy খুশি। নামের আগে _ বলে দেয় এগুলো ভেতরের ডেটা। ঋণাত্মক স্টকে ShopError নয়, RuntimeError: service-এর নিয়ম ঠিক থাকলে এটি কখনো ঘটার কথা নয়, তাই ঘটলে সেটি বাগ — জোরে চিৎকার করাই ঠিক, ঠিক যেমন SQLite IntegrityError তোলে। আর record_sale-এ আগে নতুন মান হিসাব ও যাচাই, তারপর দুটি বদল — memory-তে transaction নেই, তাই “আগে যাচাই, পরে বদল”-ই আমাদের atomicity.

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমস্তরে ভাগ করা একটি দোকানের সিস্টেমে sell rice 3 কমান্ডের ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. ⌨️ CLI কমান্ড-লাইনের লেখা ভাগ করে পরিমাণকে int-এ বদলায়
  2. 🧠 Service storage থেকে পণ্য আর বর্তমান স্টক পড়ে
  3. 🛡️ Domain-এর Sale তৈরির সময় পরিমাণ যাচাই হয়, তারপর স্টকের নিয়ম মেলে
  4. 💾 Storage এক transaction-এ স্টক কমায় আর বিক্রি লেখে
  5. 📝 Service একটি INFO log লেখে
  6. ✅ CLI নিশ্চিতকরণ ছাপে আর exit code ০ দেয়

একটি হিসাবের সফটওয়্যারে ৳১৪০.৫০-এর মতো দাম রাখার সবচেয়ে নিরাপদ উপায় কোনটি?

  • float হিসেবে, যেমন 140.5
  • string হিসেবে, যেমন "140.50"
  • পূর্ণসংখ্যার পয়সায়, যেমন 14050
  • float, কিন্তু প্রতিবার round(x, 2) করে

Python-এর sqlite3-এ ব্যবহারকারীর দেওয়া sku দিয়ে নিরাপদে খোঁজার সঠিক লেখা কোনটি?

  • conn.execute("SELECT * FROM item WHERE sku = ?", (sku,))
  • conn.execute(f"SELECT * FROM item WHERE sku = '{sku}'")
  • conn.execute("SELECT * FROM item WHERE sku = '" + sku + "'")
  • conn.execute("SELECT * FROM item WHERE sku = '%s'" % sku)

Python-এর sqlite3-এ with conn: ব্লক (যেখানে conn একটি connection) কী করে?

  • ব্লক শেষে connection বন্ধ করে দেয়
  • ব্লক সফল হলে commit, exception হলে rollback করে
  • ব্লকের ভেতরের সব exception চুপচাপ উপেক্ষা করে
  • ডেটাবেস ফাইলটি অন্য প্রোগ্রামের জন্য স্থায়ীভাবে লক করে

class Store(Protocol)-এ get_item আর record_sale method ঘোষিত। MemoryStore class-এ এই দুটি method আছে, কিন্তু সে Store-কে উত্তরাধিকার নেয়নি। mypy-র চোখে কী হবে?

  • mypy error দেবে, কারণ MemoryStore Store-এর subclass নয়
  • runtime-এ TypeError উঠবে
  • MemoryStore-কে Store.register() দিয়ে নিবন্ধন করতেই হবে
  • কোনো সমস্যা নেই — method মিললেই MemoryStore একটি Store

একটি package-এর নাম shop. টার্মিনালে python -m shop চালালে Python কোন ফাইলটি চালায়?

  • shop/__init__.py, মূল প্রোগ্রাম হিসেবে
  • shop/__main__.py
  • shop/shop.py
  • pyproject.toml-এ লেখা script

class ShopError(Exception) আর class OutOfStock(ShopError). একটি ফাংশন raise OutOfStock("only 5 left") করলে নিচের কোন except সেটি ধরবে না?

  • except OutOfStock:
  • except ShopError:
  • except ValueError:
  • except Exception:

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. সিস্টেম = অনেক ছোট অংশ, প্রতিটির একটি কাজ: CLI কথা বলে, service নিয়ম চালায়, domain নিজেকে যাচাই করে, storage রাখে। নির্ভরতা শুধু ভেতরের দিকে।
  2. Storage থাকে একটি Protocol-এর পেছনে; টেস্টে MemoryStore, আসল কাজে SQLite — একই টেস্ট দুটোতে পাস করাই প্রমাণ।
  3. টাকা পূর্ণসংখ্যার পয়সায়, ইনপুট Decimal দিয়ে যাচাই; SQL সবসময় ? দিয়ে; একসাথে বদলানো লেখা একটি transaction-এ.
  4. ব্যবসার ভুল custom exception — ডেটা বদলানোর আগেই ওঠে, বাইরের স্তরে একটি except ShopError-এ ধরা পড়ে; সত্যিকারের বাগ জোরে চিৎকার করে।
  5. ভেতর থেকে বাইরে ছয় মাইলস্টোন, প্রতিটিতে টেস্ট আর commit; শেষে README, ডেমো আর GitHub. AI প্রস্তাব দেয়, সিদ্ধান্ত আর ব্যাখ্যা আপনার।

পরের পর্বে: মডিউল ৭ শেষ — আপনি এখন শুরু থেকে শেষ পর্যন্ত একটি আসল Python সিস্টেম বানাতে পারেন! মডিউল ৮-এ পরীক্ষা ও ইন্টারভিউর প্রস্তুতি, শুরু হচ্ছে “আউটপুট কী হবে?” দিয়ে — কোড না চালিয়ে মাথায় চালানোর অনুশীলন, যেখানে mutable default, closure আর exception-এর পুরোনো ফাঁদগুলো আবার দেখা দেবে।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান