Skip to content

Simple Blog System

Abstract

A blog system in fewer than 300 lines of Python exercises every fundamental of a real content app: data modeling, persistence, CRUD, search, tags, draft vs. published workflow, statistics, and export. In this tutorial you build a CLI blog manager that stores posts in JSON, supports tags and search, tracks publication status, and exports to plain text or Markdown. Then we discuss the natural growth path: SQLite, Markdown rendering, a Flask web UI, and finally a published static-site generator.

You will learn:

  • Designing a data class for a domain object (BlogPostBlogPost).
  • Persisting a list of objects to JSON safely.
  • Implementing search, tag filtering, and statistics.
  • Building a non-trivial menu-driven CLI without spaghetti.
  • The path from CLI → web app → static site.

Prerequisites

  • Python 3.6 or above (3.7+ recommended for dataclassesdataclasses).
  • A text editor or IDE.
  • Comfort with classes, lists, and dictionaries.
  • Familiarity with the Personal Diary project — same JSON + class pattern.

Getting Started

Create the project

  1. Create folder simple-blogsimple-blog.
  2. Inside, create simpleblogsystem.pysimpleblogsystem.py.

Write the code

Simple Blog SystemSource
Simple Blog System
# Simple Blog System
 
import json
import os
from datetime import datetime
from typing import List, Dict, Optional
 
class BlogPost:
    def __init__(self, title: str, content: str, author: str = "Anonymous", post_id: str = None):
        self.id = post_id or str(int(datetime.now().timestamp()))
        self.title = title
        self.content = content
        self.author = author
        self.created_at = datetime.now().isoformat()
        self.updated_at = self.created_at
        self.tags = []
        self.published = False
    
    def to_dict(self) -> Dict:
        """Convert blog post to dictionary"""
        return {
            'id': self.id,
            'title': self.title,
            'content': self.content,
            'author': self.author,
            'created_at': self.created_at,
            'updated_at': self.updated_at,
            'tags': self.tags,
            'published': self.published
        }
    
    @classmethod
    def from_dict(cls, data: Dict) -> 'BlogPost':
        """Create blog post from dictionary"""
        post = cls(data['title'], data['content'], data['author'], data['id'])
        post.created_at = data['created_at']
        post.updated_at = data['updated_at']
        post.tags = data.get('tags', [])
        post.published = data.get('published', False)
        return post
    
    def add_tag(self, tag: str):
        """Add a tag to the post"""
        if tag.lower() not in [t.lower() for t in self.tags]:
            self.tags.append(tag)
            self.updated_at = datetime.now().isoformat()
    
    def remove_tag(self, tag: str):
        """Remove a tag from the post"""
        self.tags = [t for t in self.tags if t.lower() != tag.lower()]
        self.updated_at = datetime.now().isoformat()
    
    def publish(self):
        """Publish the post"""
        self.published = True
        self.updated_at = datetime.now().isoformat()
    
    def unpublish(self):
        """Unpublish the post"""
        self.published = False
        self.updated_at = datetime.now().isoformat()
    
    def update_content(self, title: str = None, content: str = None):
        """Update post content"""
        if title:
            self.title = title
        if content:
            self.content = content
        self.updated_at = datetime.now().isoformat()
 
class SimpleBlogSystem:
    def __init__(self, data_file: str = "blog_data.json"):
        self.data_file = data_file
        self.posts = []
        self.load_posts()
    
    def load_posts(self):
        """Load posts from JSON file"""
        if os.path.exists(self.data_file):
            try:
                with open(self.data_file, 'r', encoding='utf-8') as f:
                    data = json.load(f)
                    self.posts = [BlogPost.from_dict(post_data) for post_data in data]
            except (json.JSONDecodeError, KeyError) as e:
                print(f"Error loading posts: {e}")
                self.posts = []
    
    def save_posts(self):
        """Save posts to JSON file"""
        try:
            with open(self.data_file, 'w', encoding='utf-8') as f:
                json.dump([post.to_dict() for post in self.posts], f, indent=2)
        except Exception as e:
            print(f"Error saving posts: {e}")
    
    def create_post(self, title: str, content: str, author: str = "Anonymous") -> BlogPost:
        """Create a new blog post"""
        post = BlogPost(title, content, author)
        self.posts.append(post)
        self.save_posts()
        return post
    
    def get_post_by_id(self, post_id: str) -> Optional[BlogPost]:
        """Get a post by its ID"""
        for post in self.posts:
            if post.id == post_id:
                return post
        return None
    
    def get_all_posts(self, published_only: bool = False) -> List[BlogPost]:
        """Get all posts, optionally filtered by published status"""
        if published_only:
            return [post for post in self.posts if post.published]
        return self.posts.copy()
    
    def get_posts_by_author(self, author: str) -> List[BlogPost]:
        """Get all posts by a specific author"""
        return [post for post in self.posts if post.author.lower() == author.lower()]
    
    def get_posts_by_tag(self, tag: str) -> List[BlogPost]:
        """Get all posts with a specific tag"""
        return [post for post in self.posts if tag.lower() in [t.lower() for t in post.tags]]
    
    def search_posts(self, query: str) -> List[BlogPost]:
        """Search posts by title or content"""
        query = query.lower()
        results = []
        for post in self.posts:
            if (query in post.title.lower() or 
                query in post.content.lower() or 
                query in post.author.lower()):
                results.append(post)
        return results
    
    def delete_post(self, post_id: str) -> bool:
        """Delete a post by ID"""
        post = self.get_post_by_id(post_id)
        if post:
            self.posts.remove(post)
            self.save_posts()
            return True
        return False
    
    def get_post_statistics(self) -> Dict:
        """Get blog statistics"""
        total_posts = len(self.posts)
        published_posts = len([p for p in self.posts if p.published])
        authors = set(post.author for post in self.posts)
        all_tags = []
        for post in self.posts:
            all_tags.extend(post.tags)
        unique_tags = set(all_tags)
        
        return {
            'total_posts': total_posts,
            'published_posts': published_posts,
            'draft_posts': total_posts - published_posts,
            'total_authors': len(authors),
            'total_tags': len(unique_tags),
            'most_common_tags': self._get_most_common_tags(all_tags)
        }
    
    def _get_most_common_tags(self, tags: List[str], limit: int = 5) -> List[tuple]:
        """Get most common tags"""
        tag_count = {}
        for tag in tags:
            tag_count[tag] = tag_count.get(tag, 0) + 1
        
        sorted_tags = sorted(tag_count.items(), key=lambda x: x[1], reverse=True)
        return sorted_tags[:limit]
    
    def export_posts(self, filename: str, published_only: bool = True):
        """Export posts to a file"""
        posts = self.get_all_posts(published_only)
        
        try:
            with open(filename, 'w', encoding='utf-8') as f:
                for post in posts:
                    f.write(f"Title: {post.title}\n")
                    f.write(f"Author: {post.author}\n")
                    f.write(f"Created: {post.created_at}\n")
                    f.write(f"Tags: {', '.join(post.tags)}\n")
                    f.write(f"Published: {'Yes' if post.published else 'No'}\n")
                    f.write("-" * 50 + "\n")
                    f.write(post.content)
                    f.write("\n" + "=" * 50 + "\n\n")
            
            print(f"Posts exported to {filename}")
        except Exception as e:
            print(f"Error exporting posts: {e}")
 
def display_post(post: BlogPost):
    """Display a single post"""
    print(f"\n{'='*60}")
    print(f"Title: {post.title}")
    print(f"Author: {post.author}")
    print(f"Created: {post.created_at}")
    print(f"Status: {'Published' if post.published else 'Draft'}")
    if post.tags:
        print(f"Tags: {', '.join(post.tags)}")
    print(f"{'='*60}")
    print(post.content)
    print(f"{'='*60}\n")
 
def main():
    """Main function to run the blog system"""
    blog = SimpleBlogSystem()
    
    while True:
        print("\n=== Simple Blog System ===")
        print("1. Create new post")
        print("2. View all posts")
        print("3. View published posts")
        print("4. Search posts")
        print("5. View post by ID")
        print("6. Edit post")
        print("7. Delete post")
        print("8. Manage tags")
        print("9. Publish/Unpublish post")
        print("10. View statistics")
        print("11. Export posts")
        print("0. Exit")
        
        try:
            choice = input("\nEnter your choice: ").strip()
            
            if choice == '1':
                title = input("Enter post title: ").strip()
                if not title:
                    print("Title cannot be empty!")
                    continue
                
                author = input("Enter author name (or press Enter for Anonymous): ").strip()
                if not author:
                    author = "Anonymous"
                
                print("Enter post content (type 'END' on a new line to finish):")
                content_lines = []
                while True:
                    line = input()
                    if line.strip() == 'END':
                        break
                    content_lines.append(line)
                
                content = '\n'.join(content_lines)
                post = blog.create_post(title, content, author)
                print(f"Post created with ID: {post.id}")
            
            elif choice == '2':
                posts = blog.get_all_posts()
                if not posts:
                    print("No posts found.")
                else:
                    for post in posts:
                        display_post(post)
            
            elif choice == '3':
                posts = blog.get_all_posts(published_only=True)
                if not posts:
                    print("No published posts found.")
                else:
                    for post in posts:
                        display_post(post)
            
            elif choice == '4':
                query = input("Enter search query: ").strip()
                if query:
                    results = blog.search_posts(query)
                    if not results:
                        print("No posts found matching your query.")
                    else:
                        print(f"Found {len(results)} posts:")
                        for post in results:
                            display_post(post)
            
            elif choice == '5':
                post_id = input("Enter post ID: ").strip()
                post = blog.get_post_by_id(post_id)
                if post:
                    display_post(post)
                else:
                    print("Post not found.")
            
            elif choice == '6':
                post_id = input("Enter post ID to edit: ").strip()
                post = blog.get_post_by_id(post_id)
                if post:
                    print(f"Current title: {post.title}")
                    new_title = input("Enter new title (or press Enter to keep current): ").strip()
                    
                    print(f"Current content:\n{post.content}")
                    print("Enter new content (type 'END' on a new line to finish, or just 'END' to keep current):")
                    content_lines = []
                    while True:
                        line = input()
                        if line.strip() == 'END':
                            break
                        content_lines.append(line)
                    
                    new_content = '\n'.join(content_lines) if content_lines else None
                    
                    if new_title or new_content:
                        post.update_content(new_title if new_title else None, new_content)
                        blog.save_posts()
                        print("Post updated successfully!")
                    else:
                        print("No changes made.")
                else:
                    print("Post not found.")
            
            elif choice == '7':
                post_id = input("Enter post ID to delete: ").strip()
                post = blog.get_post_by_id(post_id)
                if post:
                    confirm = input(f"Are you sure you want to delete '{post.title}'? (y/N): ").strip().lower()
                    if confirm == 'y':
                        blog.delete_post(post_id)
                        print("Post deleted successfully!")
                    else:
                        print("Deletion cancelled.")
                else:
                    print("Post not found.")
            
            elif choice == '8':
                post_id = input("Enter post ID to manage tags: ").strip()
                post = blog.get_post_by_id(post_id)
                if post:
                    print(f"Current tags: {', '.join(post.tags) if post.tags else 'None'}")
                    print("1. Add tag")
                    print("2. Remove tag")
                    tag_choice = input("Enter choice: ").strip()
                    
                    if tag_choice == '1':
                        tag = input("Enter tag to add: ").strip()
                        if tag:
                            post.add_tag(tag)
                            blog.save_posts()
                            print("Tag added successfully!")
                    elif tag_choice == '2':
                        if post.tags:
                            tag = input("Enter tag to remove: ").strip()
                            if tag:
                                post.remove_tag(tag)
                                blog.save_posts()
                                print("Tag removed successfully!")
                        else:
                            print("No tags to remove.")
                else:
                    print("Post not found.")
            
            elif choice == '9':
                post_id = input("Enter post ID: ").strip()
                post = blog.get_post_by_id(post_id)
                if post:
                    current_status = "Published" if post.published else "Draft"
                    print(f"Current status: {current_status}")
                    
                    if post.published:
                        confirm = input("Unpublish this post? (y/N): ").strip().lower()
                        if confirm == 'y':
                            post.unpublish()
                            blog.save_posts()
                            print("Post unpublished!")
                    else:
                        confirm = input("Publish this post? (y/N): ").strip().lower()
                        if confirm == 'y':
                            post.publish()
                            blog.save_posts()
                            print("Post published!")
                else:
                    print("Post not found.")
            
            elif choice == '10':
                stats = blog.get_post_statistics()
                print("\n=== Blog Statistics ===")
                print(f"Total posts: {stats['total_posts']}")
                print(f"Published posts: {stats['published_posts']}")
                print(f"Draft posts: {stats['draft_posts']}")
                print(f"Total authors: {stats['total_authors']}")
                print(f"Total unique tags: {stats['total_tags']}")
                
                if stats['most_common_tags']:
                    print("\nMost common tags:")
                    for tag, count in stats['most_common_tags']:
                        print(f"  {tag}: {count}")
            
            elif choice == '11':
                filename = input("Enter filename for export (e.g., blog_export.txt): ").strip()
                if not filename:
                    filename = "blog_export.txt"
                
                published_only = input("Export only published posts? (Y/n): ").strip().lower()
                published_only = published_only != 'n'
                
                blog.export_posts(filename, published_only)
            
            elif choice == '0':
                print("Goodbye!")
                break
            
            else:
                print("Invalid choice. Please try again.")
        
        except KeyboardInterrupt:
            print("\n\nGoodbye!")
            break
        except Exception as e:
            print(f"An error occurred: {e}")
 
if __name__ == "__main__":
    main()
 
Simple Blog System
# Simple Blog System
 
import json
import os
from datetime import datetime
from typing import List, Dict, Optional
 
class BlogPost:
    def __init__(self, title: str, content: str, author: str = "Anonymous", post_id: str = None):
        self.id = post_id or str(int(datetime.now().timestamp()))
        self.title = title
        self.content = content
        self.author = author
        self.created_at = datetime.now().isoformat()
        self.updated_at = self.created_at
        self.tags = []
        self.published = False
    
    def to_dict(self) -> Dict:
        """Convert blog post to dictionary"""
        return {
            'id': self.id,
            'title': self.title,
            'content': self.content,
            'author': self.author,
            'created_at': self.created_at,
            'updated_at': self.updated_at,
            'tags': self.tags,
            'published': self.published
        }
    
    @classmethod
    def from_dict(cls, data: Dict) -> 'BlogPost':
        """Create blog post from dictionary"""
        post = cls(data['title'], data['content'], data['author'], data['id'])
        post.created_at = data['created_at']
        post.updated_at = data['updated_at']
        post.tags = data.get('tags', [])
        post.published = data.get('published', False)
        return post
    
    def add_tag(self, tag: str):
        """Add a tag to the post"""
        if tag.lower() not in [t.lower() for t in self.tags]:
            self.tags.append(tag)
            self.updated_at = datetime.now().isoformat()
    
    def remove_tag(self, tag: str):
        """Remove a tag from the post"""
        self.tags = [t for t in self.tags if t.lower() != tag.lower()]
        self.updated_at = datetime.now().isoformat()
    
    def publish(self):
        """Publish the post"""
        self.published = True
        self.updated_at = datetime.now().isoformat()
    
    def unpublish(self):
        """Unpublish the post"""
        self.published = False
        self.updated_at = datetime.now().isoformat()
    
    def update_content(self, title: str = None, content: str = None):
        """Update post content"""
        if title:
            self.title = title
        if content:
            self.content = content
        self.updated_at = datetime.now().isoformat()
 
class SimpleBlogSystem:
    def __init__(self, data_file: str = "blog_data.json"):
        self.data_file = data_file
        self.posts = []
        self.load_posts()
    
    def load_posts(self):
        """Load posts from JSON file"""
        if os.path.exists(self.data_file):
            try:
                with open(self.data_file, 'r', encoding='utf-8') as f:
                    data = json.load(f)
                    self.posts = [BlogPost.from_dict(post_data) for post_data in data]
            except (json.JSONDecodeError, KeyError) as e:
                print(f"Error loading posts: {e}")
                self.posts = []
    
    def save_posts(self):
        """Save posts to JSON file"""
        try:
            with open(self.data_file, 'w', encoding='utf-8') as f:
                json.dump([post.to_dict() for post in self.posts], f, indent=2)
        except Exception as e:
            print(f"Error saving posts: {e}")
    
    def create_post(self, title: str, content: str, author: str = "Anonymous") -> BlogPost:
        """Create a new blog post"""
        post = BlogPost(title, content, author)
        self.posts.append(post)
        self.save_posts()
        return post
    
    def get_post_by_id(self, post_id: str) -> Optional[BlogPost]:
        """Get a post by its ID"""
        for post in self.posts:
            if post.id == post_id:
                return post
        return None
    
    def get_all_posts(self, published_only: bool = False) -> List[BlogPost]:
        """Get all posts, optionally filtered by published status"""
        if published_only:
            return [post for post in self.posts if post.published]
        return self.posts.copy()
    
    def get_posts_by_author(self, author: str) -> List[BlogPost]:
        """Get all posts by a specific author"""
        return [post for post in self.posts if post.author.lower() == author.lower()]
    
    def get_posts_by_tag(self, tag: str) -> List[BlogPost]:
        """Get all posts with a specific tag"""
        return [post for post in self.posts if tag.lower() in [t.lower() for t in post.tags]]
    
    def search_posts(self, query: str) -> List[BlogPost]:
        """Search posts by title or content"""
        query = query.lower()
        results = []
        for post in self.posts:
            if (query in post.title.lower() or 
                query in post.content.lower() or 
                query in post.author.lower()):
                results.append(post)
        return results
    
    def delete_post(self, post_id: str) -> bool:
        """Delete a post by ID"""
        post = self.get_post_by_id(post_id)
        if post:
            self.posts.remove(post)
            self.save_posts()
            return True
        return False
    
    def get_post_statistics(self) -> Dict:
        """Get blog statistics"""
        total_posts = len(self.posts)
        published_posts = len([p for p in self.posts if p.published])
        authors = set(post.author for post in self.posts)
        all_tags = []
        for post in self.posts:
            all_tags.extend(post.tags)
        unique_tags = set(all_tags)
        
        return {
            'total_posts': total_posts,
            'published_posts': published_posts,
            'draft_posts': total_posts - published_posts,
            'total_authors': len(authors),
            'total_tags': len(unique_tags),
            'most_common_tags': self._get_most_common_tags(all_tags)
        }
    
    def _get_most_common_tags(self, tags: List[str], limit: int = 5) -> List[tuple]:
        """Get most common tags"""
        tag_count = {}
        for tag in tags:
            tag_count[tag] = tag_count.get(tag, 0) + 1
        
        sorted_tags = sorted(tag_count.items(), key=lambda x: x[1], reverse=True)
        return sorted_tags[:limit]
    
    def export_posts(self, filename: str, published_only: bool = True):
        """Export posts to a file"""
        posts = self.get_all_posts(published_only)
        
        try:
            with open(filename, 'w', encoding='utf-8') as f:
                for post in posts:
                    f.write(f"Title: {post.title}\n")
                    f.write(f"Author: {post.author}\n")
                    f.write(f"Created: {post.created_at}\n")
                    f.write(f"Tags: {', '.join(post.tags)}\n")
                    f.write(f"Published: {'Yes' if post.published else 'No'}\n")
                    f.write("-" * 50 + "\n")
                    f.write(post.content)
                    f.write("\n" + "=" * 50 + "\n\n")
            
            print(f"Posts exported to {filename}")
        except Exception as e:
            print(f"Error exporting posts: {e}")
 
def display_post(post: BlogPost):
    """Display a single post"""
    print(f"\n{'='*60}")
    print(f"Title: {post.title}")
    print(f"Author: {post.author}")
    print(f"Created: {post.created_at}")
    print(f"Status: {'Published' if post.published else 'Draft'}")
    if post.tags:
        print(f"Tags: {', '.join(post.tags)}")
    print(f"{'='*60}")
    print(post.content)
    print(f"{'='*60}\n")
 
def main():
    """Main function to run the blog system"""
    blog = SimpleBlogSystem()
    
    while True:
        print("\n=== Simple Blog System ===")
        print("1. Create new post")
        print("2. View all posts")
        print("3. View published posts")
        print("4. Search posts")
        print("5. View post by ID")
        print("6. Edit post")
        print("7. Delete post")
        print("8. Manage tags")
        print("9. Publish/Unpublish post")
        print("10. View statistics")
        print("11. Export posts")
        print("0. Exit")
        
        try:
            choice = input("\nEnter your choice: ").strip()
            
            if choice == '1':
                title = input("Enter post title: ").strip()
                if not title:
                    print("Title cannot be empty!")
                    continue
                
                author = input("Enter author name (or press Enter for Anonymous): ").strip()
                if not author:
                    author = "Anonymous"
                
                print("Enter post content (type 'END' on a new line to finish):")
                content_lines = []
                while True:
                    line = input()
                    if line.strip() == 'END':
                        break
                    content_lines.append(line)
                
                content = '\n'.join(content_lines)
                post = blog.create_post(title, content, author)
                print(f"Post created with ID: {post.id}")
            
            elif choice == '2':
                posts = blog.get_all_posts()
                if not posts:
                    print("No posts found.")
                else:
                    for post in posts:
                        display_post(post)
            
            elif choice == '3':
                posts = blog.get_all_posts(published_only=True)
                if not posts:
                    print("No published posts found.")
                else:
                    for post in posts:
                        display_post(post)
            
            elif choice == '4':
                query = input("Enter search query: ").strip()
                if query:
                    results = blog.search_posts(query)
                    if not results:
                        print("No posts found matching your query.")
                    else:
                        print(f"Found {len(results)} posts:")
                        for post in results:
                            display_post(post)
            
            elif choice == '5':
                post_id = input("Enter post ID: ").strip()
                post = blog.get_post_by_id(post_id)
                if post:
                    display_post(post)
                else:
                    print("Post not found.")
            
            elif choice == '6':
                post_id = input("Enter post ID to edit: ").strip()
                post = blog.get_post_by_id(post_id)
                if post:
                    print(f"Current title: {post.title}")
                    new_title = input("Enter new title (or press Enter to keep current): ").strip()
                    
                    print(f"Current content:\n{post.content}")
                    print("Enter new content (type 'END' on a new line to finish, or just 'END' to keep current):")
                    content_lines = []
                    while True:
                        line = input()
                        if line.strip() == 'END':
                            break
                        content_lines.append(line)
                    
                    new_content = '\n'.join(content_lines) if content_lines else None
                    
                    if new_title or new_content:
                        post.update_content(new_title if new_title else None, new_content)
                        blog.save_posts()
                        print("Post updated successfully!")
                    else:
                        print("No changes made.")
                else:
                    print("Post not found.")
            
            elif choice == '7':
                post_id = input("Enter post ID to delete: ").strip()
                post = blog.get_post_by_id(post_id)
                if post:
                    confirm = input(f"Are you sure you want to delete '{post.title}'? (y/N): ").strip().lower()
                    if confirm == 'y':
                        blog.delete_post(post_id)
                        print("Post deleted successfully!")
                    else:
                        print("Deletion cancelled.")
                else:
                    print("Post not found.")
            
            elif choice == '8':
                post_id = input("Enter post ID to manage tags: ").strip()
                post = blog.get_post_by_id(post_id)
                if post:
                    print(f"Current tags: {', '.join(post.tags) if post.tags else 'None'}")
                    print("1. Add tag")
                    print("2. Remove tag")
                    tag_choice = input("Enter choice: ").strip()
                    
                    if tag_choice == '1':
                        tag = input("Enter tag to add: ").strip()
                        if tag:
                            post.add_tag(tag)
                            blog.save_posts()
                            print("Tag added successfully!")
                    elif tag_choice == '2':
                        if post.tags:
                            tag = input("Enter tag to remove: ").strip()
                            if tag:
                                post.remove_tag(tag)
                                blog.save_posts()
                                print("Tag removed successfully!")
                        else:
                            print("No tags to remove.")
                else:
                    print("Post not found.")
            
            elif choice == '9':
                post_id = input("Enter post ID: ").strip()
                post = blog.get_post_by_id(post_id)
                if post:
                    current_status = "Published" if post.published else "Draft"
                    print(f"Current status: {current_status}")
                    
                    if post.published:
                        confirm = input("Unpublish this post? (y/N): ").strip().lower()
                        if confirm == 'y':
                            post.unpublish()
                            blog.save_posts()
                            print("Post unpublished!")
                    else:
                        confirm = input("Publish this post? (y/N): ").strip().lower()
                        if confirm == 'y':
                            post.publish()
                            blog.save_posts()
                            print("Post published!")
                else:
                    print("Post not found.")
            
            elif choice == '10':
                stats = blog.get_post_statistics()
                print("\n=== Blog Statistics ===")
                print(f"Total posts: {stats['total_posts']}")
                print(f"Published posts: {stats['published_posts']}")
                print(f"Draft posts: {stats['draft_posts']}")
                print(f"Total authors: {stats['total_authors']}")
                print(f"Total unique tags: {stats['total_tags']}")
                
                if stats['most_common_tags']:
                    print("\nMost common tags:")
                    for tag, count in stats['most_common_tags']:
                        print(f"  {tag}: {count}")
            
            elif choice == '11':
                filename = input("Enter filename for export (e.g., blog_export.txt): ").strip()
                if not filename:
                    filename = "blog_export.txt"
                
                published_only = input("Export only published posts? (Y/n): ").strip().lower()
                published_only = published_only != 'n'
                
                blog.export_posts(filename, published_only)
            
            elif choice == '0':
                print("Goodbye!")
                break
            
            else:
                print("Invalid choice. Please try again.")
        
        except KeyboardInterrupt:
            print("\n\nGoodbye!")
            break
        except Exception as e:
            print(f"An error occurred: {e}")
 
if __name__ == "__main__":
    main()
 

Run it

run
python simpleblogsystem.py
run
python simpleblogsystem.py
text
1. Create post
2. List all
3. Search
4. View by ID
5. Edit
6. Delete
7. Publish/unpublish
8. Manage tags
9. Statistics
10. Export
11. Quit
> 1
Title: First post
Author: Madhur
Content (end with empty line):
> Hello world.
>
Created post 1700000000.
text
1. Create post
2. List all
3. Search
4. View by ID
5. Edit
6. Delete
7. Publish/unpublish
8. Manage tags
9. Statistics
10. Export
11. Quit
> 1
Title: First post
Author: Madhur
Content (end with empty line):
> Hello world.
>
Created post 1700000000.

Step-by-Step Explanation

1. The BlogPostBlogPost data class

simpleblogsystem.py
from dataclasses import dataclass, field, asdict
from datetime import datetime
from time import time as now_ts
 
@dataclass
class BlogPost:
    title: str
    content: str
    author: str = "Anonymous"
    id: str = field(default_factory=lambda: str(int(now_ts())))
    created_at: str = field(default_factory=lambda: datetime.now().isoformat())
    updated_at: str = field(default_factory=lambda: datetime.now().isoformat())
    tags: list[str] = field(default_factory=list)
    published: bool = False
 
    def to_dict(self): return asdict(self)
    @classmethod
    def from_dict(cls, d): return cls(**d)
 
    def add_tag(self, tag: str):
        tag = tag.strip().lower()
        if tag and tag not in self.tags:
            self.tags.append(tag)
            self.touch()
 
    def remove_tag(self, tag: str):
        if tag in self.tags:
            self.tags.remove(tag)
            self.touch()
 
    def update(self, title=None, content=None):
        if title is not None: self.title = title
        if content is not None: self.content = content
        self.touch()
 
    def publish(self):
        self.published = True
        self.touch()
 
    def touch(self):
        self.updated_at = datetime.now().isoformat()
simpleblogsystem.py
from dataclasses import dataclass, field, asdict
from datetime import datetime
from time import time as now_ts
 
@dataclass
class BlogPost:
    title: str
    content: str
    author: str = "Anonymous"
    id: str = field(default_factory=lambda: str(int(now_ts())))
    created_at: str = field(default_factory=lambda: datetime.now().isoformat())
    updated_at: str = field(default_factory=lambda: datetime.now().isoformat())
    tags: list[str] = field(default_factory=list)
    published: bool = False
 
    def to_dict(self): return asdict(self)
    @classmethod
    def from_dict(cls, d): return cls(**d)
 
    def add_tag(self, tag: str):
        tag = tag.strip().lower()
        if tag and tag not in self.tags:
            self.tags.append(tag)
            self.touch()
 
    def remove_tag(self, tag: str):
        if tag in self.tags:
            self.tags.remove(tag)
            self.touch()
 
    def update(self, title=None, content=None):
        if title is not None: self.title = title
        if content is not None: self.content = content
        self.touch()
 
    def publish(self):
        self.published = True
        self.touch()
 
    def touch(self):
        self.updated_at = datetime.now().isoformat()

@dataclass@dataclass gives us a real constructor, repr, and equality without boilerplate. field(default_factory=...)field(default_factory=...) defers default computation so each new post gets its own timestamp and ID.

2. The BlogBlog system class

simpleblogsystem.py
import json
from pathlib import Path
 
class Blog:
    def __init__(self, datafile="blog_data.json"):
        self.datafile = Path(datafile)
        self.posts: list[BlogPost] = []
        self.load()
 
    def load(self):
        if self.datafile.exists() and self.datafile.stat().st_size > 0:
            data = json.loads(self.datafile.read_text(encoding="utf-8"))
            self.posts = [BlogPost.from_dict(d) for d in data]
 
    def save(self):
        self.datafile.write_text(
            json.dumps([p.to_dict() for p in self.posts], indent=2),
            encoding="utf-8")
 
    def add(self, post: BlogPost):
        self.posts.append(post); self.save()
 
    def by_id(self, post_id: str):
        return next((p for p in self.posts if p.id == post_id), None)
 
    def delete(self, post_id: str) -> bool:
        post = self.by_id(post_id)
        if post:
            self.posts.remove(post); self.save(); return True
        return False
simpleblogsystem.py
import json
from pathlib import Path
 
class Blog:
    def __init__(self, datafile="blog_data.json"):
        self.datafile = Path(datafile)
        self.posts: list[BlogPost] = []
        self.load()
 
    def load(self):
        if self.datafile.exists() and self.datafile.stat().st_size > 0:
            data = json.loads(self.datafile.read_text(encoding="utf-8"))
            self.posts = [BlogPost.from_dict(d) for d in data]
 
    def save(self):
        self.datafile.write_text(
            json.dumps([p.to_dict() for p in self.posts], indent=2),
            encoding="utf-8")
 
    def add(self, post: BlogPost):
        self.posts.append(post); self.save()
 
    def by_id(self, post_id: str):
        return next((p for p in self.posts if p.id == post_id), None)
 
    def delete(self, post_id: str) -> bool:
        post = self.by_id(post_id)
        if post:
            self.posts.remove(post); self.save(); return True
        return False

Three patterns to notice:

  • Load on init, save on every mutation — never lose data.
  • next((... for ... if ...), None)next((... for ... if ...), None) is the idiomatic way to find-or-None.
  • Mutating methods always call save()save() so the file matches memory.

3. Search and filter

simpleblogsystem.py
def search(self, query: str):
    q = query.lower()
    return [p for p in self.posts
            if q in p.title.lower()
            or q in p.content.lower()
            or q in p.author.lower()]
 
def by_author(self, author: str):
    return [p for p in self.posts if p.author == author]
 
def by_tag(self, tag: str):
    return [p for p in self.posts if tag.lower() in p.tags]
 
def published_only(self):
    return [p for p in self.posts if p.published]
simpleblogsystem.py
def search(self, query: str):
    q = query.lower()
    return [p for p in self.posts
            if q in p.title.lower()
            or q in p.content.lower()
            or q in p.author.lower()]
 
def by_author(self, author: str):
    return [p for p in self.posts if p.author == author]
 
def by_tag(self, tag: str):
    return [p for p in self.posts if tag.lower() in p.tags]
 
def published_only(self):
    return [p for p in self.posts if p.published]

4. Statistics

simpleblogsystem.py
from collections import Counter
 
def stats(self):
    tag_counts = Counter(t for p in self.posts for t in p.tags)
    return {
        "total":        len(self.posts),
        "published":    sum(1 for p in self.posts if p.published),
        "drafts":       sum(1 for p in self.posts if not p.published),
        "authors":      len({p.author for p in self.posts}),
        "top_tags":     tag_counts.most_common(5),
    }
simpleblogsystem.py
from collections import Counter
 
def stats(self):
    tag_counts = Counter(t for p in self.posts for t in p.tags)
    return {
        "total":        len(self.posts),
        "published":    sum(1 for p in self.posts if p.published),
        "drafts":       sum(1 for p in self.posts if not p.published),
        "authors":      len({p.author for p in self.posts}),
        "top_tags":     tag_counts.most_common(5),
    }

5. Export to text or Markdown

simpleblogsystem.py
def export(self, filename: str, published_only=True):
    posts = self.published_only() if published_only else self.posts
    lines = []
    for p in posts:
        lines.append(f"# {p.title}")
        lines.append(f"_by {p.author} on {p.created_at[:10]}_")
        if p.tags: lines.append("Tags: " + ", ".join(f"#{t}" for t in p.tags))
        lines.append(""); lines.append(p.content); lines.append("\n---\n")
    Path(filename).write_text("\n".join(lines), encoding="utf-8")
simpleblogsystem.py
def export(self, filename: str, published_only=True):
    posts = self.published_only() if published_only else self.posts
    lines = []
    for p in posts:
        lines.append(f"# {p.title}")
        lines.append(f"_by {p.author} on {p.created_at[:10]}_")
        if p.tags: lines.append("Tags: " + ", ".join(f"#{t}" for t in p.tags))
        lines.append(""); lines.append(p.content); lines.append("\n---\n")
    Path(filename).write_text("\n".join(lines), encoding="utf-8")

The same output is readable as plain text and renders nicely on GitHub or any Markdown viewer.

Multi-line Content

multiline.py
def read_multiline(prompt: str) -> str:
    print(prompt + " (end with an empty line)")
    lines = []
    while True:
        line = input("> ")
        if line == "": break
        lines.append(line)
    return "\n".join(lines)
multiline.py
def read_multiline(prompt: str) -> str:
    print(prompt + " (end with an empty line)")
    lines = []
    while True:
        line = input("> ")
        if line == "": break
        lines.append(line)
    return "\n".join(lines)

Used in the create-post and edit-post flows so users can write paragraphs.

Common Mistakes

ProblemCauseFix
json.decoder.JSONDecodeErrorjson.decoder.JSONDecodeError on first runEmpty/missing fileCheck stat().st_size > 0stat().st_size > 0
Duplicate IDsReused timestampAppend a UUID or check uniqueness
Loaded post has wrong field shapeSchema changedUse .get().get() in from_dictfrom_dict or migrate explicitly
Tags case-insensitive matching failsMixed case in storeNormalize on add_tagadd_tag (tag.lower()tag.lower())
Lost editsForgot save()save()Save at the end of every mutating method
Slow with 10k postsLinear scansMigrate to SQLite with indexes

Variations to Try

1. Markdown rendering

pip install markdownpip install markdown and convert post content to HTML when exporting:

md.py
import markdown
html = markdown.markdown(p.content)
md.py
import markdown
html = markdown.markdown(p.content)

2. Slug-based URLs

Generate a URL-safe slug from the title (e.g. My First PostMy First Postmy-first-postmy-first-post):

slug.py
import re
def slugify(s): return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")
slug.py
import re
def slugify(s): return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")

3. Static site export

Iterate posts → render each to an HTML file via a template (Jinja2). The result is a deployable site. Hosts: GitHub Pages, Netlify, Cloudflare Pages — all free.

4. RSS feed

Generate feed.xmlfeed.xml with the standard RSS 2.0 schema. See RSS Feed Reader for the reader side.

5. Web frontend with Flask

See Basic Web Server. Two routes: GET /GET / for the index, GET /post/<id>GET /post/<id> for individual posts.

6. Admin authentication

Protect create/edit/delete with a password. werkzeug.security.generate_password_hashwerkzeug.security.generate_password_hash is the right tool.

7. SQLite backend

JSON works through ~thousands of posts. Past that, swap to SQLite with FTS5 for full-text search:

schema.sql
CREATE VIRTUAL TABLE posts USING fts5(title, content, author, tags);
SELECT * FROM posts WHERE posts MATCH 'python';
schema.sql
CREATE VIRTUAL TABLE posts USING fts5(title, content, author, tags);
SELECT * FROM posts WHERE posts MATCH 'python';

8. Comments

Sub-collection per post. Moderation queue, spam filter (pip install python-akismetpip install python-akismet).

9. Image upload

A simple upload endpoint (Flask request.filesrequest.files) that saves to static/uploads/static/uploads/ and returns a Markdown image reference.

10. Multi-author

Author becomes its own entity with email and avatar. Add an AuthorAuthor dataclass and join on author_idauthor_id.

11. Drafts and scheduling

Add a publish_at: datetime | Nonepublish_at: datetime | None field. Background job promotes posts when their time arrives.

12. Templates

Pre-fill new posts from templates (how-to-tutorialhow-to-tutorial, code-reviewcode-review, book-notesbook-notes).

Architecture Path

text
CLI (JSON)            ← you are here

CLI (SQLite)

Flask web (SQLite)

Flask + JWT auth + API

Static generator (Hugo / Astro-style) ← optional fork
text
CLI (JSON)            ← you are here

CLI (SQLite)

Flask web (SQLite)

Flask + JWT auth + API

Static generator (Hugo / Astro-style) ← optional fork

Take one step at a time. Trying to jump from a JSON CLI to a multi-tenant Markdown CMS in one go is the usual cause of abandoned blog projects.

Real-World Applications

  • Personal knowledge base — Zettelkasten / digital garden.
  • Internal wiki for a team — keep notes versioned, search them.
  • CMS prototypes before committing to WordPress/Ghost.
  • Documentation backends for small products.
  • Project journals — track decisions and rationale per project.

Educational Value

  • Domain modeling — what fields belong on BlogPostBlogPost?
  • Persistence patterns — JSON ↔ class via to_dictto_dict / from_dictfrom_dict.
  • CRUD UX — confirmations, validation, friendly errors.
  • Search and filter — list comprehensions as a query language.
  • Statistics with CounterCounter — the small standard-library jewel.

Next Steps

  • Add Markdown rendering to the export.
  • Add slug-based filenames for static export.
  • Migrate to SQLite with FTS5 full-text search.
  • Wrap with Flask for a real blog at localhost:5000localhost:5000.
  • Add RSS feed generation and cross-link with RSS Feed Reader.
  • Pair with Personal Diary — same architecture, different domain.

Conclusion

You built a complete blog manager with CRUD, search, tags, statistics, and export — all backed by a single JSON file you can read in a text editor. Every CMS in the world reuses these primitives; the difference is scale, not concept. Full source on GitHub. Find more content-management projects on Python Central Hub.

If this helped you, consider buying me a coffee ☕

Buy me a coffee

Was this page helpful?

Let us know how we did