fuzzyworld-tools / files / server / atom.py
atom.py
0e603cf7 2026-10-31 Pavel Safronov | 1 | """Atom 1.0 feeds, standard library only. |
| 2 | ||
| 3 | atom_feed(title, url, updated, entries, rights, author=None, feed_url=None, subtitle=None) -> str | |
| 4 | ||
| 5 | Used by blog.py for the blog's /feed/. Each entry is a dict: | |
| 6 | title, url, updated required (`updated` is a datetime, a date or an RFC 3339 string) | |
| 7 | published, content, summary, id, author optional (`content` is HTML) | |
| 8 | The XML is built with ElementTree, so every text is escaped by the library. | |
| 9 | """ | |
| 10 | ||
| 11 | import datetime | |
| 12 | import re | |
| 13 | import xml.etree.ElementTree as ET | |
| 14 | ||
| 15 | NS = "http://www.w3.org/2005/Atom" | |
| 16 | ET.register_namespace("", NS) | |
| 17 | ||
| 18 | # Characters XML 1.0 cannot carry at all (a stray form feed in a post would make the feed unparseable). | |
| 19 | _ILLEGAL = re.compile("[\x00-\x08\x0b\x0c\x0e-\x1f]") | |
| 20 | ||
| 21 | ||
| 22 | def rfc3339(value): | |
| 23 | """2018-03-02T00:00:00Z. Naive datetimes and dates are written as UTC.""" | |
| 24 | if isinstance(value, str): | |
| 25 | return value | |
| 26 | if isinstance(value, datetime.datetime): | |
| 27 | if value.tzinfo: | |
| 28 | value = value.astimezone(datetime.timezone.utc).replace(tzinfo=None) | |
| 29 | return value.strftime("%Y-%m-%dT%H:%M:%SZ") | |
| 30 | return datetime.datetime(value.year, value.month, value.day).strftime("%Y-%m-%dT%H:%M:%SZ") | |
| 31 | ||
| 32 | ||
| 33 | def _el(parent, tag, text=None, **attrs): | |
| 34 | e = ET.SubElement(parent, f"{{{NS}}}{tag}", {k: v for k, v in attrs.items() if v is not None}) | |
| 35 | if text is not None: | |
| 36 | e.text = _ILLEGAL.sub("", text) | |
| 37 | return e | |
| 38 | ||
| 39 | ||
| 40 | def _author(parent, name): | |
| 41 | _el(_el(parent, "author"), "name", name) | |
| 42 | ||
| 43 | ||
| 44 | def atom_feed(title, url, updated, entries, rights="", author=None, feed_url=None, subtitle=None): | |
| 45 | root = ET.Element(f"{{{NS}}}feed") | |
| 46 | _el(root, "title", title) | |
| 47 | if subtitle: | |
| 48 | _el(root, "subtitle", subtitle) | |
| 49 | _el(root, "id", url) | |
| 50 | _el(root, "updated", rfc3339(updated)) | |
| 51 | _el(root, "link", rel="alternate", type="text/html", href=url) | |
| 52 | _el(root, "link", rel="self", type="application/atom+xml", href=feed_url or url) | |
| 53 | if rights: | |
| 54 | _el(root, "rights", rights) | |
| 55 | if author: | |
| 56 | _author(root, author) | |
| 57 | for e in entries: | |
| 58 | item = _el(root, "entry") | |
| 59 | _el(item, "title", e["title"]) | |
| 60 | _el(item, "id", e.get("id") or e["url"]) | |
| 61 | _el(item, "link", rel="alternate", type="text/html", href=e["url"]) | |
| 62 | _el(item, "updated", rfc3339(e["updated"])) | |
| 63 | if e.get("published"): | |
| 64 | _el(item, "published", rfc3339(e["published"])) | |
| 65 | if e.get("author"): | |
| 66 | _author(item, e["author"]) | |
| 67 | if e.get("summary"): | |
| 68 | _el(item, "summary", e["summary"], type="text") | |
| 69 | if e.get("content"): | |
| 70 | _el(item, "content", e["content"], type="html") | |
| 71 | ET.indent(root) | |
| 72 | return '<?xml version="1.0" encoding="utf-8"?>\n' + ET.tostring(root, encoding="unicode") + "\n" |