Build TaskForge in Python
Build a tested Python CLI task manager with persistent JSON storage.
Introduction
30 Second Summary
A task list loses its value when completed work looks unfinished or everything disappears after you close it. Reliable tools remember where you left off.
In this project, you will build TaskForge as a Python command-line interface for managing everyday tasks. Its data persists between runs in a readable JSON file.
What You'll Build
After reopening TaskForge, you will see your saved tasks waiting in the terminal with clear pending or completed statuses.
By the end of this project, you'll have:
- Manage tasks from the terminal with commands that add, list, or complete each item.
- Keep your data between separate runs through the readable tasks.json file.
- Prove the logic with four automated tests that finish with OK. Preserve the project with a README plus a clean local Git commit.
- Secret Mission: Add a safe remove command that deletes tasks by ID without changing data when the ID is missing.
Are there any prerequisites?
You need intermediate Python skills on a Windows computer. Your installed tools should include Python 3.9 or newer, Visual Studio Code, plus Git.
Before We Start
This is your moment to define who TaskForge serves before the hands-on work begins. You will choose the personal tasks it tracks and capture why reliable command-line logic comes before a graphical interface.
Confirm Your Windows Workspace
You have chosen the personal tasks TaskForge should track. The next risk is building on tools that the same terminal cannot reach.
The integrated terminal in Visual Studio Code gives every command one known folder. You will confirm that Python 3.9 or newer runs there before checking that Git is available.
In this step, get ready to:
- Open the taskforge project folder in Visual Studio Code.
- Confirm Python 3.9 or newer plus Git in the integrated terminal.
- Produce the workspace-ready message from taskforge.py.
Create the TaskForge workspace
A workspace keeps the script plus every future project file in one place. Opening that folder in Visual Studio Code also gives its integrated terminal the correct project location.
- Start a Windows terminal from the Start menu.
- Create the taskforge folder inside the terminal's current location. Enter the folder before opening it as a Visual Studio Code workspace by running these commands:
mkdir taskforge
cd taskforge
code .
What do these commands do?
- The first line creates the taskforge project folder.
- The second line moves the terminal into that folder.
- The third line asks Visual Studio Code to use the current folder as its workspace.
✔️ Visual Studio Code opened the folder
Good. You should see taskforge as the open folder in the Explorer sidebar.
ⓧ The terminal did not open Visual Studio Code
The taskforge folder still exists. Open it through the Visual Studio Code interface instead.
- Press the Windows key to open search.
- Type Visual Studio Code into the search box.
- Press Enter to start Visual Studio Code.
- Select File from the top menu.
- Select Open Folder.
- Choose the taskforge folder you created.
Verify Python and Git
TaskForge later uses Python syntax introduced in version 3.9. Git also needs to answer from the same workspace terminal.
- Press Ctrl+` to open the integrated terminal in Visual Studio Code.
- Read the Python version available inside this workspace by running this command:
python --version
What does this check prove?
Python prints the interpreter version available to this terminal. TaskForge needs version 3.9 or newer because its final code uses built-in collection type annotations.
✔️ Python meets the required version
Your interpreter reports Python 3.9 or newer. That interpreter can run every TaskForge feature in this project.
ⓧ I see an older Python version
The current interpreter is below the version TaskForge requires. Update Python before creating the script.
- Visit the Python downloads page.
- Install the current Python Install Manager.
- Close the existing integrated terminal with the trash button in its toolbar.
- Select Terminal from the Visual Studio Code top menu.
- Select New Terminal.
- Check the updated Python version by running this command:
python --version
What should you check?
The fresh terminal should report Python 3.9 or newer. The updated version clears the compatibility requirement for TaskForge.
Still seeing the older version?
Close every Visual Studio Code window before reopening the taskforge folder. This gives the application a fresh view of your installed tools.
If the older version remains active, help me connect the updated Python installation to my VS Code terminal.
ⓧ The Python command is unavailable
This terminal cannot currently find Python. Install the supported Windows manager before continuing.
- Visit the Python downloads page.
- Install the current Python Install Manager.
- Close the existing integrated terminal with the trash button in its toolbar.
- Select Terminal from the Visual Studio Code top menu.
- Select New Terminal.
- Verify the installation from the fresh terminal by running this command:
python --version
What should you check?
Python should now print version 3.9 or newer. This confirms the command is available inside your TaskForge workspace.
Python still unavailable?
Close every Visual Studio Code window after the installation. Reopen the taskforge folder before creating another integrated terminal.
If the command remains unavailable, help me make Python available in my VS Code terminal on Windows.
- Read the Git version available in the same integrated terminal by running this command:
git --version
What does this check prove?
Git prints the installed Git suite version. A version response confirms that this workspace can use local version control later in the project.
✔️ Git prints its installed version
Git is ready in the same terminal as Python. Your workspace now has both tools needed for the project.
ⓧ The Git command is unavailable
The integrated terminal cannot currently find Git. Install Git for Windows before continuing.
- Visit the official Git for Windows installation page.
- Complete the Git for Windows installer.
- Close the existing integrated terminal with the trash button in its toolbar.
- Select Terminal from the Visual Studio Code top menu.
- Select New Terminal.
- Verify Git from the fresh terminal by running this command:
git --version
What should you check?
Git should now print its installed version. That response confirms the new terminal can reach Git.
Git still unavailable?
Close every Visual Studio Code window after the installation. Reopen the taskforge folder before creating another integrated terminal.
If the command remains unavailable, help me make Git available in my VS Code terminal on Windows.
Run the workspace-ready script
The first TaskForge file is deliberately small. Its output proves that the integrated terminal can find the project script before you build the command-line features.
- Select New File in the Visual Studio Code Explorer sidebar.
- Enter taskforge.py as the file name.
You should see taskforge.py listed directly inside the taskforge folder.
- Add the workspace-ready message to taskforge.py by pasting this code:
print("TaskForge workspace ready")
What does this code do?
The print() call sends one line of text to the terminal. That visible message gives you a direct check that Python executed this file.
- Save taskforge.py.
Before you run it, make a quick prediction about whether Python prints the workspace message.
- Execute taskforge.py from the integrated terminal by running this command:
python taskforge.py
What does this run prove?
The command asks the verified Python interpreter to execute taskforge.py from the current folder. A printed message proves that the workspace path plus the script are connected.
You should see TaskForge workspace ready in the terminal. That is your first runnable TaskForge result.
Don't see the workspace message?
- Confirm that taskforge.py is saved directly inside the taskforge folder.
- Check that the integrated terminal is currently inside the taskforge folder.
- Compare your print statement with the full-file reference below.
- If the message still does not appear, help me run my TaskForge Python file from the correct VS Code terminal folder.
✔️ Awesome, I've got everything!
Great. Double check that you saved taskforge.py after seeing the workspace-ready message.
ⓧ I'd like to double check the full code
Compare your complete taskforge.py file with this reference:
print("TaskForge workspace ready")
What should the file contain?
The complete file contains one print() call. Its text must match the workspace-ready message exactly.
Your Windows workspace can run TaskForge from the integrated terminal. Next, you will turn this one-line script into an in-memory command-line task manager.
Build the In-Memory CLI
Your TaskForge workspace now runs successfully from the Visual Studio Code terminal. That first result proves your file plus Python setup are ready for real program logic.
The next goal is a command-line interface that understands task actions. Python's argparse module turns terminal input into structured command data.
Can a task added by one command still appear in another command? You will test that assumption after building the first working version.
In this step, get ready to:
- Define typed task operations that use memory.
- Build the required command interface for add plus list.
- Run separate commands to reveal the lifetime of in-memory data.
Define the in-memory task logic
A task needs a predictable shape before functions can work with it. Type hints document the expected values throughout the program.
- Select taskforge.py from the Explorer sidebar in Visual Studio Code.
- Find the existing print("TaskForge workspace ready") line.
- Add the typed task structure plus creation function above the existing line by pasting this code:
from typing import TypedDict
class Task(TypedDict):
id: int
title: str
completed: bool
def add_task(tasks: list[Task], title: str) -> Task:
"""Create a pending task with the next available ID."""
task: Task = {
"id": len(tasks) + 1,
"title": title,
"completed": False,
}
tasks.append(task)
return task
What Does This Code Do?
- The Task type defines the required ID plus title plus completion fields.
- The add_task() function assigns the next sequential ID.
- The new task starts with completed set to False.
- The function appends the task to the supplied list.
- The returned task gives the caller access to its assigned ID.
- Save taskforge.py.
- Confirm the script remains runnable by running this command:
python taskforge.py
What Should I See?
You should still see TaskForge workspace ready. This confirms Python can load the new type plus function definitions.
Seeing a Syntax Error?
- Check that each field inside Task is indented by four spaces.
- Check that the closing brace for task aligns with its assignment line.
- Compare every quote plus colon with the code block.
- Help me fix the new task structure in taskforge.py.
Task creation handles one half of the in-memory logic. The CLI also needs a readable view for an empty list plus a populated list.
- Add the display function below add_task() by pasting this code:
def display_tasks(tasks: list[Task]) -> None:
"""Print tasks in a readable terminal format."""
if not tasks:
print("No tasks yet.")
return
for task in tasks:
marker = "x" if task["completed"] else " "
print(f"[{marker}] {task['id']}. {task['title']}")
How Does the List View Work?
- The empty-list branch prints No tasks yet..
- The loop handles every task in the supplied list.
- The marker value becomes x for a completed task.
- The formatted output includes each task's status plus ID plus title.
- Save taskforge.py.
- Check the updated file for syntax errors by running this command:
python taskforge.py
What Does This Check Prove?
You should see TaskForge workspace ready again. Python has now loaded both task operations successfully.
Does the Script Stop Before Printing?
- Check that display_tasks() starts at the left edge of the file.
- Confirm the for loop is outside the empty-list branch.
- Check that the final formatted string uses matching quotes.
- Help me debug the display_tasks function.
Build the command parser and routing
The parser maps terminal words to a selected command. Required subcommands ensure TaskForge always receives an action before it runs.
- Return to the top of taskforge.py.
- Find the current import line:
from typing import TypedDict
Why Start With This Line?
This existing import supports the typed task structure. The parser needs one more standard-library import beside it.
- Replace the existing import with these two imports:
import argparse
from typing import TypedDict
What Changed?
The argparse import provides the parser classes used by the command interface. TypedDict continues to describe each task.
- Add the parser function below display_tasks() by pasting this code:
def build_parser() -> argparse.ArgumentParser:
"""Create the TaskForge command-line parser."""
parser = argparse.ArgumentParser(
description="Track tasks from the terminal."
)
subparsers = parser.add_subparsers(dest="command", required=True)
add_parser = subparsers.add_parser("add", help="Add a new task.")
add_parser.add_argument("title", help="The task description.")
subparsers.add_parser("list", help="List all tasks.")
return parser
How Does the Parser Work?
- The ArgumentParser description explains TaskForge in its help output.
- The required subparser stores the selected action in command.
- The add command requires a positional title value.
- The list command needs no additional value.
- The returned parser is ready to process terminal input.
- Save taskforge.py.
- Confirm Python can load the parser definition by running this command:
python taskforge.py
What Should Still Work?
You should see TaskForge workspace ready. This confirms the new import plus parser function load without a syntax error.
Seeing an Error in build_parser?
- Check that import argparse appears at the top of the file.
- Check the closing parenthesis after the parser description.
- Confirm every parser line inside build_parser() uses four spaces of indentation.
- Help me debug the TaskForge parser.
The parser can recognize input once main() asks it to parse the command. The routing branches then call the matching task operation.
- Find the original print("TaskForge workspace ready") line at the bottom of taskforge.py.
- Replace that line with the following routing code:
def main() -> int:
"""Run the selected TaskForge command."""
parser = build_parser()
args = parser.parse_args()
tasks: list[Task] = []
if args.command == "list":
display_tasks(tasks)
return 0
if args.command == "add":
task = add_task(tasks, args.title)
print(f"Added task #{task['id']}: {task['title']}")
return 0
return 1
if __name__ == "__main__":
raise SystemExit(main())
How Does main Route Commands?
- The parser converts the terminal input into args.
- The tasks variable starts as an empty typed list.
- The list branch sends that list to display_tasks().
- The add branch creates a task from args.title.
- The final guard runs main() when you launch the script directly.
- Save taskforge.py.
Before you run the help check, which two command names do you expect the parser to show?
- Display TaskForge's automatic help text by running this command:
python taskforge.py --help
What Should the Help Show?
You should see usage text with add plus list. The add help should name the title argument.
Help Text Missing a Command?
- Confirm both add_parser plus the list parser appear inside build_parser().
- Check that return parser remains inside build_parser().
- Confirm main() calls parser.parse_args().
- Help me fix TaskForge's missing help entries.
✔️ Awesome, I've got everything!
Your help output lists both commands. Double check that you have saved taskforge.py.
ⓧ I'd like to double check the full code
Compare your complete taskforge.py file with this version:
import argparse
from typing import TypedDict
class Task(TypedDict):
id: int
title: str
completed: bool
def add_task(tasks: list[Task], title: str) -> Task:
"""Create a pending task with the next available ID."""
task: Task = {
"id": len(tasks) + 1,
"title": title,
"completed": False,
}
tasks.append(task)
return task
def display_tasks(tasks: list[Task]) -> None:
"""Print tasks in a readable terminal format."""
if not tasks:
print("No tasks yet.")
return
for task in tasks:
marker = "x" if task["completed"] else " "
print(f"[{marker}] {task['id']}. {task['title']}")
def build_parser() -> argparse.ArgumentParser:
"""Create the TaskForge command-line parser."""
parser = argparse.ArgumentParser(
description="Track tasks from the terminal."
)
subparsers = parser.add_subparsers(dest="command", required=True)
add_parser = subparsers.add_parser("add", help="Add a new task.")
add_parser.add_argument("title", help="The task description.")
subparsers.add_parser("list", help="List all tasks.")
return parser
def main() -> int:
"""Run the selected TaskForge command."""
parser = build_parser()
args = parser.parse_args()
tasks: list[Task] = []
if args.command == "list":
display_tasks(tasks)
return 0
if args.command == "add":
task = add_task(tasks, args.title)
print(f"Added task #{task['id']}: {task['title']}")
return 0
return 1
if __name__ == "__main__":
raise SystemExit(main())
What Are You Comparing?
Check the imports plus four definitions plus final entry-point guard. Their order should match this reference exactly.
Test separate command runs
A useful task manager must behave predictably across separate commands. This two-command check tests the first complete user flow.
- Add a practice task by running this command:
python taskforge.py add "Practise Python"
What Should I See?
You should see Added task #1: Practise Python. The command has successfully created its first in-memory task.
Before you run the next command, do you expect TaskForge to show the task you just added?
- Check the task list in a separate program run by running this command:
python taskforge.py list
The Disappearing Task Is Intentional
You will see No tasks yet. The result is the intended shortfall.
Each invocation starts with a fresh tasks list inside main(). Process memory lasts only for that invocation.
Seeing a Different Result?
- Confirm the add command printed task ID 1.
- Check that tasks: list[Task] = [] appears inside main().
- Confirm you ran the list command as a separate terminal command.
- Help me understand my TaskForge add and list results.
You have built a working CLI with automatic help plus typed task operations. Next, you will make its task data survive separate program runs.
Persist and Complete Tasks
Your TaskForge workspace is still open in Visual Studio Code. Its command-line interface can add or list tasks.
Each terminal command starts a fresh Python process. The process loses its in-memory task list when it exits.
This step stores tasks in JSON between runs. You will also add a command that completes a task by its numeric ID.
In this step, get ready to:
- Create validated storage functions for tasks.json.
- Load saved tasks before every command.
- Complete tasks by ID while handling invalid data safely.
Create validated JSON storage
A persistent program writes important state to disk. TaskForge uses a readable tasks.json file beside taskforge.py.
Why JSON for TaskForge?
A JSON file makes each saved task easy to inspect. It also works without installing another package.
A database such as SQLite would introduce schema management. TaskForge only needs one small local list.
- Select taskforge.py in the Explorer sidebar.
- Replace the two import lines at the top of taskforge.py with this import group:
import argparse
import json
from pathlib import Path
from typing import TypedDict, cast
Why These Imports?
- The json module converts the task list between Python values and JSON data.
- The Path class gives TaskForge a path to its data file.
- The cast helper records the validated data type for type checking.
- Add the storage path and task validator below the Task definition by pasting this code:
DATA_FILE = Path(__file__).with_name("tasks.json")
def is_task(value: object) -> bool:
"""Return whether a value has the expected task structure."""
if not isinstance(value, dict):
return False
return (
isinstance(value.get("id"), int)
and isinstance(value.get("title"), str)
and isinstance(value.get("completed"), bool)
)
What Does This Code Do?
- The DATA_FILE path always points to tasks.json beside the script.
- The is_task() function rejects values that are not dictionaries.
- The final check confirms that every task has the expected value types.
- Save taskforge.py.
- Check the updated file for syntax problems by running:
python taskforge.py list
What Should You See?
You should still see No tasks yet. This confirms that the new imports and validator load successfully.
Seeing a Syntax Error?
- Check that DATA_FILE starts at the left edge of the file.
- Confirm that each line inside the return expression has matching parentheses.
- Ask for help with the exact terminal output: Help me find the syntax error in my TaskForge storage validator.
Validation protects TaskForge from loading values that do not match the Task structure. The next functions control how that validated data enters and leaves the file.
- Add load_tasks() below is_task() by pasting this function:
def load_tasks(path: Path = DATA_FILE) -> list[Task]:
"""Load and validate tasks from a JSON file."""
if not path.exists():
return []
with path.open("r", encoding="utf-8") as file:
data = json.load(file)
if not isinstance(data, list) or not all(is_task(item) for item in data):
raise ValueError("Task data must be a list of valid tasks.")
return cast(list[Task], data)
How Does Loading Stay Safe?
- A missing data file produces an empty task list for a clean first run.
- The json.load() call reads the stored JSON document.
- The validation check raises ValueError when the document does not contain a valid task list.
- The cast() call records the validated result as list[Task].
- Add save_tasks() below load_tasks() by pasting this function:
def save_tasks(tasks: list[Task], path: Path = DATA_FILE) -> None:
"""Save tasks as readable JSON."""
with path.open("w", encoding="utf-8") as file:
json.dump(tasks, file, indent=2, ensure_ascii=False)
How Does Saving Work?
- The write mode creates tasks.json when the file does not exist.
- The indent=2 setting keeps the saved document readable.
- The ensure_ascii=False setting preserves readable non-ASCII characters in task titles.
- Save taskforge.py.
- Confirm the storage helpers compile by running:
python taskforge.py list
What Does This Check Prove?
You should see No tasks yet. TaskForge can now define both storage functions without disrupting the existing list command.
Storage Helpers Not Loading?
- Confirm that load_tasks() appears before save_tasks().
- Check that both function definitions begin at the left edge of taskforge.py.
- Ask for help with the function placement: Help me debug my TaskForge load_tasks and save_tasks functions.
Connect storage to task changes
The storage functions exist, but main() still creates an empty list. Every command must load the saved file before it chooses an action.
- Find tasks: list[Task] = [] inside main().
- Replace that line with this guarded load:
try:
tasks = load_tasks()
except (OSError, json.JSONDecodeError, ValueError) as error:
print(f"Error: Could not read {DATA_FILE.name}: {error}")
return 1
Why Guard the Load?
- The try block loads the saved task list before command routing begins.
- The exception handler reports file access failures.
- The same handler reports malformed JSON or invalid task structures.
- Returning 1 protects the existing file from an accidental overwrite.
- Save taskforge.py.
- Confirm the missing data file produces a clean empty list by running:
python taskforge.py list
What Should Happen on the First Load?
You should see No tasks yet. The missing tasks.json file is treated as a new task list.
Seeing a Task Data Error?
- Check whether an old tasks.json file already exists in the taskforge folder.
- Confirm that the file contains a JSON list of task objects.
- Ask for help without replacing the file: Help me understand why TaskForge rejected my tasks.json file.
Task titles also need validation before they reach storage. Trimming whitespace prevents an empty-looking task from receiving an ID.
- Select the current add_task() function in taskforge.py.
- Replace the selected function with this validated version:
def add_task(tasks: list[Task], title: str) -> Task:
"""Create a pending task with the next available ID."""
clean_title = title.strip()
if not clean_title:
raise ValueError("Task title cannot be blank.")
next_id = max((task["id"] for task in tasks), default=0) + 1
task: Task = {
"id": next_id,
"title": clean_title,
"completed": False,
}
tasks.append(task)
return task
What Changed in add_task()?
- The strip() call removes surrounding whitespace from the title.
- A blank title raises ValueError before the task list changes.
- The max() expression assigns an ID above every saved task ID.
- The saved title uses the cleaned value.
- Find the existing if args.command == "add": branch inside main().
- Replace that branch with this save-aware version:
if args.command == "add":
try:
task = add_task(tasks, args.title)
save_tasks(tasks)
except (OSError, ValueError) as error:
print(f"Error: {error}")
return 1
print(f"Added task #{task['id']}: {task['title']}")
return 0
How Does the Add Command Stay Consistent?
- The branch saves only after add_task() accepts the title.
- A validation failure prints a clear error.
- A file write failure also produces an error result.
- The success message appears after the task reaches storage.
- Save taskforge.py.
Before you run these commands, decide whether a separate process can now see the saved task.
- Add a task and start a separate list command by running:
python taskforge.py add "Finish maths homework"
python taskforge.py list
What Should You See?
The first command should print Added task #1: Finish maths homework.
The second command should print [ ] 1. Finish maths homework. That separate process loaded the task from tasks.json.
Good progress. TaskForge now remembers a task after the command that created it has finished.
Does the Task Still Disappear?
- Confirm that main() calls load_tasks() before the command branches.
- Confirm that the add branch calls save_tasks(tasks) after add_task().
- Check the Explorer sidebar for tasks.json beside taskforge.py.
- Ask for help with the persistence flow: Help me find why TaskForge does not retain tasks between commands.
Add task completion
A completion command changes an existing task instead of creating one. Its function returns whether the requested ID was found.
- Add complete_task() below add_task() by pasting this function:
def complete_task(tasks: list[Task], task_id: int) -> bool:
"""Complete the matching task and report whether it was found."""
for task in tasks:
if task["id"] == task_id:
task["completed"] = True
return True
return False
How Does Completion Work?
- The loop checks each saved task ID.
- A matching task receives completed=True.
- The function returns True after a match.
- A missing ID produces False without changing the list.
- Find the list subcommand inside build_parser().
- Add this parser block below that subcommand:
complete_parser = subparsers.add_parser(
"complete", help="Mark a task as complete."
)
complete_parser.add_argument("task_id", type=int, help="The numeric task ID.")
Why Use a Typed Argument?
The type=int setting converts the supplied task ID into an integer. The parser handles non-numeric input before main() runs the completion logic.
- Save taskforge.py.
- Check that the new subcommand appears in the automatic help by running:
python taskforge.py --help
What Should the Help Show?
The available command list should now include complete. Its help text identifies the command as the way to mark a task complete.
Complete Missing from Help?
- Confirm that the complete parser block appears before return parser.
- Check that complete_parser uses the same spelling on both lines.
- Ask for help with the parser definition: Help me add the complete subcommand to my TaskForge parser.
The final branch connects the parsed ID to complete_task(). Successful changes are saved before TaskForge confirms them.
- Add this branch below the add branch inside main().
if args.command == "complete":
if not complete_task(tasks, args.task_id):
print(f"Task #{args.task_id} was not found.")
return 1
try:
save_tasks(tasks)
except OSError as error:
print(f"Error: {error}")
return 1
print(f"Completed task #{args.task_id}.")
return 0
How Does the Branch Protect Stored Data?
- A missing ID returns an error before any save occurs.
- A matching ID changes the task in memory.
- The updated list is written to tasks.json.
- A successful write produces the completion message.
- Save taskforge.py.
Before you run these commands, predict which task will show an x marker.
- Continue the end-to-end check with the saved task by running:
python taskforge.py complete 1
python taskforge.py list
python taskforge.py complete 99
What Should You See?
- The first command should print Completed task #1.
- The list command should print [x] 1. Finish maths homework.
- The final command should print Task #99 was not found.
That is the persistence loop working. Successful changes now return to disk.
Task Not Marked Complete?
- Confirm that complete_task() sets task["completed"] to True.
- Check that the complete branch calls save_tasks(tasks) after a successful match.
- Confirm that display_tasks() uses x when the completed value is true.
- Ask for help with the state change: Help me debug why TaskForge does not persist completed tasks.
✔️ Awesome, I've got everything!
Your saved task now remains complete across separate TaskForge commands.
ⓧ I'd like to double check the full code
- Compare your saved taskforge.py file with this complete version:
import argparse
import json
from pathlib import Path
from typing import TypedDict, cast
class Task(TypedDict):
id: int
title: str
completed: bool
DATA_FILE = Path(__file__).with_name("tasks.json")
def is_task(value: object) -> bool:
"""Return whether a value has the expected task structure."""
if not isinstance(value, dict):
return False
return (
isinstance(value.get("id"), int)
and isinstance(value.get("title"), str)
and isinstance(value.get("completed"), bool)
)
def load_tasks(path: Path = DATA_FILE) -> list[Task]:
"""Load and validate tasks from a JSON file."""
if not path.exists():
return []
with path.open("r", encoding="utf-8") as file:
data = json.load(file)
if not isinstance(data, list) or not all(is_task(item) for item in data):
raise ValueError("Task data must be a list of valid tasks.")
return cast(list[Task], data)
def save_tasks(tasks: list[Task], path: Path = DATA_FILE) -> None:
"""Save tasks as readable JSON."""
with path.open("w", encoding="utf-8") as file:
json.dump(tasks, file, indent=2, ensure_ascii=False)
def add_task(tasks: list[Task], title: str) -> Task:
"""Create a pending task with the next available ID."""
clean_title = title.strip()
if not clean_title:
raise ValueError("Task title cannot be blank.")
next_id = max((task["id"] for task in tasks), default=0) + 1
task: Task = {
"id": next_id,
"title": clean_title,
"completed": False,
}
tasks.append(task)
return task
def complete_task(tasks: list[Task], task_id: int) -> bool:
"""Complete the matching task and report whether it was found."""
for task in tasks:
if task["id"] == task_id:
task["completed"] = True
return True
return False
def display_tasks(tasks: list[Task]) -> None:
"""Print tasks in a readable terminal format."""
if not tasks:
print("No tasks yet.")
return
for task in tasks:
marker = "x" if task["completed"] else " "
print(f"[{marker}] {task['id']}. {task['title']}")
def build_parser() -> argparse.ArgumentParser:
"""Create the TaskForge command-line parser."""
parser = argparse.ArgumentParser(
description="Track tasks from the terminal."
)
subparsers = parser.add_subparsers(dest="command", required=True)
add_parser = subparsers.add_parser("add", help="Add a new task.")
add_parser.add_argument("title", help="The task description.")
subparsers.add_parser("list", help="List all tasks.")
complete_parser = subparsers.add_parser(
"complete", help="Mark a task as complete."
)
complete_parser.add_argument("task_id", type=int, help="The numeric task ID.")
return parser
def main() -> int:
"""Run the selected TaskForge command."""
parser = build_parser()
args = parser.parse_args()
try:
tasks = load_tasks()
except (OSError, json.JSONDecodeError, ValueError) as error:
print(f"Error: Could not read {DATA_FILE.name}: {error}")
return 1
if args.command == "list":
display_tasks(tasks)
return 0
if args.command == "add":
try:
task = add_task(tasks, args.title)
save_tasks(tasks)
except (OSError, ValueError) as error:
print(f"Error: {error}")
return 1
print(f"Added task #{task['id']}: {task['title']}")
return 0
if args.command == "complete":
if not complete_task(tasks, args.task_id):
print(f"Task #{args.task_id} was not found.")
return 1
try:
save_tasks(tasks)
except OSError as error:
print(f"Error: {error}")
return 1
print(f"Completed task #{args.task_id}.")
return 0
return 1
if __name__ == "__main__":
raise SystemExit(main())
TaskForge now saves reliable state and completes tasks by ID. Next, you will turn its core behaviour into repeatable automated tests.
Test the Core Logic
Your persistent TaskForge command-line interface now saves tasks between runs. A few manual checks cover only the paths you happened to try.
This step turns expected behaviour into repeatable automated tests. You will use Python's unittest framework to catch regressions across TaskForge's logic and storage.
In this step, get ready to:
- Test how TaskForge assigns IDs and rejects blank titles.
- Check both completion outcomes with controlled task data.
- Prove JSON persistence without changing your real task file.
Test sequential IDs
A unit test calls one function with controlled input. It checks whether the result matches a precise expectation.
The first test locks in TaskForge's ID rule. A new task must receive the number after the highest existing ID.
- In the Explorer sidebar, create test_taskforge.py beside taskforge.py.
You should see the blank test_taskforge.py file listed beside the application file.
- Build the test case with its first ID check by pasting this code into test_taskforge.py:
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from taskforge import add_task, complete_task, load_tasks, save_tasks
class TaskForgeTests(unittest.TestCase):
def test_add_task_assigns_next_id(self) -> None:
tasks = [
{"id": 2, "title": "Existing task", "completed": False}
]
task = add_task(tasks, "New task")
self.assertEqual(task["id"], 3)
self.assertEqual(task["title"], "New task")
self.assertFalse(task["completed"])
What does this test do?
- The imports bring in the testing framework and the TaskForge functions that the complete test suite uses.
- The starting list contains a task with ID 2. This makes the next expected ID unambiguous.
- The assertions check the new task's ID and title. They also confirm that a new task starts incomplete.
- Save test_taskforge.py.
- Run the first discovered test in the VS Code terminal from earlier by using this command:
python -m unittest -v
What does this check prove?
Python discovers the test file from its name. Verbose output shows the test method that ran.
You should see one named test pass. The run ends with OK.
Test file not discovered?
- Confirm that test_taskforge.py sits beside taskforge.py in the taskforge folder.
- Check that the filename begins with test_.
- Make sure the terminal is still using the taskforge folder.
- Help me diagnose why my TaskForge unittest file is not discovered.
Test validation and completion
Validation tests give known bad input to a function and confirm that it refuses the input predictably. TaskForge should raise ValueError when a title contains only spaces.
- Inside TaskForgeTests, add this method directly below test_add_task_assigns_next_id by pasting the code:
def test_add_task_rejects_blank_title(self) -> None:
with self.assertRaises(ValueError):
add_task([], " ")
Why test the error path?
The assertion passes only when add_task raises ValueError. This preserves the rule that every stored task needs a usable title.
- Save test_taskforge.py.
- Run the expanded test suite in the terminal by using this command:
python -m unittest -v
What should you see?
You should see two named tests pass. The final result remains OK.
Blank-title test failing?
- Check that the value passed to add_task contains exactly three spaces.
- Confirm that assertRaises receives ValueError.
- Help me fix my blank-title unit test.
Completion has two meaningful outcomes. The test must prove that a matching ID changes the task and that a missing ID reports failure.
- Add the completion test directly below test_add_task_rejects_blank_title by pasting this method:
def test_complete_task_reports_success_and_failure(self) -> None:
tasks = [
{"id": 1, "title": "Write tests", "completed": False}
]
self.assertTrue(complete_task(tasks, 1))
self.assertTrue(tasks[0]["completed"])
self.assertFalse(complete_task(tasks, 99))
What does the completion test cover?
- The first assertion checks that ID 1 is found.
- The second assertion checks that the matching task becomes complete.
- The final assertion checks that missing ID 99 returns failure without changing another task.
- Save test_taskforge.py.
- Run all three tests in the terminal by using this command:
python -m unittest -v
What does this run confirm?
You should see three named tests pass. The output ends with OK.
Completion test failing?
- Confirm that the test task uses numeric ID 1.
- Check that the successful call happens before the assertion on completed.
- Help me debug my complete_task unit test.
Isolate the storage test
A storage test needs a real file path to prove that saving and loading work together. A temporary directory keeps that test away from your real tasks.json file.
The temporary directory is removed after the test finishes. This keeps every test run independent.
- Add the storage test and test runner entry point below the completion test by pasting this code:
def test_save_and_load_round_trip(self) -> None:
tasks = [
{"id": 1, "title": "Persist me", "completed": False}
]
with TemporaryDirectory() as temporary_directory:
path = Path(temporary_directory) / "tasks.json"
save_tasks(tasks, path)
self.assertTrue(path.exists())
self.assertEqual(load_tasks(path), tasks)
if __name__ == "__main__":
unittest.main()
How does the storage test stay isolated?
- TemporaryDirectory creates a short-lived folder for this test.
- Path builds a temporary tasks.json location inside that folder.
- save_tasks writes the controlled task list to the temporary path.
- load_tasks reads the file back. The final assertion confirms that no task data changed during the round trip.
- Save test_taskforge.py.
Before you run the complete suite, do you expect the storage test to leave a new file beside taskforge.py?
- Run the complete test suite in the VS Code terminal by using this command:
python -m unittest -v
What should the final run prove?
You should see four named tests finish. The final result is OK.
The storage test removes its temporary folder automatically. Your real tasks.json remains untouched.
Suite not ending with OK?
- Read the first failing test name in the verbose output.
- Compare that method with the matching block in test_taskforge.py.
- Check that every test method remains indented inside TaskForgeTests.
- Help me diagnose the failing TaskForge test suite.
✔️ Awesome, I've got everything!
Great work. Your four tests now protect task creation, validation, completion, and JSON persistence.
Double check that test_taskforge.py is saved.
ⓧ I'd like to double check the full code
Compare your saved test_taskforge.py with this complete version:
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from taskforge import add_task, complete_task, load_tasks, save_tasks
class TaskForgeTests(unittest.TestCase):
def test_add_task_assigns_next_id(self) -> None:
tasks = [
{"id": 2, "title": "Existing task", "completed": False}
]
task = add_task(tasks, "New task")
self.assertEqual(task["id"], 3)
self.assertEqual(task["title"], "New task")
self.assertFalse(task["completed"])
def test_add_task_rejects_blank_title(self) -> None:
with self.assertRaises(ValueError):
add_task([], " ")
def test_complete_task_reports_success_and_failure(self) -> None:
tasks = [
{"id": 1, "title": "Write tests", "completed": False}
]
self.assertTrue(complete_task(tasks, 1))
self.assertTrue(tasks[0]["completed"])
self.assertFalse(complete_task(tasks, 99))
def test_save_and_load_round_trip(self) -> None:
tasks = [
{"id": 1, "title": "Persist me", "completed": False}
]
with TemporaryDirectory() as temporary_directory:
path = Path(temporary_directory) / "tasks.json"
save_tasks(tasks, path)
self.assertTrue(path.exists())
self.assertEqual(load_tasks(path), tasks)
if __name__ == "__main__":
unittest.main()
How to use this reference
Compare the import order and method order with your file. Keep every test method indented inside TaskForgeTests.
That is a strong safety net in place. Next up, you will document TaskForge and record a clean local Git commit.
Document and Commit the Project
Your four automated tests now prove TaskForge's core behaviour. A new reader still cannot tell how to run it.
A README.md turns the project into something another person can use. A .gitignore file keeps generated task data out of its version history.
A local Git repository records a recoverable snapshot of the tested project.
In this step, get ready to:
- Create README.md as a practical guide to TaskForge.
- Exclude generated runtime data with .gitignore.
- Record the tested project in local Git history.
Document TaskForge in README.md
Good documentation gives someone enough context to understand a project before reading its source code. Start with TaskForge's purpose and its basic commands.
- Create README.md beside taskforge.py in the open taskforge workspace by adding this first section:
# TaskForge
TaskForge is a terminal-first task manager written in Python. It focuses on clear program logic before graphical interface design.
## Requirements
- Python 3.9 or newer
## Commands
Add a task:
```text
python taskforge.py add "Practise Python"
```
List tasks:
```text
python taskforge.py list
```
What does this documentation cover?
- The opening explains why TaskForge uses a terminal-first design.
- The requirements section states which Python versions support the project.
- The first command examples show how to add a task or list saved tasks.
- Save README.md.
- Check the top of README.md in the editor.
You should see the TaskForge description followed by the requirements and the first two command examples.
Does the Markdown Look Broken?
- Check that each heading starts with the same number of # characters shown above.
- Confirm each command sits between matching triple-backtick lines.
- Make sure the file is named README.md beside taskforge.py.
- Help me fix the formatting in my TaskForge README.
- Append the remaining commands and project notes to README.md by adding this content below the list example:
Complete a task:
```text
python taskforge.py complete 1
```
Show help:
```text
python taskforge.py --help
```
## Storage
TaskForge stores local task data in `tasks.json`. This generated file is excluded from Git.
## Tests
```text
python -m unittest -v
```
What do these sections add?
- The remaining command examples show how to complete a task or view automatic help.
- The storage section explains where TaskForge keeps its local data.
- The tests section gives readers one repeatable command for checking the project.
- Save README.md.
- Check the bottom of README.md in the editor.
You should see the completion and help examples followed by the storage and tests sections.
Are Any README Sections Missing?
- Confirm the new content begins immediately after the list command's closing code fence.
- Check that the storage section names tasks.json.
- Confirm the final code fence contains the test command.
- Help me compare my TaskForge README with the required sections.
✔️ Awesome, I've got everything!
Your README.md now explains how to use TaskForge and verify its behaviour.
ⓧ I'd like to double check the full code
# TaskForge
TaskForge is a terminal-first task manager written in Python. It focuses on clear program logic before graphical interface design.
## Requirements
- Python 3.9 or newer
## Commands
Add a task:
```text
python taskforge.py add "Practise Python"
```
List tasks:
```text
python taskforge.py list
```
Complete a task:
```text
python taskforge.py complete 1
```
Show help:
```text
python taskforge.py --help
```
## Storage
TaskForge stores local task data in `tasks.json`. This generated file is excluded from Git.
## Tests
```text
python -m unittest -v
```
Exclude generated files
TaskForge creates JSON task data while you use it. Python also creates cache files while it runs your code.
These generated files do not belong in the project snapshot. The .gitignore file tells Git to leave them untracked.
- Create .gitignore beside README.md by adding this content:
tasks.json
__pycache__/
What does this file exclude?
- The tasks.json entry excludes personal task data created by the command-line application.
- The __pycache__/ entry excludes generated Python cache files.
- Save .gitignore.
- Check .gitignore in the editor.
You should see exactly two entries. One excludes TaskForge data while the other excludes Python's cache directory.
Are Generated Files Still Included?
- Confirm the first entry is spelled tasks.json.
- Check that __pycache__/ ends with a forward slash.
- Make sure the file name begins with a full stop.
- Help me check why my TaskForge .gitignore is not excluding generated files.
✔️ Awesome, I've got everything!
Your generated task data and Python cache files are now excluded from the project snapshot.
ⓧ I'd like to double check the full code
tasks.json
__pycache__/
Before you rerun the tests, do you expect the ignore rules to change TaskForge's behaviour?
- Verify the documented project still passes its tests by running this command:
python -m unittest -v
You should see four tests run with a final result of OK. That confirms the documentation files did not change TaskForge's logic.
Did a Test Fail After Documentation?
- Confirm taskforge.py still contains the persistent command-line application from the previous step.
- Check that test_taskforge.py still contains all four tests.
- Make sure the documentation content was added only to README.md.
- Help me diagnose why my TaskForge tests failed after adding documentation files.
Commit the tested project
A version control snapshot records the exact files behind a working result. Your first commit becomes a recovery point for the tested TaskForge project.
- Initialize a local Git repository in the open taskforge workspace by running this command:
git init
Git confirms that it initialized a repository inside the taskforge workspace.
Could Git Not Initialize the Repository?
- Confirm the terminal is using the open taskforge workspace.
- Check that git --version still prints an installed version.
- Help me initialize Git inside my TaskForge workspace.
- Inspect which project files Git can record by running this command:
git status
You should see README.md, .gitignore, taskforge.py, and test_taskforge.py as untracked files.
The generated tasks.json file and __pycache__/ directory should stay out of the report.
- Stage the project files by running this command:
git add .
- Confirm the project files are staged by running this command:
git status
You should see the four project files ready to be committed. Generated task data and cache files should remain excluded.
Are Files Missing from the Staged List?
- Confirm each project file is saved before staging it.
- Check that .gitignore contains only the two generated-file patterns.
- Run the staging command again after saving any missing file.
- Help me understand why a TaskForge file is missing from git status.
- Create the project history point by running this commit command:
git commit -m "Build tested TaskForge CLI"
✔️ Git created the commit
Git confirms that it created a commit containing the staged TaskForge files.
ⓧ Git needs an author identity
Git needs an author name before it can create the commit. Keep this identity inside the TaskForge repository by setting a repository-local value.
- Set the repository-local author name by running this command after replacing the example name with yours:
git config user.name "Your Name"
Where is this name stored?
This command stores the author name in the current repository's configuration.
- Set the repository-local author email by running this command after replacing the example email with yours:
git config user.email "you@example.com"
Where is this email stored?
This command stores the author email in the current repository's configuration.
- Retry the TaskForge commit by running this command:
git commit -m "Build tested TaskForge CLI"
Git should now confirm that it created the commit.
Is the Commit Still Failing?
Check that both local identity commands completed before retrying the commit.
Help me complete my local TaskForge Git commit.
Before the final checks, do you expect the committed code to pass the same four tests?
- Verify the committed application by running the full test suite:
python -m unittest -v
You should see all four tests run with a final result of OK.
Did the Final Test Run Fail?
- Check the named failing test to identify which behaviour changed.
- Confirm the committed versions of taskforge.py and test_taskforge.py match the versions that passed earlier.
- Help me diagnose a failing TaskForge unittest before I check Git status.
Before you check Git, do you expect the test run to create any uncommitted project changes?
- Confirm the repository is clean by running this command:
git status
Git should report no uncommitted project changes. You have documented TaskForge and recorded its passing state in local history.
Does Git Still Show Changes?
- Check which file Git lists as changed or untracked.
- Confirm generated tasks.json data is covered by .gitignore.
- Save intentional documentation changes before creating another commit.
- Help me understand why my TaskForge repository is not clean after the tests pass.
Secret mission
Remove Tasks Safely
TaskForge can add and complete tasks, but old tasks remain in the file forever. Extend the command-line interface with a safe removal flow that changes storage only when the requested task exists, then prove the behaviour with a fifth automated test.
Clean Up Your Resources
Clean Up Your Resources
TaskForge runs entirely on your Windows computer with no ongoing costs. You can keep the project, pause your work, or delete it.
Resources you used:
- The taskforge project folder containing taskforge.py, test_taskforge.py, README.md, and .gitignore.
- The generated tasks.json data file inside taskforge.
- The generated __pycache__/ folder inside taskforge.
- The local Git history stored inside taskforge.
Keep everything running
Keeping the folder preserves your working app, five-test suite, documentation, task data, and local commit history.
- Leave the taskforge folder in its current location.
- Return to the same workspace whenever you want to add features or manage tasks.
Pause - I'll come back to this later
TaskForge exits after each command, so there is no server to stop. Pausing only means saving your files before closing Visual Studio Code.
- Save each open file in Visual Studio Code.
- Close the Visual Studio Code window.
- Reopen the same taskforge workspace when you are ready to continue.
Delete - I don't want to use this again
Deleting taskforge permanently erases your only local copy of the app. The same deletion removes your task data and Git history.
- Move to the parent folder and permanently delete taskforge by running these commands in the Windows terminal:
cd ..
rmdir /s /q taskforge
What do these commands do?
- The first line moves the terminal out of taskforge so Windows can remove the folder.
- The second line deletes the folder with all files and local history inside it.
- Check the Explorer sidebar in Visual Studio Code.
You should no longer see the files inside taskforge. This confirms that the local project has been removed.
Folder Still Present?
- Confirm that the terminal is one folder above taskforge.
- Close any open files from taskforge.
- Run the deletion commands again.
Help me work out why Windows cannot delete my TaskForge folder.
Nice Work!
Nice Work!
You did it! TaskForge is now a persistent task manager you can run from the terminal.
You've learned how to:
- Build a command-line interface for adding tasks. Display each task's pending or completed status. Complete tasks by numeric ID.
- Keep task data between program runs with readable JSON storage. Reject blank titles. Report malformed task data clearly.
- Prove core behavior with five isolated automated tests. Explain the project through README.md. Record a recoverable version in a local Git commit.
- Complete the optional Secret Mission by adding a safely tested remove command. Leave tasks.json unchanged when an ID is missing.
Ready to quiz yourself?