Beginner3h

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 dataclass for 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:

text
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. Logout

This 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.

ClassResponsibility
Userstores user details
Draftstores draft text
UserStorecreates and validates users
DraftStorestores drafts for each user
TextMetricscalculates words, sentences, and reading time
KeywordFinderfinds common important words
ToneCheckergives a simple tone hint
ContentReviewServicecombines all review tools
ConsoleApphandles 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:

text
ConsoleApp
    uses UserStore for accounts
    uses DraftStore for saved drafts
    uses ContentReviewService for draft reviews

ContentReviewService
    uses TextMetrics
    uses KeywordFinder
    uses ToneChecker

This 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:

python
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:

python
@dataclass
class User:
    name: str
    email: str
    password_hash: str

A data class automatically creates useful methods such as __init__().

So this:

python
user = User("Asha", "[email protected]", "abc123")

works without writing a manual constructor.

Draft also uses field(default_factory=...).

python
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:

python
self._users_by_email = {}

The rest of the app does not directly edit the dictionary.

Instead, it calls methods:

python
success, message = self._user_store.register(name, email, password)

and:

python
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.

python
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.

python
self._drafts_by_email = {}

The key is the user's email.

The value is a list of Draft objects.

python
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.

python
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.

python
metrics = TextMetrics()
print(metrics.words("Python makes OOP practical."))

Output:

text
['python', 'makes', 'oop', 'practical']

6. KeywordFinder Finds Repeated Topic Words

KeywordFinder ignores very common words.

python
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.

python
counts[word] = counts.get(word, 0) + 1

Finally, it sorts words by frequency:

python
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.

python
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.

python
report = self._review_service.review(draft)

Inside the service, it combines:

  • TextMetrics
  • KeywordFinder
  • ToneChecker

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.

python
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 appsBetter habit used here
One giant classSeveral small classes
Plain password storageHash passwords
Hard-coded secret keysNo external key needed
Recursive menu callsMain loop controls flow
Mixed UI and logicService classes handle logic
Raw dictionaries everywhereStore access behind methods
Difficult to testAnalysis classes can be tested separately

Testing Individual Classes

You do not need to run the whole menu to test the useful logic.

Example:

python
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:

text
['clear', 'python', 'examples', 'make', 'learning', 'easier']
2
2

Test the keyword finder:

python
finder = KeywordFinder()
words = ["python", "python", "blog", "blog", "blog", "the", "and"]

print(finder.top_keywords(words))

Output:

text
[('blog', 3), ('python', 2)]

Test the tone checker:

python
checker = ToneChecker()

print(checker.check(["clear", "helpful", "simple"]))
print(checker.check(["confusing", "problem", "slow"]))
print(checker.check(["python", "draft", "lesson"]))

Output:

text
positive
needs care
neutral

How 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 .txt file
  • show the longest sentence
  • detect too many repeated words
  • add unit tests for TextMetrics, KeywordFinder, and ToneChecker

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:

python
from abc import ABC, abstractmethod

class Analyzer(ABC):
    @abstractmethod
    def analyze(self, draft):
        pass

Then each analyzer can implement analyze():

python
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:

python
analyzers = [
    ReadingTimeAnalyzer(TextMetrics()),
]

Then run them in a loop:

python
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:

text
5. Delete draft

Then 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:

text
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:

text
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:

text
asha-example-com-drafts.txt

Solution Key

Practice Solutions

Solution 1: Delete Draft

Add this method to DraftStore:

python
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():

python
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():

python
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():

python
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:

python
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

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.

Sign in to submit your project for review.