অধ্যায় 5 · প্রজেক্ট ও পরের ধাপ
প্রজেক্ট, পর্ব ২: টেস্ট, ডকুমেন্টেশন ও প্রকাশ
- পৃষ্ঠা 21 / 22
- 30 মিনিট পড়া
Data Explorer API এখন আপনার কম্পিউটারে, একটা ব্রাঞ্চে, হাতে-কলমে পরীক্ষা করা অবস্থায় চলছে। কিন্তু একটা টিম এর ওপর ভরসা করতে পারে না। এই পাতায় আপনি কাজটা শেষ করবেন চাকরিতে যেভাবে করা হয় সেভাবে: Practice API ছাড়াই এক সেকেন্ডে চলা অটোমেটেড টেস্ট, যে README পড়ে অচেনা কেউ প্রজেক্ট চালাতে পারবে, গোছানো .gitignore, রিভিউসহ একটা পুল রিকোয়েস্ট, সেই রিভিউ থেকে আসা একটা রিফ্যাক্টর ও একটা বাগ ফিক্স, আর শেষে main-এ মার্জ করে নতুন ক্লোন সত্যিই চলে কি না তার যাচাই।
বাস্তব ব্যবহারকারীওয়ালা প্রতিটা AI প্রোডাক্ট সপ্তাহে বহুবার এই চক্রের মধ্য দিয়ে যায়। আপস্ট্রিম (upstream) যখন একটা LLM, তখন এটা আরও জরুরি: আসল কল ধীর, খরচ হয়, আর প্রতিবার আলাদা উত্তর আসে। তাই নিজের লজিক নির্ভরযোগ্যভাবে টেস্ট করার একমাত্র উপায় প্রোভাইডারের জায়গায় এমন একটা নকল (fake) বসানো, যা তাৎক্ষণিকভাবে এবং সবসময় একই উত্তর দেয়।
যা শিখবেন
- অন্য একটা API-র ওপর নির্ভর করে এমন API কীভাবে টেস্ট করবেন:
monkeypatchদিয়ে আপস্ট্রিম দুই স্তরে নকল করা, ক্লায়েন্ট ফাংশনে আর HTTP সেশনে। - যেসব ব্যর্থতার পথ (টাইমআউট, কানেকশন এরর, খারাপ ডেটা) সত্যিকারভাবে ঘটানো কঠিন, সেগুলো কীভাবে টেস্ট করবেন।
- এমন README কীভাবে লিখবেন, যা জানায় "এটা কী, কীভাবে চালাব, কীভাবে টেস্ট করব"।
- পুল রিকোয়েস্টের প্রবাহ: ব্রাঞ্চ push, বিবরণ লেখা, চেকলিস্ট ধরে রিভিউ, ফিক্স push, মার্জ, ব্রাঞ্চ মোছা।
- নিরাপদে রিফ্যাক্টর করা, আগে টেস্ট লিখে বাগ ঠিক করা, আর নতুন ক্লোন দিয়ে ফলাফল প্রমাণ করা।
পর্ব ১ যেখানে শেষ হয়েছিল
আপনি যদি একই টার্মিনালে প্রজেক্ট, পর্ব ১: একটা ডেটা এক্সপ্লোরার API বানানো থেকে চালিয়ে আসেন, তাহলে এসবই আপনার কাছে আছে: শুধু cd ~/projects/data-explorer করে নিচের git log কমান্ডে চলে যান। আর যদি নতুন করে শুরু করেন (নতুন কম্পিউটার, বা ফোল্ডার মুছে ফেলেছেন), তাহলে পরের ব্লকগুলো প্রজেক্টটাকে ঠিক পর্ব ১-এর শেষ অবস্থায় আবার বানিয়ে দেয়: একই ফাইল, একই ব্রাঞ্চে একই কমিট। আগে ফোল্ডার:
mkdir -p ~/projects/data-explorer/app
cd ~/projects/data-explorerতারপর পর্ব ১-এর চারটা ফাইল, একটুও না বদলে। প্রতিটা ফাইল তার প্রথম লাইনের নামে সেভ করুন:
# file: .gitignore
# Python
__pycache__/
*.pyc
# Virtual environment (rebuilt from uv.lock with `uv sync`)
.venv/
# Secrets and local settings
.env
# Logs from local runs
*.log# file: app/schemas.py
"""The shapes of the data this API sends out and accepts from upstream."""
from typing import Literal
from pydantic import BaseModel, Field
Level = Literal["beginner", "intermediate", "advanced"]
class Course(BaseModel):
id: int = Field(ge=1)
title: str = Field(min_length=1)
level: Level
hours: int = Field(ge=0)
rating: float = Field(ge=0, le=5)
class CourseList(BaseModel):
count: int
items: list[Course]
class LevelSummary(BaseModel):
level: Level
courses: int
total_hours: int
average_rating: float
class UpstreamWeather(BaseModel):
"""What the Practice API sends for /v1/weather (its names, not ours)."""
city: str
temp_c: float
humidity: int
condition: str
class Weather(BaseModel):
city: str
temperature_c: float
humidity_pct: int = Field(ge=0, le=100)
condition: str
advice: str# file: app/upstream.py
"""Everything that talks to the BitByte Practice API lives in this one file."""
import os
import requests
from pydantic import BaseModel, ValidationError
from app.schemas import Course, UpstreamWeather
BASE_URL = os.environ.get("PRACTICE_API_URL", "http://localhost:8800")
TIMEOUT_SECONDS = float(os.environ.get("UPSTREAM_TIMEOUT", "5"))
MAX_PAGES = 50
session = requests.Session()
session.headers["User-Agent"] = "bitbyte-data-explorer/0.1"
class UpstreamError(Exception):
"""The Practice API was unreachable, too slow, or sent something we cannot use."""
def __init__(self, message: str, status_code: int = 502):
super().__init__(message)
self.message = message
self.status_code = status_code
def get_json(path: str, params: dict | None = None) -> dict | None:
"""GET a Practice API path and return the JSON body, or None for a 404."""
try:
response = session.get(f"{BASE_URL}{path}", params=params, timeout=TIMEOUT_SECONDS)
except requests.Timeout:
raise UpstreamError(f"Practice API did not answer within {TIMEOUT_SECONDS:g} s", 504)
except requests.ConnectionError:
raise UpstreamError("Practice API is unreachable")
if response.status_code == 404:
return None
if not response.ok:
raise UpstreamError(f"Practice API failed with status {response.status_code}")
try:
return response.json()
except ValueError:
raise UpstreamError("Practice API sent a body that is not JSON")
def parse(model: type[BaseModel], data: dict):
"""Validate upstream data before we trust it."""
try:
return model.model_validate(data)
except ValidationError:
raise UpstreamError(f"Practice API sent {model.__name__} data in an unexpected shape")
def fetch_courses(level: str | None = None) -> list[Course]:
"""All courses (optionally of one level), following next_page to the end."""
courses, page = [], 1
while page is not None:
if page > MAX_PAGES:
raise UpstreamError("Practice API returned too many pages")
data = get_json("/v1/courses", {"level": level, "page": page, "per_page": 20})
if data is None:
raise UpstreamError("Practice API has no /v1/courses endpoint")
courses += [parse(Course, item) for item in data["items"]]
page = data["next_page"]
return courses
def fetch_course(course_id: int) -> Course | None:
data = get_json(f"/v1/courses/{course_id}")
return None if data is None else parse(Course, data)
def fetch_weather(city: str) -> UpstreamWeather | None:
data = get_json("/v1/weather", {"city": city})
return None if data is None else parse(UpstreamWeather, data)# file: app/main.py
"""BitByte Data Explorer API: clean, validated endpoints over the BitByte Practice API."""
from typing import Annotated, Literal
from fastapi import FastAPI, HTTPException, Path, Query, Request
from fastapi.responses import JSONResponse
from app import upstream
from app.schemas import Course, CourseList, Level, LevelSummary, Weather
app = FastAPI(title="BitByte Data Explorer API", version="0.1.0")
@app.exception_handler(upstream.UpstreamError)
def upstream_failed(request: Request, exc: upstream.UpstreamError) -> JSONResponse:
return JSONResponse({"detail": exc.message}, status_code=exc.status_code)
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/courses")
def list_courses(
level: Level | None = None,
min_rating: Annotated[float, Query(ge=0, le=5)] = 0,
sort: Literal["id", "rating", "hours"] = "id",
) -> CourseList:
courses = [c for c in upstream.fetch_courses(level) if c.rating >= min_rating]
courses.sort(key=lambda c: getattr(c, sort), reverse=sort != "id")
return CourseList(count=len(courses), items=courses)
@app.get("/courses/{course_id}")
def get_course(course_id: Annotated[int, Path(ge=1)]) -> Course:
course = upstream.fetch_course(course_id)
if course is None:
raise HTTPException(404, f"Course {course_id} not found")
return course
@app.get("/summary")
def summary() -> list[LevelSummary]:
by_level: dict[str, list[Course]] = {}
for course in upstream.fetch_courses():
by_level.setdefault(course.level, []).append(course)
return [
LevelSummary(
level=level,
courses=len(group),
total_hours=sum(c.hours for c in group),
average_rating=round(sum(c.rating for c in group) / len(group), 2),
)
for level, group in by_level.items()
]
def advice_for(temp_c: float, condition: str) -> str:
if condition == "Rain":
return "Carry an umbrella."
if temp_c >= 30:
return "Hot day: drink water and avoid the midday sun."
return "Good weather to go out."
@app.get("/weather/{city}")
def weather(city: Annotated[str, Path(min_length=2, max_length=40)]) -> Weather:
data = upstream.fetch_weather(city)
if data is None:
raise HTTPException(404, f"No weather data for {city}")
return Weather(
city=data.city,
temperature_c=data.temp_c,
humidity_pct=data.humidity,
condition=data.condition,
advice=advice_for(data.temp_c, data.condition),
)তারপর রিপোজিটরি, uv প্রজেক্ট আর চারটা কমিট:
git init -q
uv init -q --bare --name data-explorer
uv add -q "fastapi[standard]" requests
cat >> pyproject.toml <<'EOF'
[tool.fastapi]
entrypoint = "app.main:app"
EOF
touch app/__init__.py
git add .gitignore pyproject.toml uv.lock app/__init__.py
git commit -q -m "Set up the Data Explorer project with uv"
git switch -q -c feature/data-explorer
git add app/schemas.py
git commit -q -m "Add response and upstream schemas"
git add app/upstream.py
git commit -q -m "Add Practice API client with timeouts, pagination and validation"
git add app/main.py
git commit -q -m "Add courses, summary and weather endpoints"যেভাবেই আসুন, এখন আপনার এখানে থাকার কথা:
git log --oneline --graph --decorate --all* 3a8f75a (HEAD -> feature/data-explorer) Add courses, summary and weather endpoints
* 089f152 Add Practice API client with timeouts, pagination and validation
* 6bba991 Add response and upstream schemas
* 04b26f9 (main) Set up the Data Explorer project with uvধাপ ১: টেস্ট পরিকল্পনা
অ্যাপের দুটো স্তর, তাই টেস্টেরও দুটো স্তর। প্রতিটা স্তর নিজের নিচের অংশকে নকল দিয়ে বদলে নেয়:
| টেস্ট ফাইল | কী টেস্ট করে | কী নকল করা হয় | কী প্রমাণ হয় |
|---|---|---|---|
tests/test_api.py | app/main.py-এর রাউট, TestClient দিয়ে | upstream.fetch_courses, fetch_course, fetch_weather | ফিল্টার, সর্টিং, হিসাব, ডেটার নতুন আকার, স্ট্যাটাস কোড, এররের ফরম্যাট |
tests/test_upstream.py | app/upstream.py-এর ক্লায়েন্ট | upstream.session.get (HTTP কলটাই) | পেজিনেশন, প্রতিটা কলে টাইমআউট, প্রতিটা ব্যর্থতা ঠিক UpstreamError হয়ে ওঠা |
কোনো টেস্ট আসল Practice API-র সাথে কথা বলে না। এটা ইচ্ছাকৃত। যে টেস্ট লাইভ সার্ভিস কল করে, সেটা ধীর, নেটওয়ার্ক বা সার্ভিসের দিন খারাপ গেলে ফেল করে, আর সার্ভিস আজ যে উত্তর দিচ্ছে শুধু সেটুকুই টেস্ট করতে পারে। নকল দিয়ে উত্তরটা আপনি ঠিক করেন: টাইমআউট, একটা 503, বা JSON নয় এমন বডি। আসল সংযোগ ঠিক আছে কি না দেখা আলাদা, ছোট কাজ (এই পাতার শেষের স্মোক টেস্ট)।
ধাপ ২: টেস্টের ডিপেন্ডেন্সি ও সেটিংস
pytest টেস্ট চালায়; httpx2 হলো সেটা, যেটা FastAPI-র TestClient রিকোয়েস্ট পাঠাতে ব্যবহার করে (httpx নয়, httpx2 কেন, তা জানতে pytest দিয়ে আপনার API টেস্ট দেখুন)। এগুলো শুধু ডেভেলপমেন্টের সময় লাগে, তাই uv-র dev গ্রুপে যায়, যা প্রোডাকশন ইমেজে ইনস্টল হয় না:
uv add -q --dev pytest httpx2
cat >> pyproject.toml <<'EOF'
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
EOF
mkdir -p tests
cat pyproject.toml[project]
name = "data-explorer"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"fastapi[standard]>=0.143.0",
"requests>=2.34.2",
]
[tool.fastapi]
entrypoint = "app.main:app"
[dependency-groups]
dev = [
"httpx2>=2.13.1",
"pytest>=9.1.1",
]
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]pythonpath = ["."] দিয়ে tests/-এর ফাইলগুলো প্রজেক্ট ফোল্ডার থেকে app ইমপোর্ট করতে পারে; testpaths pytest-কে বলে কোথায় খুঁজতে হবে। pip দিয়ে সমতুল্য কাজ হলো pip install pytest httpx2 আর সেগুলো তালিকাভুক্ত করা একটা requirements-dev.txt ফাইল।
ধাপ ৩: ফিক্সচার: একটা ক্লায়েন্ট আর একটা নকল আপস্ট্রিম
conftest.py ফাইলে এমন ফিক্সচার থাকে, যা প্রতিটা টেস্ট ফাইল ব্যবহার করতে পারে। নকল আপস্ট্রিম চারটা বানানো কোর্স ফেরত দেয়, ইচ্ছে করেই Practice API-র ডেটা থেকে আলাদা, আর মনে রাখে কোন কোন কল হয়েছে:
# file: tests/conftest.py
"""Shared fixtures: a test client and a fake upstream with fixed data."""
import pytest
from fastapi.testclient import TestClient
from app import upstream
from app.main import app
from app.schemas import Course, UpstreamWeather
FAKE_COURSES = [
Course(id=1, title="Intro to AI", level="beginner", hours=4, rating=4.0),
Course(id=2, title="Agents", level="advanced", hours=10, rating=4.9),
Course(id=3, title="Python", level="beginner", hours=12, rating=4.6),
Course(id=4, title="Prompting", level="intermediate", hours=5, rating=4.4),
]
FAKE_WEATHER = {
"dhaka": UpstreamWeather(city="Dhaka", temp_c=31.5, humidity=78, condition="Humid"),
"sylhet": UpstreamWeather(city="Sylhet", temp_c=27.4, humidity=85, condition="Rain"),
}
@pytest.fixture
def client():
return TestClient(app)
@pytest.fixture
def fake_courses():
return FAKE_COURSES
@pytest.fixture
def fake_upstream(monkeypatch):
"""Replace the upstream functions with fakes that never touch the network."""
calls = []
def fake_fetch_courses(level=None):
calls.append(("courses", level))
return [c for c in FAKE_COURSES if level is None or c.level == level]
def fake_fetch_course(course_id):
calls.append(("course", course_id))
return next((c for c in FAKE_COURSES if c.id == course_id), None)
def fake_fetch_weather(city):
calls.append(("weather", city))
return FAKE_WEATHER.get(city.lower())
monkeypatch.setattr(upstream, "fetch_courses", fake_fetch_courses)
monkeypatch.setattr(upstream, "fetch_course", fake_fetch_course)
monkeypatch.setattr(upstream, "fetch_weather", fake_fetch_weather)
return callsmonkeypatch.setattr(upstream, "fetch_courses", ...) একটা টেস্ট চলার সময়ের জন্য upstream মডিউলের ভেতরের ফাংশনটা বদলে দেয়, আর শেষে আসলটা ফিরিয়ে রাখে। এটা কাজ করে কারণ app/main.py রিকোয়েস্টের সময় মডিউলের মাধ্যমে upstream.fetch_courses(...) কল করে। যদি সে from app.upstream import fetch_courses ব্যবহার করত, main-এর নিজের একটা রেফারেন্স থাকত আসল ফাংশনের দিকে, আর প্যাচ সেটাকে ধরতে পারত না: নামটা প্যাচ করুন যেখানে সেটা খোঁজা হয়, যেখানে সংজ্ঞায়িত সেখানে নয়।
ধাপ ৪: এন্ডপয়েন্ট টেস্ট
# file: tests/test_api.py
"""Endpoint tests. The upstream is replaced by fakes (see conftest.py)."""
from app import upstream
def test_health(client):
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
def test_courses_sorted_by_rating(client, fake_upstream):
response = client.get("/courses", params={"sort": "rating"})
assert response.status_code == 200
body = response.json()
assert body["count"] == 4
assert [c["id"] for c in body["items"]] == [2, 3, 4, 1]
def test_courses_filters(client, fake_upstream):
body = client.get("/courses", params={"level": "beginner", "min_rating": 4.5}).json()
assert [c["title"] for c in body["items"]] == ["Python"]
assert fake_upstream == [("courses", "beginner")]
def test_bad_parameters_never_reach_upstream(client, fake_upstream):
assert client.get("/courses", params={"min_rating": 6}).status_code == 422
assert client.get("/courses", params={"sort": "price"}).status_code == 422
assert client.get("/courses/0").status_code == 422
assert fake_upstream == []
def test_course_found_and_missing(client, fake_upstream):
assert client.get("/courses/2").json()["title"] == "Agents"
missing = client.get("/courses/99")
assert missing.status_code == 404
assert missing.json() == {"detail": "Course 99 not found"}
def test_summary_numbers(client, fake_upstream):
rows = {row["level"]: row for row in client.get("/summary").json()}
assert rows["beginner"] == {
"level": "beginner", "courses": 2, "total_hours": 16, "average_rating": 4.3,
}
def test_weather_is_reshaped(client, fake_upstream):
assert client.get("/weather/Sylhet").json() == {
"city": "Sylhet", "temperature_c": 27.4, "humidity_pct": 85,
"condition": "Rain", "advice": "Carry an umbrella.",
}
assert client.get("/weather/Comilla").status_code == 404
def test_upstream_failure_becomes_502(client, monkeypatch):
def broken(level=None):
raise upstream.UpstreamError("Practice API is unreachable")
monkeypatch.setattr(upstream, "fetch_courses", broken)
response = client.get("/courses")
assert response.status_code == 502
assert response.json() == {"detail": "Practice API is unreachable"}যা লক্ষ করবেন:
- প্রত্যাশিত মান হাতে হিসাব করা, নকল ডেটা থেকে, API-র আউটপুট থেকে কপি করা নয়: দুটো বিগিনার কোর্সে মোট ৪ + ১২ = ১৬ ঘণ্টা, আর গড় রেটিং (৪.০ + ৪.৬) / ২ = ৪.৩। কোড যা ফেরত দেয় তা-ই কপি করে নেওয়া টেস্ট শুধু প্রমাণ করে যে কোড যা ফেরত দেয় তা-ই ফেরত দেয়।
fake_upstreamএকজন গোয়েন্দাও (spy)।test_courses_filtersদেখে যেlevelফিল্টার আপস্ট্রিমে পাঠানো হয়েছে (তাতে কম ডেটা যায়), আরtest_bad_parameters_never_reach_upstreamদেখে যে খারাপ ইনপুট কোনো আপস্ট্রিম কলের আগেই প্রত্যাখ্যাত হয়। প্রতিটা আপস্ট্রিম কলে টাকা খরচ হলে (যেমন LLM কলে) এটা গুরুত্বপূর্ণ।- ব্যর্থতার টেস্টে নেটওয়ার্কের কোনো কৌশল লাগে না:
UpstreamErrorছোড়ে এমন একটা নকলই যথেষ্ট প্রমাণ করতে যে এক্সেপশন হ্যান্ডলার সেটাকে আমাদের বার্তাসহ 502 বানায়।
ধাপ ৫: ক্লায়েন্ট টেস্ট
এবার এক স্তর নিচে। এখানে নকল বসে upstream.session.get-এর জায়গায়, যা মেশিন থেকে রিকোয়েস্ট বেরোনোর একমাত্র জায়গা; তাই আসল get_json, parse আর fetch_courses কোড চলে:
# file: tests/test_upstream.py
"""Client tests. session.get is replaced, so no request leaves the machine."""
import pytest
import requests
from app import upstream
class FakeResponse:
"""Just enough of requests.Response for app/upstream.py."""
def __init__(self, status_code=200, data=None):
self.status_code = status_code
self.ok = status_code < 400
self._data = data
def json(self):
if self._data is None:
raise ValueError("not JSON")
return self._data
def course(course_id):
return {"id": course_id, "title": f"Course {course_id}", "level": "beginner",
"hours": 2, "rating": 4.5}
def test_fetch_courses_follows_pages_with_a_timeout(monkeypatch):
pages = {
1: {"items": [course(1), course(2)], "next_page": 2},
2: {"items": [course(3)], "next_page": None},
}
seen = []
def fake_get(url, params=None, timeout=None):
seen.append((params["page"], timeout))
return FakeResponse(200, pages[params["page"]])
monkeypatch.setattr(upstream.session, "get", fake_get)
assert [c.id for c in upstream.fetch_courses()] == [1, 2, 3]
assert seen == [(1, upstream.TIMEOUT_SECONDS), (2, upstream.TIMEOUT_SECONDS)]
@pytest.mark.parametrize(
("error", "status"),
[(requests.Timeout(), 504), (requests.ConnectionError(), 502)],
)
def test_network_errors(monkeypatch, error, status):
def fake_get(url, params=None, timeout=None):
raise error
monkeypatch.setattr(upstream.session, "get", fake_get)
with pytest.raises(upstream.UpstreamError) as caught:
upstream.fetch_course(1)
assert caught.value.status_code == status
@pytest.mark.parametrize(
"response",
[FakeResponse(503, {"detail": "down"}), FakeResponse(200, None), FakeResponse(200, {"id": 1})],
ids=["status-503", "not-json", "wrong-shape"],
)
def test_bad_answers_become_502(monkeypatch, response):
monkeypatch.setattr(upstream.session, "get", lambda *args, **kwargs: response)
with pytest.raises(upstream.UpstreamError) as caught:
upstream.fetch_course(1)
assert caught.value.status_code == 502
def test_404_means_none(monkeypatch):
monkeypatch.setattr(upstream.session, "get", lambda *args, **kwargs: FakeResponse(404, {}))
assert upstream.fetch_course(99) is NoneFakeResponse-এ শুধু তিনটা জিনিস আছে যা get_json ব্যবহার করে: status_code, ok আর json()। @pytest.mark.parametrize একই টেস্ট ফাংশনকে আলাদা আলাদা ইনপুট দিয়ে কয়েকবার চালায়, তাই পাঁচটা ব্যর্থতার ক্ষেত্রের জন্য পাঁচটা কপির বদলে দুটো ছোট ফাংশনই যথেষ্ট। প্রথম টেস্ট এটাও দেখে যে প্রতিটা কলে টাইমআউট ছিল: ব্রিফের একটা শর্ত, যা এখন চিরকালের জন্য যাচাই হচ্ছে।
সব চালান। কোনো টেস্টেরই Practice API লাগে না, সেটা প্রমাণ করতে অ্যাপকে এমন একটা পোর্টে পাঠান যেখানে কেউ শোনে না:
PRACTICE_API_URL=http://127.0.0.1:9 uv run pytest -q............... [100%]
15 passed in 0.05s১৫টা টেস্ট (প্যারামিটারাইজড টেস্ট প্রতিটা কেসে একবার করে গোনা হয়), সব সবুজ, এক সেকেন্ডেরও অনেক কম সময়ে। সেগুলো সেটিংস আর আপডেট হওয়া লক ফাইলসহ কমিট করুন:
git add pyproject.toml uv.lock tests/
git commit -q -m "Add endpoint and client tests with a faked upstream"ধাপ ৬: README
README এই ক্রমে উত্তর দেয়: এটা কী, কীভাবে চালাব, কীভাবে ব্যবহার করব, কীভাবে টেস্ট করব, আর কী কনফিগার করা যায় (দেখুন পুল রিকোয়েস্ট, README ও আপনার প্রথম প্রকাশিত প্রজেক্ট)। বর্ণনার চেয়ে উদাহরণ ভালো: পাঠক যেন একটা কমান্ড কপি করে নথিভুক্ত ফলাফল দেখতে পারেন।
cat > README.md <<'EOF'
# BitByte Data Explorer API
A small FastAPI service that serves clean, validated course and weather data from the
BitByte Practice API. It is the project of *Git, APIs and Backend for AI: Beginner*.
## Endpoints
| Request | Returns |
|---|---|
| `GET /health` | `{"status": "ok"}` |
| `GET /courses?level=&min_rating=&sort=` | `{"count": n, "items": [...]}`; `sort` is `id`, `rating` or `hours` |
| `GET /courses/{course_id}` | one course |
| `GET /summary` | per level: number of courses, total hours, average rating |
| `GET /weather/{city}` | temperature, humidity, condition and a one-line advice |
Errors are JSON, `{"detail": "..."}`:
| Status | Meaning |
|---|---|
| 404 | no such course or city |
| 422 | a parameter is invalid (the body says which) |
| 502 | the Practice API failed, is unreachable or sent unexpected data |
| 504 | the Practice API did not answer in time |
## Run it
Needs Python 3.12+ and [uv](https://docs.astral.sh/uv/). Start the BitByte Practice API first.
```bash
uv sync # create .venv and install the locked versions
uv run fastapi dev # development server with auto-reload; open /docs
```
Example: `GET /weather/sylhet` returns
```json
{"city": "Sylhet", "temperature_c": 27.4, "humidity_pct": 85, "condition": "Rain", "advice": "Carry an umbrella."}
```
## Configuration
| Variable | Default | Meaning |
|---|---|---|
| `PRACTICE_API_URL` | the Practice API on this computer (see `app/upstream.py`) | base URL of the upstream API |
| `UPSTREAM_TIMEOUT` | `5` | seconds to wait for the upstream before answering 504 |
## Tests
```bash
uv run pytest -q
```
The tests replace the Practice API with fakes, so they need no network and no running server.
## Project layout
```
app/main.py routes, parameters and status codes
app/upstream.py every call to the Practice API
app/schemas.py Pydantic models
tests/ pytest tests (endpoints and client)
```
EOF
head -n 4 README.md# BitByte Data Explorer API
A small FastAPI service that serves clean, validated course and weather data from the
BitByte Practice API. It is the project of *Git, APIs and Backend for AI: Beginner*.ধাপ ৭: টুলগুলোকে ঢেকে দেওয়া একটা .gitignore
টেস্ট চালানোয় একটা .pytest_cache/ ফোল্ডার তৈরি হয়েছে। pytest নিজের ভেতরে নিজের একটা .gitignore রেখে দেয়, কিন্তু কভারেজ রিপোর্ট, লিন্টার আর নোটবুক তা করে না, আর আপনার .gitignore যিনি পড়বেন তাঁর সব নিয়ম এক জায়গায় দেখতে পাওয়া উচিত। চূড়ান্ত সংস্করণ:
# file: .gitignore
# Python
__pycache__/
*.pyc
# Virtual environment (rebuilt from uv.lock with `uv sync`)
.venv/
# Secrets and local settings: commit a .env.example with fake values instead
.env
.env.*
!.env.example
# Test and tool caches
.pytest_cache/
.coverage
htmlcov/
.ruff_cache/
# Logs from local runs
*.logনিয়মগুলো যা ভাবছেন তা-ই করছে কি না যাচাই করুন, এখনো তৈরি হয়নি এমন ফাইলের জন্যও:
git check-ignore -v .env.local .pytest_cache/README.md server.log app/__pycache__/main.cpython-314.pyc
git check-ignore -v .env.example || echo ".env.example is not ignored"
git status --short.gitignore:11:.env.* .env.local
.gitignore:15:.pytest_cache/ .pytest_cache/README.md
.gitignore:21:*.log server.log
.gitignore:3:__pycache__/ app/__pycache__/main.cpython-314.pyc
.gitignore:12:!.env.example .env.example
M .gitignore
?? README.md-v দেখায় কোন ফাইলের কোন লাইন মিলেছে। !.env.example নিয়মটা .env.*-এর বাদ দেওয়ার পর example ফাইলটাকে আবার ভেতরে আনে; আসল সিক্রেট বাইরেই থাকে। git status শুধু সেই দুটো ফাইল দেখায় যেগুলো আপনি বদলাতে চেয়েছিলেন। সেগুলো আলাদাভাবে কমিট করুন, প্রতি কমিটে একটা উদ্দেশ্য:
git add README.md
git commit -q -m "Add README with endpoints, setup, configuration and tests"
git add .gitignore
git commit -q -m "Ignore test caches and local env files"
git log --oneline main..feature/data-explorer26ccb95 Ignore test caches and local env files
a7d6e20 Add README with endpoints, setup, configuration and tests
b5fc148 Add endpoint and client tests with a faked upstream
3a8f75a Add courses, summary and weather endpoints
089f152 Add Practice API client with timeouts, pagination and validation
6bba991 Add response and upstream schemasmain..feature/data-explorer মানে "ফিচার ব্রাঞ্চের যে কমিটগুলো main-এ নেই": পুল রিকোয়েস্ট ঠিক এই তালিকাটাই দেখাবে। একজন রিভিউয়ারের চোখে বার্তাগুলো পড়ুন: প্রতিটা বলে কী বদলেছে, আদেশসূচক (imperative) ভঙ্গিতে, আর কোনোটাই "fix stuff" বা "wip" বলে না।
ধাপ ৮: ব্রাঞ্চ push করে পুল রিকোয়েস্ট খোলা
GitHub: রিমোট, push, pull ও clone পাতার মতোই, একটা লোকাল বেয়ার (bare) রিপোজিটরি GitHub-এর জায়গা নেয়। Git-এর দৃষ্টিতে এটা হুবহু GitHub-ই: ওয়ার্কিং ফোল্ডার ছাড়া একটা রিপোজিটরি, যেখানে আপনি push করেন আর যেখান থেকে fetch করেন।
git init -q --bare ~/remotes/data-explorer.git
git remote add origin ~/remotes/data-explorer.git
git push -q origin main
git push -u origin feature/data-explorerTo /home/nadia/remotes/data-explorer.git
* [new branch] feature/data-explorer -> feature/data-explorer
branch 'feature/data-explorer' set up to track 'origin/feature/data-explorer'.GitHub-এ এখন আপনি Compare & pull request ক্লিক করতেন, বেস হিসেবে main বেছে নিতেন, আর বিবরণ লিখতেন। gh CLI দিয়ে একই কাজ এক কমান্ডে হয় (এর জন্য আসল GitHub রিপোজিটরি লাগে, তাই এখানে চালানো হয়নি):
gh pr create --base main --head feature/data-explorer \
--title "Data Explorer API: courses, summary and weather" \
--body-file pr-description.md
gh pr view --webভালো বিবরণ রিভিউয়ারকে বলে কী দেখতে হবে আর কীভাবে যাচাই করতে হবে। এই ব্রাঞ্চের বিবরণটা এমন:
## What
A FastAPI service over the BitByte Practice API: /courses (filter, sort),
/courses/{id}, /summary and /weather/{city}, with /health.
## Why
The study assistant needs short, validated answers from one place
instead of calling the Practice API directly.
## How
- app/upstream.py owns every upstream call: shared session, timeout on
every request, pagination with a page limit, Pydantic validation.
- Upstream trouble becomes UpstreamError -> 502 (504 for timeouts)
through one exception handler.
## How to test
uv sync && uv run pytest -q (15 tests, no network needed)
Manual: start the Practice API, uv run fastapi dev, open /docs.
## Not in this PR
Caching and retries for upstream calls.ধাপ ৯: রিভিউ
রিভিউ করা নিজেই একটা দক্ষতা, আর চেকলিস্ট থাকলে তা রিভিউয়ারের মেজাজের ওপর নির্ভর করে না। ছোট একটা API পরিবর্তনের জন্য এটা ভালো একটা চেকলিস্ট; অন্য কাউকে দেখতে বলার আগে নিজের কোডেও এটা মিলিয়ে নিন:
| দিক | প্রশ্ন |
|---|---|
| আচরণ | ব্রিফে যা বলা আছে, তা-ই কি করছে? প্রতিটা ক্ষেত্রে স্ট্যাটাস কোড ঠিক আছে কি (200, 404, 422, 502, 504)? |
| ব্যর্থতার পথ | প্রতিটা বাইরে-যাওয়া কলে টাইমআউট আছে? আপস্ট্রিম বন্ধ, ধীর বা খারাপ ডেটা পাঠালে কী হয়? |
| নিরাপত্তা | কোডে বা হিস্ট্রিতে কোনো সিক্রেট নেই তো? ইনপুটে সীমা আছে? এরর বার্তায় ভেতরের তথ্য বা আপস্ট্রিমের ডেটা ফাঁস হচ্ছে না তো? |
| টেস্ট | শুধু সুখের পথ নয়, ব্যর্থতার পথও কি ঢাকা? নেটওয়ার্ক ছাড়া চলে? কোড ভুল হলে কি ফেল করে? |
| ডিজাইন ও পাঠযোগ্যতা | প্রতিটা মডিউলের কি একটাই কাজ? নাম পরিষ্কার? লজিক কি ভুল জায়গায় বসে আছে? |
| ডকুমেন্টেশন | README আপডেট হয়েছে? নতুন সেটিং নথিভুক্ত? |
| Git-এর পরিচ্ছন্নতা | ছোট কমিট, স্পষ্ট বার্তা? ডিফে তৈরি-হওয়া ফাইল, লগ বা .venv নেই তো? |
রিভিউয়ার শুরু করেন পরিবর্তনের আকার দেখে, তারপর ফাইল ধরে ধরে ডিফ পড়েন:
git diff --stat main...feature/data-explorer .gitignore | 10 +++++++-
README.md | 62 +++++++++++++++++++++++++++++++++++++++++++++
app/main.py | 78 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
app/schemas.py | 43 +++++++++++++++++++++++++++++++
app/upstream.py | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
pyproject.toml | 10 ++++++++
tests/conftest.py | 53 +++++++++++++++++++++++++++++++++++++++
tests/test_api.py | 62 +++++++++++++++++++++++++++++++++++++++++++++
tests/test_upstream.py | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++++
uv.lock | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
10 files changed, 559 insertions(+), 1 deletion(-)তানভীর, একজন সহকর্মী, PR রিভিউ করে দুটো মন্তব্য রাখলেন:
১.
app/main.py: "advice_forআর summary-র হিসাব বিজনেস রুল, কিন্তু আছে রাউটের ফাইলে। এগুলো নিজের মডিউলে নেওয়া যায়? তাহলে HTTP ছাড়াই ইউনিট টেস্ট করা যাবে।"২.
/summary: "লেভেলগুলো আসছে আপস্ট্রিম যে ক্রমে কোর্স সাজায় সেই ক্রমে। ওয়েবসাইট চায় নির্দিষ্ট ক্রম: beginner, intermediate, advanced।"
প্রথমটা একটা রিফ্যাক্টর: গঠনের পরিবর্তন, যা আচরণ বদলাবে না। দ্বিতীয়টা একটা আচরণের পরিবর্তন (বাগ ফিক্স)। এ দুটো আলাদা কমিটে রাখুন, আগে রিফ্যাক্টর: কিছু ভাঙলে আপনি জানবেন দুটোর কোনটা ভেঙেছে।
ধাপ ১০: টেস্টকে সুরক্ষা-জাল করে রিফ্যাক্টর
দুটো নিয়মকে হুবহু app/services.py-এ সরান:
# file: app/services.py
"""Business rules of the Data Explorer, kept free of HTTP so they are easy to test."""
from app.schemas import Course, LevelSummary
def advice_for(temp_c: float, condition: str) -> str:
if condition == "Rain":
return "Carry an umbrella."
if temp_c >= 30:
return "Hot day: drink water and avoid the midday sun."
return "Good weather to go out."
def summarise(courses: list[Course]) -> list[LevelSummary]:
by_level: dict[str, list[Course]] = {}
for course in courses:
by_level.setdefault(course.level, []).append(course)
return [
LevelSummary(
level=level,
courses=len(group),
total_hours=sum(c.hours for c in group),
average_rating=round(sum(c.rating for c in group) / len(group), 2),
)
for level, group in by_level.items()
]আর রাউটগুলোকে সেগুলো ব্যবহার করান। রাউট এখন শুধু HTTP আর বাকি কোডের মধ্যে অনুবাদের কাজ করে:
# file: app/main.py
"""BitByte Data Explorer API: clean, validated endpoints over the BitByte Practice API."""
from typing import Annotated, Literal
from fastapi import FastAPI, HTTPException, Path, Query, Request
from fastapi.responses import JSONResponse
from app import services, upstream
from app.schemas import Course, CourseList, Level, LevelSummary, Weather
app = FastAPI(title="BitByte Data Explorer API", version="0.1.0")
@app.exception_handler(upstream.UpstreamError)
def upstream_failed(request: Request, exc: upstream.UpstreamError) -> JSONResponse:
return JSONResponse({"detail": exc.message}, status_code=exc.status_code)
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/courses")
def list_courses(
level: Level | None = None,
min_rating: Annotated[float, Query(ge=0, le=5)] = 0,
sort: Literal["id", "rating", "hours"] = "id",
) -> CourseList:
courses = [c for c in upstream.fetch_courses(level) if c.rating >= min_rating]
courses.sort(key=lambda c: getattr(c, sort), reverse=sort != "id")
return CourseList(count=len(courses), items=courses)
@app.get("/courses/{course_id}")
def get_course(course_id: Annotated[int, Path(ge=1)]) -> Course:
course = upstream.fetch_course(course_id)
if course is None:
raise HTTPException(404, f"Course {course_id} not found")
return course
@app.get("/summary")
def summary() -> list[LevelSummary]:
return services.summarise(upstream.fetch_courses())
@app.get("/weather/{city}")
def weather(city: Annotated[str, Path(min_length=2, max_length=40)]) -> Weather:
data = upstream.fetch_weather(city)
if data is None:
raise HTTPException(404, f"No weather data for {city}")
return Weather(
city=data.city,
temperature_c=data.temp_c,
humidity_pct=data.humidity,
condition=data.condition,
advice=services.advice_for(data.temp_c, data.condition),
)এন্ডপয়েন্ট টেস্টগুলো লেখা হয়েছিল HTTP-র বিরুদ্ধে, main.py-এর ভেতরের গড়নের বিরুদ্ধে নয়, তাই সেগুলো বদলাতে হয়নি। চালান: পাস করলে আচরণ একই আছে।
uv run pytest -q............... [100%]
15 passed in 0.05sadvice_for এখন সাধারণ ফাংশন, তাই ক্লায়েন্ট বা নকল ছাড়াই এর প্রান্তিক অবস্থাগুলো সস্তায় টেস্ট করা যায়। মজার মান হলো সীমানা, ঠিক ৩০ ডিগ্রি:
# file: tests/test_services.py
"""Unit tests for the business rules: plain functions, no HTTP, no fakes."""
import pytest
from app import services
@pytest.mark.parametrize(
("temp_c", "condition", "advice"),
[
(25.0, "Rain", "Carry an umbrella."),
(35.0, "Rain", "Carry an umbrella."),
(30.0, "Sunny", "Hot day: drink water and avoid the midday sun."),
(29.9, "Sunny", "Good weather to go out."),
],
)
def test_advice_for(temp_c, condition, advice):
assert services.advice_for(temp_c, condition) == advice
def test_summarise_counts_every_course(fake_courses):
rows = services.summarise(fake_courses)
assert sum(row.courses for row in rows) == len(fake_courses)uv run pytest -q
git add app/services.py app/main.py tests/test_services.py
git commit -q -m "Move business rules into app/services.py".................... [100%]
20 passed in 0.04sধাপ ১১: বাগ ঠিক করা, আগে টেস্ট লিখে
দ্বিতীয় মন্তব্যের জন্য ফিক্সের আগে সেই টেস্ট লিখুন, যা চাওয়া আচরণ বর্ণনা করে। নকল কোর্স তালিকায় আছে beginner, advanced, beginner, intermediate, তাই বর্তমান কোড লেভেলগুলো ভুল ক্রমে ফেরত দেয়, আর টেস্টটা ফেল করার কথা:
cat >> tests/test_services.py <<'EOF'
def test_summarise_uses_a_fixed_level_order(fake_courses):
levels = [row.level for row in services.summarise(fake_courses)]
assert levels == ["beginner", "intermediate", "advanced"]
EOF
uv run pytest -q --tb=short.............F....... [100%]
============================================= FAILURES =============================================
_____________________________ test_summarise_uses_a_fixed_level_order ______________________________
tests/test_services.py:28: in test_summarise_uses_a_fixed_level_order
assert levels == ["beginner", "intermediate", "advanced"]
E AssertionError: assert ['beginner', ...intermediate'] == ['beginner', ...', 'advanced']
E
E At index 1 diff: 'advanced' != 'intermediate'
E Use -v to get more diff
===================================== short test summary info ======================================
FAILED tests/test_services.py::test_summarise_uses_a_fixed_level_order - AssertionError: assert [...
1 failed, 20 passed in 0.06sব্যর্থতাটা নিচ থেকে ওপরে পড়ুন: সারাংশের লাইন টেস্টের নাম বলে, আর অ্যাসার্শন দুটো তালিকাই দেখায়, প্রথম পার্থক্য ইন্ডেক্স ১-এ। যে টেস্টকে আপনি ফেল করতে দেখেছেন, সেটা শুধু পাস করতে দেখা টেস্টের চেয়ে মূল্যবান: এটা প্রমাণ করে টেস্টটা এই বাগ ধরতে পারে। Practice API-র আসল ডেটায় ক্রমটা ঘটনাক্রমে ঠিক ছিল, আর ঠিক এ কারণেই কেউ খেয়াল করেনি। এবার ফিক্স। লেভেলের ক্রম একটা জায়গায় আগে থেকেই আছে, Level টাইপে, তাই তালিকা আবার না লিখে সেখান থেকেই নিন:
# file: app/services.py
"""Business rules of the Data Explorer, kept free of HTTP so they are easy to test."""
from typing import get_args
from app.schemas import Course, Level, LevelSummary
LEVELS = get_args(Level) # ("beginner", "intermediate", "advanced")
def advice_for(temp_c: float, condition: str) -> str:
if condition == "Rain":
return "Carry an umbrella."
if temp_c >= 30:
return "Hot day: drink water and avoid the midday sun."
return "Good weather to go out."
def summarise(courses: list[Course]) -> list[LevelSummary]:
by_level: dict[str, list[Course]] = {}
for course in courses:
by_level.setdefault(course.level, []).append(course)
summaries = []
for level in LEVELS: # our fixed order, not the upstream's
group = by_level.get(level)
if group:
summaries.append(LevelSummary(
level=level,
courses=len(group),
total_hours=sum(c.hours for c in group),
average_rating=round(sum(c.rating for c in group) / len(group), 2),
))
return summariesuv run pytest -q
git add app/services.py tests/test_services.py
git commit -q -m "Return summary levels in a fixed order"
git push..................... [100%]
21 passed in 0.04s
To /home/nadia/remotes/data-explorer.git
26ccb95..96a7ec5 feature/data-explorer -> feature/data-explorerসাধারণ git push কাজ করে কারণ আগে -u ট্র্যাকিং ঠিক করে দিয়েছিল। GitHub-এ পুল রিকোয়েস্ট নতুন দুটো কমিটসহ নিজে থেকেই আপডেট হতো, আর আপনি প্রতিটা মন্তব্যের উত্তরে সেটা সমাধান করা কমিটটা উল্লেখ করতেন। তানভীর অনুমোদন (approve) দেন।
ধাপ ১২: মার্জ, পরিষ্কার করা, আর নতুন ক্লোন থেকে যাচাই
GitHub-এ আপনি Merge pull request চাপেন, আর GitHub main-এ একটা মার্জ কমিট বানায়। বেয়ার রিপোজিটরিতে কোনো বোতাম নেই, তাই একই মার্জ কমিট আপনি নিজে বানিয়ে push করেন। রিপোজিটরিতে ফলাফল একই:
git switch main
git merge --no-ff feature/data-explorer -m "Merge pull request #1 from nadia/feature/data-explorer"
git push origin mainSwitched to branch 'main'
Merge made by the 'ort' strategy.
.gitignore | 10 +++++++-
README.md | 62 +++++++++++++++++++++++++++++++++++++++++++++
app/main.py | 59 +++++++++++++++++++++++++++++++++++++++++++
app/schemas.py | 43 +++++++++++++++++++++++++++++++
app/services.py | 32 ++++++++++++++++++++++++
app/upstream.py | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
pyproject.toml | 10 ++++++++
tests/conftest.py | 53 +++++++++++++++++++++++++++++++++++++++
tests/test_api.py | 62 +++++++++++++++++++++++++++++++++++++++++++++
tests/test_services.py | 28 +++++++++++++++++++++
tests/test_upstream.py | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++++
uv.lock | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
12 files changed, 600 insertions(+), 1 deletion(-)
create mode 100644 README.md
create mode 100644 app/main.py
create mode 100644 app/schemas.py
create mode 100644 app/services.py
create mode 100644 app/upstream.py
create mode 100644 tests/conftest.py
create mode 100644 tests/test_api.py
create mode 100644 tests/test_services.py
create mode 100644 tests/test_upstream.py
To /home/nadia/remotes/data-explorer.git
04b26f9..b94b4cd main -> mainতারপর ব্রাঞ্চটা মুছুন, লোকালে আর রিমোটে। এটা মার্জ হয়ে গেছে, তাই কিছু হারায় না: এর কমিটগুলো এখন main-এর অংশ।
git branch -d feature/data-explorer
git push origin --delete feature/data-explorer
git log --oneline --graph --decorateDeleted branch feature/data-explorer (was 96a7ec5).
To /home/nadia/remotes/data-explorer.git
- [deleted] feature/data-explorer
* b94b4cd (HEAD -> main, origin/main) Merge pull request #1 from nadia/feature/data-explorer
|\
| * 96a7ec5 Return summary levels in a fixed order
| * 40763f0 Move business rules into app/services.py
| * 26ccb95 Ignore test caches and local env files
| * a7d6e20 Add README with endpoints, setup, configuration and tests
| * b5fc148 Add endpoint and client tests with a faked upstream
| * 3a8f75a Add courses, summary and weather endpoints
| * 089f152 Add Practice API client with timeouts, pagination and validation
| * 6bba991 Add response and upstream schemas
|/
* 04b26f9 Set up the Data Explorer project with uvগ্রাফটা সেই গল্প বলে, যা একজন সহকর্মী পরে পড়বেন: main-এ সেটআপ, একটা ব্রাঞ্চে ফিচারের উন্নয়ন যেখানে প্রতিটা ধাপ আর রিভিউয়ের প্রতিটা ফিক্স আলাদা কমিট, আর একটা মার্জ কমিট যা বলে "এটা PR #১-এর মাধ্যমে এসেছে"। ফাস্ট-ফরোয়ার্ড সম্ভব থাকলেও --no-ff মার্জ কমিটটা বানাতে বাধ্য করে। GitHub-এর বাকি দুটো বোতাম অন্য কাজ করে: Squash and merge পুরো ব্রাঞ্চকে main-এ একটা কমিট বানায়, আর Rebase and merge মার্জ কমিট ছাড়াই কমিটগুলো main-এ কপি করে বসায়। অনেক টিম ছোট PR squash করে; অ্যাডভান্সড টিউটোরিয়ালে তিনটার তুলনা আছে।
শেষ যাচাইটা সেই যাচাই, যা "আমার মেশিনে চলে" সমস্যা ধরে: রিমোট থেকে একটা নতুন ফোল্ডারে ক্লোন করুন, লক ফাইল থেকে ইনস্টল করুন, আর টেস্ট চালান। যে ফাইল কমিট করতে ভুলে গেছেন, বা আপনার .gitignore ভুল করে যেটা লুকিয়ে রেখেছে, সেটা এখানেই ধরা পড়ে, আর অন্য কোথাও নয়:
cd ~
git clone -q ~/remotes/data-explorer.git data-explorer-check
cd data-explorer-check
uv sync -q
ls
uv run pytest -qREADME.md
app
pyproject.toml
tests
uv.lock
..................... [100%]
21 passed in 0.04sআর নতুন ক্লোন থেকে আসল Practice API-র বিরুদ্ধে একটা স্মোক টেস্ট, রিভিউয়ের ফিক্সটা সরাসরি দেখতে:
export PRACTICE_API_URL=http://localhost:8800
uv run fastapi run --port 8000 > server.log 2>&1 &
curl -s --retry 20 --retry-connrefused --retry-delay 0 http://localhost:8000/health > /dev/null
curl -s http://localhost:8000/summary | python -m json.tool[
{
"level": "beginner",
"courses": 5,
"total_hours": 42,
"average_rating": 4.64
},
{
"level": "intermediate",
"courses": 2,
"total_hours": 11,
"average_rating": 4.55
},
{
"level": "advanced",
"courses": 3,
"total_hours": 30,
"average_rating": 4.67
}
]প্রজেক্ট শেষ: টেস্ট করা, ডকুমেন্টেড, রিভিউ হওয়া, মার্জ করা, আর পরিষ্কার চেকআউট থেকে যাচাই করা। GitHub-এ এটা এখন একটা পাবলিক রিপোজিটরি হতো, যার লিংক আপনি CV-তে দিতে পারেন।
বাস্তবে কোথায় দেখা যায়
- LLM ফিচারের টেস্ট:
fake_upstreamফিক্সচারের ধারণা AI ব্যাকএন্ডে নকল LLM প্রোভাইডারের মতোই: নির্দিষ্ট, তাৎক্ষণিক উত্তর, সাথে করা কলের তালিকা, যাতে আপনি যাচাই করতে পারেন "প্রম্পটে ব্যবহারকারীর প্রশ্ন ছিল" বা "অবৈধ ইনপুটের জন্য কোনো মডেল কল হয়নি"। - CI: নতুন ক্লোনের যাচাই ঠিক সেটাই, যা CI সার্ভার প্রতিটা push-এ করে: ক্লোন,
uv sync,pytest। অ্যাডভান্সড টিউটোরিয়ালের GitHub Actions পাতা ঠিক এই কমান্ডগুলো অটোমেট করে, আর তারপর একটা ব্রাঞ্চ রুল এগুলো পাস না করা পর্যন্ত মার্জ আটকে দিতে পারে। - কোড রিভিউ সংস্কৃতি: ছোট PR, স্পষ্ট বিবরণ আর সবার জন্য একই চেকলিস্ট দিয়ে টিমগুলো গতি না কমিয়ে মান ধরে রাখে, আর অন্যের PR রিভিউ করা একটা কোডবেস শেখার দ্রুততম উপায়গুলোর একটা।
- পোর্টফোলিও: README, টেস্ট আর পরিষ্কার হিস্ট্রিওয়ালা একটা রিপোজিটরি একজন জুনিয়র AI ইঞ্জিনিয়ার নিয়োগকর্তাকে যা দেখাতে পারেন তার মধ্যে সবচেয়ে বিশ্বাসযোগ্য।
সাধারণ ভুল
- যে টেস্ট আসল আপস্ট্রিম কল করে। সেগুলো ধীর, অস্থির, ব্যর্থতার পথে পৌঁছাতে পারে না, আর পেইড API-তে প্রতিবার চালালেই টাকা যায়। সমাধান: আপস্ট্রিম নকল করুন; আসল সংযোগের জন্য আলাদা একটা স্মোক টেস্ট রাখুন।
- ভুল নাম প্যাচ করা।
main.pyযদিfrom app.upstream import fetch_coursesকরে থাকে, তাহলেmonkeypatch.setattr("app.upstream.fetch_courses", ...)কিছুই করে না। সমাধান: মডিউলের মাধ্যমে কল করুন (upstream.fetch_courses) আর সেখানেই প্যাচ করুন, অথবা যে মডিউল নামটা ব্যবহার করে সেখানকার নাম প্যাচ করুন। - আউটপুট থেকে কপি করা প্রত্যাশিত মান। তখন কোড যা-ই করুক টেস্ট পাস করে। সমাধান: ছোট, বানানো ডেটা থেকে প্রত্যাশিত মান হাতে হিসাব করুন।
- একই কমিটে রিফ্যাক্টর আর ফিক্স মেশানো। টেস্ট ভাঙলে আপনি বলতে পারেন না কোন পরিবর্তনটা ভেঙেছে, রিভিউয়ারও পারেন না। সমাধান: আগে টেস্ট সবুজ রেখে রিফ্যাক্টর, তারপর আচরণের পরিবর্তন নিজের টেস্টসহ।
- কখনো নতুন ক্লোন না চালানো। একটা হারানো ফাইল বা অতি-উৎসাহী
.gitignoreনিয়ম শুধু অন্য কারও মেশিনে ধরা পড়ে। সমাধান: শেষ বলার আগে ক্লোন, sync আর টেস্ট করুন (আর প্রতিটা push-এ CI-কে দিয়ে করান)।
নিজে চেষ্টা করুন
- সহজ:
~/projects/data-explorer-এ (এখনmain-এ), এমন একটা টেস্ট যোগ করুন যে/weather/x(একটা মাত্র অক্ষর) 422 দিয়ে প্রত্যাখ্যাত হয় আর কখনো আপস্ট্রিমে পৌঁছায় না। টেস্ট চালান। - মাঝারি: একটা ক্লায়েন্ট টেস্ট যোগ করুন, যা প্রমাণ করে যে আপস্ট্রিম চিরকাল
next_pageপাঠাতে থাকলেfetch_coursesUpstreamErrorছোড়ে। টেস্ট দ্রুত রাখতেmonkeypatchদিয়েupstream.MAX_PAGES৩ করে নিন। - কঠিন: একটা ছোট ফিচারের জন্য পুরো চক্র আবার চালান:
feature/levelsব্রাঞ্চ, কোর্স আছে এমন লেভেলগুলোর সাজানো তালিকা ফেরত দেওয়া একটাGET /levelsএন্ডপয়েন্ট, তার টেস্ট, একটা কমিট, একটা push,main-এ একটা--no-ffমার্জ,mainpush, আর দুই দিকেই ব্রাঞ্চ মোছা।
উত্তর
cd ~/projects/data-explorer
cat >> tests/test_api.py <<'EOF'
def test_one_letter_city_is_rejected(client, fake_upstream):
assert client.get("/weather/x").status_code == 422
assert fake_upstream == []
EOF
cat >> tests/test_upstream.py <<'EOF'
def test_endless_pages_are_stopped(monkeypatch):
monkeypatch.setattr(upstream, "MAX_PAGES", 3)
def fake_get(url, params=None, timeout=None):
return FakeResponse(200, {"items": [course(params["page"])], "next_page": params["page"] + 1})
monkeypatch.setattr(upstream.session, "get", fake_get)
with pytest.raises(upstream.UpstreamError, match="too many pages"):
upstream.fetch_courses()
EOF
uv run pytest -q....................... [100%]
23 passed in 0.04s# ৩. GET /levels-এর পুরো চক্র
git add tests/
git commit -q -m "Test the city length limit and the page limit"
git switch -q -c feature/levels
cat >> app/main.py <<'EOF'
@app.get("/levels")
def levels() -> list[str]:
return sorted({course.level for course in upstream.fetch_courses()})
EOF
cat >> tests/test_api.py <<'EOF'
def test_levels(client, fake_upstream):
assert client.get("/levels").json() == ["advanced", "beginner", "intermediate"]
EOF
uv run pytest -q
git add app/main.py tests/test_api.py
git commit -q -m "Add GET /levels"
git push -q -u origin feature/levels
git switch -q main
git merge -q --no-ff feature/levels -m "Merge pull request #2 from nadia/feature/levels"
git push -q origin main
git branch -q -d feature/levels
git push -q origin --delete feature/levels
git log --oneline --graph -n 6........................ [100%]
24 passed in 0.05s
* 0bc9e2a Merge pull request #2 from nadia/feature/levels
|\
| * 3eac711 Add GET /levels
|/
* 15de305 Test the city length limit and the page limit
* b94b4cd Merge pull request #1 from nadia/feature/data-explorer
|\
| * 96a7ec5 Return summary levels in a fixed order
| * 40763f0 Move business rules into app/services.pyপ্রথম দুটো কমান্ড সংক্ষেপে রাখতে ১ ও ২ নম্বর অনুশীলনের উত্তর সরাসরি main-এ কমিট করে; টিমে এগুলোও PR দিয়ে যেত। GitHub-এ "Merge pull request #২" আসত বোতাম চেপে; চক্রের বাকিটা একই।
সারসংক্ষেপ
- অন্য API-র ওপর নির্ভরশীল সার্ভিস দুই স্তরে টেস্ট করুন: রাউট টেস্ট করতে ক্লায়েন্ট ফাংশন নকল করুন, ক্লায়েন্ট টেস্ট করতে HTTP কল নকল করুন। কোনো টেস্ট নেটওয়ার্ক ছোঁয় না।
monkeypatchএকটা টেস্টের জন্য একটা নাম বদলে শেষে ফিরিয়ে দেয়; নামটা প্যাচ করুন যেখানে সেটা খোঁজা হয়। কল মনে রাখা নকল দিয়ে আপনি যাচাই করতে পারেন আপস্ট্রিমে কী পাঠানো হয়েছে (আর কী হয়নি)।- README উত্তর দেয় কী, চালানো, ব্যবহার, টেস্ট আর কনফিগারেশন নিয়ে, পাঠক কপি করতে পারেন এমন উদাহরণসহ;
.gitignoreপ্রতিটা টুলের জঞ্জাল তালিকাভুক্ত করে আর.env.exampleরাখে। - পুল রিকোয়েস্ট মানে একটা ব্রাঞ্চ, একটা বিবরণ আর একটা রিভিউ। চেকলিস্ট ধরে রিভিউ করুন; ফিডব্যাকের উত্তর দিন ছোট কমিটে; রিফ্যাক্টর আর ফিক্স আলাদা রাখুন; ফিক্সের আগে ব্যর্থ টেস্ট লিখুন।
- মার্জ করুন, ব্রাঞ্চ মুছুন, তারপর নতুন ক্লোন থেকে প্রমাণ করুন:
git clone,uv sync,uv run pytest।
এরপর: সারসংক্ষেপ, চিট শিট ও এরপর কী পাতা টার্মিনাল আর Git থেকে HTTP, FastAPI ও pytest পর্যন্ত পুরো টিউটোরিয়ালের কমান্ড এক পাতায় এনে দেখাবে, আর অ্যাডভান্সড টিউটোরিয়াল কী যোগ করে তাও জানাবে।