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
- Create folder
simple-blogsimple-blog. - Inside, create
simpleblogsystem.pysimpleblogsystem.py.
Write the code
Simple Blog System
Source# 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
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
python simpleblogsystem.pypython simpleblogsystem.py1. 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.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
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()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
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 Falseimport 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 FalseThree 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
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]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
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),
}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
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")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
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)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
| Problem | Cause | Fix |
|---|---|---|
json.decoder.JSONDecodeErrorjson.decoder.JSONDecodeError on first run | Empty/missing file | Check stat().st_size > 0stat().st_size > 0 |
| Duplicate IDs | Reused timestamp | Append a UUID or check uniqueness |
| Loaded post has wrong field shape | Schema changed | Use .get().get() in from_dictfrom_dict or migrate explicitly |
| Tags case-insensitive matching fails | Mixed case in store | Normalize on add_tagadd_tag (tag.lower()tag.lower()) |
| Lost edits | Forgot save()save() | Save at the end of every mutating method |
| Slow with 10k posts | Linear scans | Migrate to SQLite with indexes |
Variations to Try
1. Markdown rendering
pip install markdownpip install markdown and convert post content to HTML when exporting:
import markdown
html = markdown.markdown(p.content)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 Post → my-first-postmy-first-post):
import re
def slugify(s): return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")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:
CREATE VIRTUAL TABLE posts USING fts5(title, content, author, tags);
SELECT * FROM posts WHERE posts MATCH 'python';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
CLI (JSON) ← you are here
↓
CLI (SQLite)
↓
Flask web (SQLite)
↓
Flask + JWT auth + API
↓
Static generator (Hugo / Astro-style) ← optional forkCLI (JSON) ← you are here
↓
CLI (SQLite)
↓
Flask web (SQLite)
↓
Flask + JWT auth + API
↓
Static generator (Hugo / Astro-style) ← optional forkTake 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 coffeeWas this page helpful?
Let us know how we did
