Python Lesson 37: The Install Problem Nobody Warns You About
š Full written solution: https://interview-kit-fe.vercel.app/python-lesson-37-capstone-building-a-complete-application š» Code on GitHub: https://github.com/timepasshub2539-hue/leetcode-python/tree/main/python-lesson-37-capstone-building-a-complete-application Chapters: 0:00 Introduction 0:38 Project Overview 0:50 Core Domain Design 1:26 Untested Code Risks 1:37 pytest Behavior Locking 2:03 Python Concurrency Problem 2:18 Async Data Fetching 3:00 Component Integration 3:14 Python Package Install Errors 3:34 Python Packaging Setup 4:02 Project Payoff 4:53 Recap and Summary Build a complete Python application from scratch, combining everything the series taught: OOP for the core domain, pytest to lock in behavior, async/concurrency to fetch real data, and packaging so it actually installs and runs like a real project. You'll see why untested code is a liability, why the pieces have to talk to each other cleanly, and why 'pip install' failing is the step tutorials skip. Ends with a fully packaged, shippable app and a recap of what you actually built. #python #pythontutorial #softwareengineering #coding #programming Watch next: - Python Lesson 36: Your Script Broke In Their Email -- pyproject.toml Fixes That: https://youtu.be/DVNz5iY-u1Y - 3069. Distribute Elements Into Two Arrays I | LeetCode Daily Python Solution (the trick nobody sees): https://youtu.be/iF-_VMcVEaU - 0 of 47 Bookmarked Courses Finished ā The Real Cost #shorts: https://youtu.be/PER3nR4WpEg
Introduction
Most Python tutorials teach you in isolation. One lesson on classes. One lesson on testing. One lesson on async. You finish each one feeling capable, and then you sit down to build something real and nothing connects. The classes don't know about the tests. The tests don't know about the async layer. And nobody ever gets to the part where you type pip install . and it just works.
That gap is exactly what shows up in technical interviews and on real engineering teams. Interviewers don't just want to know if you can write a for loop ā they want to see if you can structure a small system: model the domain cleanly, protect it with tests, make it fast where it needs to be, and hand it off in a form someone else can actually run. This lesson walks through building one small application ā a command-line expense tracker ā that does all four, so you can see how the pieces actually fit together instead of living as four separate skills you've never combined.
Problem Overview
Here's the goal, striped down to its core: build a command-line expense tracker that a stranger could install and run without ever seeing your project folder.
That means:
- A clean domain model for expenses and a ledger of them.
- Automated tests that catch regressions before a user does.
- Live currency conversion fetched concurrently instead of one request at a time.
- A packaging setup so the whole thing installs as a real command, not a script you run by remembering its file path.
None of this requires new syntax. It requires knowing how dataclasses, pytest, asyncio, and pyproject.toml sit next to each other in one working project.
Example
Imagine adding two expenses to the ledger:
Coffee ā 4.50 USD
Groceries ā 12.00 USD
Calling ledger.total() should return 16.50. That's the obvious case. But two more examples matter just as much:
- An empty ledger ā
total()should return0, not raise an error. - A ledger with expenses in different currencies ā the total only makes sense once every amount is converted to one currency, which is exactly where the async layer comes in.
Each of these is a separate test case later, because each one fails in a different way if the code is wrong.
Intuition
An experienced engineer doesn't start with tests, async, or packaging. They start with the plainest possible objects that describe the problem, because everything downstream ā tests, concurrency, distribution ā is easier to reason about when the core domain is simple and honest.
So the first question is: what are the nouns in this problem? An expense (a description, an amount, a currency) and a ledger (a collection of expenses that can be totaled). Nothing about HTTP requests or currency rates belongs in these classes yet ā that's a separate concern that gets layered on later, which is itself a lesson: keep the thing that models your data separate from the thing that fetches data for it.
Once that core exists, the next question an engineer asks isn't "does it work" ā it's "how do I know if I break it later?" That's what pushes you toward tests before you touch anything else. Only after the model is trustworthy does it make sense to make it fast (async) and shippable (packaging), because optimizing or distributing something you don't trust just multiplies the blast radius of a bug.
Brute Force Solution
Idea: Skip structure entirely ā track expenses as raw tuples or dictionaries in a script, fetch currency rates one at a time with requests, and run everything as python main.py.
Algorithm:
- Store expenses as
list[dict]. - Loop over them to compute a total.
- For each unique currency, make a blocking HTTP call to fetch its rate.
- Print the result.
Advantages: Fast to write for a one-off script; no dependencies beyond requests.
Disadvantages: No safety net ā a typo in a dict key fails silently. No way to distribute it ā anyone who wants to run it needs your folder and the right command memorized. Network calls happen sequentially, so twenty expenses in twenty currencies means twenty round trips stacked end to end.
Complexity: Computing the total is O(n) for n expenses. Fetching rates sequentially is O(k Ć latency) for k unique currencies ā that latency term is the whole problem, because it scales with network round-trip time, not CPU work.
Optimal Solution
The optimal version isn't algorithmically different ā it's structurally different, and structure is what makes the same logic testable, fast, and distributable.
Step 1 ā Model the domain with dataclasses.
Expense and Ledger are declared as @dataclass instead of hand-written classes. A dataclass generates __init__ and __repr__ for you, so the class body only shows the fields and the methods that actually matter ā the boilerplate that would otherwise bury the logic is gone.
Step 2 ā Lock behavior in with pytest before adding anything else.
Three tests, each covering a distinct case:
| Test | What it checks | Why it matters |
|---|---|---|
| Single expense | Total equals that one amount | Baseline sanity check |
| Multiple expenses | Total equals their sum | The case most people remember |
| Empty ledger | Total equals zero, no crash | The edge case people skip ā and the one that breaks silently when total() is refactored later |
Step 3 ā Fetch currency rates concurrently with asyncio.
fetch_rate is a single coroutine that awaits one network call for one currency. attach_rates first de-duplicates which currencies actually appear in the ledger ā no reason to fetch USD twice ā then hands all the resulting coroutines to asyncio.gather, which launches them together and waits for all of them at once instead of one after another.
Sequential: [req1]ā[req2]ā[req3]ā...ā[req20] (20x latency)
Concurrent: [req1]
[req2]
[req3] (~1x latency)
...
[req20]
Step 4 ā Package it so it installs like real software.
A pyproject.toml names the package, lists aiohttp as the one dependency, and ā the part that actually matters ā defines an entry point mapping the command expenses to the main function in expenses.cli. pip install . reads that entry point and writes an expenses executable into your environment's bin folder. After that, expenses is a command, not a script whose path you have to remember.
Python Code
# expenses/models.py
from dataclasses import dataclass, field
@dataclass
class Expense:
description: str
amount: float
currency: str = "USD"
@dataclass
class Ledger:
expenses: list[Expense] = field(default_factory=list)
def add(self, expense: Expense) -> None:
self.expenses.append(expense)
def total(self) -> float:
return sum(e.amount for e in self.expenses)
def currencies(self) -> set[str]:
return {e.currency for e in self.expenses}
# expenses/rates.py
import asyncio
import aiohttp
RATE_URL = "https://api.exchangerate.host/latest?base={currency}"
async def fetch_rate(session: aiohttp.ClientSession, currency: str) -> tuple[str, float]:
async with session.get(RATE_URL.format(currency=currency)) as resp:
data = await resp.json()
return currency, data["rates"]["USD"]
async def attach_rates(ledger) -> dict[str, float]:
currencies = ledger.currencies()
async with aiohttp.ClientSession() as session:
results = await asyncio.gather(
*(fetch_rate(session, c) for c in currencies)
)
return dict(results)
# expenses/cli.py
import asyncio
from expenses.models import Expense, Ledger
from expenses.rates import attach_rates
def main() -> None:
ledger = Ledger()
ledger.add(Expense("Coffee", 4.50))
ledger.add(Expense("Groceries", 12.00))
print(f"Total: {ledger.total():.2f}")
if __name__ == "__main__":
main()
# pyproject.toml
[project]
name = "expenses"
version = "0.1.0"
dependencies = ["aiohttp"]
[project.scripts]
expenses = "expenses.cli:main"
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
Code Walkthrough
@dataclassonExpenseandLedgergenerates__init__,__repr__, and__eq__, so the class body is just fields and real methods.field(default_factory=list)avoids the classic mutable-default-argument bug ā a bare= []would share one list across everyLedgerinstance.Ledger.total()uses a generator expression insidesum(), so an empty list correctly sums to0without a special case.fetch_rateis a coroutine ā calling it doesn't run it, it returns an awaitable thatasyncio.gatherschedules.attach_ratesde-duplicates currencies with a set comprehension before firing off requests, so fetching the same currency twice never happens.asyncio.gather(*coroutines)unpacks the coroutines as separate arguments and runs them concurrently on the event loop, returning results in the same order they were passed in.- The
[project.scripts]table inpyproject.tomlis what turnsexpenses.cli:maininto a realexpensescommand after install.
Dry Run
Starting state: empty ledger.
ledger.add(Expense("Coffee", 4.50))āledger.expenses = [Expense("Coffee", 4.50, "USD")]ledger.add(Expense("Groceries", 12.00))āledger.expensesnow has two entries.ledger.total()sums4.50 + 12.00ā16.50.- Output:
Total: 16.50.
Every step is backed by a test: the single-add test covers step 1's shape, the multi-add test covers step 3's arithmetic, and the empty-ledger test guarantees step 3 doesn't crash before any add call happens.
Complexity Analysis
- Time:
Ledger.total()is O(n) for n expenses ā one pass, no way around touching every entry once. Fetching rates concurrently is bounded by the slowest single request, not the sum of all of them, so it's O(max latency) instead of O(k Ć latency) for k currencies. - Space: O(n) for storing n expenses, O(k) for k unique currencies' rates ā both linear and both necessary, since you can't total or convert what you haven't stored.
- These bounds are correct because nothing in the design re-scans the ledger more than once per operation, and the concurrency doesn't change how much data you hold, only how long you wait for it.
Alternative Solutions
- Threading instead of asyncio: Works for I/O-bound fetches too, but adds thread-safety concerns for shared state (the ledger) that
asyncio's single-threaded event loop sidesteps entirely. For a small number of network calls,asynciois simpler and just as fast. - A database instead of in-memory dataclasses: Reasonable once the tracker needs to persist across runs, but premature here ā it would add a schema and a migration story to a project whose actual bottleneck is structure and shipping, not storage.
Edge Cases
- Empty ledger ā
total()must return0, not raise. - Duplicate currencies ā
attach_ratesmust fetch each one exactly once, not once per expense. - Negative or zero amounts ā a refund or correction entry should still sum correctly.
- A single failing rate request ā
asyncio.gatherraises the first exception it hits and cancels the rest by default, which silently kills an otherwise-fine batch unless you passreturn_exceptions=Trueand handle failures per-currency. - Renaming the entry point after install ā
expenseskeeps running the old function until youpip install .again, because the wrapper script was written at install time, not resolved dynamically.
Common Mistakes
- Using a mutable default argument (
expenses: list = []) instead offield(default_factory=list), causing everyLedgerto share one list. - Writing tests only for the happy path and skipping the empty-input case ā exactly the one that breaks silently later.
- Assuming
asyncio.gatherskips failed requests by default ā it doesn't, it cancels the whole batch on the first exception. - Forgetting to de-duplicate currencies before fetching, wasting requests on the same rate.
- Editing
pyproject.toml's entry point after installing and expecting the change to take effect without reinstalling. - Mixing async and sync code carelessly ā calling a coroutine without
awaitand getting a coroutine object instead of a result.
Interview Questions
- Why use
@dataclassinstead of a plain class here, and when would you not use one? - What does
asyncio.gatherdo differently from running the same coroutines in a loop withawait? - How would you make
gathercontinue past a single failed request instead of cancelling everything? - Why test the empty-ledger case specifically, and what kind of bug does it catch that the two-item test wouldn't?
- What actually happens when you run
pip install .ā what does the entry point mechanism do under the hood?
Similar Problems
- Design a Rate Limiter ā another problem where correctness depends on concurrent behavior, not just sequential logic.
- Design an LRU Cache ā same theme of building a small, self-contained data structure with clear invariants worth testing.
- Merge Intervals ā practicing the habit of identifying the edge case (empty input, single interval) before trusting a solution.
Key Takeaways
Classes gave the project structure. Tests gave it a safety net. Async gave it speed. Packaging gave it to the world. None of these are separate skills in a real project ā they're four angles on the same small system, and the moment you connect them, you've built something a stranger could actually run, not just a snippet that works on your machine.
Watch the Video
For the full walkthrough with live coding and the async gather question worked out on screen, watch the video here: {LINK}
About the Series
This lesson is part of Fun with Learning Technology, a series built around one idea: tutorials teach pieces, but real software is what happens when those pieces are forced to work together. Each lesson takes a concept most developers already know in isolation and shows where it actually lives inside a working project.
Call To Action
If this connected some dots for you, drop a like on the video ā that's genuinely how the channel knows what's landing. Subscribe on YouTube for the rest of the series, and subscribe to the Substack newsletter for the write-ups that go deeper than the video has time for. Comment with your guess on what asyncio.gather does when one request fails, and share this with anyone still writing classes, tests, and async code that never talk to each other.
The solution
import pytest
from ledger import Expense, Ledger
def test_add_expense():
ledger = Ledger()
ledger.add(Expense("Coffee", 4.5))
assert ledger.total() == 4.5
def test_total_multiple():
ledger = Ledger()
ledger.add(Expense("Coffee", 4.5))
ledger.add(Expense("Lunch", 12))
assert ledger.total() == 16.5
def test_empty_ledger_total_is_zero():
assert Ledger().total() == 0Ready to try it yourself? Solve Python problems with instant feedback in the practice sandbox.
Practice now ā


