1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296
|
# Your ✨Fancy✨ Project Deserves a ✨Fancy✨ PyPI Readme!
[](https://github.com/pypa/hatch)
[](https://github.com/hynek/hatch-fancy-pypi-readme/blob/main/LICENSE.txt)
[](https://pypi.org/project/hatch-fancy-pypi-readme)
[](https://pypi.org/project/hatch-fancy-pypi-readme)
[](https://pepy.tech/project/hatch-fancy-pypi-readme)
*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!
> [!NOTE]
> “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](https://peps.python.org/pep-0621/)-based `pyproject.toml` files.
### Showcases 🧐
<!-- Please add your project in alphabetic order, except leave hatch-fancy-pypi-readme last. If your project is lower-case, add it emphasized (surrounded by *), otherwise leave it plain. -->
- [Anthropic SDK](https://pypi.org/project/anthropic/) ([`pyproject.toml`](https://github.com/anthropics/anthropic-sdk-python/blob/main/pyproject.toml))
- [*attrs*](https://pypi.org/project/attrs/) ([`pyproject.toml`](https://github.com/python-attrs/attrs/blob/main/pyproject.toml))
- [Awkward Array](https://pypi.org/project/awkward/) ([`pyproject.toml`](https://github.com/scikit-hep/awkward/blob/main/pyproject.toml))
- [Black](https://pypi.org/project/black/) ([`pyproject.toml`](https://github.com/psf/black/blob/main/pyproject.toml))
- [*doc2dash*](https://pypi.org/project/doc2dash/) ([`pyproject.toml`](https://github.com/hynek/doc2dash/blob/main/pyproject.toml))
- [*environ-config*](https://pypi.org/project/environ-config/) ([`pyproject.toml`](https://github.com/hynek/environ-config/blob/main/pyproject.toml))
- [*jsonschema*](https://pypi.org/project/jsonschema/) ([`pyproject.toml`](https://github.com/python-jsonschema/jsonschema/blob/main/pyproject.toml))
- [Gradio](https://pypi.org/project/gradio/) ([`pyproject.toml`](https://github.com/gradio-app/gradio/blob/main/pyproject.toml))
- [*httpx*](https://pypi.org/project/httpx/) ([`pyproject.toml`](https://github.com/encode/httpx/blob/master/pyproject.toml))
- [OpenAI SDK](https://pypi.org/project/openai/) ([`pyproject.toml`](https://github.com/openai/openai-python/blob/main/pyproject.toml))
- [Pydantic](https://pypi.org/project/pydantic/) ([`pyproject.toml`](https://github.com/pydantic/pydantic/blob/main/pyproject.toml))
- [*pytermgui*](https://pypi.org/project/pytermgui/) ([`pyproject.toml`](https://github.com/bczsalba/pytermgui/blob/master/pyproject.toml))
- [*scikit-build*](https://pypi.org/project/scikit-build/) ([`pyproject.toml`](https://github.com/scikit-build/scikit-build/blob/main/pyproject.toml))
- [*stamina*](https://pypi.org/project/stamina/) ([`pyproject.toml`](https://github.com/hynek/stamina/blob/main/pyproject.toml))
- [*structlog*](https://pypi.org/project/structlog/) ([`pyproject.toml`](https://github.com/hynek/structlog/blob/main/pyproject.toml))
- [Twisted](https://pypi.org/project/twisted/) ([`pyproject.toml`](https://github.com/twisted/twisted/blob/trunk/pyproject.toml))
*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.
<!-- start docs -->
Please [open a pull request](https://github.com/hynek/hatch-fancy-pypi-readme/edit/main/README.md) to add *your* ✨fancy✨ project!
## Motivation
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](https://github.com/python-attrs/attrs/blob/b3dfebe2e10b44437c4f97d788fb5220d790efd0/setup.py#L110-L124) is the code that gave me the PyPI readme for [*attrs* 22.1.0](https://pypi.org/project/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`.
## Configuration
*hatch-fancy-pypi-readme* is, like [Hatch], configured in your project’s `pyproject.toml`[^hatch-toml].
[^hatch-toml]: 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.
First you add *hatch-fancy-pypi-readme* to your `[build-system]`:
```toml
[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:
```toml
[project]
# ...
dynamic = ["readme"]
```
> [!IMPORTANT]
> 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.
```toml
[tool.hatch.metadata.hooks.fancy-pypi-readme]
content-type = "text/markdown"
```
### Fragments
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](https://toml.io/en/v1.0.0#array-of-tables) and will use it throughout this documentation.
#### Text
Text fragments consist of a single `text` key and are appended to the readme exactly as you specify them:
```toml
[[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]]
text = "Fragment #1"
[[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]]
text = "Fragment #2"
```
results in:
```
Fragment #1Fragment #2
```
Note that there’s no additional space or empty lines between fragments unless you specify them.
#### File
A file fragment reads a file specified by the `path` key and appends it:
```toml
[[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]]
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*](https://docs.python.org/3/library/re.html) 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
```python
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:
```markdown
# Boring Header
<!-- cut after this -->
This is the *interesting* body!
<!-- but before this -->
Uninteresting Footer
```
together with:
```toml
[[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]]
path = "path.md"
start-after = "<!-- cut after this -->\n\n"
end-before = "\n\n<!-- but before this -->"
pattern = "the (.*?) body"
```
would append:
```markdown
*interesting*
```
to your readme.
> [!TIP]
>
> - 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][example-config].
## Substitutions
After a readme is assembled out of fragments, it’s possible to run an arbitrary number of [*regular expression*](https://docs.python.org/3/library/re.html)-based substitutions over it:
```toml
[[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]]
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:
```toml
[[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]]
# 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:
```toml
[[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]]
# Regular TOML strings (double quotes) do need escaping.
pattern = "#(\\d+)"
replacement = "[#\\1](https://github.com/hynek/hatch-fancy-pypi-readme/issues/\\1)"
```
Or, replacing [GitHub-style callouts](https://github.com/orgs/community/discussions/16925) that aren't supported by PyPI with bolded text:
```toml
[[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]]
pattern = '\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]'
replacement = '**\1**:'
```
Again, please check out our [example configuration][example-config] for a complete example.
### Referencing Packaging Metadata
If the final readme contains the strings `$HFPR_PACKAGE_NAME` or `$HFPR_VERSION`, they will be replaced by the current package name or 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_PACKAGE_NAME` is hardcoded to `your-package`, and `$HFPR_VERSION` 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*](https://pypa.github.io/pipx/):
```shell
$ pipx run hatch-fancy-pypi-readme
```
---
You can pipe the output into tools like [*rich-cli*](https://github.com/Textualize/rich-cli#markdown) or [*bat*](https://github.com/sharkdp/bat) to verify your markup.
For example, if you run
```shell
$ pipx run hatch-fancy-pypi-readme | pipx run rich-cli --markdown --hyperlinks -
```
with our [example configuration][example-config], you will get the following output:

> [!WARNING]
> 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*](https://github.com/hynek/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*](https://jqlang.github.io/jq/)).
[example-config]: tests/example_pyproject.toml
[Hatch]: https://hatch.pypa.io/
|