rich_text_renderer turns a Contentful Rich Text document — the nested dict
returned by the Content Delivery API for a RichText field — into a string.
Out of the box that string is HTML, but every part of the serialization is
replaceable, so the same traversal can emit Markdown, plain text, or anything
else.
The library has no runtime dependencies. setup.py declares
requirements = [] and passes it as install_requires, and the package never
imports the Contentful Delivery SDK — see "Duck-typed asset detection" below.
| Path | Contents |
|---|---|
rich_text_renderer/__init__.py |
Public surface: re-exports RichTextRenderer, and holds __version__, __author__, __email__ (read by setup.py via regex) |
rich_text_renderer/rich_text_renderer.py |
RichTextRenderer — the entry point and the default node-type → renderer mapping |
rich_text_renderer/base_node_renderer.py |
BaseNodeRenderer — the base class every renderer extends; owns mappings and _find_renderer |
rich_text_renderer/document_renderers.py |
DocumentRenderer — handles the top-level document node |
rich_text_renderer/block_renderers.py |
Block and inline-block renderers (headings, lists, tables, hyperlinks, assets, hr) plus the _safe_url URL allow-list |
rich_text_renderer/text_renderers.py |
TextRenderer and the mark renderers (bold, italic, underline, code, superscript, subscript) |
rich_text_renderer/null_renderer.py |
NullRenderer — raises for node types with no mapping |
tests/ |
unittest test cases, one module per source module |
runtests.py |
Test entry point used by CI: from tests import * then unittest.main() |
There is no visitor, no registry singleton, and no AST. Rendering is a
recursive walk in which each renderer looks up the renderer for its children in
a mappings dict it carries with it.
RichTextRenderer.__init__buildsDEFAULT_MAPPINGS, a dict keyed by ContentfulnodeTypestrings ("heading-1","paragraph","embedded-asset-block", …) whose values are renderer classes, not instances. A user-suppliedmappingsdict is merged over it withdict.update, so callers override individual keys rather than supplying a complete table. The keyNonemaps toNullRendererand acts as the fallback for unrecognised node types.RichTextRenderer.render(document)callsself._find_renderer(document), which for a well-formed document resolves"document"→DocumentRenderer.BaseNodeRenderer._find_rendererreadsnode["nodeType"], looks the class up inself.mappings, and instantiates it asrenderer(self.mappings). The mapping table is therefore threaded down the whole tree — every child renderer sees the same overrides the caller passed at the top.DocumentRenderer.renderiteratesdocument["content"], renders each child, and joins the results with"\n".BaseBlockRenderer._render_contentdoes the same for nested content but joins with"", andBaseBlockRenderer.renderwraps the result in<{_render_tag}>…</{_render_tag}>.
_find_renderer returns None when a node has no nodeType key at all, and
both DocumentRenderer.render and BaseBlockRenderer._render_content skip such
nodes with continue. Only nodes that do carry an unmapped nodeType reach
NullRenderer and raise.
Text nodes carry formatting in node["marks"], and marks are keyed by type,
not nodeType. TextRenderer therefore overrides _find_renderer to read
node.get("type"). TextRenderer.render escapes the value once, then folds
each mark renderer over it — node["value"] = renderer.render(node) — so marks
compose by successive wrapping. Mark renderers extend BaseInlineRenderer,
whose render emits <tag>{node["value"]}</tag> and deliberately does not
re-escape; the escaping already happened in TextRenderer.
Nearly every concrete renderer is a subclass that overrides one thing. Block
renderers override the _render_tag property (ParagraphRenderer → "p",
TableRowRenderer → "tr", and so on); mark renderers do the same against
BaseInlineRenderer. That is the entire pattern for structural tags.
Anything else is a render override. The contract for a custom renderer,
as documented in README.rst and enforced implicitly by _find_renderer:
- It must be constructible with a single positional argument, the
mappingsdict. SubclassingBaseNodeRenderersatisfies this. - It must expose
render(self, node)and return a string. - If it renders children, it should subclass
BaseBlockRendererand callself._render_content(node)so the mapping table keeps propagating.
Two overrides matter in practice:
"embedded-entry-block"— the defaultEntryBlockRendereronly emits<div>{str(node["data"]["target"])}</div>. Any application that embeds entries is expected to replace it.None— replacingNullRendererwith a renderer that returns""turns unknown node types from an exception into a silent skip.
Untrusted content is neutralised at the point of serialization, in three places
(all introduced by 2b0518b):
TextRenderer.renderHTML-escapesnode["value"]on adeepcopyof the node before any mark renderer sees it._safe_urlinblock_renderers.pyrejects protocol-relative URLs, checks the scheme against_ALLOWED_SCHEMES(https,http,mailto,tel, and""for scheme-less relative links), substitutes"#"for anything else, and HTML-escapes what remains.HyperlinkRenderer.renderandAssetHyperlinkRenderer._renderroute every URL through it.AssetHyperlinkRenderer._renderescapesalt/link text whenformatted=False, which is the pathAssetBlockRendereruses for asset titles.
The consequence for extenders is direct: a custom render that interpolates
node["value"] or a data.uri itself bypasses all of it. See
docs/ADRs/2026-08-25-escape-at-the-render-boundary.md.
AssetHyperlinkRenderer.render accepts either a Contentful SDK Asset object
or a plain dict from raw API JSON, and distinguishes them with
asset.__class__.__name__ == "Asset" rather than isinstance. The inline
comment states the reason: it avoids depending on the Contentful SDK. Asset
objects are read via asset.url() / asset.title / asset.file; dicts are
read via asset["fields"]["file"]["url"] / asset["fields"]["title"].
AssetBlockRenderer subclasses it and overrides both branches to emit <img>
when the file's contentType contains "image".
- Tests are plain
unittest.tests/__init__.pystar-imports the test modules andruntests.pyrunsunittest.main()over them. - CI is CircleCI (
.circleci/config.yml): a singletestjob oncimg/python:<version>for a matrix of3.7,3.8,3.9, which installsrequirements.txtand runspython runtests.py..github/workflows/codeql.ymladditionally CodeQL-scans the GitHub Actions workflows themselves, not the Python source. requirements.txtis a pinned development manifest (flake8, coverage, ipython). It is not the package's dependency set.Makefilewraps the local loop:make lint(flake8),make format(black),make test,make test-all(tox),make coverage,make dist,make release(python setup.py publish).tox.inisetsmax_line_length = 100and lists a wider envlist (py27…py37, pypy) than CI exercises.setup.py publishbuilds withbuild, uploads withtwine, then tags the version from__init__.pyand pushes. Version bumps are manual edits torich_text_renderer/__init__.py, recorded by hand inCHANGELOG.md.- Ownership is
@contentful/team-developer-experience(.github/CODEOWNERS,catalog-info.yaml); service tier 4.