ProvenPin Install

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
pinned versions3
feedparser@6.0.14pydantic@2.14.1pytest@9.1.1
run it
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt
.venv/bin/python -m pytest -q
test environment
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.

requirements.txt
feedparser==6.0.14
pydantic==2.14.1
pytest==9.1.1
feed.py
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],
    )
tests/test_feed.py
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 &lt;b&gt;world&lt;/b&gt;</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.ini
[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.

terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp

Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.