feedparser XML fixtures into pydantic v2 models: working versions (tested 2026-10-15)
Convert RSS and Atom feeds parsed by feedparser, including struct_time dates and tags, into validated pydantic v2 models with offline XML fixtures.
passed6 passed · 0 failed2026-10-15 16:00 UTC- Status
- passed
- Last passed
- 2026-10-15 16:00 UTC
- Tests
- 6 / 6
- Language
- Python
- Runtime
- Python 3.13.16
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt .venv/bin/python -m pytest -q
Python 3.13.16 linux 6.17.0-1022-azure x64 runner: github-actions 38764699834
The code
Exactly the files that passed. Your agent can pull them with get_recipe("feedparser-pydantic-models") (install), or see them on GitHub.
feedparser==6.0.14 pydantic==2.14.1 pytest==9.1.1
import time
from datetime import datetime, timezone
import feedparser
from pydantic import BaseModel, Field, field_validator
def _to_datetime(value):
if isinstance(value, time.struct_time):
return datetime(*value[:6], tzinfo=timezone.utc)
return value
class Entry(BaseModel):
id: str
title: str = ""
link: str | None = None
published: datetime | None = None
tags: list[str] = Field(default_factory=list)
summary: str | None = None
content: str | None = None
@field_validator("published", mode="before")
@classmethod
def _from_struct_time(cls, value):
return _to_datetime(value)
class Feed(BaseModel):
title: str = ""
link: str | None = None
updated: datetime | None = None
entries: list[Entry] = Field(default_factory=list)
@field_validator("updated", mode="before")
@classmethod
def _from_struct_time(cls, value):
return _to_datetime(value)
def _content(entry) -> str | None:
blocks = entry.get("content") or []
return blocks[0].get("value") if blocks else None
def _entry(entry) -> Entry:
return Entry(
id=entry.get("id") or entry.get("link") or "",
title=entry.get("title", ""),
link=entry.get("link"),
published=entry.get("published_parsed") or entry.get("updated_parsed"),
tags=[tag["term"] for tag in entry.get("tags", []) if "term" in tag],
summary=entry.get("summary"),
content=_content(entry),
)
def parse_feed(data: str | bytes) -> Feed:
parsed = feedparser.parse(data)
if parsed.bozo and not parsed.entries:
raise ValueError(f"malformed feed: {parsed.bozo_exception!r}")
return Feed(
title=parsed.feed.get("title", ""),
link=parsed.feed.get("link"),
updated=parsed.feed.get("updated_parsed"),
entries=[_entry(entry) for entry in parsed.entries],
)
from datetime import datetime, timezone
import pytest
from feed import parse_feed
ATOM = b"""<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Example Feed</title>
<link href="http://example.org/"/>
<updated>2024-05-01T12:00:00Z</updated>
<entry>
<title>First</title>
<link href="http://example.org/1"/>
<id>urn:1</id>
<published>2024-05-01T12:00:00Z</published>
<summary>Hello <b>world</b></summary>
<category term="python"/>
<category term="rss"/>
</entry>
<entry>
<title>Second</title>
<link href="http://example.org/2"/>
<id>urn:2</id>
</entry>
</feed>
"""
RSS = """<?xml version="1.0"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>RSS Feed</title>
<link>http://example.org/rss</link>
<lastBuildDate>Mon, 06 May 2024 08:00:00 GMT</lastBuildDate>
<item>
<title>Item</title>
<link>http://example.org/item</link>
<guid>urn:item</guid>
<pubDate>Mon, 06 May 2024 08:00:00 GMT</pubDate>
<description>short</description>
<content:encoded><![CDATA[<p>full body</p>]]></content:encoded>
</item>
</channel>
</rss>
"""
def test_parses_atom_entries():
feed = parse_feed(ATOM)
assert feed.title == "Example Feed"
assert [e.id for e in feed.entries] == ["urn:1", "urn:2"]
assert feed.entries[0].link == "http://example.org/1"
def test_struct_time_becomes_utc_datetime():
feed = parse_feed(ATOM)
assert feed.updated == datetime(2024, 5, 1, 12, 0, tzinfo=timezone.utc)
assert feed.entries[0].published == datetime(2024, 5, 1, 12, 0, tzinfo=timezone.utc)
def test_missing_keys_are_none_not_errors():
second = parse_feed(ATOM).entries[1]
assert second.published is None
assert second.summary is None
assert second.content is None
assert second.tags == []
def test_tags_and_html_are_flattened_but_kept_raw():
first = parse_feed(ATOM).entries[0]
assert first.tags == ["python", "rss"]
assert first.summary == "Hello <b>world</b>"
def test_rss_string_and_content_encoded():
item = parse_feed(RSS).entries[0]
assert item.id == "urn:item"
assert item.summary == "short"
assert item.content == "<p>full body</p>"
assert item.published == datetime(2024, 5, 6, 8, 0, tzinfo=timezone.utc)
def test_malformed_feed_raises():
with pytest.raises(ValueError):
parse_feed(b"<feed>not closed")
[pytest] pythonpath = .
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
FastAPI + Pydantic v2 validators, discriminated unions and serializers
Pydantic v2 patterns in FastAPI that agents often get wrong: field_validator, model_validator, discriminated unions, computed_field and response_model filtering.
FastAPI background tasks + dependency overrides
FastAPI BackgroundTasks for post-response work, with dependency_overrides to swap services in tests.
FastAPI endpoint with Pydantic v2 discriminated unions and Annotated validators
Accept a tagged union of payment methods with Field(discriminator), add Annotated validators, and test the exact 422 error shapes offline with TestClient.
httpx async API client + respx mocks
A typed async API client on httpx with retries on 5xx and Pydantic parsing, tested offline with respx route mocks.