Skip to main content
Pre-release

This release is a pre-release and may not be stable for production use.

mdreport

Build Markdown reports in Python with a chainable API. mdreport supports headings, text, nested lists, callouts, figures, captions, code blocks, YAML frontmatter, tables of contents, Jinja templates, and Polars DataFrames.

from mdreport import CalloutKind, MarkdownReport

report = (
    MarkdownReport()
    .frontmatter(title="Q3 review", author="Data team")
    .directive("class", "report")
    .title("Q3 review")
    .table_of_contents(start_level=2, depth=2)
    .heading("Summary")
    .text("Revenue grew {{growth}}%.", params={"growth": 4})
    .callout("Numbers are provisional.", kind=CalloutKind.WARNING)
    .figure(
        "charts/revenue.png",
        alt_text="Revenue by region",
        caption="Quarterly revenue by region.",
    )
    .bullet_list(["EMEA", ["Enterprise", "Consumer"], "APAC"])
    .numbered_list(["Collect", "Review", "Publish"])
    .table(metrics, title="Revenue by region", decimal_places=1)
    .csv(metrics, title="Copyable data")
    .code_block("select * from revenue", language="sql", title="Source query")
    .line_break()
    .horizontal_rule()
)
report.save("q3-review.md")
print(report)

Installation

pip install mdreport

mdreport requires Python 3.12 or later.

MarkdownReport reference

Create and combine reports

MarkdownReport(anchor_style=HeadingAnchorStyle.IMPLICIT)

Creates an empty report. The default anchor style relies on the Markdown renderer to create heading anchors. See Heading links for explicit anchor options.

append(block)

Adds a built-in or custom report block. A regular block is evaluated immediately. A deferred block is evaluated by render() after the document is complete.

from mdreport import CodeBlock

report.append(CodeBlock("print('hello')", language="python"))

copy()

Returns an independent report with the same content, frontmatter, parser state, and anchor style.

report += block and report + block

+= appends a block to the current report. + returns a copy with the block appended and leaves the original report unchanged.

Metadata and text

frontmatter(data=None, **kwargs)

Merges fields into YAML frontmatter at the top of the rendered document. Calls accumulate fields; later values replace earlier values. Use a mapping for keys that are not valid Python identifiers.

report.frontmatter(title="Q3 review", author="Data team")
report.frontmatter({"table-of-contents": True})

No frontmatter is written when you do not add any fields.

title(text, params=None)

Adds a level-one heading. It is shorthand for heading(text, level=1).

heading(text, level=2, params=None)

Adds a heading from level 1 through 6. The text supports inline Markdown and optional Jinja parameters. A level outside that range raises ValueError.

report.heading("Region: {{region}}", level=3, params={"region": "EMEA"})

text(content, params=None)

Adds Markdown text. Pass one string or a list of strings; each list entry becomes a separate block.

report.text("A paragraph with **emphasis**.")
report.text(["First paragraph.", "Second paragraph."])

markdown(content, params=None)

Parses and adds arbitrary Markdown. Use it for content such as block quotes, images, links, or a complete section.

report.markdown("> See the [source](https://example.com).")

directive(name, value=None)

Adds a smolslides HTML-comment directive. Other Markdown renderers ignore the comment.

report.directive("class", "title")  # <!-- _class: title -->
report.directive("paginate")        # <!-- _paginate -->

callout(message, kind=CalloutKind.NOTE, title=None, params=None)

Adds a titled block quote. The message supports Markdown. Choose NOTE, TIP, IMPORTANT, WARNING, or CAUTION, or pass a custom title.

from mdreport import CalloutKind

report.callout(
    "Numbers for **{{ period }}** are provisional.",
    kind=CalloutKind.WARNING,
    params={"period": "Q3"},
)

Lists and spacing

bullet_list(items, params=None)

Adds an unordered list. List items support inline Markdown and Jinja parameters.

numbered_list(items, params=None)

Adds an ordered list and generates numbering from 1 at each level.

Both methods support nesting to any depth. Put a list immediately after the item it belongs to:

report.bullet_list(
    [
        "Infrastructure",
        ["Database", "Cache", ["Redis", "Memcached"]],
        "Application",
    ]
)

A nested list without a preceding item raises ValueError.

line_break()

Adds one extra blank line. Report blocks already have one blank line between them.

horizontal_rule()

Adds a thematic break rendered as ---.

DataFrames and code

table(df, title=None, params=None, decimal_places=2)

Adds every row and column of a Polars DataFrame as a GitHub-Flavored Markdown table. Float values are rounded for display, and list columns are joined with commas. Slice a large DataFrame before adding it if you do not want the whole frame in the report.

report.table(metrics.head(20), title="Top regions", decimal_places=1)

csv(df, title=None, params=None, decimal_places=2, wrap_code=True)

Adds a Polars DataFrame as CSV. The default wraps it in a fenced csv code block so readers can copy it safely. Set wrap_code=False to add the CSV as raw text.

code_block(code, language="", title=None, params=None)

Adds a fenced code block. language supplies the syntax-highlighting info string. When you pass params, Jinja templating applies to both the code and title.

report.code_block("select 1", language="sql", title="Health check")

Leave params as None when code contains Jinja-like braces that must remain literal.

Figures and captions

figure(source, alt_text, caption=None, params=None, is_embedded=False)

Adds a figure numbered in document order. The alternative text is literal, while the optional caption supports inline Markdown and Jinja parameters.

report.figure(
    "charts/revenue.png",
    alt_text="Revenue by region",
    caption="Quarterly **revenue** by region.",
)

The default leaves the source as a path or URL. With is_embedded=True, a local raster image becomes a base64 data URL and a local SVG is copied into the document as inline markup. Embedding does not fetch remote URLs. Only embed trusted SVG files because their markup is preserved; some hosted Markdown viewers also disallow image data URLs.

Table of contents

table_of_contents(start_level=1, depth=6, is_linked=True)

Adds a nested list of headings at that position. The list is deferred until render(), so it includes headings added after the method call.

  • start_level is the shallowest heading level to include. Use 2 to omit a level-one title.
  • depth is the number of levels to include, counting from start_level.
  • is_linked=False writes plain text instead of links.
report = (
    MarkdownReport()
    .title("Q3 review")
    .table_of_contents(start_level=2, depth=2)
    .heading("Revenue")
    .heading("By region", level=3)
)

Render and save

render()

Returns the complete Markdown document with a trailing newline. It resolves deferred blocks, adds the configured heading anchors, and prepends frontmatter. It does not change the report.

save(filename)

Renders the report and writes it as UTF-8. It overwrites an existing file and returns the report. The parent directory must already exist.

str(report)

Returns report.render(), so print(report) prints the Markdown document.

Templates

Methods with a params argument treat their text as a short Jinja template only when you supply the mapping.

report.text(
    "Generated on {{date}} by {{author}}.",
    params={"date": "2026-08-28", "author": "Data team"},
)

Template values work in headings, text, Markdown, list items at every nesting level, callouts, figures, code blocks, and titles for tables and CSV. If params is None, braces pass through unchanged.

Heading links

By default, HeadingAnchorStyle.IMPLICIT writes ordinary headings and links the table of contents to anchors that GitHub, GitLab, Pandoc, MkDocs, and similar renderers derive from the heading text. Repeated headings receive numbered anchors such as #findings and #findings-1.

Use an explicit style when your renderer does not generate anchors:

from mdreport import HeadingAnchorStyle, MarkdownReport, slugify

html_report = MarkdownReport(anchor_style=HeadingAnchorStyle.HTML)
# Renders: ## <a id="revenue"></a>Revenue

attribute_report = MarkdownReport(anchor_style=HeadingAnchorStyle.ATTRIBUTE)
# Renders: ## Revenue {#revenue}

report.markdown(f"Back to [revenue](#{slugify('Revenue')}).")

HeadingAnchorStyle.ATTRIBUTE works with Pandoc, kramdown, and Python-Markdown's attr_list extension. Renderers that do not support heading attributes display {#revenue} as text.

Built-in blocks

The fluent methods use reusable block values internally. You can construct these values directly and pass them to append, +=, or +.

Block Purpose
Callout A titled, semantic block quote.
CodeBlock A fenced code block with an optional caption.
Figure A numbered image with an optional caption.
Table A complete Polars DataFrame as a GFM table.
TableOfContents A deferred, nested heading list.

TableOfContentsEntry is the data record returned by TableOfContents.entries(). It contains level, the heading's inline token, its slug, and its child entries. TableOfContents.end_level returns the deepest included heading level, clamped to level 6.

Custom blocks

A regular block is any object with a __report__ method. It can return Markdown text, one markdown_it.token.Token, or a sequence of tokens. You do not need to inherit from ReportBlock or register the class.

from dataclasses import dataclass

from mdreport import BlockContent, MarkdownReport


@dataclass(frozen=True)
class Aside:
    content: str

    def __report__(self, report: MarkdownReport) -> BlockContent:
        return f"> {self.content}"


report = MarkdownReport().append(Aside("Numbers are provisional."))

Do not modify the report inside __report__. Return the block content instead.

Return tokens

Return Markdown text when you can write the complete block as a string. Return tokens when you need literal output or want the token builders to handle Markdown edge cases.

from dataclasses import dataclass

from markdown_it.token import Token

from mdreport import MarkdownReport, bold_paragraph_tokens, fence_token


@dataclass(frozen=True)
class Query:
    sql: str
    title: str

    def __report__(self, report: MarkdownReport) -> list[Token]:
        tokens = bold_paragraph_tokens(report.parser, self.title)
        tokens.append(fence_token(self.sql, "sql"))
        return tokens

Read the completed document

A deferred block has a __resolve__ method instead of __report__. The report stores a placeholder and resolves it during each render, after all content has been added.

from dataclasses import dataclass

from markdown_it.tree import SyntaxTreeNode

from mdreport import BlockContent, MarkdownReport


@dataclass(frozen=True)
class HeadingCount:
    def __resolve__(
        self,
        document: SyntaxTreeNode,
        report: MarkdownReport,
    ) -> BlockContent:
        count = sum(1 for node in document.walk() if node.type == "heading")
        return f"This report has {count} headings."


report = MarkdownReport().append(HeadingCount()).title("Findings").heading("Revenue")

The deferred block sees the completed report, but not the output of other deferred blocks. It resolves again every time you render, so its content stays current as the report grows. TableOfContents uses this mechanism.

ReportBlock and DeferredReportBlock are runtime-checkable protocols for type annotations and isinstance checks. BlockContent is the return type shared by both protocols.

Advanced public API

Most callers only need MarkdownReport and the built-in blocks. The package also exports helpers for custom blocks and low-level integrations.

Token builders

Functions that accept a MarkdownParser parse their text as inline Markdown.

Function Result
paragraph_tokens(parser, content, *, is_hidden=False) A paragraph containing parsed inline Markdown.
bold_paragraph_tokens(parser, content) A paragraph whose complete content is bold.
heading_tokens(parser, content, level) A level 1–6 heading.
list_tokens(parser, items, *, is_ordered) A nested ordered or unordered list.
list_item_tokens(parser, content) One complete unordered-list item.
table_tokens(parser, dataframe) A complete GFM table.
table_cell_tokens(parser, content, *, is_header) One table header or body cell.
fence_token(content, language="") A fenced code block.
raw_token(content) Verbatim output that bypasses Markdown parsing.

NestedListItem is the recursive item type accepted by list_tokens, bullet_list, and numbered_list.

Template and DataFrame helpers

Function Purpose
render_template(text, params=None) Renders a Jinja template only when parameters are supplied.
render_template_items(items, params=None) Renders templates throughout a nested list.
format_dataframe(dataframe, decimal_places) Joins string-list columns and rounds float columns for output.
format_dataframe_csv(dataframe, decimal_places=2) Returns a formatted CSV string without its final record terminator.
slugify(text) Returns the GitHub-style anchor slug for heading text.

MarkdownParser

Each report owns a MarkdownParser at report.parser. Its reference-definition environment persists across blocks. The class exposes three methods:

  • parse(content) returns block-level tokens.
  • parse_inline(content) returns one inline container token.
  • render(tokens) serializes a token sequence as Markdown.

Use the report's parser inside custom blocks so their Markdown shares the report's environment.

License

mdreport is available under the MIT License.

Release files for mdreport 0.0.1a2

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for mdreport 0.0.1a2
File Size Uploaded
mdreport-0.0.1a2.tar.gz 37.0 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for mdreport 0.0.1a2
File Interpreter ABI Platform
mdreport-0.0.1a2-py3-none-any.whl Python 3 none any Details

Total release size:68.5 kB

Release files / mdreport-0.0.1a2.tar.gz

Download URL mdreport-0.0.1a2.tar.gz
Size 37.0 kB
Tags Source
SHA-256 checksum
How to use checksums
a694591db1644ffc49dc4bf5f2ef2f72b898fe3f71712f6d994a59d3954599a4
BLAKE2b-256 checksum
How to use checksums
b9701b44c113acf801f4765ec8c19619fde3567b6db69d115428d1440f2364c8
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.10

Release files / mdreport-0.0.1a2-py3-none-any.whl

Download URL mdreport-0.0.1a2-py3-none-any.whl
Size 31.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
1a0544751e72ea2b211a45f5a583a2ad69135999623f7f77db25f2fea73d0b34
BLAKE2b-256 checksum
How to use checksums
cdf5d6bab5ded667bd11bdf1949f698e34b2a28898fb091f90459d4cb1554d03
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.10

Release history Release notifications | RSS feed

This release

0.0.1a2 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page