Learning Python - Day 4-5: Where Python Stopped Feeling Like Tutorials

By the end of day 3, I closed out my notes saying modules and JSON handling were next, and that after that I’d be moving straight into FastAPI. Days 4 and 5 turned out to be the point where the language stopped feeling like a series of tutorial exercises and started feeling like something I had to actually think through. The bugs I hit in these two days weren’t typos. They were genuine design and logic mistakes, the kind you only run into once you’re building something with more than one moving part.

Day 4 — Modules, packages, and JSON

Modules, the basics

Coming from JS, where export and import are explicit and you have to opt a value into being shared, Python’s approach felt looser at first.

# math_utils.py
def add(a: int, b: int) -> int:
    return a + b

PI = 3.14159
# main.py
import math_utils

print(math_utils.add(3, 4))   # 7
print(math_utils.PI)          # 3.14159

There’s no export keyword anywhere. Everything sitting at the top level of a module is importable by default. It felt like a small relief after JS’s more ceremonial import/export syntax, even though it also meant I had to get comfortable with the idea that “everything is public unless you do something to hide it” rather than the other way around.

The variants came quickly after that:

from math_utils import add, PI      # named imports, no prefix needed
import math_utils as mu             # aliasing

Packages, the moment folders became importable

The concept that actually clicked here was realizing __init__.py is just a marker file. It tells Python “this folder is a package,” nothing more magical than that.

project/
├── main.py
└── utils/
    ├── __init__.py
    └── math_utils.py
# utils/math_utils.py
def add(a: int, b: int) -> int:
    return a + b
# main.py
from utils.math_utils import add

This is the same structure FastAPI projects end up using later, with routers/, models/, and services/ all being packages in exactly this sense. I’m glad I picked up the convention here, disconnected from any framework, instead of meeting it for the first time buried inside FastAPI’s own folder layout.

__init__.py as a re-export point

# utils/__init__.py
from utils.math_utils import add, PI
# main.py
from utils import add, PI   # caller doesn't need to know which submodule it lives in

I almost made a mistake here that’s worth writing down because of what it taught me. Inside utils/__init__.py, I first tried from math_utils import add, dropping the utils. prefix on the assumption that since I was already inside the utils folder, Python would resolve the import relative to where the file lived. That got me a ModuleNotFoundError. The fix was realizing imports inside __init__.py still need the full path from the project root, exactly the same as anywhere else in the project. There’s no “I’m already inside this folder” shortcut. Python doesn’t care where the importing file physically sits, it cares about the path from the root.

JSON, strings versus files

The mnemonic that made this stick for me: the “s” means string.

import json

user = {"name": "Dakshina", "age": 25, "skills": ["Python", "JavaScript"]}

# string versions
json_string = json.dumps(user)      # dict -> JSON string
parsed = json.loads(json_string)    # JSON string -> dict

# file versions (drop the "s")
with open("user.json", "w") as f:
    json.dump(user, f, indent=2)    # dict -> file

with open("user.json", "r") as f:
    loaded = json.load(f)           # file -> dict

dump and load always take a file object as an argument. dumps and loads always work with strings. If I’m holding a file handle, I drop the “s.” That one rule of thumb has saved me from second-guessing myself every time since.

The bug hunt: load_data crashing on missing files

My first version of a data-loading helper looked like this:

def load_data(filename: str) -> dict:
    with open(filename, "r") as f:
        return json.load(f)

This crashes with FileNotFoundError the very first time the program runs, because there’s no file yet to open. My first instinct to fix it was a bare except:, and that’s a trap I’m glad I caught before it became a habit. A bare except swallows everything, including real bugs that have nothing to do with a missing file. If something else broke inside that block, I’d have no idea, because the bare except would quietly eat the error and move on. I corrected it to catch the specific exception instead:

def load_data(filename: str) -> dict:
    try:
        with open(filename, "r") as f:
            return json.load(f)
    except FileNotFoundError:
        return {}

A real design problem: one storage pair, two data shapes

I tried to reuse save_data/load_data, which were typed for a dict, for a log file that needed to store a list of messages instead. That’s where I ran into a genuine design conflict rather than a typo. Union types came up as something I could reach for here, but I deliberately set that aside as overkill for where I am right now. Instead I wrote separate, honestly-typed functions rather than forcing one pair of functions to handle two different shapes:

def save_data(filename: str, data: dict) -> None:
    with open(filename, "w") as f:
        json.dump(data, f, indent=2)

def load_data(filename: str) -> dict:
    try:
        with open(filename, "r") as f:
            return json.load(f)
    except (FileNotFoundError, json.JSONDecodeError):
        return {}


def save_list_data(filename: str, data: list) -> None:
    with open(filename, "w") as f:
        json.dump(data, f, indent=2)

def load_list_data(filename: str) -> list:
    try:
        with open(filename, "r") as f:
            return json.load(f)
    except (FileNotFoundError, json.JSONDecodeError):
        return []

Notice the second exception type, json.JSONDecodeError. I found the need for that one the hard way. An empty file, zero bytes, isn’t “missing” as far as the filesystem is concerned, so FileNotFoundError alone didn’t catch that case. json.load choked trying to parse zero bytes into anything, and threw a decode error instead. That was a real bug, found via a real traceback, not something I anticipated up front. The fix was catching both exception types together as a tuple.

Tying it together: a save_log function

from utils import save_list_data, load_list_data

def save_log(log: str) -> None:
    logs = load_list_data("log.json")
    logs.append(log)
    save_list_data("log.json", logs)

The bug I hit here was subtle, and it’s a very “Python, not JS” kind of mistake. list.append() mutates the list in place and returns None. Several JS array methods return a new array, so my first instinct, straight out of that habit, was to write logs = logs.append(log). That silently set logs to None, with no error anywhere to point me at what went wrong. The fix was calling .append() on its own line and trusting the mutation rather than trying to capture a return value that doesn’t exist.

Day 5 — Building a CLI task manager

This was my first multi-file, multi-class project, and it pulled together everything from the days before it. OOP from day 3, JSON persistence and module structure from day 4, plus new pieces like input() and the main program loop, all into one program that actually runs and does something.

The Task class

# task.py
class Task:
    def __init__(self, id: int, description: str, completed: bool = False):
        self.id = id
        self.description = description
        self.completed = completed

    def to_dict(self) -> dict:
        return {
            "id": self.id,
            "description": self.description,
            "completed": self.completed
        }

    @classmethod
    def from_dict(cls, data: dict) -> "Task":
        return cls(
            id=data["id"],
            description=data["description"],
            completed=data["completed"]
        )

    def __repr__(self) -> str:
        status = "✓" if self.completed else "✗"
        return f"[{status}] ({self.id}) {self.description}"

That "Task" written in quotes as a return type looks strange until you know why it’s there. Python hasn’t finished defining the Task class yet at the point where from_dict is being read, so the string is a forward reference that Python resolves once the whole class exists.

It’s also worth asking the question out loud that I asked myself here: why use a class instead of just a plain dict for something this small? Honestly, either approach works at this scale. But the difference shows up the moment you make a typo. Writing task["compelted"] = True on a dict silently creates a new key with no error at all, while task.compelted = True on an object either fails immediately or gets caught by a type checker before it ever runs. Objects also give me one place to add behavior later instead of scattering logic around wherever the dict happens to get used. Building that habit now, while the stakes are this low, is going to matter once this becomes a FastAPI and Pydantic model in a few days.

TaskManager, load, save, and CRUD

# task_manager.py
import json
from task import Task

class TaskManager:
    def __init__(self, filename: str = "tasks.json"):
        self.filename = filename
        self.tasks: list[Task] = self.load_tasks()

    def load_tasks(self) -> list[Task]:
        try:
            with open(self.filename, "r") as f:
                data = json.load(f)
                return [Task.from_dict(item) for item in data]
        except (FileNotFoundError, json.JSONDecodeError):
            return []

    def save_tasks(self) -> None:
        with open(self.filename, "w") as f:
            data = [task.to_dict() for task in self.tasks]
            json.dump(data, f, indent=2)

    def add_task(self, description: str) -> Task:
        new_id = len(self.tasks) + 1
        task = Task(id=new_id, description=description)
        self.tasks.append(task)
        self.save_tasks()
        return task

    def list_tasks(self) -> list[Task]:
        return self.tasks

    def complete_task(self, task_id: int) -> bool:
        for task in self.tasks:
            if task.id == task_id:
                task.completed = True
                self.save_tasks()
                return True
        return False

    def delete_task(self, task_id: int) -> bool:
        original_length = len(self.tasks)
        new_list = [task for task in self.tasks if task.id != task_id]
        if len(new_list) < original_length:
            self.tasks = new_list
            self.save_tasks()
            return True
        return False

Two real bugs are worth telling in detail here, because of how each one revealed itself.

The first was that I forgot to return inside from_dict. The method built the Task object correctly, but never handed it back, so from_dict implicitly returned None for every loaded task. The bug didn’t surface right away. The very first call to add_task had nothing to load yet, since load_tasks just returned an empty list, so the broken path never ran. It wasn’t until a second run, with real saved data sitting in the file, that load_tasks actually called from_dict against existing data and save_tasks crashed trying to call .to_dict() on None. That was a good lesson in how some bugs only show up once there’s real persisted state to exercise the path that was broken all along.

The second was that list_tasks originally called task.to_dict(task) instead of task.to_dict(). I was passing the object as an argument to its own method, when calling it as a method already supplies self automatically behind the scenes. I’d doubled it up without realizing it. It’s an easy mistake to make while methods and plain functions still feel interchangeable in my head.

There’s also a design decision buried in here worth a paragraph of its own. list_tasks originally returned [task.to_dict() for task in self.tasks], converting everything to dicts immediately on the way out. I reconsidered that after noticing add_task returns a Task object, not a dict, which meant the two methods were inconsistent about what shape of data callers should expect. I settled on a rule: every method in TaskManager passes Task objects around internally, and conversion to dict or JSON happens in exactly one place, inside save_tasks, right before anything gets written to disk.

The known limitation I left open

new_id = len(self.tasks) + 1 works fine until a task gets deleted. If tasks 1, 2, and 3 exist and I delete task 2, then add a new task, that new task becomes id 3 again, colliding with the task 3 that’s still sitting there. I noticed this and flagged it honestly rather than quietly patching around it, because it’s a problem that disappears naturally once this moves to a real database with auto-incrementing primary keys, which is coming up around day 9. Some problems are worth leaving open on purpose, once you know why they’ll resolve themselves later.

The interactive CLI loop

# main.py
from task_manager import TaskManager

manager = TaskManager()

while True:
    print("\nTask Manager")
    print("1. Add task")
    print("2. List tasks")
    print("3. Complete task")
    print("4. Delete task")
    print("5. Quit")

    choice = input("Enter choice 1-5\n")

    if choice == "1":
        task = input("Enter your task name\n")
        manager.add_task(task)

    elif choice == "2":
        tasks = manager.list_tasks()
        if not tasks:
            print("No tasks available yet. Create one first.")
        else:
            for task in tasks:
                print(task)

    elif choice == "3":
        task_id = input("Enter your task id\n")
        try:
            success = manager.complete_task(int(task_id))
            print("Action success" if success else "Action failed!")
        except ValueError:
            print("Enter a valid task id as an integer")

    elif choice == "4":
        task_id = input("Enter your task id\n")
        try:
            success = manager.delete_task(int(task_id))
            print("Action success" if success else "Action failed!")
        except ValueError:
            print("Enter a valid task id as an integer")

    elif choice == "5":
        print("Good bye")
        break

    else:
        print(f"You entered {choice}")

This loop is where the most “aha” moment of day 5 happened, and it took two separate bugs stacked on top of each other to get there.

The first version of this loop had break at the end of every single branch, not just the “Quit” branch. The result was that the loop ran exactly once no matter which option I picked. I’d choose “Add task,” it would add the task, and then the entire program would just exit instead of looping back to show the menu again. The fix was realizing break should only ever fire for option 5. Every other branch needs to finish its work and let the loop naturally return to the top on its own.

Right after I fixed that, a second bug showed up. The branches had been written as separate if statements rather than one if/elif chain. Each if gets evaluated independently of the others, so even after the correct branch ran, execution kept falling through into the later checks, and the catch-all else at the bottom printed regardless of which valid option I’d actually picked. Switching everything into a single if/elif/elif/…/else chain fixed it, because only one branch runs per loop iteration once elif stops checking after it finds a match.

Two bugs, two completely different root causes, one about loop control flow and one about conditional chaining, and I found both of them the same way: by actually running the program, watching the behavior, and noticing it didn’t match what I intended. Not by guessing, and not by staring at the code until something looked wrong.

What two days in actually feels like

This was the first stretch where the bugs felt less like typos and more like genuine design and logic mistakes, which is honestly a good sign. It means I’m past the syntax-memorization stage and into something closer to actual programming judgment, where the mistakes are about how pieces fit together rather than whether I spelled a keyword right.

The debugging process mattered more than any individual bug did. Reading the traceback, isolating the exact failing line, and testing something as small as my_list.append() on its own in a throwaway script to confirm what it actually returns, that’s the skill that’s going to transfer to every future bug I hit, regardless of language. The bugs themselves are forgettable. The habit of cornering them methodically is not.

I’ll admit there was a stretch in the middle of the storage design problem, the one about needing two different shapes of data, where I genuinely felt a little lost and unsure how to structure things cleanly. Pushing through that feeling instead of working around it is what made the eventual fix, writing two honestly-typed function pairs instead of one clever generic one, actually make sense to me rather than just being something that happened to work.

Four real, non-trivial bugs across two days, a missing return, a method called on itself by accident, a stray break, and an if/elif mixup, all debugged independently from a cold start in the language. That’s solid progress, and it’s exactly the kind of progress that doesn’t show up cleanly in a list of features I built. Next up is straight into FastAPI, which is the actual destination this whole detour through the core language has been pointing toward.