├── .git_archival.txt ├── .github ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── FUNDING.yml ├── SECURITY.md ├── dependabot.yml └── workflows │ ├── ci.yml │ ├── codeql-analysis.yml │ ├── pypi-package.yml │ └── zizmor.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .python-version-default ├── AUTHORS.md ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── pyproject.toml ├── rich-cli-out.svg ├── src └── hatch_fancy_pypi_readme │ ├── __init__.py │ ├── __main__.py │ ├── _builder.py │ ├── _cli.py │ ├── _config.py │ ├── _fragments.py │ ├── _substitutions.py │ ├── exceptions.py │ ├── hooks.py │ └── py.typed ├── tests ├── __init__.py ├── conftest.py ├── example_changelog.md ├── example_pyproject.toml ├── example_text.md ├── test_builder.py ├── test_cli.py ├── test_config.py ├── test_end_to_end.py ├── test_fragments.py ├── test_substitutions.py └── utils.py └── tox.ini /.git_archival.txt: -------------------------------------------------------------------------------- 1 | node: $Format:%H$ 2 | node-date: $Format:%cI$ 3 | describe-name: $Format:%(describe:tags=true,match=*[0-9]*)$ 4 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributor Covenant Code of Conduct 3 | 4 | ## Our Pledge 5 | 6 | We as members, contributors, and leaders pledge to make participation in our 7 | community a harassment-free experience for everyone, regardless of age, body 8 | size, visible or invisible disability, ethnicity, sex characteristics, gender 9 | identity and expression, level of experience, education, socio-economic status, 10 | nationality, personal appearance, race, caste, color, religion, or sexual 11 | identity and orientation. 12 | 13 | We pledge to act and interact in ways that contribute to an open, welcoming, 14 | diverse, inclusive, and healthy community. 15 | 16 | ## Our Standards 17 | 18 | Examples of behavior that contributes to a positive environment for our 19 | community include: 20 | 21 | * Demonstrating empathy and kindness toward other people 22 | * Being respectful of differing opinions, viewpoints, and experiences 23 | * Giving and gracefully accepting constructive feedback 24 | * Accepting responsibility and apologizing to those affected by our mistakes, 25 | and learning from the experience 26 | * Focusing on what is best not just for us as individuals, but for the overall 27 | community 28 | 29 | Examples of unacceptable behavior include: 30 | 31 | * The use of sexualized language or imagery, and sexual attention or advances of 32 | any kind 33 | * Trolling, insulting or derogatory comments, and personal or political attacks 34 | * Public or private harassment 35 | * Publishing others' private information, such as a physical or email address, 36 | without their explicit permission 37 | * Other conduct which could reasonably be considered inappropriate in a 38 | professional setting 39 | 40 | ## Enforcement Responsibilities 41 | 42 | Community leaders are responsible for clarifying and enforcing our standards of 43 | acceptable behavior and will take appropriate and fair corrective action in 44 | response to any behavior that they deem inappropriate, threatening, offensive, 45 | or harmful. 46 | 47 | Community leaders have the right and responsibility to remove, edit, or reject 48 | comments, commits, code, wiki edits, issues, and other contributions that are 49 | not aligned to this Code of Conduct, and will communicate reasons for moderation 50 | decisions when appropriate. 51 | 52 | ## Scope 53 | 54 | This Code of Conduct applies within all community spaces, and also applies when 55 | an individual is officially representing the community in public spaces. 56 | Examples of representing our community include using an official e-mail address, 57 | posting via an official social media account, or acting as an appointed 58 | representative at an online or offline event. 59 | 60 | ## Enforcement 61 | 62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 63 | reported to the community leaders responsible for enforcement at 64 | . 65 | All complaints will be reviewed and investigated promptly and fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series of 87 | actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or permanent 94 | ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within the 114 | community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 119 | version 2.1, available at 120 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 121 | 122 | Community Impact Guidelines were inspired by 123 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 124 | 125 | For answers to common questions about this code of conduct, see the FAQ at 126 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 127 | [https://www.contributor-covenant.org/translations][translations]. 128 | 129 | [homepage]: https://www.contributor-covenant.org 130 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 131 | [Mozilla CoC]: https://github.com/mozilla/diversity 132 | [FAQ]: https://www.contributor-covenant.org/faq 133 | [translations]: https://www.contributor-covenant.org/translations 134 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How To Contribute 2 | 3 | First off, thank you for considering contributing to *hatch-fancy-pypi-readme*! 4 | It's people like *you* who make it such a great tool for everyone. 5 | 6 | This document intends to make contribution more accessible by codifying tribal knowledge and expectations. 7 | Don't be afraid to open half-finished PRs, and ask questions if something is unclear! 8 | 9 | Please note that this project is released with a Contributor [Code of Conduct](https://github.com/hynek/hatch-fancy-pypi-readme/blob/main/.github/CODE_OF_CONDUCT.md). 10 | By participating in this project you agree to abide by its terms. 11 | Please report any harm to [Hynek Schlawack] in any way you find appropriate. 12 | 13 | 14 | ## Workflow 15 | 16 | - No contribution is too small! 17 | Please submit as many fixes for typos and grammar bloopers as you can! 18 | - Try to limit each pull request to *one* change only. 19 | - Since we squash on merge, it's up to you how you handle updates to the main branch. 20 | Whether you prefer to rebase on main or merge main into your branch, do whatever is more comfortable for you. 21 | - *Always* add tests and docs for your code. 22 | This is a hard rule; patches with missing tests or documentation can't be merged. 23 | - Make sure your changes pass our [CI]. 24 | You won't get any feedback until it's green unless you ask for it. 25 | For the CI to pass, the coverage must be 100%. 26 | If you have problems to test something, open anyway and ask for advice. 27 | In some situations, we may agree to add a `# pragma: no cover`. 28 | - Once you've addressed review feedback, make sure to bump the pull request with a short note, so we know you're done. 29 | - Don’t break backwards-compatibility. 30 | 31 | 32 | ## Local Development Environment 33 | 34 | You can (and should) run our test suite using [*tox*]. 35 | However, you’ll probably want a more traditional environment as well. 36 | 37 | We recommend using the Python version from the `.python-version-default` file in the project's root directory, because that's the one that is used in the CI by default, too. 38 | 39 | If you're using [*direnv*](https://direnv.net), you can automate the creation of the project virtual environment with the correct Python version by adding the following `.envrc` to the project root: 40 | 41 | ```bash 42 | layout python python$(cat .python-version-default) 43 | ``` 44 | 45 | You can now install the package with its development dependencies into the virtual environment: 46 | 47 | ```console 48 | $ pip install -e .[dev] 49 | ``` 50 | 51 | Now you can run the test suite: 52 | 53 | ```console 54 | $ python -m pytest 55 | ``` 56 | 57 | To avoid committing code that violates our style guide, we strongly advise you to install [*pre-commit*] and its hooks: 58 | 59 | ```console 60 | $ pre-commit install 61 | ``` 62 | 63 | This is not strictly necessary, because our [*tox*] file contains an environment that runs: 64 | 65 | ```console 66 | $ pre-commit run --all-files 67 | ``` 68 | 69 | and our CI has integration with [pre-commit.ci](https://pre-commit.ci). 70 | But it's way more comfortable to run it locally and Git catching avoidable errors. 71 | 72 | 73 | ## Code 74 | 75 | - Obey [PEP 8](https://www.python.org/dev/peps/pep-0008/) and [PEP 257](https://www.python.org/dev/peps/pep-0257/). 76 | We use the `"""`-on-separate-lines style for docstrings: 77 | 78 | ```python 79 | def func(x): 80 | """ 81 | Do something. 82 | 83 | :param str x: A very important parameter. 84 | 85 | :rtype: str 86 | """ 87 | ``` 88 | 89 | - If you add or change public APIs, tag the docstring using `.. versionadded:: 16.0.0 WHAT` or `.. versionchanged:: 16.2.0 WHAT`. 90 | 91 | - We use [Ruff](https://ruff.rs/) to sort our imports and format our code with a line length of 79 characters. 92 | As long as you run our full [*tox*] suite before committing, or install our [*pre-commit*] hooks (ideally you'll do both – see [*Local Development Environment*](#local-development-environment) above), you won't have to spend any time on formatting your code at all. 93 | If you don't, [CI] will catch it for you – but that seems like a waste of your time! 94 | 95 | 96 | ## Tests 97 | 98 | - Write your asserts as `expected == actual` to line them up nicely: 99 | 100 | ```python 101 | x = f() 102 | 103 | assert 42 == x.some_attribute 104 | assert "foo" == x._a_private_attribute 105 | ``` 106 | 107 | - To run the test suite, all you need is a recent [*tox*]. 108 | It will ensure the test suite runs with all dependencies against all Python versions just as it will in our [CI]. 109 | If you lack some Python versions, you can can always limit the environments like `tox -e py38,py39`, or make it a non-failure using `tox --skip-missing-interpreters`. 110 | 111 | In that case you should look into [*asdf*](https://asdf-vm.com) or [*pyenv*](https://github.com/pyenv/pyenv), which make it very easy to install many different Python versions in parallel. 112 | - Write [good test docstrings](https://jml.io/pages/test-docstrings.html). 113 | - If you've changed or added public APIs, please update our type stubs (files ending in `.pyi`). 114 | 115 | 116 | ## Documentation 117 | 118 | - Use [semantic newlines] in [*reStructuredText*] and *Markdown* files (files ending in `.rst` and `.md`): 119 | 120 | ```rst 121 | This is a sentence. 122 | This is another sentence. 123 | ``` 124 | 125 | - If you start a new section, add two blank lines before and one blank line after the header, except if two headers follow immediately after each other: 126 | 127 | ```rst 128 | Last line of previous section. 129 | 130 | 131 | Header of New Top Section 132 | ------------------------- 133 | 134 | Header of New Section 135 | ^^^^^^^^^^^^^^^^^^^^^ 136 | 137 | First line of new section. 138 | ``` 139 | 140 | 141 | ### Changelog 142 | 143 | If your change is noteworthy, there needs to be a changelog entry in [`CHANGELOG.md`](https://github.com/hynek/hatch-fancy-pypi-readme/blob/main/CHANGELOG.md), so our users can learn about it! 144 | 145 | - The changelog follows the [*Keep a Changelog*](https://keepachangelog.com/en/1.0.0/) standard. 146 | Please add the best-fitting section if it's missing for the current release. 147 | We use the following order: `Security`, `Removed`, `Deprecated`, `Added`, `Changed`, `Fixed`. 148 | - As with other docs, please use [semantic newlines] in the changelog. 149 | - Make the last line a link to your pull request. 150 | You probably have to open the pull request first to know the number. 151 | - Wrap symbols like modules, functions, or classes into backticks so they are rendered in a `monospace font`. 152 | - Wrap arguments into asterisks like in docstrings: 153 | `Added new argument *an_argument*.` 154 | - If you mention functions or other callables, add parentheses at the end of their names: 155 | `hatch-fancy-pypi-readme.func()` or `hatch-fancy-pypi-readme.Class.method()`. 156 | This makes the changelog a lot more readable. 157 | - Prefer simple past tense or constructions with "now". 158 | For example: 159 | 160 | * Added `hatch-fancy-pypi-readme.func()`. 161 | * `hatch-fancy-pypi-readme.func()` now doesn't crash the Large Hadron Collider anymore when passed the *foobar* argument. 162 | 163 | #### Example entries 164 | 165 | ```markdown 166 | - Added `hatch-fancy-pypi-readme.func()`. 167 | The feature really *is* awesome. 168 | [#1](https://github.com/hynek/hatch-fancy-pypi-readme/pull/1) 169 | ``` 170 | 171 | or: 172 | 173 | ```markdown 174 | - `hatch-fancy-pypi-readme.func()` now doesn't crash the Large Hadron Collider anymore when passed the *foobar* argument. 175 | The bug really *was* nasty. 176 | [#1](https://github.com/hynek/hatch-fancy-pypi-readme/pull/1) 177 | ``` 178 | 179 | 180 | [CI]: https://github.com/hynek/hatch-fancy-pypi-readme/actions 181 | [Hynek Schlawack]: https://hynek.me/about/ 182 | [*pre-commit*]: https://pre-commit.com/ 183 | [*tox*]: https://tox.wiki/ 184 | [semantic newlines]: https://rhodesmill.org/brandon/2012/one-sentence-per-line/ 185 | [*reStructuredText*]: https://www.sphinx-doc.org/en/stable/usage/restructuredtext/basics.html 186 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | --- 2 | github: hynek 3 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | We are following [CalVer](https://calver.org) with generous backwards-compatibility guarantees. 6 | Therefore we only support the latest version. 7 | 8 | 9 | ## Reporting a Vulnerability 10 | 11 | To report a security vulnerability, please email the author. 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | 4 | on: 5 | push: 6 | branches: [main] 7 | tags: ["*"] 8 | pull_request: 9 | workflow_dispatch: 10 | 11 | env: 12 | FORCE_COLOR: "1" # Make tools pretty. 13 | PYTHONIOENCODING: utf-8 14 | PIP_DISABLE_PIP_VERSION_CHECK: "1" 15 | PIP_NO_PYTHON_VERSION_WARNING: "1" 16 | 17 | permissions: {} 18 | 19 | 20 | jobs: 21 | build-package: 22 | name: Build & verify package 23 | runs-on: ubuntu-latest 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | with: 28 | fetch-depth: 0 29 | persist-credentials: false 30 | 31 | - uses: hynek/build-and-inspect-python-package@v2 32 | id: baipp 33 | 34 | outputs: 35 | # Used to define the matrix for tests below. The value is based on 36 | # packaging metadata (trove classifiers). 37 | python-versions: ${{ steps.baipp.outputs.supported_python_classifiers_json_array }} 38 | 39 | tests: 40 | name: Tests on ${{ matrix.python-version }} 41 | runs-on: ubuntu-latest 42 | needs: build-package 43 | 44 | strategy: 45 | fail-fast: false 46 | matrix: 47 | # Created by the build-and-inspect-python-package action above. 48 | python-version: ${{ fromJson(needs.build-package.outputs.python-versions) }} 49 | 50 | env: 51 | PYTHON: ${{ matrix.python-version }} 52 | 53 | steps: 54 | - name: Download pre-built packages 55 | uses: actions/download-artifact@v4 56 | with: 57 | name: Packages 58 | path: dist 59 | - run: tar xf dist/*.tar.gz --strip-components=1 # needed for config files 60 | - uses: actions/setup-python@v5 61 | with: 62 | python-version: ${{ matrix.python-version }} 63 | allow-prereleases: true 64 | - uses: hynek/setup-cached-uv@v2 65 | 66 | - run: > 67 | uvx --with tox-uv 68 | tox run 69 | --installpkg dist/*.whl 70 | -f py$(echo $PYTHON | tr -d .) 71 | 72 | - name: Upload coverage data 73 | uses: actions/upload-artifact@v4 74 | with: 75 | name: coverage-data-${{ matrix.python-version }} 76 | path: .coverage.* 77 | include-hidden-files: true 78 | if-no-files-found: ignore 79 | 80 | coverage: 81 | runs-on: ubuntu-latest 82 | needs: tests 83 | if: always() 84 | 85 | steps: 86 | - uses: actions/checkout@v4 87 | with: 88 | persist-credentials: false 89 | - uses: actions/setup-python@v5 90 | with: 91 | python-version-file: .python-version-default 92 | - uses: hynek/setup-cached-uv@v2 93 | 94 | - name: Download coverage data 95 | uses: actions/download-artifact@v4 96 | with: 97 | pattern: coverage-data-* 98 | merge-multiple: true 99 | 100 | - name: Combine coverage and fail if it's <100%. 101 | run: | 102 | uv tool install coverage[toml] 103 | 104 | coverage combine 105 | coverage html --skip-covered --skip-empty 106 | 107 | # Report and write to summary. 108 | coverage report --format=markdown >> $GITHUB_STEP_SUMMARY 109 | 110 | # Report again and fail if under 100%. 111 | coverage report --fail-under=100 112 | 113 | - name: Upload HTML report if check failed. 114 | uses: actions/upload-artifact@v4 115 | with: 116 | name: html-report 117 | path: htmlcov 118 | if: ${{ failure() }} 119 | 120 | mypy: 121 | name: Mypy 122 | runs-on: ubuntu-latest 123 | needs: build-package 124 | 125 | steps: 126 | - name: Download pre-built packages 127 | uses: actions/download-artifact@v4 128 | with: 129 | name: Packages 130 | path: dist 131 | - run: tar xf dist/*.tar.gz --strip-components=1 # needed for config files 132 | - uses: actions/setup-python@v5 133 | with: 134 | python-version-file: .python-version-default 135 | - uses: hynek/setup-cached-uv@v2 136 | 137 | - run: > 138 | uvx --with tox-uv 139 | tox run -e mypy 140 | 141 | install-dev: 142 | name: Verify dev env 143 | runs-on: ${{ matrix.os }} 144 | strategy: 145 | matrix: 146 | os: [ubuntu-latest, windows-latest] 147 | 148 | steps: 149 | - uses: actions/checkout@v4 150 | with: 151 | persist-credentials: false 152 | - uses: actions/setup-python@v5 153 | with: 154 | python-version-file: .python-version-default 155 | 156 | - run: python -Im pip install -e .[dev] 157 | - run: python -Ic 'import hatch_fancy_pypi_readme' 158 | - run: python -m hatch_fancy_pypi_readme tests/example_pyproject.toml 159 | - run: hatch-fancy-pypi-readme tests/example_pyproject.toml 160 | 161 | # Ensure everything required is passing for branch protection. 162 | required-checks-pass: 163 | if: always() 164 | 165 | needs: 166 | - coverage 167 | - install-dev 168 | - mypy 169 | 170 | runs-on: ubuntu-latest 171 | 172 | steps: 173 | - name: Decide whether the needed jobs succeeded or failed 174 | uses: re-actors/alls-green@release/v1 175 | with: 176 | jobs: ${{ toJSON(needs) }} 177 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CodeQL 3 | 4 | on: 5 | schedule: 6 | - cron: "30 22 * * 4" 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [python] 24 | 25 | steps: 26 | - name: Checkout repository 27 | uses: actions/checkout@v4 28 | with: 29 | persist-credentials: false 30 | 31 | - name: Initialize CodeQL 32 | uses: github/codeql-action/init@v3 33 | with: 34 | languages: ${{ matrix.language }} 35 | 36 | - name: Autobuild 37 | uses: github/codeql-action/autobuild@v3 38 | 39 | - name: Perform CodeQL Analysis 40 | uses: github/codeql-action/analyze@v3 41 | -------------------------------------------------------------------------------- /.github/workflows/pypi-package.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Build & upload PyPI package 3 | 4 | on: 5 | push: 6 | branches: [main] 7 | tags: ["*"] 8 | release: 9 | types: 10 | - published 11 | workflow_dispatch: 12 | 13 | 14 | jobs: 15 | # Always build & lint package. 16 | build-package: 17 | name: Build & verify package 18 | runs-on: ubuntu-latest 19 | permissions: 20 | attestations: write 21 | id-token: write 22 | 23 | steps: 24 | - uses: actions/checkout@v4 25 | with: 26 | fetch-depth: 0 27 | persist-credentials: false 28 | 29 | - uses: hynek/build-and-inspect-python-package@v2 30 | with: 31 | attest-build-provenance-github: 'true' 32 | 33 | 34 | # Upload to Test PyPI on every commit on main. 35 | release-test-pypi: 36 | name: Publish tagged package to test.pypi.org 37 | environment: release-test-pypi 38 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') 39 | runs-on: ubuntu-latest 40 | needs: build-package 41 | 42 | permissions: 43 | id-token: write 44 | 45 | steps: 46 | - name: Download packages built by build-and-inspect-python-package 47 | uses: actions/download-artifact@v4 48 | with: 49 | name: Packages 50 | path: dist 51 | 52 | - name: Upload package to Test PyPI 53 | uses: pypa/gh-action-pypi-publish@release/v1 54 | with: 55 | attestations: true 56 | repository-url: https://test.pypi.org/legacy/ 57 | 58 | 59 | # Upload to real PyPI on GitHub Releases. 60 | release-pypi: 61 | name: Publish released package to pypi.org 62 | environment: release-pypi 63 | if: github.repository_owner == 'hynek' && github.event.action == 'published' 64 | runs-on: ubuntu-latest 65 | needs: build-package 66 | 67 | permissions: 68 | id-token: write 69 | 70 | steps: 71 | - name: Download packages built by build-and-inspect-python-package 72 | uses: actions/download-artifact@v4 73 | with: 74 | name: Packages 75 | path: dist 76 | 77 | - name: Upload package to PyPI 78 | uses: pypa/gh-action-pypi-publish@release/v1 79 | with: 80 | attestations: true 81 | -------------------------------------------------------------------------------- /.github/workflows/zizmor.yml: -------------------------------------------------------------------------------- 1 | # https://github.com/woodruffw/zizmor 2 | name: Zizmor 3 | 4 | on: 5 | push: 6 | branches: ["main"] 7 | pull_request: 8 | branches: ["*"] 9 | 10 | permissions: 11 | contents: read 12 | 13 | 14 | jobs: 15 | zizmor: 16 | name: Zizmor latest via PyPI 17 | runs-on: ubuntu-latest 18 | permissions: 19 | security-events: write 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@v4 23 | with: 24 | persist-credentials: false 25 | - uses: hynek/setup-cached-uv@v2 26 | 27 | - name: Run zizmor 🌈 28 | run: uvx zizmor --format sarif . > results.sarif 29 | env: 30 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | 32 | - name: Upload SARIF file 33 | uses: github/codeql-action/upload-sarif@v3 34 | with: 35 | # Path to SARIF file relative to the root of the repository 36 | sarif_file: results.sarif 37 | # Optional category for the results 38 | # Used to differentiate multiple results for one commit 39 | category: zizmor 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.pyc 3 | *.pyo 4 | .DS_Store 5 | .cache 6 | .coverage* 7 | .direnv 8 | .envrc 9 | .mypy_cache 10 | .pytest_cache 11 | .tox 12 | .vscode 13 | build 14 | dist 15 | htmlcov 16 | tmp 17 | Justfile 18 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | ci: 3 | autoupdate_schedule: monthly 4 | 5 | repos: 6 | - repo: https://github.com/astral-sh/ruff-pre-commit 7 | rev: v0.11.11 8 | hooks: 9 | - id: ruff 10 | args: [--fix, --exit-non-zero-on-fix] 11 | - id: ruff-format 12 | 13 | - repo: https://github.com/pre-commit/pre-commit-hooks 14 | rev: v5.0.0 15 | hooks: 16 | - id: trailing-whitespace 17 | - id: end-of-file-fixer 18 | exclude: (.*\.svg) 19 | - id: check-toml 20 | - id: check-yaml 21 | -------------------------------------------------------------------------------- /.python-version-default: -------------------------------------------------------------------------------- 1 | 3.13 2 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | # Authors 2 | 3 | *hatch-fancy-pypi-readme* is written and maintained by [Hynek Schlawack](https://hynek.me/) and released under the [MIT license](https://github.com/hynek/hatch-fancy-pypi-readme/blob/main/LICENSE.txt). 4 | 5 | The development is kindly supported by my employer [Variomedia AG](https://www.variomedia.de/) and all my amazing [GitHub Sponsors](https://github.com/sponsors/hynek). 6 | 7 | A full list of contributors can be found on GitHub’s [overview](https://github.com/hynek/hatch-fancy-pypi-readme/graphs/contributors). 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [*Keep a Changelog*](https://keepachangelog.com/en/1.0.0/) and this project adheres to [*Calendar Versioning*](https://calver.org/). 6 | 7 | The **first number** of the version is the year. 8 | The **second number** is incremented with each release, starting at 1 for each year. 9 | The **third number** is for emergencies when we need to start branches for older releases. 10 | 11 | 12 | 13 | 14 | ## [Unreleased](https://github.com/hynek/hatch-fancy-pypi-readme/compare/25.1.0...HEAD) 15 | 16 | 17 | ## [25.1.0](https://github.com/hynek/hatch-fancy-pypi-readme/compare/24.1.0...25.1.0) - 2025-05-01 18 | 19 | ### Added 20 | 21 | - `$HFPR_PACKAGE_NAME` is now replaced by the package name in the PyPI readme. 22 | The version is not available in CLI mode, therefore it's replaced by the dummy value of `your-package`. 23 | [#64](https://github.com/hynek/hatch-fancy-pypi-readme/pull/64) 24 | 25 | 26 | ### Removed 27 | 28 | - Support for Python 3.7. 29 | 30 | 31 | ## [24.1.0](https://github.com/hynek/hatch-fancy-pypi-readme/compare/23.2.0...24.1.0) - 2024-01-01 32 | 33 | ### Fixed 34 | 35 | - Added a default to an internal API that is used by *scikit-build-core*. 36 | 37 | 38 | ## [23.2.0](https://github.com/hynek/hatch-fancy-pypi-readme/compare/23.1.0...23.2.0) - 2023-12-31 39 | 40 | ### Added 41 | 42 | - `$HFPR_VERSION` is now replaced by the package version in the PyPI readme. 43 | The version is not available in CLI mode, therefore it's replaced by the dummy value of `42.0`. 44 | [#39](https://github.com/hynek/hatch-fancy-pypi-readme/pull/39) 45 | 46 | 47 | ## [23.1.0](https://github.com/hynek/hatch-fancy-pypi-readme/compare/22.8.0...23.1.0) - 2023-05-22 48 | 49 | ### Added 50 | 51 | - CLI support for `hatch.toml`. 52 | [#27](https://github.com/hynek/hatch-fancy-pypi-readme/issues/27) 53 | 54 | 55 | ## [22.8.0](https://github.com/hynek/hatch-fancy-pypi-readme/compare/22.7.0...22.8.0) - 2022-10-02 56 | 57 | ### Added 58 | 59 | - Added `start-at` in addition to `start-after` that preserves the string that is looked for. This often removes the need for adding markers because you can define the starting point using a heading that becomes part of the fragment. 60 | 61 | For example: `start-at = "## License"` gives you `## License` and everything that follows. 62 | [#16](https://github.com/hynek/hatch-fancy-pypi-readme/issues/16) 63 | 64 | 65 | ## [22.7.0](https://github.com/hynek/hatch-fancy-pypi-readme/compare/22.6.0...22.7.0) - 2022-09-12 66 | 67 | ### Changed 68 | 69 | - Removed another circular dependency: this time the wonderful [*jsonschema*](https://python-jsonschema.readthedocs.io/). 70 | The price of building packaging tools is to not use packages. 71 | 72 | 73 | ## [22.6.0](https://github.com/hynek/hatch-fancy-pypi-readme/compare/22.5.0...22.6.0) - 2022-09-11 74 | 75 | ### Changed 76 | 77 | - Unfortunately, life is unfair and depending on oneself is problematic for others packaging your code. 78 | So absolutely nothing changed again, except that we’re back to a boring PyPI readme so you don’t have to. 79 | 80 | 81 | ## [22.5.0](https://github.com/hynek/hatch-fancy-pypi-readme/compare/22.4.0...22.5.0) - 2022-09-10 82 | 83 | ### Changed 84 | 85 | - Absolutely nothing – just working around the hen-egg problem to use substitutions in the PyPI readme! 86 | 87 | 88 | ## [22.4.0](https://github.com/hynek/hatch-fancy-pypi-readme/compare/22.3.0...22.4.0) - 2022-09-10 89 | 90 | ### Added 91 | 92 | - It is now possible to run *regular expression*-based substitutions over the final readme. 93 | [#9](https://github.com/hynek/hatch-fancy-pypi-readme/issues/9) 94 | [#11](https://github.com/hynek/hatch-fancy-pypi-readme/issues/11) 95 | 96 | 97 | ## [22.3.0](https://github.com/hynek/hatch-fancy-pypi-readme/compare/22.2.0...22.3.0) - 2022-08-06 98 | 99 | ### Added 100 | 101 | - Support for Python 3.7. 102 | While our Python version only applies when building a package, a package is built whenever it is installed. 103 | This includes *tox* environments. 104 | *hatch-fancy-pypi-readme* will always *at least* support the same Python version as the latest version of *Hatchling* – *Hatch*’s build backend – does. 105 | 106 | To get this version out, we had to stop dog-fooding *hatch-fancy-pypi-readme*. 😢 107 | 108 | 109 | ## [22.2.0](https://github.com/hynek/hatch-fancy-pypi-readme/compare/22.1.0...22.2.0) - 2022-08-05 110 | 111 | ### Changed 112 | 113 | - We can finally use *hatch-fancy-pypi-readme* for our own ✨fancy✨ PyPI readme! 114 | 115 | 116 | ### Fixed 117 | 118 | - Hopefully fixed readmes with emojis on Windows. 119 | 120 | 121 | ## [22.1.0](https://github.com/hynek/hatch-fancy-pypi-readme/tree/22.1.0) - 2022-08-05 122 | 123 | ### Added 124 | 125 | - Initial release. 126 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Hynek Schlawack and the hatch-fancy-pypi-readme contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Your ✨Fancy✨ Project Deserves a ✨Fancy✨ PyPI Readme! 2 | 3 | [![Hatch project](https://img.shields.io/badge/%F0%9F%A5%9A-Hatch-4051b5.svg)](https://github.com/pypa/hatch) 4 | [![License: MIT](https://img.shields.io/badge/license-MIT-C06524)](https://github.com/hynek/hatch-fancy-pypi-readme/blob/main/LICENSE.txt) 5 | [![PyPI - Version](https://img.shields.io/pypi/v/hatch-fancy-pypi-readme.svg)](https://pypi.org/project/hatch-fancy-pypi-readme) 6 | [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/hatch-fancy-pypi-readme.svg)](https://pypi.org/project/hatch-fancy-pypi-readme) 7 | [![Downloads](https://static.pepy.tech/badge/hatch-fancy-pypi-readme/month)](https://pepy.tech/project/hatch-fancy-pypi-readme) 8 | 9 | *hatch-fancy-pypi-readme* is a [Hatch] metadata plugin for everyone who cares about the first impression of their project’s PyPI landing page. 10 | It allows you to define your PyPI project description in terms of concatenated fragments that are based on **static strings**, **files**, and most importantly: 11 | **parts of files** defined using **cut-off points** or **regular expressions**. 12 | 13 | Once you’ve assembled your readme, you can additionally run **regular expression-based substitutions** over it. 14 | For instance to make relative links absolute or to linkify users and issue numbers in your changelog. 15 | 16 | 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? 17 | You’ve come to the right place! 18 | 19 | > [!NOTE] 20 | > “PyPI project description”, “PyPI landing page”, and “PyPI readme” all refer to the same thing. 21 | > In *setuptools* it’s called `long_description` and is the text shown on a project’s PyPI page. 22 | > 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. 23 | 24 | 25 | ### Showcases 🧐 26 | 27 | 28 | - [Anthropic SDK](https://pypi.org/project/anthropic/) ([`pyproject.toml`](https://github.com/anthropics/anthropic-sdk-python/blob/main/pyproject.toml)) 29 | - [*attrs*](https://pypi.org/project/attrs/) ([`pyproject.toml`](https://github.com/python-attrs/attrs/blob/main/pyproject.toml)) 30 | - [Awkward Array](https://pypi.org/project/awkward/) ([`pyproject.toml`](https://github.com/scikit-hep/awkward/blob/main/pyproject.toml)) 31 | - [Black](https://pypi.org/project/black/) ([`pyproject.toml`](https://github.com/psf/black/blob/main/pyproject.toml)) 32 | - [*doc2dash*](https://pypi.org/project/doc2dash/) ([`pyproject.toml`](https://github.com/hynek/doc2dash/blob/main/pyproject.toml)) 33 | - [*environ-config*](https://pypi.org/project/environ-config/) ([`pyproject.toml`](https://github.com/hynek/environ-config/blob/main/pyproject.toml)) 34 | - [*jsonschema*](https://pypi.org/project/jsonschema/) ([`pyproject.toml`](https://github.com/python-jsonschema/jsonschema/blob/main/pyproject.toml)) 35 | - [Gradio](https://pypi.org/project/gradio/) ([`pyproject.toml`](https://github.com/gradio-app/gradio/blob/main/pyproject.toml)) 36 | - [*httpx*](https://pypi.org/project/httpx/) ([`pyproject.toml`](https://github.com/encode/httpx/blob/master/pyproject.toml)) 37 | - [OpenAI SDK](https://pypi.org/project/openai/) ([`pyproject.toml`](https://github.com/openai/openai-python/blob/main/pyproject.toml)) 38 | - [Pydantic](https://pypi.org/project/pydantic/) ([`pyproject.toml`](https://github.com/pydantic/pydantic/blob/main/pyproject.toml)) 39 | - [*pytermgui*](https://pypi.org/project/pytermgui/) ([`pyproject.toml`](https://github.com/bczsalba/pytermgui/blob/master/pyproject.toml)) 40 | - [*scikit-build*](https://pypi.org/project/scikit-build/) ([`pyproject.toml`](https://github.com/scikit-build/scikit-build/blob/main/pyproject.toml)) 41 | - [*stamina*](https://pypi.org/project/stamina/) ([`pyproject.toml`](https://github.com/hynek/stamina/blob/main/pyproject.toml)) 42 | - [*structlog*](https://pypi.org/project/structlog/) ([`pyproject.toml`](https://github.com/hynek/structlog/blob/main/pyproject.toml)) 43 | - [Twisted](https://pypi.org/project/twisted/) ([`pyproject.toml`](https://github.com/twisted/twisted/blob/trunk/pyproject.toml)) 44 | 45 | *hatch-fancy-pypi-readme* doesn’t use itself to avoid a circular dependency that can be problematic in some cases. 46 | The shoemaker’s kids always go barefoot. 47 | 48 | 49 | 50 | Please [open a pull request](https://github.com/hynek/hatch-fancy-pypi-readme/edit/main/README.md) to add *your* ✨fancy✨ project! 51 | 52 | 53 | ## Motivation 54 | 55 | 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. 56 | 57 | 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/). 58 | Especially having a summary of the *latest* changes is something I’ve found users to appreciate. 59 | 60 | [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. 61 | Now *you* too can have fancy PyPI readmes – just by adding a few lines of configuration to your `pyproject.toml`. 62 | 63 | 64 | ## Configuration 65 | 66 | *hatch-fancy-pypi-readme* is, like [Hatch], configured in your project’s `pyproject.toml`[^hatch-toml]. 67 | 68 | [^hatch-toml]: As with Hatch, you can also use `hatch.toml` for configuration options that start with `tool.hatch` and leave that prefix out. 69 | That means `pyprojects.toml`’s `[tool.hatch.metadata.hooks.fancy-pypi-readme]` becomes `[metadata.hooks.fancy-pypi-readme]` when in `hatch.toml`. 70 | To keep the documentation simple, the more common `pyproject.toml` syntax is used throughout. 71 | 72 | First you add *hatch-fancy-pypi-readme* to your `[build-system]`: 73 | 74 | ```toml 75 | [build-system] 76 | requires = ["hatchling", "hatch-fancy-pypi-readme"] 77 | build-backend = "hatchling.build" 78 | ``` 79 | 80 | Next, you tell the build system that your readme is dynamic by adding it to the `project.dynamic` list: 81 | 82 | ```toml 83 | [project] 84 | # ... 85 | dynamic = ["readme"] 86 | ``` 87 | 88 | > [!IMPORTANT] 89 | > Don’t forget to remove the old `readme` key! 90 | 91 | Next, you add a `[tool.hatch.metadata.hooks.fancy-pypi-readme]` section. 92 | 93 | Here, you **must** supply a `content-type`. 94 | Currently, only `text/markdown` and `text/x-rst` are supported by PyPI. 95 | 96 | ```toml 97 | [tool.hatch.metadata.hooks.fancy-pypi-readme] 98 | content-type = "text/markdown" 99 | ``` 100 | 101 | 102 | ### Fragments 103 | 104 | Finally, you also **must** supply an *array* of `fragments`. 105 | A fragment is a piece of text that is appended to your readme in the order that it’s specified. 106 | 107 | 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. 108 | 109 | 110 | #### Text 111 | 112 | Text fragments consist of a single `text` key and are appended to the readme exactly as you specify them: 113 | 114 | ```toml 115 | [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] 116 | text = "Fragment #1" 117 | 118 | [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] 119 | text = "Fragment #2" 120 | ``` 121 | 122 | results in: 123 | 124 | ``` 125 | Fragment #1Fragment #2 126 | ``` 127 | 128 | Note that there’s no additional space or empty lines between fragments unless you specify them. 129 | 130 | 131 | #### File 132 | 133 | A file fragment reads a file specified by the `path` key and appends it: 134 | 135 | ```toml 136 | [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] 137 | path = "AUTHORS.md" 138 | ``` 139 | 140 | Additionally it’s possible to cut away parts of the file before appending it: 141 | 142 | - **`start-after`** cuts away everything *before and including* the string specified. 143 | - **`start-at`** cuts away everything before the string specified too, but the string itself is *preserved*. 144 | This is useful when you want to start at a heading without adding a marker *before* it. 145 | 146 | `start-after` and `start-at` are mutually exclusive. 147 | - **`end-before`** cuts away everything after. 148 | - **`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: `(.*?)`). 149 | Internally, it uses 150 | 151 | ```python 152 | re.search(pattern, whatever_is_left_after_slicing, re.DOTALL).group(1) 153 | ``` 154 | 155 | to find it. 156 | 157 | Both Markdown and reStructuredText (reST) have comments (`` and `.. this is a reST comment`) that you can use for invisible markers: 158 | 159 | ```markdown 160 | # Boring Header 161 | 162 | 163 | 164 | This is the *interesting* body! 165 | 166 | 167 | 168 | Uninteresting Footer 169 | ``` 170 | 171 | together with: 172 | 173 | ```toml 174 | [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] 175 | path = "path.md" 176 | start-after = "\n\n" 177 | end-before = "\n\n" 178 | pattern = "the (.*?) body" 179 | ``` 180 | 181 | would append: 182 | 183 | ```markdown 184 | *interesting* 185 | ``` 186 | 187 | to your readme. 188 | 189 | > [!TIP] 190 | > 191 | > - You can insert the same file **multiple times** – each time a different part! 192 | > - The order of the options in a fragment block does *not* matter. 193 | > They’re always executed in the same order: 194 | > 195 | > 1. `start-after` / `start-at` 196 | > 2. `end-before` 197 | > 3. `pattern` 198 | 199 | For a complete example, please see our [example configuration][example-config]. 200 | 201 | 202 | ## Substitutions 203 | 204 | 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: 205 | 206 | ```toml 207 | [[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]] 208 | pattern = "This is a (.*) that we'll replace later." 209 | replacement = 'It was a "\1"!' 210 | ignore-case = true # optional; false by default 211 | ``` 212 | 213 | --- 214 | 215 | Substitutions can be useful for replacing relative links with absolute ones: 216 | 217 | ```toml 218 | [[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]] 219 | # Literal TOML strings (single quotes) need no escaping of backslashes. 220 | pattern = '\[(.+?)\]\(((?!https?://)\S+?)\)' 221 | replacement = '[\1](https://github.com/hynek/hatch-fancy-pypi-readme/tree/main\g<2>)' 222 | ``` 223 | 224 | Or, expanding GitHub issue/pull request IDs to links: 225 | 226 | ```toml 227 | [[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]] 228 | # Regular TOML strings (double quotes) do need escaping. 229 | pattern = "#(\\d+)" 230 | replacement = "[#\\1](https://github.com/hynek/hatch-fancy-pypi-readme/issues/\\1)" 231 | ``` 232 | 233 | Or, replacing [GitHub-style callouts](https://github.com/orgs/community/discussions/16925) that aren't supported by PyPI with bolded text: 234 | 235 | ```toml 236 | [[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]] 237 | pattern = '\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]' 238 | replacement = '**\1**:' 239 | ``` 240 | 241 | Again, please check out our [example configuration][example-config] for a complete example. 242 | 243 | 244 | ### Referencing Packaging Metadata 245 | 246 | If the final readme contains the strings `$HFPR_PACKAGE_NAME` or `$HFPR_VERSION`, they will be replaced by the current package name or version. 247 | 248 | When running *hatch-fancy-pypi-readme* in CLI mode (as described in the next section), packaging metadata is not available. 249 | In that case `$HFPR_PACKAGE_NAME` is hardcoded to `your-package`, and `$HFPR_VERSION` to `42.0`, so you can still test your readme. 250 | 251 | 252 | ## CLI Interface 253 | 254 | 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. 255 | 256 | Your can run it either as `hatch-fancy-pypi-readme` or `python -m hatch_fancy_pypi_readme`. 257 | If you don’t pass an argument, it looks for a `pyproject.toml` in the current directory. 258 | You can optionally pass a `-o` option to write the output into a file instead of to standard out. 259 | 260 | Since *hatch-fancy-pypi-readme* is part of the isolated build system, it shouldn’t be installed along with your projects. 261 | Therefore we recommend running it using [*pipx*](https://pypa.github.io/pipx/): 262 | 263 | 264 | ```shell 265 | $ pipx run hatch-fancy-pypi-readme 266 | ``` 267 | 268 | --- 269 | 270 | 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. 271 | 272 | For example, if you run 273 | 274 | ```shell 275 | $ pipx run hatch-fancy-pypi-readme | pipx run rich-cli --markdown --hyperlinks - 276 | ``` 277 | 278 | with our [example configuration][example-config], you will get the following output: 279 | 280 | ![rich-cli output](rich-cli-out.svg) 281 | 282 | > [!WARNING] 283 | > 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. 284 | > 285 | > It will **not** help you debug **packaging issues**, though. 286 | > 287 | > 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. 288 | > 289 | > 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: 290 | > 291 | > - `hatch project metadata readme` gives you a rendered version of the readme. 292 | > - `hatch project metadata | jq -r .readme.text` gives you the raw Markdown (needs [*jq*](https://jqlang.github.io/jq/)). 293 | 294 | 295 | [example-config]: tests/example_pyproject.toml 296 | [Hatch]: https://hatch.pypa.io/ 297 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | 6 | [project] 7 | name = "hatch-fancy-pypi-readme" 8 | version = "25.2.0.dev0" 9 | description = "Fancy PyPI READMEs with Hatch" 10 | requires-python = ">=3.8" 11 | keywords = ["hatch", "pypi", "readme", "documentation"] 12 | authors = [{ name = "Hynek Schlawack", email = "hs@ox.cx" }] 13 | license = "MIT" 14 | license-files = ["LICENSE.txt"] 15 | classifiers = [ 16 | "Development Status :: 5 - Production/Stable", 17 | "Framework :: Hatch", 18 | "Operating System :: OS Independent", 19 | "Topic :: Software Development :: Build Tools", 20 | 21 | "Programming Language :: Python :: 3.8", 22 | "Programming Language :: Python :: 3.9", 23 | "Programming Language :: Python :: 3.10", 24 | "Programming Language :: Python :: 3.11", 25 | "Programming Language :: Python :: 3.12", 26 | "Programming Language :: Python :: 3.13", 27 | "Programming Language :: Python :: 3.14", 28 | ] 29 | dependencies = [ 30 | "hatchling", 31 | "tomli; python_version<'3.11'", 32 | ] 33 | 34 | [project.entry-points.hatch] 35 | fancy-pypi-readme = "hatch_fancy_pypi_readme.hooks" 36 | 37 | [project.scripts] 38 | hatch-fancy-pypi-readme = "hatch_fancy_pypi_readme.__main__:main" 39 | 40 | [project.optional-dependencies] 41 | tests = ["pytest", "build", "wheel"] 42 | dev = ["hatch-fancy-pypi-readme[tests]", "mypy"] 43 | 44 | [project.urls] 45 | Documentation = "https://github.com/hynek/hatch-fancy-pypi-readme#readme" 46 | Changelog = "https://github.com/hynek/hatch-fancy-pypi-readme/blob/main/CHANGELOG.md" 47 | "Source Code" = "https://github.com/hynek/hatch-fancy-pypi-readme" 48 | Funding = "https://github.com/sponsors/hynek" 49 | 50 | [project.readme] 51 | content-type = "text/markdown" 52 | text = """# Your ✨Fancy✨ Project Deserves a ✨Fancy✨ PyPI Readme! 53 | 54 | *hatch-fancy-pypi-readme* is an MIT-licensed metadata plugin for [Hatch](https://hatch.pypa.io/) by [Hynek Schlawack](https://hynek.me/). 55 | 56 | Its purpose is to help you to have fancy PyPI readmes – unlike *this* one you’re looking at right now. 57 | 58 | Please check out the [documentation](https://github.com/hynek/hatch-fancy-pypi-readme#readme) to see what *hatch-fancy-pypi-readme* can do for you and your projects! 59 | """ 60 | 61 | 62 | [tool.pytest.ini_options] 63 | addopts = ["-ra", "--strict-markers", "--strict-config"] 64 | xfail_strict = true 65 | testpaths = "tests" 66 | markers = ["slow: marks tests as slow (deselect with '-m \"not slow\"')"] 67 | filterwarnings = ["once::Warning"] 68 | 69 | 70 | [tool.coverage.run] 71 | parallel = true 72 | branch = true 73 | source = ["hatch_fancy_pypi_readme"] 74 | 75 | [tool.coverage.paths] 76 | source = ["src", ".tox/py*/**/site-packages"] 77 | 78 | [tool.coverage.report] 79 | show_missing = true 80 | skip_covered = true 81 | omit = ["src/hatch_fancy_pypi_readme/hooks.py"] 82 | exclude_lines = [ 83 | # a more strict default pragma 84 | "\\# pragma: no cover\\b", 85 | 86 | # allow defensive code 87 | "^\\s*raise AssertionError\\b", 88 | "^\\s*raise NotImplementedError\\b", 89 | "^\\s*return NotImplemented\\b", 90 | "^\\s*raise$", 91 | 92 | # typing-related code 93 | "^if (False|TYPE_CHECKING):", 94 | ": \\.\\.\\.(\\s*#.*)?$", 95 | "^ +\\.\\.\\.$", 96 | "-> ['\"]?NoReturn['\"]?:", 97 | ] 98 | partial_branches = [ 99 | "pragma: no branch", 100 | # _cli._fail never returns, creating uncovered branches as far as coverage.py 101 | # is concerned. See 102 | # https://github.com/nedbat/coveragepy/issues/1433#issuecomment-1211465570 103 | "^\\s*_fail\\(", 104 | ] 105 | 106 | 107 | [tool.mypy] 108 | strict = true 109 | follow_imports = "normal" 110 | enable_error_code = ["ignore-without-code"] 111 | show_error_codes = true 112 | warn_no_return = true 113 | ignore_missing_imports = true 114 | 115 | [[tool.mypy.overrides]] 116 | module = "tests.*" 117 | ignore_errors = true 118 | 119 | 120 | [tool.ruff] 121 | src = ["src", "tests"] 122 | line-length = 79 123 | 124 | [tool.ruff.lint] 125 | select = ["ALL"] 126 | ignore = [ 127 | "ANN", # Mypy is better at this. 128 | "C901", # Leave complexity to me. 129 | "COM", # Leave commas to formatter. 130 | "D", # We have different ideas about docstrings. 131 | "E501", # leave line-length enforcement to formatter. 132 | "ISC001", # conflicts with formatter 133 | "PLR0912", # Leave complexity to me. 134 | "TRY301", # Raise in try blocks can totally make sense. 135 | ] 136 | 137 | [tool.ruff.lint.per-file-ignores] 138 | "src/hatch_fancy_pypi_readme/_cli.py" = ["T201"] # need print in CLI 139 | "tests/*" = [ 140 | "PLC1901", # empty strings are falsey, but are less specific in tests 141 | "S", # Security is not an issue in our tests. 142 | "SIM300", # Yoda rocks in tests 143 | ] 144 | 145 | [tool.ruff.lint.isort] 146 | lines-between-types = 1 147 | lines-after-imports = 2 148 | -------------------------------------------------------------------------------- /rich-cli-out.svg: -------------------------------------------------------------------------------- 1 | Rich╔═════════════════════════════════════════════════════════════════════════════╗ 2 | Level 1 Header 3 | ╚═════════════════════════════════════════════════════════════════════════════╝ 4 | 5 | This is Markdown in a literal string. 6 | 7 | Let's import AUTHORS.md without its header and last paragraph next: 8 | 9 | hatch-fancy-pypi-readme is written and maintained by Hynek Schlawack and  10 | released under the MIT license. 11 | 12 | The development is kindly supported by Variomedia AG and all my amazing GitHub  13 | Sponsors. 14 | 15 | ─────────────────────────────────────────────────────────────────────────────── 16 | Now let's add an extract from tests/example_changelog.md: 17 | 18 | 19 | 1.1.0 - 2022-08-04 20 | 21 | Added 22 | 23 |  • Neat features. #4 24 |  • Here's a GitHub-relative link -- that would make no sense in a PyPI readme! 25 | 26 | Fixed 27 | 28 |  • Nasty bugs. #3 29 | 30 | ─────────────────────────────────────────────────────────────────────────────── 31 | Pretty cool, huh? ✨ 32 | -------------------------------------------------------------------------------- /src/hatch_fancy_pypi_readme/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Hynek Schlawack 2 | # 3 | # SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /src/hatch_fancy_pypi_readme/__main__.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Hynek Schlawack 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | from __future__ import annotations 6 | 7 | import argparse 8 | import sys 9 | 10 | from contextlib import closing 11 | from pathlib import Path 12 | from typing import TextIO 13 | 14 | from ._cli import cli_run 15 | 16 | 17 | if sys.version_info < (3, 11): 18 | import tomli as tomllib 19 | else: 20 | import tomllib 21 | 22 | 23 | def main() -> None: 24 | parser = argparse.ArgumentParser( 25 | description="Render a README from a pyproject.toml & hatch.toml." 26 | " If a hatch.toml is passed / detected, it's preferred." 27 | ) 28 | parser.add_argument( 29 | "pyproject_path", 30 | nargs="?", 31 | metavar="PATH-TO-PYPROJECT.TOML", 32 | default="pyproject.toml", 33 | help="Path to the pyproject.toml to use for rendering. " 34 | "Default: pyproject.toml in current directory.", 35 | ) 36 | parser.add_argument( 37 | "--hatch-toml", 38 | nargs="?", 39 | metavar="PATH-TO-HATCH.TOML", 40 | default=None, 41 | help="Path to an additional hatch.toml to use for rendering. " 42 | "Default: Auto-detect in the current directory.", 43 | ) 44 | parser.add_argument( 45 | "-o", 46 | help="Target file for output. Default: standard out.", 47 | metavar="TARGET-FILE-PATH", 48 | ) 49 | args = parser.parse_args() 50 | 51 | pyproject = tomllib.loads(Path(args.pyproject_path).read_text()) 52 | hatch_toml = _maybe_load_hatch_toml(args.hatch_toml) 53 | 54 | out: TextIO 55 | out = Path(args.o).open("w") if args.o else sys.stdout # noqa: SIM115 56 | 57 | with closing(out): 58 | cli_run(pyproject, hatch_toml, out) 59 | 60 | 61 | def _maybe_load_hatch_toml(hatch_toml_arg: str | None) -> dict[str, object]: 62 | """ 63 | If hatch.toml is passed or detected, load it. 64 | """ 65 | if hatch_toml_arg: 66 | return tomllib.loads(Path(hatch_toml_arg).read_text()) 67 | 68 | if Path("hatch.toml").exists(): 69 | return tomllib.loads(Path("hatch.toml").read_text()) 70 | 71 | return {} 72 | 73 | 74 | if __name__ == "__main__": 75 | main() 76 | -------------------------------------------------------------------------------- /src/hatch_fancy_pypi_readme/_builder.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Hynek Schlawack 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | from __future__ import annotations 6 | 7 | from typing import TYPE_CHECKING 8 | 9 | 10 | if TYPE_CHECKING: 11 | from ._fragments import Fragment 12 | from ._substitutions import Substituter 13 | 14 | 15 | def build_text( 16 | fragments: list[Fragment], 17 | substitutions: list[Substituter], 18 | package_name: str = "", 19 | version: str = "", 20 | ) -> str: 21 | """ 22 | Try avoiding breaking the API unnecessarily; it's used directly by 23 | scikit-build-core. 24 | """ 25 | text = "".join(f.render() for f in fragments) 26 | 27 | for sub in substitutions: 28 | text = sub.substitute(text) 29 | 30 | return text.replace("$HFPR_PACKAGE_NAME", package_name).replace( 31 | "$HFPR_VERSION", version 32 | ) 33 | -------------------------------------------------------------------------------- /src/hatch_fancy_pypi_readme/_cli.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Hynek Schlawack 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | from __future__ import annotations 6 | 7 | import sys 8 | 9 | from contextlib import suppress 10 | from typing import Any, NoReturn, TextIO 11 | 12 | from hatch_fancy_pypi_readme.exceptions import ConfigurationError 13 | 14 | from ._builder import build_text 15 | from ._config import load_and_validate_config 16 | 17 | 18 | def cli_run( 19 | pyproject: dict[str, Any], hatch_toml: dict[str, Any], out: TextIO 20 | ) -> None: 21 | """ 22 | Best-effort verify config and print resulting PyPI readme. 23 | """ 24 | is_dynamic = False 25 | with suppress(KeyError): 26 | is_dynamic = "readme" in pyproject["project"]["dynamic"] 27 | 28 | if not is_dynamic: 29 | _fail("You must add 'readme' to 'project.dynamic'.") 30 | 31 | try: 32 | if ( 33 | pyproject["tool"]["hatch"]["metadata"]["hooks"][ 34 | "fancy-pypi-readme" 35 | ] 36 | and hatch_toml["metadata"]["hooks"]["fancy-pypi-readme"] 37 | ): 38 | _fail( 39 | "Both pyproject.toml and hatch.toml contain " 40 | "hatch-fancy-pypi-readme configuration." 41 | ) 42 | except KeyError: 43 | pass 44 | 45 | try: 46 | cfg = hatch_toml["metadata"]["hooks"]["fancy-pypi-readme"] 47 | except KeyError: 48 | try: 49 | cfg = pyproject["tool"]["hatch"]["metadata"]["hooks"][ 50 | "fancy-pypi-readme" 51 | ] 52 | except KeyError: 53 | _fail( 54 | "Missing configuration " 55 | "(`[tool.hatch.metadata.hooks.fancy-pypi-readme]` in" 56 | " pyproject.toml or `[metadata.hooks.fancy-pypi-readme]`" 57 | " in hatch.toml)", 58 | ) 59 | 60 | try: 61 | config = load_and_validate_config(cfg) 62 | except ConfigurationError as e: 63 | _fail( 64 | "Configuration has errors:\n\n" 65 | + "\n".join(f"- {msg}" for msg in e.errors), 66 | ) 67 | 68 | print( 69 | build_text( 70 | config.fragments, config.substitutions, "your-package", "42.0" 71 | ), 72 | file=out, 73 | ) 74 | 75 | 76 | def _fail(msg: str) -> NoReturn: 77 | print(msg, file=sys.stderr) 78 | sys.exit(1) 79 | -------------------------------------------------------------------------------- /src/hatch_fancy_pypi_readme/_config.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Hynek Schlawack 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | from __future__ import annotations 6 | 7 | from dataclasses import dataclass 8 | from typing import Any, cast 9 | 10 | from ._fragments import VALID_FRAGMENTS, Fragment 11 | from ._substitutions import Substituter 12 | from .exceptions import ConfigurationError 13 | 14 | 15 | @dataclass 16 | class Config: 17 | content_type: str 18 | fragments: list[Fragment] 19 | substitutions: list[Substituter] 20 | 21 | 22 | _BASE = "tool.hatch.metadata.hooks.fancy-pypi-readme." 23 | 24 | 25 | def load_and_validate_config(config: dict[str, Any]) -> Config: 26 | errs = [] 27 | 28 | ct = config.get("content-type") 29 | if ct is None: 30 | errs.append(f"{_BASE}content-type is missing.") 31 | elif ct not in ("text/markdown", "text/x-rst"): 32 | errs.append( 33 | f"{_BASE}content-type: '{ct}' is not one of " 34 | "['text/markdown', 'text/x-rst']" 35 | ) 36 | 37 | try: 38 | fragments = _load_fragments(config.get("fragments")) 39 | except ConfigurationError as e: 40 | errs.extend(e.errors) 41 | 42 | try: 43 | subs_cfg = config.get("substitutions", []) 44 | if not isinstance(subs_cfg, list): 45 | raise ConfigurationError( 46 | [f"{_BASE}substitutions must be an array."] 47 | ) 48 | 49 | substitutions = [ 50 | Substituter.from_config(sub_cfg) for sub_cfg in subs_cfg 51 | ] 52 | except ConfigurationError as e: 53 | errs.extend(e.errors) 54 | 55 | if errs: 56 | raise ConfigurationError(errs) 57 | 58 | return Config( 59 | content_type=cast("str", ct), 60 | fragments=fragments, 61 | substitutions=substitutions, 62 | ) 63 | 64 | 65 | def _load_fragments(config: list[dict[str, str]] | None) -> list[Fragment]: 66 | """ 67 | Load fragments from *config*. 68 | """ 69 | if config is None: 70 | raise ConfigurationError([f"{_BASE}fragments is missing."]) 71 | if not config: 72 | raise ConfigurationError([f"{_BASE}fragments must not be empty."]) 73 | 74 | frags = [] 75 | errs = [] 76 | 77 | for frag_cfg in config: 78 | for frag in VALID_FRAGMENTS: 79 | if frag.key not in frag_cfg: 80 | continue 81 | 82 | try: 83 | frags.append(frag.from_config(frag_cfg)) 84 | except ConfigurationError as e: 85 | errs.extend(e.errors) 86 | 87 | # We have either detected and added or detected and errored, but in 88 | # any case we're done with this fragment. 89 | break 90 | else: 91 | errs.append(f"Unknown fragment type {frag_cfg!r}.") 92 | 93 | if errs: 94 | raise ConfigurationError(errs) 95 | 96 | return frags 97 | -------------------------------------------------------------------------------- /src/hatch_fancy_pypi_readme/_fragments.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Hynek Schlawack 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | from __future__ import annotations 6 | 7 | import re 8 | 9 | from dataclasses import dataclass 10 | from pathlib import Path 11 | from typing import ClassVar, Iterable, Protocol 12 | 13 | from .exceptions import ConfigurationError 14 | 15 | 16 | class Fragment(Protocol): 17 | key: ClassVar[str] 18 | 19 | @classmethod 20 | def from_config(cls, cfg: dict[str, str]) -> Fragment: ... 21 | 22 | def render(self) -> str: ... 23 | 24 | 25 | @dataclass 26 | class TextFragment: 27 | """ 28 | A static text fragment. 29 | """ 30 | 31 | key: ClassVar[str] = "text" 32 | 33 | _text: str 34 | 35 | @classmethod 36 | def from_config(cls, cfg: dict[str, str]) -> Fragment: 37 | text = cfg[cls.key] 38 | if not text: 39 | raise ConfigurationError(["Text fragments must not be empty."]) 40 | 41 | return cls(text) 42 | 43 | def render(self) -> str: 44 | return self._text 45 | 46 | 47 | @dataclass 48 | class FileFragment: 49 | """ 50 | A static text fragment. 51 | """ 52 | 53 | key: ClassVar[str] = "path" 54 | 55 | _contents: str 56 | 57 | @classmethod 58 | def from_config(cls, cfg: dict[str, str]) -> Fragment: 59 | path = Path(cfg.pop(cls.key)) 60 | start_after = cfg.pop("start-after", None) 61 | start_at = cfg.pop("start-at", None) 62 | end_before = cfg.pop("end-before", None) 63 | pattern = cfg.pop("pattern", None) 64 | 65 | errs: list[str] = [] 66 | 67 | try: 68 | contents = path.read_text(encoding="utf-8") 69 | except FileNotFoundError: 70 | raise ConfigurationError( 71 | [f"Fragment file '{path}' not found."] 72 | ) from None 73 | 74 | if start_after and start_at: 75 | raise ConfigurationError( 76 | [ 77 | "file fragment: 'start-after' and 'start-at' are " 78 | "mutually exclusive." 79 | ] 80 | ) 81 | 82 | if start_after is not None: 83 | try: 84 | _, contents = contents.split(start_after, 1) 85 | except ValueError: 86 | errs.append( 87 | f"file fragment: 'start-after' {start_after!r} not found." 88 | ) 89 | elif start_at is not None: 90 | p = contents.find(start_at) 91 | if p == -1: 92 | errs.append( 93 | f"file fragment: 'start-at' {start_at!r} not found." 94 | ) 95 | contents = contents[p:] 96 | 97 | if end_before is not None: 98 | try: 99 | contents, _ = contents.split(end_before, 1) 100 | except ValueError: 101 | errs.append( 102 | f"file fragment: 'end-before' {end_before!r} not found." 103 | ) 104 | 105 | if pattern: 106 | m = re.search(pattern, contents, re.DOTALL) 107 | if not m: 108 | errs.append(f"file fragment: pattern {pattern!r} not found.") 109 | else: 110 | try: 111 | contents = m.group(1) 112 | except IndexError: 113 | errs.append( 114 | "file fragment: pattern matches, but no group defined." 115 | ) 116 | 117 | if errs: 118 | raise ConfigurationError(errs) 119 | 120 | return cls(contents) 121 | 122 | def render(self) -> str: 123 | return self._contents 124 | 125 | 126 | VALID_FRAGMENTS: Iterable[type[Fragment]] = (TextFragment, FileFragment) 127 | -------------------------------------------------------------------------------- /src/hatch_fancy_pypi_readme/_substitutions.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Hynek Schlawack 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | from __future__ import annotations 6 | 7 | import re 8 | 9 | from dataclasses import dataclass 10 | from typing import cast 11 | 12 | from hatch_fancy_pypi_readme.exceptions import ConfigurationError 13 | 14 | 15 | @dataclass 16 | class Substituter: 17 | pattern: re.Pattern[str] 18 | replacement: str 19 | 20 | @classmethod 21 | def from_config(cls, cfg: dict[str, str]) -> Substituter: 22 | errs = [] 23 | flags = 0 24 | 25 | ignore_case = cfg.get("ignore-case", False) 26 | if not isinstance(ignore_case, bool): 27 | errs.append( 28 | f"Value {ignore_case!r} for 'ignore-case' is not a bool." 29 | ) 30 | 31 | if ignore_case: 32 | flags += re.IGNORECASE 33 | 34 | try: 35 | pattern = re.compile(cfg["pattern"], flags=flags) 36 | except KeyError: 37 | errs.append(f"Substitution {cfg} is missing a 'pattern' key.") 38 | except re.error as e: 39 | errs.append( 40 | f"{cfg['pattern']!r} is not a valid regular expression: {e}" 41 | ) 42 | 43 | replacement = cfg.get("replacement") 44 | if replacement is None: 45 | errs.append(f"Substitution {cfg} is missing a 'replacement' key.") 46 | elif not isinstance(replacement, str): 47 | errs.append(f"Replacement value {replacement!r} is not a string.") 48 | 49 | if errs: 50 | raise ConfigurationError(errs) 51 | 52 | return cls(pattern, cast("str", replacement)) 53 | 54 | def substitute(self, text: str) -> str: 55 | return self.pattern.sub(self.replacement, text) 56 | -------------------------------------------------------------------------------- /src/hatch_fancy_pypi_readme/exceptions.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Hynek Schlawack 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | from __future__ import annotations 6 | 7 | from dataclasses import dataclass 8 | 9 | 10 | @dataclass 11 | class ConfigurationError(Exception): 12 | """ 13 | Configuration is invalid. 14 | """ 15 | 16 | errors: list[str] 17 | -------------------------------------------------------------------------------- /src/hatch_fancy_pypi_readme/hooks.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Hynek Schlawack 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | from __future__ import annotations 6 | 7 | from typing import Any 8 | 9 | from hatchling.metadata.plugin.interface import MetadataHookInterface 10 | from hatchling.plugin import hookimpl 11 | 12 | from ._builder import build_text 13 | from ._config import load_and_validate_config 14 | 15 | 16 | class FancyReadmeMetadataHook(MetadataHookInterface): 17 | PLUGIN_NAME = "fancy-pypi-readme" 18 | 19 | def update(self, metadata: dict[str, Any]) -> None: 20 | """ 21 | Update the project table's metadata. 22 | """ 23 | config = load_and_validate_config(self.config) 24 | 25 | metadata["readme"] = { 26 | "content-type": config.content_type, 27 | "text": build_text( 28 | config.fragments, 29 | config.substitutions, 30 | package_name=metadata.get("name", ""), 31 | version=metadata.get("version", ""), 32 | ), 33 | } 34 | 35 | 36 | @hookimpl 37 | def hatch_register_metadata_hook() -> type[MetadataHookInterface]: 38 | return FancyReadmeMetadataHook 39 | -------------------------------------------------------------------------------- /src/hatch_fancy_pypi_readme/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hynek/hatch-fancy-pypi-readme/3953d47651c7a706124dbd46926d5e27b9329545/src/hatch_fancy_pypi_readme/py.typed -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Hynek Schlawack 2 | # 3 | # SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Hynek Schlawack 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | import shutil 6 | 7 | from pathlib import Path 8 | from tempfile import TemporaryDirectory 9 | 10 | import pytest 11 | 12 | 13 | @pytest.fixture(name="plugin_dir", scope="session") 14 | def _plugin_dir(): 15 | """ 16 | Install the plugin into a temporary directory with a random path to 17 | prevent pip from caching it. 18 | 19 | Copy only the src directory, pyproject.toml, and whatever is needed 20 | to build ourselves. 21 | """ 22 | with TemporaryDirectory() as d: 23 | directory = Path(d, "plugin") 24 | shutil.copytree(Path.cwd() / "src", directory / "src") 25 | for fn in [ 26 | "pyproject.toml", 27 | "AUTHORS.md", 28 | "CHANGELOG.md", 29 | "LICENSE.txt", 30 | "README.md", 31 | ]: 32 | shutil.copy(Path.cwd() / fn, directory / fn) 33 | 34 | yield directory.resolve() 35 | 36 | 37 | @pytest.fixture(name="new_project") 38 | def _new_project(plugin_dir, tmp_path, monkeypatch): 39 | """ 40 | Create, and cd into, a blank new project that is configured to use our 41 | temporary plugin installation. 42 | """ 43 | project_dir = tmp_path / "my-app" 44 | project_dir.mkdir() 45 | 46 | project_file = project_dir / "pyproject.toml" 47 | project_file.write_text( 48 | f"""\ 49 | [build-system] 50 | requires = ["hatchling", "hatch-fancy-pypi-readme @ {plugin_dir.as_uri()}"] 51 | build-backend = "hatchling.build" 52 | 53 | [project] 54 | name = "my-app" 55 | version = "1.0" 56 | dynamic = ["readme"] 57 | """, 58 | encoding="utf-8", 59 | ) 60 | 61 | package_dir = project_dir / "src" / "my_app" 62 | package_dir.mkdir(parents=True) 63 | 64 | package_root = package_dir / "__init__.py" 65 | package_root.write_text("") 66 | 67 | monkeypatch.chdir(project_dir) 68 | 69 | return project_dir 70 | -------------------------------------------------------------------------------- /tests/example_changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | This is a long-winded preamble that explains versioning and backwards-compatibility guarantees. 4 | Your don't want this as part of your PyPI readme! 5 | 6 | Note that there's issue/PR IDs behind the changelog entries. 7 | Wouldn't it be nice if they were links in your PyPI readme? 8 | 9 | 10 | 11 | 12 | ## 1.1.0 - 2022-08-04 13 | 14 | ### Added 15 | 16 | - Neat features. #4 17 | - Here's a [GitHub-relative link](README.md) -- that would make no sense in a PyPI readme! 18 | 19 | ### Fixed 20 | 21 | - Nasty bugs. #3 22 | 23 | 24 | ## 1.0.0 - 2021-12-16 25 | 26 | ### Added 27 | 28 | - Everything. #2 29 | 30 | 31 | ## 0.0.1 - 2020-03-01 32 | 33 | ### Removed 34 | 35 | - Precedency. #1 36 | -------------------------------------------------------------------------------- /tests/example_pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling", "hatch-fancy-pypi-readme"] 3 | build-backend = "hatchling.build" 4 | 5 | 6 | [project] 7 | name = "my-pkg" 8 | version = "1.0" 9 | dynamic = ["readme"] 10 | 11 | 12 | [tool.hatch.metadata.hooks.fancy-pypi-readme] 13 | content-type = "text/markdown" 14 | 15 | [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] 16 | text = '''# Level 1 Header 17 | 18 | This is *Markdown* in a literal string. 19 | 20 | Let's import `AUTHORS.md` without its header and last paragraph next: 21 | 22 | ''' 23 | 24 | [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] 25 | path = "AUTHORS.md" 26 | start-after = "Authors\n" 27 | end-before = "A full" 28 | 29 | [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] 30 | text = """ 31 | --- 32 | 33 | Now let's add an extract from [`tests/example_changelog.md`](https://github.com/hynek/hatch-fancy-pypi-readme/blob/main/tests/example_changelog.md): 34 | 35 | """ 36 | 37 | [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] 38 | path = "tests/example_changelog.md" 39 | pattern = "\n\n\n(.*?)\n\n## " 40 | 41 | [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] 42 | text = "\n---\n\nPretty **cool**, huh? ✨" 43 | 44 | [[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]] 45 | pattern = "#(\\d+)" 46 | replacement = "[#\\1](https://github.com/hynek/hatch-fancy-pypi-readme/issues/\\1)" 47 | 48 | [[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]] 49 | pattern = '\[(.+?)\]\(((?!https?://)\S+?)\)' 50 | replacement = '[\1](https://github.com/hynek/hatch-fancy-pypi-readme/tree/main/\g<2>)' 51 | -------------------------------------------------------------------------------- /tests/example_text.md: -------------------------------------------------------------------------------- 1 | # Boring Header 2 | 3 | 4 | 5 | This is the *interesting* body! 6 | 7 | 8 | 9 | Uninteresting Footer 10 | -------------------------------------------------------------------------------- /tests/test_builder.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Hynek Schlawack 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | from hatch_fancy_pypi_readme._builder import build_text 6 | from hatch_fancy_pypi_readme._fragments import TextFragment 7 | 8 | 9 | class TestBuildText: 10 | def test_single_text_fragment(self): 11 | """ 12 | A single text fragment becomes the readme. 13 | """ 14 | assert "This is the README for your-package 1.0!" == build_text( 15 | [ 16 | TextFragment( 17 | "This is the README for $HFPR_PACKAGE_NAME $HFPR_VERSION!" 18 | ) 19 | ], 20 | [], 21 | "your-package", 22 | "1.0", 23 | ) 24 | 25 | def test_multiple_text_fragment(self): 26 | """ 27 | A multiple text fragment are concatenated without adding any 28 | characters. 29 | """ 30 | assert "# Level 1\n\nThis is the README!" == build_text( 31 | [ 32 | TextFragment("# Level 1\n\n"), 33 | TextFragment("This is the README!"), 34 | ], 35 | [], 36 | "your-package", 37 | "1.0", 38 | ) 39 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Hynek Schlawack 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | import sys 6 | 7 | from io import StringIO 8 | from pathlib import Path 9 | 10 | import pytest 11 | 12 | from hatch_fancy_pypi_readme.__main__ import _maybe_load_hatch_toml, tomllib 13 | from hatch_fancy_pypi_readme._cli import cli_run 14 | 15 | from .utils import run 16 | 17 | 18 | @pytest.fixture(name="pyproject", scope="session") 19 | def _pyproject(): 20 | return tomllib.loads( 21 | (Path("tests") / "example_pyproject.toml").read_text() 22 | ) 23 | 24 | 25 | @pytest.fixture(name="empty_pyproject") 26 | def _empty_pyproject(): 27 | return { 28 | "project": {"dynamic": ["foo", "readme", "bar"]}, 29 | "tool": {"hatch": {"metadata": {"hooks": {"fancy-pypi-readme": {}}}}}, 30 | } 31 | 32 | 33 | class TestCLIEndToEnd: 34 | @pytest.mark.usefixtures("new_project") 35 | def test_missing_config(self): 36 | """ 37 | Missing configuration is caught and gives helpful advice. 38 | 39 | Run it as it would be run by the user. 40 | """ 41 | out = run("hatch_fancy_pypi_readme", check=False) 42 | 43 | assert ( 44 | "Missing configuration " 45 | "(`[tool.hatch.metadata.hooks.fancy-pypi-readme]` in" 46 | " pyproject.toml or `[metadata.hooks.fancy-pypi-readme]` in" 47 | " hatch.toml)\n" == out 48 | ) 49 | 50 | def test_ok(self): 51 | """ 52 | A valid config is rendered. 53 | """ 54 | out = run("hatch_fancy_pypi_readme", "tests/example_pyproject.toml") 55 | 56 | assert out.startswith("# Level 1 Header") 57 | assert "1.0.0" not in out 58 | 59 | # Check substitutions 60 | assert ( 61 | "[GitHub-relative link](https://github.com/hynek/" 62 | "hatch-fancy-pypi-readme/tree/main/README.md)" in out 63 | ) 64 | assert ( 65 | "Neat features. [#4](https://github.com/hynek/" 66 | "hatch-fancy-pypi-readme/issues/4)" in out 67 | ) 68 | 69 | def test_ok_redirect(self, tmp_path): 70 | """ 71 | It's possible to redirect output into a file. 72 | """ 73 | out = tmp_path / "out.txt" 74 | 75 | assert "" == run( 76 | "hatch_fancy_pypi_readme", 77 | "tests/example_pyproject.toml", 78 | "-o", 79 | str(out), 80 | ) 81 | 82 | assert out.read_text().startswith("# Level 1 Header") 83 | 84 | def test_empty_explicit_hatch_toml(self, tmp_path): 85 | """ 86 | Explicit empty hatch.toml is ignored. 87 | """ 88 | hatch_toml = tmp_path / "hatch.toml" 89 | hatch_toml.write_text("") 90 | 91 | assert run( 92 | "hatch_fancy_pypi_readme", 93 | "tests/example_pyproject.toml", 94 | f"--hatch-toml={hatch_toml.resolve()}", 95 | ).startswith("# Level 1 Header") 96 | 97 | def test_config_in_hatch_toml(self, tmp_path, monkeypatch): 98 | """ 99 | Implicit empty hatch.toml is used. 100 | """ 101 | pyproject = tmp_path / "pyproject.toml" 102 | pyproject.write_text( 103 | """\ 104 | [build-system] 105 | requires = ["hatchling", "hatch-fancy-pypi-readme"] 106 | build-backend = "hatchling.build" 107 | 108 | [project] 109 | name = "my-pkg" 110 | version = "1.0" 111 | dynamic = ["readme"] 112 | """ 113 | ) 114 | hatch_toml = tmp_path / "hatch.toml" 115 | hatch_toml.write_text( 116 | """\ 117 | [metadata.hooks.fancy-pypi-readme] 118 | content-type = "text/markdown" 119 | 120 | [[metadata.hooks.fancy-pypi-readme.fragments]] 121 | text = '# Level 1 Header' 122 | """ 123 | ) 124 | 125 | monkeypatch.chdir(tmp_path) 126 | 127 | assert run("hatch_fancy_pypi_readme").startswith("# Level 1 Header") 128 | 129 | 130 | class TestCLI: 131 | def test_cli_run_missing_dynamic(self, capfd): 132 | """ 133 | Missing readme in dynamic is caught and gives helpful advice. 134 | """ 135 | with pytest.raises(SystemExit): 136 | cli_run({}, {}, sys.stdout) 137 | 138 | out, err = capfd.readouterr() 139 | 140 | assert "You must add 'readme' to 'project.dynamic'.\n" == err 141 | assert "" == out 142 | 143 | def test_cli_run_missing_config(self, capfd): 144 | """ 145 | Missing configuration is caught and gives helpful advice. 146 | """ 147 | with pytest.raises(SystemExit): 148 | cli_run( 149 | {"project": {"dynamic": ["foo", "readme", "bar"]}}, 150 | {}, 151 | sys.stdout, 152 | ) 153 | 154 | out, err = capfd.readouterr() 155 | 156 | assert ( 157 | "Missing configuration " 158 | "(`[tool.hatch.metadata.hooks.fancy-pypi-readme]` in" 159 | " pyproject.toml or `[metadata.hooks.fancy-pypi-readme]` in" 160 | " hatch.toml)\n" == err 161 | ) 162 | assert "" == out 163 | 164 | def test_cli_run_two_configs(self, capfd): 165 | """ 166 | Ambiguous two configs. 167 | """ 168 | meta = { 169 | "metadata": { 170 | "hooks": { 171 | "fancy-pypi-readme": {"content-type": "text/markdown"} 172 | } 173 | } 174 | } 175 | with pytest.raises(SystemExit): 176 | cli_run( 177 | { 178 | "project": { 179 | "dynamic": ["foo", "readme", "bar"], 180 | }, 181 | "tool": {"hatch": meta}, 182 | }, 183 | meta, 184 | sys.stdout, 185 | ) 186 | 187 | out, err = capfd.readouterr() 188 | 189 | assert ( 190 | "Both pyproject.toml and hatch.toml contain " 191 | "hatch-fancy-pypi-readme configuration.\n" == err 192 | ) 193 | assert "" == out 194 | 195 | def test_cli_run_config_error(self, capfd, empty_pyproject): 196 | """ 197 | Configuration errors are detected and give helpful advice. 198 | """ 199 | with pytest.raises(SystemExit): 200 | cli_run(empty_pyproject, {}, sys.stdout) 201 | 202 | out, err = capfd.readouterr() 203 | 204 | assert ( 205 | "Configuration has errors:\n\n" 206 | "- tool.hatch.metadata.hooks.fancy-pypi-readme." 207 | "content-type is missing.\n" 208 | "- tool.hatch.metadata.hooks.fancy-pypi-readme.fragments " 209 | "is missing.\n" == err 210 | ) 211 | assert "" == out 212 | 213 | def test_cli_run_ok(self, capfd, pyproject): 214 | """ 215 | Correct configuration gives correct output to the file selected. 216 | """ 217 | sio = StringIO() 218 | 219 | cli_run(pyproject, {}, sio) 220 | 221 | out, err = capfd.readouterr() 222 | 223 | assert "" == err 224 | assert "" == out 225 | assert sio.getvalue().startswith("# Level 1 Header") 226 | 227 | 228 | class TestMaybeLoadHatchToml: 229 | def test_none(self, tmp_path, monkeypatch): 230 | """ 231 | If nothing is passed and not hatch.toml is found, return empty dict. 232 | """ 233 | monkeypatch.chdir(tmp_path) 234 | 235 | assert {} == _maybe_load_hatch_toml(None) 236 | 237 | def test_explicit(self, tmp_path, monkeypatch): 238 | """ 239 | If one is passed, return its parsed content and ignore files called 240 | hatch.toml. 241 | """ 242 | monkeypatch.chdir(tmp_path) 243 | 244 | hatch_toml = tmp_path / "hatch.toml" 245 | hatch_toml.write_text("gibberish") 246 | 247 | not_hatch_toml = tmp_path / "not-hatch.toml" 248 | not_hatch_toml.write_text("[foo]\nbar='qux'") 249 | 250 | assert {"foo": {"bar": "qux"}} == _maybe_load_hatch_toml( 251 | str(not_hatch_toml) 252 | ) 253 | 254 | def test_implicit(self, tmp_path, monkeypatch): 255 | """ 256 | If none is passed, but a hatch.toml is present in current dir, parse 257 | it. 258 | """ 259 | monkeypatch.chdir(tmp_path) 260 | 261 | hatch_toml = tmp_path / "hatch.toml" 262 | hatch_toml.write_text("[foo]\nbar='qux'") 263 | 264 | assert {"foo": {"bar": "qux"}} == _maybe_load_hatch_toml(None) 265 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Hynek Schlawack 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | import pytest 6 | 7 | from hatch_fancy_pypi_readme._config import load_and_validate_config 8 | from hatch_fancy_pypi_readme.exceptions import ConfigurationError 9 | 10 | 11 | class TestValidateConfig: 12 | @pytest.mark.parametrize( 13 | "cfg", 14 | [{"content-type": "text/markdown", "fragments": [{"text": "foo"}]}], 15 | ) 16 | def test_valid(self, cfg): 17 | """ 18 | Valid configurations return empty error lists. 19 | """ 20 | load_and_validate_config(cfg) 21 | 22 | def test_missing_content_type(self): 23 | """ 24 | Missing content-type is caught. 25 | """ 26 | with pytest.raises(ConfigurationError) as ei: 27 | load_and_validate_config({"fragments": [{"text": "foo"}]}) 28 | 29 | assert ( 30 | [ 31 | "tool.hatch.metadata.hooks.fancy-pypi-readme." 32 | "content-type is missing." 33 | ] 34 | == ei.value.errors 35 | == ei.value.args[0] 36 | ) 37 | 38 | def test_wrong_content_type(self): 39 | """ 40 | Missing content-type is caught. 41 | """ 42 | with pytest.raises(ConfigurationError) as ei: 43 | load_and_validate_config( 44 | {"content-type": "text/html", "fragments": [{"text": "foo"}]} 45 | ) 46 | 47 | assert [ 48 | "tool.hatch.metadata.hooks.fancy-pypi-readme.content-type: " 49 | "'text/html' is not one of ['text/markdown', 'text/x-rst']" 50 | ] == ei.value.errors 51 | 52 | 53 | VALID_FOR_FRAG = {"content-type": "text/markdown"} 54 | 55 | 56 | def cow_add_frag(**kw): 57 | d = VALID_FOR_FRAG.copy() 58 | d["fragments"] = [kw] 59 | 60 | return d 61 | 62 | 63 | class TestValidateConfigFragments: 64 | def test_empty_fragments(self): 65 | """ 66 | Empty fragments are caught. 67 | """ 68 | with pytest.raises(ConfigurationError) as ei: 69 | load_and_validate_config( 70 | {"content-type": "text/markdown", "fragments": []} 71 | ) 72 | 73 | assert ( 74 | [ 75 | "tool.hatch.metadata.hooks.fancy-pypi-readme.fragments must " 76 | "not be empty." 77 | ] 78 | == ei.value.errors 79 | == ei.value.args[0] 80 | ) 81 | 82 | def test_missing_fragments(self): 83 | """ 84 | Missing fragments are caught. 85 | """ 86 | with pytest.raises(ConfigurationError) as ei: 87 | load_and_validate_config({"content-type": "text/markdown"}) 88 | 89 | assert ( 90 | [ 91 | "tool.hatch.metadata.hooks.fancy-pypi-readme.fragments" 92 | " is missing." 93 | ] 94 | == ei.value.errors 95 | == ei.value.args[0] 96 | ) 97 | 98 | def test_empty_fragment_dict(self): 99 | """ 100 | Empty fragment dicts are handled gracefully. 101 | """ 102 | with pytest.raises(ConfigurationError) as ei: 103 | load_and_validate_config( 104 | {"content-type": "text/markdown", "fragments": [{}]} 105 | ) 106 | 107 | assert ["Unknown fragment type {}."] == ei.value.errors 108 | 109 | def test_empty_text_fragment(self): 110 | """ 111 | Text fragments can't be empty. 112 | """ 113 | with pytest.raises(ConfigurationError) as ei: 114 | load_and_validate_config(cow_add_frag(text="")) 115 | 116 | assert ["Text fragments must not be empty."] == ei.value.errors 117 | 118 | def test_invalid_fragments(self): 119 | """ 120 | Invalid fragments are caught. 121 | """ 122 | with pytest.raises(ConfigurationError) as ei: 123 | load_and_validate_config( 124 | { 125 | "content-type": "text/markdown", 126 | "fragments": [ 127 | {"text": "this is ok"}, 128 | {"foo": "this is not"}, 129 | {"bar": "neither is this"}, 130 | ], 131 | } 132 | ) 133 | 134 | assert { 135 | "Unknown fragment type {'foo': 'this is not'}.", 136 | "Unknown fragment type {'bar': 'neither is this'}.", 137 | } == set(ei.value.errors) 138 | 139 | def test_fragment_loading_errors(self): 140 | """ 141 | Errors that happen while loading a fragment are propagated. 142 | """ 143 | with pytest.raises(ConfigurationError) as ei: 144 | load_and_validate_config( 145 | { 146 | "content-type": "text/markdown", 147 | "fragments": [{"path": "yolo"}], 148 | } 149 | ) 150 | 151 | assert ["Fragment file 'yolo' not found."] == ei.value.errors 152 | 153 | 154 | VALID_FOR_SUB = { 155 | "content-type": "text/markdown", 156 | "fragments": [{"text": "foobar"}], 157 | } 158 | 159 | 160 | def cow_add_sub(**kw): 161 | d = VALID_FOR_SUB.copy() 162 | d["substitutions"] = [kw] 163 | 164 | return d 165 | 166 | 167 | class TestValidateConfigSubstitutions: 168 | def test_invalid_substitution(self): 169 | """ 170 | Invalid substitutions are caught and reported. 171 | """ 172 | with pytest.raises(ConfigurationError) as ei: 173 | load_and_validate_config( 174 | { 175 | "content-type": "text/markdown", 176 | "fragments": [{"text": "foo"}], 177 | "substitutions": [{"foo": "bar"}], 178 | } 179 | ) 180 | 181 | assert { 182 | "Substitution {'foo': 'bar'} is missing a 'pattern' key.", 183 | "Substitution {'foo': 'bar'} is missing a 'replacement' key.", 184 | } == set(ei.value.errors) 185 | 186 | def test_empty(self): 187 | """ 188 | Empty dict is not valid. 189 | """ 190 | with pytest.raises(ConfigurationError) as ei: 191 | load_and_validate_config(cow_add_sub()) 192 | 193 | assert { 194 | "Substitution {} is missing a 'pattern' key.", 195 | "Substitution {} is missing a 'replacement' key.", 196 | } == set(ei.value.errors) 197 | 198 | def test_ignore_case_not_bool(self): 199 | """ 200 | Ignore case is either bool or nothing. 201 | """ 202 | with pytest.raises(ConfigurationError) as ei: 203 | load_and_validate_config( 204 | cow_add_sub( 205 | pattern="foo", replacement="bar", **{"ignore-case": 42} 206 | ) 207 | ) 208 | 209 | assert {"Value 42 for 'ignore-case' is not a bool."} == set( 210 | ei.value.errors 211 | ) 212 | 213 | def test_pattern_no_valid_regexp(self): 214 | """ 215 | Pattern must be a valid re-regexp. 216 | """ 217 | with pytest.raises(ConfigurationError) as ei: 218 | load_and_validate_config( 219 | cow_add_sub(pattern="foo???", replacement="bar") 220 | ) 221 | 222 | assert { 223 | "'foo???' is not a valid regular expression: multiple repeat at " 224 | "position 5" 225 | } == set(ei.value.errors) 226 | 227 | def test_replacement_not_a_string(self): 228 | """ 229 | Replacements must be strings. 230 | """ 231 | with pytest.raises(ConfigurationError) as ei: 232 | load_and_validate_config( 233 | cow_add_sub(pattern="foo", replacement=42) 234 | ) 235 | 236 | assert {"Replacement value 42 is not a string."} == set( 237 | ei.value.errors 238 | ) 239 | 240 | def test_substitutions_not_array(self): 241 | """ 242 | Substitutions key must be a list. 243 | """ 244 | cfg = VALID_FOR_SUB.copy() 245 | cfg["substitutions"] = {} 246 | 247 | with pytest.raises(ConfigurationError) as ei: 248 | load_and_validate_config(cfg) 249 | 250 | assert { 251 | "tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions must " 252 | "be an array." 253 | } == set(ei.value.errors) 254 | -------------------------------------------------------------------------------- /tests/test_end_to_end.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Hynek Schlawack 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | import email.parser 6 | 7 | import pytest 8 | 9 | from .utils import append, run 10 | 11 | 12 | def build_project(*args, check=True): 13 | if not args: 14 | args = ["-w"] 15 | 16 | return run("build", *args, check=check) 17 | 18 | 19 | @pytest.mark.slow 20 | def test_build(new_project): 21 | """ 22 | Build a fake project end-to-end and verify wheel contents. 23 | """ 24 | append( 25 | new_project / "pyproject.toml", 26 | """ 27 | [tool.hatch.metadata.hooks.fancy-pypi-readme] 28 | content-type = "text/markdown" 29 | 30 | [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] 31 | text = '''# Level 1 32 | 33 | Fancy *Markdown*. 34 | ''' 35 | 36 | [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] 37 | text = "---\\nFooter" 38 | """, 39 | ) 40 | 41 | build_project() 42 | 43 | whl = new_project / "dist" / "my_app-1.0-py2.py3-none-any.whl" 44 | 45 | assert whl.exists() 46 | 47 | run("wheel", "unpack", whl) 48 | 49 | metadata = email.parser.Parser().parsestr( 50 | ( 51 | new_project / "my_app-1.0" / "my_app-1.0.dist-info" / "METADATA" 52 | ).read_text() 53 | ) 54 | 55 | assert "text/markdown" == metadata["Description-Content-Type"] 56 | assert ( 57 | "# Level 1\n\nFancy *Markdown*.\n---\nFooter" == metadata.get_payload() 58 | ) 59 | 60 | 61 | @pytest.mark.slow 62 | def test_invalid_config(new_project): 63 | """ 64 | Missing config makes the build fail with a meaningful error message. 65 | """ 66 | pyp = new_project / "pyproject.toml" 67 | 68 | # If we leave out the config for good, the plugin doesn't get activated. 69 | pyp.write_text( 70 | pyp.read_text() + "[tool.hatch.metadata.hooks.fancy-pypi-readme]" 71 | ) 72 | 73 | out = build_project(check=False) 74 | 75 | assert "hatch_fancy_pypi_readme.exceptions.ConfigurationError" in out, out 76 | assert ( 77 | "tool.hatch.metadata.hooks.fancy-pypi-readme.content-type is missing." 78 | in out 79 | ), out 80 | assert ( 81 | "tool.hatch.metadata.hooks.fancy-pypi-readme.fragments is missing." 82 | in out 83 | ), out 84 | -------------------------------------------------------------------------------- /tests/test_fragments.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Hynek Schlawack 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | from __future__ import annotations 6 | 7 | import secrets 8 | 9 | from pathlib import Path 10 | 11 | import pytest 12 | 13 | from hatch_fancy_pypi_readme._fragments import FileFragment, TextFragment 14 | from hatch_fancy_pypi_readme.exceptions import ConfigurationError 15 | 16 | 17 | class TestTextFragment: 18 | def test_ok(self): 19 | """ 20 | The text that is passed in is rendered without changes. 21 | """ 22 | text = secrets.token_urlsafe() 23 | 24 | assert text == TextFragment.from_config({"text": text}).render() 25 | 26 | 27 | @pytest.fixture(name="txt_path") 28 | def _txt_path(): 29 | return Path("tests") / "example_text.md" 30 | 31 | 32 | @pytest.fixture(name="txt") 33 | def _txt(txt_path): 34 | return txt_path.read_text() 35 | 36 | 37 | class TestFileFragment: 38 | def test_simple_ok(self, txt, txt_path): 39 | """ 40 | Loading a file works. 41 | """ 42 | assert ( 43 | txt == FileFragment.from_config({"path": str(txt_path)}).render() 44 | ) 45 | 46 | def test_start_after_ok(self, txt_path): 47 | """ 48 | Specifying a `start-after` that exists in the file removes it along 49 | with what comes before. 50 | """ 51 | assert """This is the *interesting* body! 52 | 53 | 54 | 55 | Uninteresting Footer 56 | """ == FileFragment.from_config( 57 | { 58 | "path": str(txt_path), 59 | "start-after": "\n\n", 60 | } 61 | ).render() 62 | 63 | def test_start_at_ok(self, txt_path): 64 | """ 65 | Specifying a `start-at` that exists in the file removes everything 66 | before the string, but not the string itself. 67 | """ 68 | assert """This is the *interesting* body! 69 | 70 | 71 | 72 | Uninteresting Footer 73 | """ == FileFragment.from_config( 74 | { 75 | "path": str(txt_path), 76 | "start-at": "This is the *interesting* body!", 77 | } 78 | ).render() 79 | 80 | def test_end_before_ok(self, txt_path): 81 | """ 82 | Specifying an `end-before` that exists in the file cuts it off along 83 | with everything that follows. 84 | """ 85 | assert """# Boring Header 86 | 87 | 88 | 89 | This is the *interesting* body!""" == FileFragment.from_config( 90 | { 91 | "path": str(txt_path), 92 | "end-before": "\n\n", 93 | } 94 | ).render() 95 | 96 | def test_start_end_ok(self, txt_path): 97 | """ 98 | Specifying existing `start-after` and `end-before` returns exactly 99 | what's between them. 100 | """ 101 | assert ( 102 | "This is the *interesting* body!" 103 | == FileFragment.from_config( 104 | { 105 | "path": str(txt_path), 106 | "start-after": "\n\n", 107 | "end-before": "\n\n", 108 | } 109 | ).render() 110 | ) 111 | 112 | def test_start_after_end_before_not_found(self, txt_path): 113 | """ 114 | If `start-after` and/or `end-before` don't exist, a helpful error is 115 | raised. 116 | """ 117 | with pytest.raises(ConfigurationError) as ei: 118 | FileFragment.from_config( 119 | { 120 | "path": str(txt_path), 121 | "start-after": "nope", 122 | "end-before": "also nope", 123 | } 124 | ) 125 | 126 | assert [ 127 | "file fragment: 'start-after' 'nope' not found.", 128 | "file fragment: 'end-before' 'also nope' not found.", 129 | ] == ei.value.errors 130 | 131 | def test_start_at_end_before_not_found(self, txt_path): 132 | """ 133 | If `start-at` and/or `end-before` don't exist, a helpful error is 134 | raised. 135 | """ 136 | with pytest.raises(ConfigurationError) as ei: 137 | FileFragment.from_config( 138 | { 139 | "path": str(txt_path), 140 | "start-at": "nope", 141 | "end-before": "also nope", 142 | } 143 | ) 144 | 145 | assert [ 146 | "file fragment: 'start-at' 'nope' not found.", 147 | "file fragment: 'end-before' 'also nope' not found.", 148 | ] == ei.value.errors 149 | 150 | def test_start_after_at(self, txt_path): 151 | """ 152 | If both `start-after` and `start-at` are passed, abort with an error. 153 | """ 154 | with pytest.raises(ConfigurationError) as ei: 155 | FileFragment.from_config( 156 | { 157 | "path": str(txt_path), 158 | "start-after": "cut", 159 | "start-at": "cut", 160 | } 161 | ) 162 | 163 | assert [ 164 | "file fragment: 'start-after' and 'start-at' are mutually " 165 | "exclusive." 166 | ] == ei.value.errors 167 | 168 | def test_pattern_no_match(self, txt_path): 169 | """ 170 | If the pattern doesn't match, a helpful error is raises. 171 | """ 172 | with pytest.raises(ConfigurationError) as ei: 173 | FileFragment.from_config( 174 | { 175 | "path": str(txt_path), 176 | "pattern": r"wtf", 177 | } 178 | ) 179 | 180 | assert ["file fragment: pattern 'wtf' not found."] == ei.value.errors 181 | 182 | def test_pattern_no_group(self, txt_path): 183 | """ 184 | If the pattern matches but lacks a group, tell the user. 185 | """ 186 | with pytest.raises(ConfigurationError) as ei: 187 | FileFragment.from_config( 188 | { 189 | "path": str(txt_path), 190 | "pattern": r"Uninteresting", 191 | } 192 | ) 193 | 194 | assert [ 195 | "file fragment: pattern matches, but no group defined." 196 | ] == ei.value.errors 197 | 198 | def test_pattern_ok(self, txt_path): 199 | """ 200 | If the pattern matches and has a group, return it. 201 | """ 202 | assert ( 203 | "*interesting*" 204 | == FileFragment.from_config( 205 | { 206 | "path": str(txt_path), 207 | "pattern": r"the (.*) body", 208 | } 209 | ).render() 210 | ) 211 | -------------------------------------------------------------------------------- /tests/test_substitutions.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Hynek Schlawack 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | from __future__ import annotations 6 | 7 | import pytest 8 | 9 | from hatch_fancy_pypi_readme._substitutions import Substituter 10 | 11 | 12 | VALID = {"pattern": "f(o)o", "replacement": r"bar\g<1>bar"} 13 | 14 | 15 | def cow_valid(**kw): 16 | d = VALID.copy() 17 | d.update(**kw) 18 | 19 | return d 20 | 21 | 22 | class TestSubstituter: 23 | def test_ok(self): 24 | """ 25 | Valid pattern leads to correct behavior. 26 | """ 27 | sub = Substituter.from_config(VALID) 28 | 29 | assert "xxx barobar yyy" == sub.substitute("xxx foo yyy") 30 | 31 | def test_twisted(self): 32 | """ 33 | Twisted example works. 34 | 35 | https://github.com/twisted/twisted/blob/eda9d29dc7fe34e7b207781e5674dc92f798bffe/setup.py#L19-L24 36 | """ 37 | assert ( 38 | "For information on changes in this release, see the `NEWS `_ file." 39 | ) == Substituter.from_config( 40 | { 41 | "pattern": r"`([^`]+)\s+<(?!https?://)([^>]+)>`_", 42 | "replacement": r"`\1 `_", 43 | "ignore-case": True, 44 | } 45 | ).substitute( 46 | "For information on changes in this release, see the `NEWS `_ file." 47 | ) 48 | 49 | @pytest.mark.parametrize( 50 | ("pat", "repl", "text", "expect"), 51 | [ 52 | ( 53 | r"#(\d+)", 54 | r"[#\1](https://github.com/pydantic/pydantic/issues/\1)", 55 | "* Foo #4224, #4470 Bar", 56 | "* Foo [#4224](https://github.com/pydantic/pydantic/issues/" 57 | "4224), [#4470](https://github.com/pydantic/pydantic/issues/" 58 | "4470) Bar", 59 | ), 60 | ( 61 | r"( +)@([\w\-]+)", 62 | r"\1[@\2](https://github.com/\2)", 63 | "foo @github-user bar", 64 | "foo [@github-user](https://github.com/github-user) bar", 65 | ), 66 | ], 67 | ) 68 | def test_pydantic(self, pat, repl, text, expect): 69 | """ 70 | Pydantic examples work. 71 | https://github.com/hynek/hatch-fancy-pypi-readme/issues/9#issuecomment-1238584908 72 | """ 73 | assert expect == Substituter.from_config( 74 | { 75 | "pattern": pat, 76 | "replacement": repl, 77 | "ignore-case": True, 78 | } 79 | ).substitute(text) 80 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Hynek Schlawack 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | import subprocess 6 | import sys 7 | 8 | import pytest 9 | 10 | 11 | def run(*args, check=True): 12 | process = subprocess.run( # noqa: PLW1510 13 | [sys.executable, "-m", *args], 14 | stdout=subprocess.PIPE, 15 | stderr=subprocess.STDOUT, 16 | encoding="utf-8", 17 | ) 18 | if check and process.returncode: 19 | pytest.fail(process.stdout) 20 | 21 | return process.stdout 22 | 23 | 24 | def append(file, text): 25 | file.write_text(file.read_text() + text) 26 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | min_version = 4 3 | env_list = 4 | pre-commit, 5 | mypy, 6 | py3{8,9,10,11,12,13,14}, 7 | py3{8,9,10,11,12,13,14}-cli, 8 | coverage-report 9 | 10 | 11 | [pkgenv] 12 | pass_env = SETUPTOOLS_SCM_PRETEND_VERSION 13 | 14 | 15 | [testenv] 16 | package = wheel 17 | wheel_build_env = .pkg 18 | extras = tests 19 | pass_env = 20 | FORCE_COLOR 21 | NO_COLOR 22 | commands = pytest {posargs} 23 | 24 | 25 | [testenv:py3{8,10,13}-cli] 26 | deps = coverage[toml] 27 | commands = 28 | # Use -o only once, so we exercise both code paths. 29 | coverage run -m hatch_fancy_pypi_readme tests/example_pyproject.toml -o {envtmpdir}{/}t.md 30 | coverage run {envbindir}{/}hatch-fancy-pypi-readme tests/example_pyproject.toml 31 | 32 | 33 | [testenv:pre-commit] 34 | skip_install = true 35 | deps = pre-commit 36 | commands = pre-commit run --all-files 37 | 38 | 39 | [testenv:mypy] 40 | extras = tests 41 | deps = mypy 42 | commands = mypy src 43 | 44 | 45 | [testenv:py31{0,3}] 46 | deps = coverage[toml] 47 | commands = coverage run -m pytest {posargs} 48 | 49 | 50 | [testenv:coverage-report] 51 | ; Keep version in-sync with .python-version-default 52 | base_python = python3.13 53 | deps = coverage[toml] 54 | skip_install = true 55 | commands = 56 | coverage combine 57 | coverage report 58 | 59 | 60 | [testenv:svg] 61 | description = Refresh SVG, test running using Pipx. 62 | deps = pipx 63 | skip_install = true 64 | allowlist_externals = npx 65 | commands = 66 | pipx run --no-cache --spec . hatch-fancy-pypi-readme tests/example_pyproject.toml -o {envtmpdir}{/}t.md 67 | pipx run rich-cli --markdown --hyperlinks --export-svg rich-cli-out.svg --max-width 79 {envtmpdir}{/}t.md 68 | npx --quiet svgo rich-cli-out.svg 69 | --------------------------------------------------------------------------------