Guided Python Project
Before you start
You need: all of Articles 01–11.
In VS Code: a virtual environment, requirements.txt, and pytest running from the Testing panel.
Time: 10–14 hours.
Goal
Demonstrate that you can build a structured Python application: packaged, tested, type-checked, processing files safely and exposing a validated API.
Assignment
Build the NexCoding Academy student records system in three parts, each usable on its own.
| Part | Deliverable |
|---|---|
| 1. Console application | Student records with a menu, persisted to CSV |
| 2. File processing utility | Bulk import and report generation |
| 3. FastAPI backend | The same data over a validated REST API |
school-portal/
├── pyproject.toml
├── README.md
├── .env.example
├── .gitignore
├── .pre-commit-config.yaml
├── src/
│ └── school/
│ ├── __init__.py
│ ├── models.py dataclasses and enums
│ ├── calculations.py grading, percentages, fee balances
│ ├── storage/
│ │ ├── csv_store.py
│ │ └── database.py
│ ├── importing.py CSV import with error collection
│ ├── reports.py
│ ├── cli.py argparse entry point
│ └── api/
│ ├── main.py
│ ├── models.py Pydantic request and response models
│ ├── routes.py
│ └── dependencies.py
├── tests/
│ ├── conftest.py
│ ├── test_calculations.py
│ ├── test_importing.py
│ └── test_api.py
└── data/
└── students.csv
Non-negotiable requirements
pyproject.tomlwith dependencies, dev extras and tool configuration- A virtual environment; no global installs
Decimalfor every money value, neverfloat- Absent exam results store
None, never0 - Every file opened with an explicit
encoding - Every SQL query parameterised
school_idfrom configuration or the authenticated user, never from a request- Type hints throughout;
mypy --strictclean ruffandblackclean, enforced by pre-commit- Tests for every calculation, with boundary cases
- No secret in the repository or its history
Part 1: Console application
# src/school/models.py
from dataclasses import dataclass, field
from datetime import date
from decimal import Decimal
from enum import Enum
class StudentStatus(Enum):
ACTIVE = 0
INACTIVE = 1
GRADUATED = 2
TRANSFERRED = 3
@dataclass
class Student:
name: str
roll_number: str
class_name: str
section: str
date_of_birth: date
parent_name: str
parent_phone: str
address: str | None = None
status: StudentStatus = StudentStatus.ACTIVE
def __post_init__(self) -> None:
if not self.name.strip():
raise ValueError("name is required")
if not re.fullmatch(r"NCA-\d{4}-\d{4}", self.roll_number):
raise ValueError(f"roll_number must look like NCA-2024-0012, got {self.roll_number!r}")
@dataclass
class ExamResult:
student_id: int
exam_id: int
marks_obtained: Decimal | None = None
is_absent: bool = False
def __post_init__(self) -> None:
if self.is_absent and self.marks_obtained is not None:
raise ValueError("An absent student cannot have marks")
if not self.is_absent and self.marks_obtained is None:
raise ValueError("Enter marks, or mark the student absent")
marks_obtained: Decimal | None with the __post_init__ check is the requirement to get right. A 0 where None belongs prints a real student as having failed an exam they never sat.
# src/school/calculations.py
def calculate_percentage(marks_obtained: Decimal, max_marks: Decimal) -> Decimal:
if max_marks <= 0:
raise ValueError(f"max_marks must be positive, got {max_marks}")
return (marks_obtained / max_marks) * 100
def get_result_label(result: ExamResult, passing_marks: Decimal) -> str:
if result.is_absent: # FIRST — before any grade check
return "Absent"
assert result.marks_obtained is not None # guaranteed by __post_init__
return "Pass" if result.marks_obtained >= passing_marks else "Fail"
def calculate_average(results: list[ExamResult]) -> Decimal:
"""Average of students who sat the exam. Absentees are excluded."""
present = [r for r in results if not r.is_absent and r.marks_obtained is not None]
if not present:
return Decimal("0")
return sum(r.marks_obtained for r in present) / len(present)
The docstring on calculate_average states the decision. "Average excluding absentees" and "average counting absentees as zero" are different numbers, both appear on real report cards, and the code must say which it means.
The menu
def run_menu(store: CsvStudentStore) -> None:
while True:
print("\n1. List students")
print("2. Add student")
print("3. Search")
print("4. Remove student")
print("5. Exit")
choice = input("Choice: ").strip()
try:
if choice == "1":
list_students(store)
elif choice == "2":
add_student(store)
elif choice == "3":
search_students(store)
elif choice == "4":
remove_student(store)
elif choice == "5":
break
else:
print("Please choose 1 to 5.")
except ValueError as error:
print(f"Error: {error}")
Invalid input must re-prompt, never crash. The try around the dispatch catches validation failures from the dataclasses and shows them.
def list_students(store: CsvStudentStore) -> None:
students = store.load()
if not students:
print("\nNo students have been added yet.")
return
print(f"\n{'Roll number':<18}{'Name':<25}{'Class':<10}{'Parent phone':<15}")
print("-" * 68)
for student in students:
print(f"{student.roll_number:<18}{student.name:<25}"
f"{student.class_name + ' - ' + student.section:<10}{student.parent_phone:<15}")
The empty state is a requirement. An empty table with headers reads as a bug.
Part 2: File processing
# src/school/importing.py
@dataclass
class ImportResult:
students: list[Student] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
@property
def has_errors(self) -> bool:
return bool(self.errors)
REQUIRED_COLUMNS = {"RollNumber", "Name", "ClassName", "Section", "DateOfBirth",
"ParentName", "ParentPhone"}
def import_students(path: Path) -> ImportResult:
result = ImportResult()
with open(path, newline="", encoding="utf-8-sig") as file:
reader = csv.DictReader(file)
missing = REQUIRED_COLUMNS - set(reader.fieldnames or [])
if missing:
raise ValueError(f"Missing required columns: {', '.join(sorted(missing))}")
for line_number, row in enumerate(reader, start=2):
try:
result.students.append(Student(
name=row["Name"].strip(),
roll_number=row["RollNumber"].strip(),
class_name=row["ClassName"].strip(),
section=row["Section"].strip(),
date_of_birth=date.fromisoformat(row["DateOfBirth"].strip()),
parent_name=row["ParentName"].strip(),
parent_phone=row["ParentPhone"].strip(),
address=row.get("Address", "").strip() or None
))
except (ValueError, KeyError) as error:
result.errors.append(f"Line {line_number}: {error}")
return result
Five things a reviewer will check:
| Detail | What breaks without it |
|---|---|
encoding="utf-8-sig" | An Excel BOM makes the first column name unmatchable |
newline="" | Blank rows between records on Windows |
| Header validation up front | The failure is a KeyError on row 1, not a clear message |
start=2 | Reported line numbers do not match what the user sees in Excel |
| Errors collected, not raised | One bad row aborts a 5,000-row import |
Reporting every error at once matters. Fixing them one run at a time is why bulk imports take an afternoon.
row.get("Address", "").strip() or None turns a blank into None, not "" — the absent-versus-empty distinction.
def write_atomic(path: Path, content: str) -> None:
"""Write via a temporary file, then replace atomically."""
path.parent.mkdir(parents=True, exist_ok=True)
handle, temporary = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
try:
with os.fdopen(handle, "w", encoding="utf-8", newline="") as file:
file.write(content)
file.flush()
os.fsync(file.fileno())
os.replace(temporary, path)
except BaseException:
Path(temporary).unlink(missing_ok=True)
raise
A crash halfway through a plain "w" write leaves a truncated file — worse than no file, because the original is gone.
# src/school/cli.py
def main() -> int:
parser = argparse.ArgumentParser(description="NexCoding Academy student records")
subparsers = parser.add_subparsers(dest="command", required=True)
import_parser = subparsers.add_parser("import", help="Import students from a CSV file")
import_parser.add_argument("path", type=Path)
import_parser.add_argument("--dry-run", action="store_true")
report_parser = subparsers.add_parser("report", help="Generate a class report")
report_parser.add_argument("--class-name", required=True)
report_parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
try:
return run(args)
except KeyboardInterrupt:
print("\nCancelled.")
return 130
except Exception:
logger.exception("Unhandled error")
print("Something went wrong. See the log for details.")
return 1
if __name__ == "__main__":
raise SystemExit(main())
--dry-run validates without saving — the difference between a safe import and a destructive one.
raise SystemExit(main()) sets the process exit code, which matters in a scheduled job.
Part 3: FastAPI backend
# src/school/api/dependencies.py
def get_connection() -> Iterator[Connection]:
connection = create_connection(settings.database_url)
try:
yield connection
finally:
connection.close()
def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]) -> CurrentUser:
user = decode_token(token)
if user is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"})
return user
ConnectionDep = Annotated[Connection, Depends(get_connection)]
CurrentUserDep = Annotated[CurrentUser, Depends(get_current_user)]
# src/school/api/routes.py
router = APIRouter(prefix="/api/students", tags=["students"])
@router.get("")
def search_students(
user: CurrentUserDep,
connection: ConnectionDep,
term: str | None = Query(default=None, max_length=100),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100)
) -> PagedResponse[StudentResponse]:
result = repository.search(connection, user.school_id, term, page, page_size)
return PagedResponse(
items=[StudentResponse.model_validate(s) for s in result.items],
total_count=result.total_count,
page=page,
page_size=page_size
)
@router.post("", status_code=status.HTTP_201_CREATED)
def create_student(
request: StudentCreateRequest,
user: CurrentUserDep,
connection: ConnectionDep,
response: Response
) -> StudentResponse:
student = service.create(connection, user.school_id, request)
response.headers["Location"] = f"/api/students/{student.public_id}"
return StudentResponse.model_validate(student)
@router.delete("/{public_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_student(public_id: UUID, user: CurrentUserDep, connection: ConnectionDep) -> None:
if not service.deactivate(connection, user.school_id, public_id):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Student not found")
Four requirements visible here:
| Detail | Why |
|---|---|
user.school_id, never a parameter | A client-supplied tenant id chooses whose data to read |
le=100 on page_size | Without it, pageSize=1000000 takes the API down |
Location on 201 | What 201 is defined to carry |
| 404 for another tenant's record | 403 confirms the record exists |
Submission template
DECISIONS.md
Project structure:
Package layout and why:
What each module is responsible for:
Types and data:
Where Decimal is used and why:
How absent results are represented, and what a 0 would cause:
Dataclass validation in __post_init__:
File handling:
Encoding chosen per file type, and why utf-8-sig where used:
Import error strategy — collected or raised, and why:
Atomic write approach:
Database:
Parameterisation, everywhere:
Transaction boundaries:
Where the tenant filter is applied:
API:
Request and response models, and why they differ from entities:
Status code per outcome:
Where school_id comes from:
Error handling and what a 500 returns:
Quality:
mypy configuration and what strict caught:
ruff rules enabled, and any B-rule findings:
Test list, and what each protects:
Coverage, and which branches are deliberately untested:
Deliberately not done, and why:
Verification
Runs from a clean clone. Create a virtual environment, pip install -e ".[dev]", run each part. Nothing installed globally.
Configuration fails loudly. Remove a required setting from .env. Confirm the application refuses to start with a message naming it.
Money is exact. Compute a fee balance across ten part-payments. Confirm the total reconciles exactly. Then change one value to float and confirm the drift.
Absent is not zero. Create an absent result. Confirm marks_obtained is None in storage, that get_result_label returns "Absent", and that calculate_average excludes it. Then move the absent check below the grade check and confirm the student is labelled Fail.
Import survives bad data. Feed a file with five bad rows among fifty. Confirm forty-five imported and all five errors reported with line numbers matching Excel. Confirm a missing column produces a clear message naming it.
Encoding is correct. Import a file saved by Excel containing a Telugu or Hindi name. Confirm the first column matches and the name is intact. Repeat with utf-8 instead of utf-8-sig and record the difference.
CSV round-trips. Export a student whose parent name contains a comma. Re-import and confirm the fields are unchanged.
Writes are atomic. Kill the process mid-write with a plain "w" and confirm the truncated file. Repeat with write_atomic and confirm the original survives.
SQL is parameterised. Search for a name containing an apostrophe, and for '; DROP TABLE Student; --. Both must be treated as literal text.
Transactions are atomic. Force a failure between the payment insert and the balance update. Confirm neither happened.
Tenant isolation holds. Seed two schools. Confirm every query and endpoint filters by school_id, then remove the filter from one and confirm a test fails.
API validation works. POST an invalid roll number and confirm 422 with a field-level detail. POST an extra school_id and confirm the student is created in the token's school, not the requested one.
Status codes are correct. 201 with Location, 204 on delete, 404 for another tenant's record, 422 for validation. Call the delete from JavaScript with .json() and confirm 204 is handled.
Errors are safe. Force an unhandled exception and confirm the response contains no traceback. Find the full detail in the log.
Quality gates pass. pytest green, mypy --strict clean, ruff check clean, black --check clean, pre-commit installed and blocking.
No secrets. git log -p | grep -i "password\|token\|api[_-]key" returns nothing.
AI practice
Three AI exercises from this track's syllabus. Do each after the project works, and apply Track 18's discipline — every answer is a hypothesis until you have run it.
- Ask AI for exercises without solutions. Ask for five Python problems using the School entities — dictionary grouping, CSV parsing, a class model — with no code and no answers. Solve them, then ask for a critique of your solution rather than a rewrite. That order is what keeps the learning.
- Request a line-by-line explanation. Paste your class-report function and ask what each line does and what happens when the list of appeared students is empty. Check every claim against the code.
statistics.meanon an empty list raises; a generated explanation often misses that. - Debug step by step without accepting a full rewrite. When a FastAPI endpoint returns a 422, ask what the validation error means and what to check — not for corrected code. Read the
detailarray yourself; it names the field and the rule.
Also check every generated model for Decimal on money fields and Optional[int] on marks_obtained. Generated Python uses float and 0 by default, and both are wrong for the same reasons they are wrong in C#.
Track 18 — Reviewing AI-generated code — has the full checklist.
Self-assessment
Your submission is complete when someone can clone it, create an environment, follow the README, run all three parts, and read DECISIONS.md to see which choices were deliberate.
Four specific tests of quality:
- Does a test fail when the absent check moves below the grade check? That is the defect this curriculum returns to most often, and only a test catches it.
- Does a fifty-row import with five bad rows report all five? Aborting on the first is the difference between a five-minute fix and an afternoon.
- Does
mypy --strictpass? It removes mostAttributeError: 'NoneType'failures before they run. - Does
DECISIONS.mdstate what you did not do? A stated limitation is a stronger signal than a silent gap.
Track completion criteria
You can write structured Python programs, use collections, functions and OOP, process files and JSON, and build and test a small FastAPI backend.
Specifically, you can:
- Install Python and manage projects with virtual environments and
pyproject.toml - Write correct code with types, operators and f-strings, using
Decimalfor money - Choose the right collection and avoid mutation and copying bugs
- Write functions with clear signatures, type hints and no mutable defaults
- Read a traceback and handle exceptions where you can act on them
- Read and write CSV and JSON with correct encoding and atomic writes
- Structure code into packages with no circular imports
- Model concepts with classes and dataclasses
- Write tests that catch real defects, and debug with a debugger
- Query a database with parameters and transactions
- Call REST APIs handling every failure mode
- Build a validated FastAPI backend with dependency injection
Continue to Track 14 — MongoDB Foundation, or Track 16 — Git & Source Control.