Fancy PyPI READMEs with Hatch

documentation, hatch, pypi, readme, packaging, python
pip install hatch-fancy-pypi-readme==24.1.0


Your ✨Fancy✨ Project Deserves a ✨Fancy✨ PyPI Readme!

Hatch project License: MIT PyPI - Version PyPI - Python Version Downloads

hatch-fancy-pypi-readme is a Hatch metadata plugin for everyone who cares about the first impression of their project’s PyPI landing page. It allows you to define your PyPI project description in terms of concatenated fragments that are based on static strings, files, and most importantly: parts of files defined using cut-off points or regular expressions.

Once you’ve assembled your readme, you can additionally run regular expression-based substitutions over it. For instance to make relative links absolute or to linkify users and issue numbers in your changelog.

Do you want your PyPI readme to be the project readme, but without badges, followed by the license file, and the changelog section for only the last release? You’ve come to the right place!


“PyPI project description”, “PyPI landing page”, and “PyPI readme” all refer to the same thing. In setuptools it’s called long_description and is the text shown on a project’s PyPI page. We refer to it as “readme” because that’s how it’s called in PEP 621-based pyproject.toml files.

Showcases 🧐

hatch-fancy-pypi-readme doesn’t use itself to avoid a circular dependency that can be problematic in some cases. The shoemaker’s kids always go barefoot.

Please open a pull request to add your ✨fancy✨ project!


The main reason for my (past) hesitancy to move away from setup.py files is that I like to make my PyPI readmes a lot more interesting, than what static strings or static files can offer me.

For example this is the code that gave me the PyPI readme for attrs 22.1.0. Especially having a summary of the latest changes is something I’ve found users to appreciate.

Hatch’s extensibility finally allowed me to build this plugin that allows you to switch away from setup.py without compromising on the user experience. Now you too can have fancy PyPI readmes – just by adding a few lines of configuration to your pyproject.toml.


hatch-fancy-pypi-readme is, like Hatch, configured in your project’s pyproject.toml1.

First you add hatch-fancy-pypi-readme to your [build-system]:

requires = ["hatchling", "hatch-fancy-pypi-readme"]
build-backend = "hatchling.build"

Next, you tell the build system that your readme is dynamic by adding it to the project.dynamic list:

# ...
dynamic = ["readme"]


Don’t forget to remove the old readme key!

Next, you add a [tool.hatch.metadata.hooks.fancy-pypi-readme] section.

Here, you must supply a content-type. Currently, only text/markdown and text/x-rst are supported by PyPI.

content-type = "text/markdown"


Finally, you also must supply an array of fragments. A fragment is a piece of text that is appended to your readme in the order that it’s specified.

We recommend TOML’s syntactic sugar for arrays of wrapping the array name in double brackets and will use it throughout this documentation.


Text fragments consist of a single text key and are appended to the readme exactly as you specify them:

text = "Fragment #1"

text = "Fragment #2"

results in:

Fragment #1Fragment #2

Note that there’s no additional space or empty lines between fragments unless you specify them.


A file fragment reads a file specified by the path key and appends it:

path = "AUTHORS.md"

Additionally it’s possible to cut away parts of the file before appending it:

  • start-after cuts away everything before and including the string specified.

  • start-at cuts away everything before the string specified too, but the string itself is preserved. This is useful when you want to start at a heading without adding a marker before it.

    start-after and start-at are mutually exclusive.

  • end-before cuts away everything after.

  • pattern takes a regular expression and returns the first group from it (you probably want to make your capture group non-greedy by appending a question mark: (.*?)). Internally, it uses

    re.search(pattern, whatever_is_left_after_slicing, re.DOTALL).group(1)

    to find it.

Both Markdown and reStructuredText (reST) have comments (<!-- this is a Markdown comment --> and .. this is a reST comment) that you can use for invisible markers:

# Boring Header

<!-- cut after this -->

This is the *interesting* body!

<!-- but before this -->

Uninteresting Footer

together with:

path = "path.md"
start-after = "<!-- cut after this -->\n\n"
end-before = "\n\n<!-- but before this -->"
pattern = "the (.*?) body"

would append:


to your readme.


  • You can insert the same file multiple times – each time a different part!

  • The order of the options in a fragment block does not matter. They’re always executed in the same order:

    1. start-after / start-at
    2. end-before
    3. pattern

For a complete example, please see our example configuration.


After a readme is assembled out of fragments, it’s possible to run an arbitrary number of regular expression-based substitutions over it:

pattern = "This is a (.*) that we'll replace later."
replacement = 'It was a "\1"!'
ignore-case = true  # optional; false by default

Substitutions can be useful for replacing relative links with absolute ones:

# Literal TOML strings (single quotes) need no escaping of backslashes.
pattern = '\[(.+?)\]\(((?!https?://)\S+?)\)'
replacement = '[\1](https://github.com/hynek/hatch-fancy-pypi-readme/tree/main\g<2>)'

Or expanding GitHub issue/pull request IDs to links:

# Regular TOML strings (double quotes) do need escaping.
pattern = "#(\\d+)"
replacement = "[#\\1](https://github.com/hynek/hatch-fancy-pypi-readme/issues/\\1)"

Again, please check out our example configuration for a complete example.

Referencing Packaging Metadata

If the final readme contains the string $HFPR_VERSION, it is replaced by the current package version.

When running hatch-fancy-pypi-readme in CLI mode (as described in the next section), packaging metadata is not available. In that case $HFPR_VERSION is hardcoded to 42.0 so you can still test your readme.

CLI Interface

For faster feedback loops, hatch-fancy-pypi-readme comes with a CLI interface that takes a pyproject.toml file as an argument and renders out the readme that would go into respective package.

Your can run it either as hatch-fancy-pypi-readme or python -m hatch_fancy_pypi_readme. If you don’t pass an argument, it looks for a pyproject.toml in the current directory. You can optionally pass a -o option to write the output into a file instead of to standard out.

Since hatch-fancy-pypi-readme is part of the isolated build system, it shouldn’t be installed along with your projects. Therefore we recommend running it using pipx:

$ pipx run hatch-fancy-pypi-readme

You can pipe the output into tools like rich-cli or bat to verify your markup.

For example, if you run

$ pipx run hatch-fancy-pypi-readme | pipx run rich-cli --markdown --hyperlinks -

with our example configuration, you will get the following output:

rich-cli output


While the execution model is somewhat different from the Hatch-Python packaging pipeline, it uses the same configuration validator and text renderer, so the fidelity should be high.

It will not help you debug packaging issues, though.

To verify your PyPI readme using the full packaging pipeline, check out my build-and-inspect-python-package GitHub Action.

If you ensure that hatch-fancy-pypi-readme is installed in your Hatch environment (that means where the hatch CLI command lives – not your development environment), you can also let Hatch render it for you:

  • hatch project metadata readme gives you a rendered version of the readme.
  • hatch project metadata | jq -r .readme.text gives you the raw Markdown (needs jq).


  1. As with Hatch, you can also use hatch.toml for configuration options that start with tool.hatch and leave that prefix out. That means pyprojects.toml’s [tool.hatch.metadata.hooks.fancy-pypi-readme] becomes [metadata.hooks.fancy-pypi-readme] when in hatch.toml. To keep the documentation simple, the more common pyproject.toml syntax is used throughout.