Python OOP Project: Build a Content Review CLI App
Build a beginner-friendly Python OOP project from scratch: a console app with registration, login, saved drafts, reading-time analysis, keyword extraction, tone hints, and clean class design.
Python OOP Project: Build a Content Review CLI App
The best way to understand object-oriented programming is to build a small project where different objects have clear responsibilities.
In this lesson, you will build a command-line app called Content Review CLI.
It helps a writer:
- create an account
- log in
- save draft text
- list saved drafts
- review a draft
- estimate reading time
- find repeated keywords
- check whether the tone feels positive, negative, or neutral
- get a simple publish checklist
This is a beginner project, but the structure is close to how real applications are designed.
You will not use any paid API, hidden key, or external service. Everything runs locally with Python.
What You Will Learn
By the end, you should understand:
- how to split a project into multiple classes
- how to use
dataclassfor simple data objects - how to hide internal storage behind methods
- how to separate user interface code from business logic
- how to create a small service layer
- how to avoid hard-coded secret keys
- how to keep menu-driven programs readable
- how to test useful parts of an app without typing into menus every time
Project Idea
We will build a console app for reviewing blog drafts.
Example flow:
Main Menu
1. Register
2. Login
3. Exit
Choose: 1
Name: Asha
Email: [email protected]
Password:
Account created. Please log in.
Main Menu
1. Register
2. Login
3. Exit
Choose: 2
Email: [email protected]
Password:
Welcome, Asha!
Workspace
1. Add draft
2. List drafts
3. Review draft
4. LogoutThis app is small enough for beginners, but it still uses real OOP ideas.
Why This Is a Good OOP Project
A weak beginner project often puts everything into one class:
- menu text
- user accounts
- password checks
- text analysis
- saved drafts
- output formatting
That works for a tiny demo, but it becomes messy quickly.
In this project, each class gets one clear job.
| Class | Responsibility |
|---|---|
User | stores user details |
Draft | stores draft text |
UserStore | creates and validates users |
DraftStore | stores drafts for each user |
TextMetrics | calculates words, sentences, and reading time |
KeywordFinder | finds common important words |
ToneChecker | gives a simple tone hint |
ContentReviewService | combines all review tools |
ConsoleApp | handles menus and user input |
That is the main OOP lesson:
Keep related data and behavior together, but do not make one class responsible for the whole world.
Project Design
Here is the high-level design:
ConsoleApp
uses UserStore for accounts
uses DraftStore for saved drafts
uses ContentReviewService for draft reviews
ContentReviewService
uses TextMetrics
uses KeywordFinder
uses ToneCheckerThis gives us two important separations:
-
The app menu does not need to know how keyword extraction works.
-
The text analysis classes do not need to know anything about login menus.
-
Use Python 3.10 or newer.
-
Create a file named
content_review_cli.py. -
Run the app from the terminal with
python content_review_cli.py. -
Implement account registration and login.
-
Store users in memory using a dedicated store class.
-
Hash passwords before storing them.
-
Let logged-in users create draft content with a title and body.
-
Let users list saved drafts and select one for review.
-
Calculate word count, sentence count, estimated reading time, common keywords, a simple tone hint, and a publish checklist.
-
Keep menu/input code separate from text-analysis code.
-
Use small classes with clear responsibilities instead of one large class.
-
Do not use external APIs, paid AI tools, databases, or copied notebook code.
-
Add at least two improvements from the practice exercise section after the base project works.
Create a file named content_review_cli.py.
Then add this code:
from dataclasses import dataclass, field
from getpass import getpass
import hashlib
import re
from time import localtime, strftime
@dataclass
class User:
name: str
email: str
password_hash: str
@dataclass
class Draft:
title: str
body: str
created_at: str = field(default_factory=lambda: strftime("%Y-%m-%d %H:%M", localtime()))
class PasswordHasher:
def hash_password(self, password):
clean_password = password.strip()
return hashlib.sha256(clean_password.encode("utf-8")).hexdigest()
def matches(self, password, stored_hash):
return self.hash_password(password) == stored_hash
class UserStore:
def __init__(self, password_hasher):
self._users_by_email = {}
self._password_hasher = password_hasher
def register(self, name, email, password):
clean_name = name.strip()
clean_email = email.strip().lower()
if not clean_name:
return False, "Name cannot be empty."
if "@" not in clean_email:
return False, "Please enter a valid email address."
if len(password.strip()) < 6:
return False, "Password must contain at least 6 characters."
if clean_email in self._users_by_email:
return False, "An account already exists for this email."
user = User(
name=clean_name,
email=clean_email,
password_hash=self._password_hasher.hash_password(password),
)
self._users_by_email[clean_email] = user
return True, "Account created. Please log in."
def login(self, email, password):
clean_email = email.strip().lower()
user = self._users_by_email.get(clean_email)
if user is None:
return None, "No account was found for this email."
if not self._password_hasher.matches(password, user.password_hash):
return None, "Incorrect password."
return user, f"Welcome, {user.name}!"
class DraftStore:
def __init__(self) -> None:
self._drafts_by_email = {}
def add_draft(self, user, title, body):
clean_title = title.strip()
clean_body = body.strip()
if not clean_title:
return False, "Draft title cannot be empty."
if len(clean_body.split()) < 5:
return False, "Draft body is too short to review."
draft = Draft(title=clean_title, body=clean_body)
self._drafts_by_email.setdefault(user.email, []).append(draft)
return True, "Draft saved."
def list_drafts(self, user):
return self._drafts_by_email.get(user.email, [])
def get_draft(self, user, position):
drafts = self.list_drafts(user)
if position < 1 or position > len(drafts):
return None
return drafts[position - 1]
class TextMetrics:
def words(self, text):
return re.findall(r"[A-Za-z']+", text.lower())
def sentence_count(self, text):
parts = re.split(r"[.!?]+", text)
sentences = [part.strip() for part in parts if part.strip()]
return max(1, len(sentences))
def reading_minutes(self, text, words_per_minute=180):
word_count = len(self.words(text))
return max(1, round(word_count / words_per_minute))
def average_sentence_length(self, text):
word_count = len(self.words(text))
sentence_count = self.sentence_count(text)
return round(word_count / sentence_count, 1)
class KeywordFinder:
def __init__(self) -> None:
self._stop_words = {
"a", "an", "and", "are", "as", "at", "be", "but", "by",
"for", "from", "has", "have", "i", "in", "is", "it", "of",
"on", "or", "our", "that", "the", "their", "this", "to",
"was", "we", "with", "you", "your",
}
def top_keywords(self, words, limit=5):
counts = {}
for word in words:
if len(word) < 4:
continue
if word in self._stop_words:
continue
counts[word] = counts.get(word, 0) + 1
ranked_words = sorted(counts.items(), key=lambda item: (-item[1], item[0]))
return ranked_words[:limit]
class ToneChecker:
def __init__(self) -> None:
self._positive_words = {
"clear", "helpful", "easy", "better", "strong", "useful",
"simple", "practical", "confident", "improve", "ready",
}
self._negative_words = {
"hard", "confusing", "broken", "weak", "slow", "error",
"problem", "difficult", "stuck", "risk", "fail",
}
def check(self, words):
positive_score = sum(1 for word in words if word in self._positive_words)
negative_score = sum(1 for word in words if word in self._negative_words)
if positive_score > negative_score:
return "positive"
if negative_score > positive_score:
return "needs care"
return "neutral"
class ContentReviewService:
def __init__(self, metrics, keyword_finder, tone_checker):
self._metrics = metrics
self._keyword_finder = keyword_finder
self._tone_checker = tone_checker
def review(self, draft):
words = self._metrics.words(draft.body)
word_count = len(words)
sentence_count = self._metrics.sentence_count(draft.body)
reading_minutes = self._metrics.reading_minutes(draft.body)
average_sentence_length = self._metrics.average_sentence_length(draft.body)
keywords = self._keyword_finder.top_keywords(words)
tone = self._tone_checker.check(words)
checklist = self._build_checklist(
word_count=word_count,
average_sentence_length=average_sentence_length,
keywords=keywords,
)
return {
"title": draft.title,
"created_at": draft.created_at,
"word_count": word_count,
"sentence_count": sentence_count,
"reading_minutes": reading_minutes,
"average_sentence_length": average_sentence_length,
"keywords": keywords,
"tone": tone,
"checklist": checklist,
}
def _build_checklist(self, word_count, average_sentence_length, keywords):
checklist = []
if word_count < 80:
checklist.append("Add more detail before publishing.")
else:
checklist.append("Length looks healthy for a short draft.")
if average_sentence_length > 22:
checklist.append("Break a few long sentences into shorter ones.")
else:
checklist.append("Sentence length looks readable.")
if not keywords:
checklist.append("Use clearer topic words so the main idea is visible.")
else:
checklist.append("Repeated topic words are visible.")
return checklist
class ConsoleApp:
def __init__(self) -> None:
password_hasher = PasswordHasher()
user_store = UserStore(password_hasher)
draft_store = DraftStore()
review_service = ContentReviewService(
metrics=TextMetrics(),
keyword_finder=KeywordFinder(),
tone_checker=ToneChecker(),
)
self._user_store = user_store
self._draft_store = draft_store
self._review_service = review_service
self._current_user = None
def run(self):
while True:
if self._current_user is None:
self._show_main_menu()
else:
self._show_workspace_menu()
def _show_main_menu(self):
print("\nMain Menu")
print("1. Register")
print("2. Login")
print("3. Exit")
choice = input("Choose: ").strip()
if choice == "1":
self._register()
elif choice == "2":
self._login()
elif choice == "3":
print("Goodbye.")
raise SystemExit
else:
print("Please choose 1, 2, or 3.")
def _show_workspace_menu(self):
print(f"\nWorkspace: {self._current_user.name}")
print("1. Add draft")
print("2. List drafts")
print("3. Review draft")
print("4. Logout")
choice = input("Choose: ").strip()
if choice == "1":
self._add_draft()
elif choice == "2":
self._list_drafts()
elif choice == "3":
self._review_draft()
elif choice == "4":
self._logout()
else:
print("Please choose a valid option.")
def _register(self):
print("\nCreate Account")
name = input("Name: ")
email = input("Email: ")
password = getpass("Password: ")
success, message = self._user_store.register(name, email, password)
print(message)
if success:
print("You can now log in from the main menu.")
def _login(self):
print("\nLogin")
email = input("Email: ")
password = getpass("Password: ")
user, message = self._user_store.login(email, password)
print(message)
if user is not None:
self._current_user = user
def _add_draft(self):
print("\nAdd Draft")
title = input("Title: ")
print("Paste your draft below. Press Enter on an empty line to finish.")
lines = []
while True:
line = input()
if line == "":
break
lines.append(line)
body = "\n".join(lines)
success, message = self._draft_store.add_draft(self._current_user, title, body)
print(message)
def _list_drafts(self):
drafts = self._draft_store.list_drafts(self._current_user)
if not drafts:
print("No drafts saved yet.")
return
print("\nYour Drafts")
for index, draft in enumerate(drafts, start=1):
print(f"{index}. {draft.title} ({draft.created_at})")
def _review_draft(self):
drafts = self._draft_store.list_drafts(self._current_user)
if not drafts:
print("Add a draft before reviewing.")
return
self._list_drafts()
raw_position = input("Draft number to review: ").strip()
if not raw_position.isdigit():
print("Please enter a number.")
return
draft = self._draft_store.get_draft(self._current_user, int(raw_position))
if draft is None:
print("No draft exists at that number.")
return
report = self._review_service.review(draft)
self._print_report(report)
def _print_report(self, report):
print("\nReview Report")
print(f"Title: {report['title']}")
print(f"Created: {report['created_at']}")
print(f"Words: {report['word_count']}")
print(f"Sentences: {report['sentence_count']}")
print(f"Estimated reading time: {report['reading_minutes']} minute(s)")
print(f"Average sentence length: {report['average_sentence_length']} words")
print(f"Tone hint: {report['tone']}")
print("\nTop Keywords")
if report["keywords"]:
for keyword, count in report["keywords"]:
print(f"- {keyword}: {count}")
else:
print("- No strong repeated keywords found.")
print("\nPublish Checklist")
for item in report["checklist"]:
print(f"- {item}")
def _logout(self):
print(f"Logged out: {self._current_user.name}")
self._current_user = None
if __name__ == "__main__":
app = ConsoleApp()
app.run()How the Project Uses OOP
This project is not object-oriented just because it uses class.
It is object-oriented because each object has a reason to exist.
1. Data Classes Store Simple Data
User and Draft are data classes.
They mainly hold information:
@dataclass
class User:
name: str
email: str
password_hash: strA data class automatically creates useful methods such as __init__().
So this:
user = User("Asha", "[email protected]", "abc123")works without writing a manual constructor.
Draft also uses field(default_factory=...).
created_at: str = field(default_factory=lambda: strftime("%Y-%m-%d %H:%M", localtime()))This means each draft gets its own creation time when the draft object is created.
2. UserStore Handles Account Rules
UserStore manages user registration and login.
It hides this dictionary:
self._users_by_email = {}The rest of the app does not directly edit the dictionary.
Instead, it calls methods:
success, message = self._user_store.register(name, email, password)and:
user, message = self._user_store.login(email, password)This is encapsulation.
The storage details are inside the class.
The public behavior is exposed through clear methods.
3. PasswordHasher Has One Job
PasswordHasher converts a password into a hash.
class PasswordHasher:
def hash_password(self, password):
clean_password = password.strip()
return hashlib.sha256(clean_password.encode("utf-8")).hexdigest()This is better than storing plain passwords.
Important note:
For a real production app, you should use a dedicated password hashing library such as bcrypt or argon2.
For a beginner local project, hashlib is enough to understand the idea:
- do not store raw passwords
- compare hashes instead of plain text
- keep security code in one place
4. DraftStore Handles Drafts
DraftStore stores drafts per user.
self._drafts_by_email = {}The key is the user's email.
The value is a list of Draft objects.
self._drafts_by_email.setdefault(user.email, []).append(draft)setdefault() means:
- if the email is not present, create an empty list
- then append the new draft
5. TextMetrics Does Text Counting
TextMetrics knows how to count words, count sentences, estimate reading time, and calculate average sentence length.
class TextMetrics:
def words(self, text):
return re.findall(r"[A-Za-z']+", text.lower())This keeps text measurement separate from the menu.
That matters because you can test TextMetrics without running the full app.
metrics = TextMetrics()
print(metrics.words("Python makes OOP practical."))Output:
['python', 'makes', 'oop', 'practical']6. KeywordFinder Finds Repeated Topic Words
KeywordFinder ignores very common words.
self._stop_words = {
"a", "an", "and", "are", "as", "at", "be", "but", "by",
"for", "from", "has", "have", "i", "in", "is", "it", "of",
"on", "or", "our", "that", "the", "their", "this", "to",
"was", "we", "with", "you", "your",
}Then it counts the remaining words.
counts[word] = counts.get(word, 0) + 1Finally, it sorts words by frequency:
ranked_words = sorted(counts.items(), key=lambda item: (-item[1], item[0]))The negative count sorts higher counts first.
The word itself is used as a tie-breaker.
7. ToneChecker Gives a Simple Tone Hint
This is not advanced AI.
It is a beginner-friendly rule-based checker.
positive_score = sum(1 for word in words if word in self._positive_words)
negative_score = sum(1 for word in words if word in self._negative_words)If the text has more positive words, the tone is positive.
If it has more negative words, the tone is needs care.
Otherwise, it is neutral.
This teaches an important project lesson:
You can build a useful first version without external APIs.
Later, you can replace this class with a smarter implementation without rewriting the whole app.
8. ContentReviewService Combines Smaller Tools
ContentReviewService is the service layer.
It does not ask the user for input.
It does not print the menu.
It only reviews a draft.
report = self._review_service.review(draft)Inside the service, it combines:
TextMetricsKeywordFinderToneChecker
This is composition.
One object is built from other useful objects.
9. ConsoleApp Controls the User Flow
ConsoleApp is responsible for interaction.
It shows menus, reads input, and prints reports.
def run(self):
while True:
if self._current_user is None:
self._show_main_menu()
else:
self._show_workspace_menu()This loop is cleaner than making menu methods call each other repeatedly.
Why?
Because repeated menu-to-menu calls can create a deep call stack.
A loop keeps the app easier to reason about.
Important Design Improvements
This version uses several cleaner habits:
| Problem in many beginner apps | Better habit used here |
|---|---|
| One giant class | Several small classes |
| Plain password storage | Hash passwords |
| Hard-coded secret keys | No external key needed |
| Recursive menu calls | Main loop controls flow |
| Mixed UI and logic | Service classes handle logic |
| Raw dictionaries everywhere | Store access behind methods |
| Difficult to test | Analysis classes can be tested separately |
Testing Individual Classes
You do not need to run the whole menu to test the useful logic.
Example:
metrics = TextMetrics()
words = metrics.words("Clear Python examples make learning easier.")
print(words)
print(metrics.sentence_count("One idea. Another idea."))
print(metrics.reading_minutes("word " * 400))Output:
['clear', 'python', 'examples', 'make', 'learning', 'easier']
2
2Test the keyword finder:
finder = KeywordFinder()
words = ["python", "python", "blog", "blog", "blog", "the", "and"]
print(finder.top_keywords(words))Output:
[('blog', 3), ('python', 2)]Test the tone checker:
checker = ToneChecker()
print(checker.check(["clear", "helpful", "simple"]))
print(checker.check(["confusing", "problem", "slow"]))
print(checker.check(["python", "draft", "lesson"]))Output:
positive
needs care
neutralHow to Improve This Project
Once the basic app works, you can add more features.
Good beginner improvements:
- save users and drafts into a JSON file
- add a delete draft option
- add an edit draft option
- add a search drafts option
- add tags for each draft
- export the review report as a
.txtfile - show the longest sentence
- detect too many repeated words
- add unit tests for
TextMetrics,KeywordFinder, andToneChecker
Good intermediate improvements:
- split the project into multiple files
- create a proper
main.py - add a SQLite database
- add password salting with a stronger hashing strategy
- create an abstract base class for analyzers
- add a plugin-style analyzer system
- build a web UI later with Flask, FastAPI, or Django
Mini Refactor: Make Analyzers Pluggable
After learning abstraction, you can make each analyzer follow the same pattern.
Example:
from abc import ABC, abstractmethod
class Analyzer(ABC):
@abstractmethod
def analyze(self, draft):
passThen each analyzer can implement analyze():
class ReadingTimeAnalyzer(Analyzer):
def __init__(self, metrics):
self._metrics = metrics
def analyze(self, draft):
return {
"name": "Reading Time",
"value": self._metrics.reading_minutes(draft.body),
}This lets you store analyzers in a list:
analyzers = [
ReadingTimeAnalyzer(TextMetrics()),
]Then run them in a loop:
for analyzer in analyzers:
print(analyzer.analyze(draft))This is a natural next step after learning OOP basics, inheritance, polymorphism, and abstraction.
Practice Lab
Practice Exercises
Try these after building the project.
Exercise 1: Add a Delete Draft Option
Add a new workspace menu option:
5. Delete draftThen allow the logged-in user to delete a draft by number.
Exercise 2: Add a Draft Preview
When listing drafts, show the first 50 characters of each draft body.
Example:
1. Python OOP Notes - Objects combine data and behavior...Exercise 3: Add a Minimum Title Length Rule
Update DraftStore.add_draft() so a title must contain at least 4 characters.
Exercise 4: Add a Repeated Word Warning
If one keyword appears more than 5 times, add this checklist item:
Check whether one word is repeated too often.Exercise 5: Save Drafts to a File
Add a method that exports a user's drafts to a text file.
Example filename:
asha-example-com-drafts.txtSolution Key
Practice Solutions
Solution 1: Delete Draft
Add this method to DraftStore:
def delete_draft(self, user, position):
drafts = self.list_drafts(user)
if position < 1 or position > len(drafts):
return False, "No draft exists at that number."
removed_draft = drafts.pop(position - 1)
return True, f"Deleted draft: {removed_draft.title}"Then add a menu option in ConsoleApp._show_workspace_menu().
Solution 2: Draft Preview
Update _list_drafts():
for index, draft in enumerate(drafts, start=1):
preview = draft.body.replace("\n", " ")[:50]
print(f"{index}. {draft.title} - {preview}...")Solution 3: Minimum Title Length
Inside DraftStore.add_draft():
if len(clean_title) < 4:
return False, "Draft title must contain at least 4 characters."Place it after the empty title check.
Solution 4: Repeated Word Warning
Inside _build_checklist():
if keywords and keywords[0][1] > 5:
checklist.append("Check whether one word is repeated too often.")Solution 5: Export Drafts
Add this method to DraftStore:
def export_drafts(self, user):
drafts = self.list_drafts(user)
if not drafts:
return False, "No drafts available to export."
safe_email = user.email.replace("@", "-").replace(".", "-")
filename = f"{safe_email}-drafts.txt"
with open(filename, "w", encoding="utf-8") as file:
for draft in drafts:
file.write(f"Title: {draft.title}\n")
file.write(f"Created: {draft.created_at}\n")
file.write(draft.body)
file.write("\n\n---\n\n")
return True, f"Drafts exported to {filename}."Quick Check
Quick Quiz
1. Why should the menu class not perform all text analysis itself?
Because it would mix user interface code with business logic.
Keeping analysis in separate classes makes the project easier to test and change.
2. Why does UserStore use methods instead of exposing the dictionary directly?
Because the class can protect its rules.
Other code should not accidentally create duplicate users, skip validation, or change password hashes directly.
3. Why is ContentReviewService useful?
It coordinates smaller tools and returns one report.
The menu only needs to ask for a review and print the result.
4. Is this a production authentication system?
No.
It is a learning project.
For production authentication, use proven frameworks and secure password hashing tools.
5. Why is local rule-based analysis useful before adding AI?
Because it gives you a working baseline.
You can later replace one class with an AI-backed version while keeping the app structure mostly the same.
Final Takeaway
This project shows how OOP helps you organize a real program.
The goal is not to create many classes for decoration.
The goal is to give each class a clear job:
- account handling
- draft storage
- text metrics
- keyword analysis
- tone checking
- report creation
- menu control
When those responsibilities are separated, your code becomes easier to read, easier to test, and easier to improve.
Sources and Further Reading
- Python
dataclasses: https://docs.python.org/3/library/dataclasses.html - Python
hashlib: https://docs.python.org/3/library/hashlib.html - Python
re: https://docs.python.org/3/library/re.html - Python classes tutorial: https://docs.python.org/3/tutorial/classes.html
- Python
abc: https://docs.python.org/3/library/abc.html
Requirements
Build the project as a single-file Python CLI first, then refactor it only after the base version works.
Workspace
No starter files available for this project.
Try running locally or use your own editor.
