# Your first project

> Plan and build a complete command-line to-do app step by step, combining everything so far, and learn how to keep going on your own.

Learn to Code roadmap · Writing real programs · 18 min read · https://levelcode.org/roadmaps/learn-to-code/first-project

You now know variables, conditions, loops, functions, collections, files, errors and modules. That's genuinely enough to build useful programs. What's missing is the skill of going from "I want a program that does X" to working code, without a lesson telling you each line. This lesson builds a complete command-line to-do app, and the point isn't the app: it's the **process**, which is the same for every project you'll ever build.

## Step 1: describe what it should do

Before writing code, write down what the program does, from the user's point of view, as concrete examples. Vague ideas ("a to-do app") hide decisions; examples force you to make them.

```text
$ python3 todo.py add "Buy milk"
Added task 1: Buy milk

$ python3 todo.py add "Call the bank"
Added task 2: Call the bank

$ python3 todo.py list
  1. [ ] Buy milk
  2. [ ] Call the bank

$ python3 todo.py done 1
Completed task 1: Buy milk

$ python3 todo.py list
  1. [x] Buy milk
  2. [ ] Call the bank

$ python3 todo.py remove 2
Removed task 2: Call the bank
```

Also write down what's **not** included in the first version: no due dates, no categories, no undo. Keeping the first version small is how projects get finished. You can add those later.

## Step 2: decide how to store the data

Tasks must survive between runs, so they go in a file. Pick a shape for the data in memory first; the file format follows from it.

Each task needs an ID (the number users type), a title, and whether it's done. A list of dictionaries fits, and JSON stores it directly:

```json
{
  "next_id": 3,
  "tasks": [
    {"id": 1, "title": "Buy milk", "done": true},
    {"id": 2, "title": "Call the bank", "done": false}
  ]
}
```

Why store `next_id` instead of using `len(tasks) + 1`? Because after removing task 1, `len + 1` would hand out ID 2 again, and two tasks would share a number. Thinking through cases like this *before* coding is far cheaper than discovering them after.

## Step 3: sketch the program as functions

Turn the description into a list of functions, each doing one job:

- `load_data()`: read the JSON file, or return an empty structure if it doesn't exist yet.
- `save_data(data)`: write it back.
- `add_task(data, title)`, `complete_task(data, task_id)`, `remove_task(data, task_id)`: change the data.
- `print_tasks(data)`: display it.
- `main()`: read the command the user typed and call the right function.

How does the program get the words the user typed after `python3 todo.py`? From `sys.argv`, a list of the command-line arguments:

```python
import sys
print(sys.argv)
```

```text
$ python3 args.py add "Buy milk"
['args.py', 'add', 'Buy milk']
```

`sys.argv[0]` is the script's name, `sys.argv[1]` is the command, and anything after that is the command's arguments. Quotes in the shell keep `"Buy milk"` as one argument.

## Step 4: build it in small, working steps

Don't write the whole program and then run it for the first time. Build the smallest thing that runs, run it, then add one piece at a time. Start with loading, saving and listing:

```python
# todo.py
import json
import sys
from pathlib import Path

DATA_FILE = Path("todo.json")


def load_data():
    if not DATA_FILE.exists():
        return {"next_id": 1, "tasks": []}
    with open(DATA_FILE, encoding="utf-8") as f:
        return json.load(f)


def save_data(data):
    with open(DATA_FILE, "w", encoding="utf-8") as f:
        json.dump(data, f, indent=2)


def print_tasks(data):
    if not data["tasks"]:
        print("No tasks yet. Add one with: python3 todo.py add \"Task title\"")
        return
    for task in data["tasks"]:
        mark = "x" if task["done"] else " "
        print(f"{task['id']:>3}. [{mark}] {task['title']}")


def main():
    data = load_data()
    print_tasks(data)


if __name__ == "__main__":
    main()
```

Run it: you should see the "No tasks yet" message. Working? Now add the `add` command:

```python
def add_task(data, title):
    task = {"id": data["next_id"], "title": title, "done": False}
    data["tasks"].append(task)
    data["next_id"] += 1
    return task


def main():
    data = load_data()
    if len(sys.argv) < 2 or sys.argv[1] == "list":
        print_tasks(data)
    elif sys.argv[1] == "add":
        title = " ".join(sys.argv[2:]).strip()
        task = add_task(data, title)
        save_data(data)
        print(f"Added task {task['id']}: {task['title']}")
```

Run `add` twice, then `list`, and open `todo.json` to see what was saved. Every step ends with a program that works, so when something breaks, it's in the few lines you just wrote.

> [!TIP]
> Commit to version control at each working step. If you haven't learned Git yet, it's the next roadmap after this one; until then, copying the folder before a big change is a crude but real safety net.

## Step 5: finish the features and handle bad input

`done` and `remove` both need to find a task by ID, so write that once:

```python
def find_task(data, task_id):
    for task in data["tasks"]:
        if task["id"] == task_id:
            return task
    return None


def complete_task(data, task_id):
    task = find_task(data, task_id)
    if task is not None:
        task["done"] = True
    return task


def remove_task(data, task_id):
    task = find_task(data, task_id)
    if task is not None:
        data["tasks"].remove(task)
    return task
```

Returning `None` for "not found" lets the caller decide what to tell the user. Now think about what users will actually type: `done` with no number, `done abc`, `done 99`, `add` with no title, a command that doesn't exist. Each should get a helpful message, not a traceback:

```python
USAGE = """Usage:
  python3 todo.py list
  python3 todo.py add "Task title"
  python3 todo.py done <id>
  python3 todo.py remove <id>"""


def parse_id(args):
    """Return the task ID from the command's arguments, or None if missing or invalid."""
    if len(args) != 1 or not args[0].isdigit():
        return None
    return int(args[0])


def main():
    data = load_data()
    command = sys.argv[1] if len(sys.argv) > 1 else "list"
    args = sys.argv[2:]

    if command == "list":
        print_tasks(data)
    elif command == "add":
        title = " ".join(args).strip()
        if not title:
            print("Please give the task a title.")
            return
        task = add_task(data, title)
        save_data(data)
        print(f"Added task {task['id']}: {task['title']}")
    elif command in ("done", "remove"):
        task_id = parse_id(args)
        if task_id is None:
            print(f"Please give one task number, for example: python3 todo.py {command} 3")
            return
        action = complete_task if command == "done" else remove_task
        task = action(data, task_id)
        if task is None:
            print(f"There's no task {task_id}.")
            return
        save_data(data)
        verb = "Completed" if command == "done" else "Removed"
        print(f"{verb} task {task['id']}: {task['title']}")
    else:
        print(f"Unknown command: {command}")
        print(USAGE)
```

`action = complete_task if ... else remove_task` stores a *function* in a variable and calls it later. Functions are values in Python, like numbers and strings, and this avoids writing two nearly identical branches.

## Step 6: test it like a user

Walk through your examples from step 1, then try to break it: invalid IDs, empty titles, removing a task and adding a new one (does the new one get a fresh ID?), deleting `todo.json` and running `list`. Every surprise is either a bug to fix or a decision to make. Write down what you tested; in the [Python roadmap](https://levelcode.org/roadmaps/python) you'll learn to turn checks like these into automated tests that run in a second.

## How to keep going on your own

From here, the way to get better is to build things, slightly beyond what you're comfortable with:

- **Pick projects you'd use**: a budget tracker for your own bank export, a script that renames your photos by date, a quiz to practise a language.
- **Start smaller than you think.** Write the examples, cut the features in half, build that.
- **Read error messages and documentation first**, search second, ask for help third, and when you ask, include the code, the full error and what you expected.
- **Read other people's code.** The standard library's source is installed on your computer, and small open-source projects are full of patterns worth borrowing.
- **Be patient with being stuck.** Being stuck is where the learning happens. Everyone, at every level, is stuck often.

## Practice

Extend the to-do app with:

1. **Priorities**: `add` accepts an optional `--high` flag (`python3 todo.py add --high "Pay rent"`). High-priority tasks are listed first and marked with `!`.
2. **A clear command**: `python3 todo.py clear` removes all completed tasks and reports how many were removed.
3. **A summary line** at the end of `list`: `2 of 5 done`.

Keep old `todo.json` files working: tasks saved before you added priorities don't have a `priority` key.

**Solution**

```python
def add_task(data, title, high=False):
    task = {"id": data["next_id"], "title": title, "done": False, "high": high}
    data["tasks"].append(task)
    data["next_id"] += 1
    return task


def print_tasks(data):
    tasks = data["tasks"]
    if not tasks:
        print("No tasks yet. Add one with: python3 todo.py add \"Task title\"")
        return
    # High priority first; sorted() keeps the original order within each group.
    ordered = sorted(tasks, key=lambda t: not t.get("high", False))
    for task in ordered:
        mark = "x" if task["done"] else " "
        flag = "!" if task.get("high", False) else " "
        print(f"{task['id']:>3}. [{mark}] {flag} {task['title']}")
    done = sum(1 for t in tasks if t["done"])
    print(f"\n{done} of {len(tasks)} done")


def clear_completed(data):
    before = len(data["tasks"])
    data["tasks"] = [t for t in data["tasks"] if not t["done"]]
    return before - len(data["tasks"])
```

In `main`, the `add` branch becomes:

```python
    elif command == "add":
        high = "--high" in args
        title = " ".join(a for a in args if a != "--high").strip()
        if not title:
            print("Please give the task a title.")
            return
        task = add_task(data, title, high=high)
        save_data(data)
        print(f"Added task {task['id']}: {task['title']}{' (high priority)' if high else ''}")
```

and a new branch handles `clear`:

```python
    elif command == "clear":
        removed = clear_completed(data)
        save_data(data)
        print(f"Removed {removed} completed task{'s' if removed != 1 else ''}.")
```

`task.get("high", False)` is what keeps old files working: tasks saved before this change have no `high` key, and `.get` treats them as normal priority instead of crashing with `KeyError`. Sorting by `not t.get("high")` works because `False` sorts before `True`, so high-priority tasks (where the key is `False`) come first.

## Key takeaways

- Start every project by writing concrete examples of using it, and cut the first version down to the essentials.
- Choose the data shape before writing code, and think through edge cases such as reused IDs early.
- Sketch the program as small functions, then build it in steps that each end with a working program.
- Treat every user input as possibly wrong, and answer with helpful messages instead of tracebacks.
- Keep improving by building projects you care about, slightly beyond your comfort zone.

## Quiz

1. Why does the to-do app store next_id instead of using len(tasks) + 1 for new IDs?

   A. JSON can't store lengths
   B. It's faster
   C. After a task is removed, len(tasks) + 1 could give out an ID that's already in use
   D. Python lists start at 0

   **Answer: C.** With tasks 1 and 2, removing task 1 leaves one task, so len + 1 = 2, which collides with the existing task 2. A counter that only ever increases avoids that.

2. A user runs python3 todo.py add "Buy milk". What is sys.argv?

   A. ['todo.py', 'add', 'Buy milk']
   B. ['add', 'Buy milk']
   C. ['python3', 'todo.py', 'add', 'Buy', 'milk']
   D. 'add Buy milk'

   **Answer: A.** sys.argv starts with the script name, followed by each argument. The shell's quotes keep "Buy milk" as a single argument.

3. What's the main benefit of building the program in small steps that each run?

   A. The program uses less memory
   B. When something breaks, the cause is in the few lines you just added
   C. You can skip error handling
   D. Python requires it

   **Answer: B.** Each working step is a checkpoint. A failure points at the latest small change instead of anywhere in a large untested program.

4. Old todo.json files don't have a "high" key. Which expression reads the priority safely for both old and new tasks?

   A. task["high"]
   B. task.high
   C. "high" == task
   D. task.get("high", False)

   **Answer: D.** get returns the default when the key is missing, so tasks saved by the earlier version are treated as normal priority instead of raising KeyError.
