Add a blog to Python
Deliver and publish blog content from Python services, scripts, workers, and static-site builds.
Python works well for content services, scheduled jobs, importers, and static-site builds. This tutorial creates a small reusable client rather than tying the integration to one web framework.
What you will build
- A typed-enough HTTP boundary for published reads and trusted writes.
- A generator that follows every cursor without loading the entire blog at once.
- Slug retrieval with field groups and related resources.
- Safe Markdown-to-HTML output and SEO values.
- Sitemap, RSS, draft, caching, and error patterns.
Prerequisites
Use Python 3.11 or newer and a Cli Blog organization with public and private API keys.
Install dependencies
python -m pip install requests Markdown bleachPin dependencies with your project's normal requirements or lock-file workflow.
Configure environment variables
CLI_BLOG_PUBLIC_KEY=<public-api-key>
CLI_BLOG_API_KEY=<private-api-key>
CLI_BLOG_BASE_URL=https://api.cli-blog.comPublic keys are for published delivery. Private keys belong only in a trusted service, worker, CI job, or local script. Never include a private value in browser code, generated HTML, logs, notebooks, or source control.
Create a reusable client
Create cli_blog.py:
from __future__ import annotations
import os
from urllib.parse import quote
import requests
class CliBlog:
def __init__(self, api_key: str):
self.base_url = os.getenv("CLI_BLOG_BASE_URL", "https://api.cli-blog.com")
self.session = requests.Session()
self.session.headers.update({"x-api-key": api_key, "accept": "application/json"})
def request(self, method: str, path: str, **kwargs):
response = self.session.request(
method,
f"{self.base_url}{path}",
timeout=(3.05, 15),
**kwargs,
)
response.raise_for_status()
return response
def list_posts(self, *, after: str | None = None):
return self.request("GET", "/v1/posts", params={
"status": "published",
"locale": "en-US",
"fields": "summary,seo",
"include": "authors,tags",
"limit": 20,
"after": after,
}).json()
def get_post(self, slug: str):
safe_slug = quote(slug, safe="")
return self.request("GET", f"/v1/posts/{safe_slug}", params={
"locale": "en-US",
"fields": "summary,content,seo",
"include": "authors,categories,tags,media",
}).json()Create a public instance for delivery:
public_blog = CliBlog(os.environ["CLI_BLOG_PUBLIC_KEY"])Fail early when the variable is absent rather than silently sending an empty key.
List published posts
result = public_blog.list_posts()
for post in result["data"]:
print(post["title"], post["slug"])The summary field group adds list-friendly fields. seo adds search and social metadata. Includes embed authors and tags so the caller avoids separate requests.
Follow every cursor
Use a generator for an export or static build:
def iter_published_posts(blog: CliBlog):
after = None
while True:
page = blog.list_posts(after=after)
yield from page["data"]
if not page["has_more"]:
return
after = page["next_cursor"]
for post in iter_published_posts(public_blog):
build_post_page(post["slug"])Treat cursors as opaque. Keep the locale, filters, and sort unchanged between requests. The maximum limit is 100. Cursor pagination is preferred for traversal because numbered pages calculate exact totals and can shift as content changes.
Retrieve full content by slug
post = public_blog.get_post("shipping-an-api-first-blog")
print(post["body_markdown"])Request content only when building a detail page. The list remains smaller with summary alone. Slugs are locale-aware, so add locale selection to your client if the application serves multiple languages.
Render Markdown safely
Convert Markdown, then sanitize the HTML with a narrow allowlist:
import bleach
import markdown
def render_markdown(source: str) -> str:
rendered = markdown.markdown(source, extensions=["fenced_code", "tables"])
return bleach.clean(
rendered,
tags={"p", "h2", "h3", "h4", "ul", "ol", "li", "blockquote", "pre", "code", "a", "strong", "em", "table", "thead", "tbody", "tr", "th", "td"},
attributes={"a": ["href", "title"]},
protocols={"http", "https", "mailto"},
strip=True,
)
article_html = render_markdown(post["body_markdown"])Escape titles, excerpts, bylines, and alt text in the template separately. Sanitizing Markdown does not make other values safe for HTML attributes.
Build SEO metadata
metadata = {
"title": post.get("seo_title") or post["title"],
"description": post.get("seo_description") or post.get("excerpt", ""),
"canonical": post.get("canonical_url"),
}Use the rest of the seo field group for robots and social overrides. Use included media for social images. Do not fabricate a canonical URL if the public domain is not configured.
Create a draft from a trusted job
Create a separate client only where a private key is available:
admin_blog = CliBlog(os.environ["CLI_BLOG_API_KEY"])
draft = admin_blog.request("POST", "/v1/posts", json={
"title": "Quarterly product update",
"body_markdown": "## Highlights\n\nWrite the reviewed update here.",
"locale": "en-US",
"status": "draft",
}).json()
print(draft["id"], draft["status"])Validate imported content before sending it. Keep publishing as a separate approval step, especially for agent and scheduled workflows.
Fetch sitemap and RSS
sitemap_xml = public_blog.request(
"GET", "/v1/sitemap", params={"locale": "en-US", "limit": 100}
).text
feed_xml = public_blog.request(
"GET", "/v1/feed", params={"locale": "en-US", "limit": 20}
).textServe these values from /sitemap.xml and /feed.xml with application/xml and application/rss+xml content types. For a static build, write them into the build output. Avoid printing XML or secret-bearing request configuration to CI logs.
Cache and revalidate
Cache published list and detail responses in the cache your service already uses. Include locale, slug, fields, includes, filters, and cursor in the cache key. Invalidate the changed slug, list, sitemap, and feed after an approved publish. Never put private draft responses in a public cache.
For static builds, run the cursor generator after publishing or trigger the build from your trusted workflow.
Handle errors
Catch requests.HTTPError and inspect error.response.status_code. A 404 means the slug or locale was not found. 401 points to a missing or invalid key, 403 to key type or permissions, 409 to a write conflict, and 429 to rate limiting.
Retry only transient 429 and 5xx failures with bounded exponential backoff. Log the method, path, status, and safe context. Never log request headers or environment values.
Production checklist
- Store public and private keys separately and rotate them without code changes.
- Keep private operations inside trusted processes with review and validation.
- Reuse an HTTP session, set connect and read timeouts, and bound retries.
- Request lean field groups and follow cursor pagination.
- Sanitize Markdown and escape all other HTML values.
- Cache by the complete content query and invalidate after publishing.
- Test empty results, missing slugs, rate limits, sitemap, feed, and multi-page traversal.