pax_global_header00006660000000000000000000000064145445015220014514gustar00rootroot0000000000000052 comment=c340dcb0019cae8444fd390929f88ee647ac840d hynek-hatch-fancy-pypi-readme-0109610/000077500000000000000000000000001454450152200174115ustar00rootroot00000000000000hynek-hatch-fancy-pypi-readme-0109610/.github/000077500000000000000000000000001454450152200207515ustar00rootroot00000000000000hynek-hatch-fancy-pypi-readme-0109610/.github/CODE_OF_CONDUCT.md000066400000000000000000000125531454450152200235560ustar00rootroot00000000000000 # Contributor Covenant Code of Conduct ## Our Pledge We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards Examples of behavior that contributes to a positive environment for our community include: * Demonstrating empathy and kindness toward other people * Being respectful of differing opinions, viewpoints, and experiences * Giving and gracefully accepting constructive feedback * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience * Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: * The use of sexualized language or imagery, and sexual attention or advances of any kind * Trolling, insulting or derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or email address, without their explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at . All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. ### 2. Warning **Community Impact**: A violation through a single incident or series of actions. **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. ### 3. Temporary Ban **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. [homepage]: https://www.contributor-covenant.org [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html [Mozilla CoC]: https://github.com/mozilla/diversity [FAQ]: https://www.contributor-covenant.org/faq [translations]: https://www.contributor-covenant.org/translations hynek-hatch-fancy-pypi-readme-0109610/.github/CONTRIBUTING.md000066400000000000000000000163661454450152200232160ustar00rootroot00000000000000# How To Contribute First off, thank you for considering contributing to *hatch-fancy-pypi-readme*! It's people like *you* who make it such a great tool for everyone. This document intends to make contribution more accessible by codifying tribal knowledge and expectations. Don't be afraid to open half-finished PRs, and ask questions if something is unclear! 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). By participating in this project you agree to abide by its terms. Please report any harm to [Hynek Schlawack] in any way you find appropriate. ## Workflow - No contribution is too small! Please submit as many fixes for typos and grammar bloopers as you can! - Try to limit each pull request to *one* change only. - Since we squash on merge, it's up to you how you handle updates to the main branch. Whether you prefer to rebase on main or merge main into your branch, do whatever is more comfortable for you. - *Always* add tests and docs for your code. This is a hard rule; patches with missing tests or documentation can't be merged. - Make sure your changes pass our [CI]. You won't get any feedback until it's green unless you ask for it. For the CI to pass, the coverage must be 100%. If you have problems to test something, open anyway and ask for advice. In some situations, we may agree to add a `# pragma: no cover`. - Once you've addressed review feedback, make sure to bump the pull request with a short note, so we know you're done. - Don’t break backwards-compatibility. ## Local Development Environment You can (and should) run our test suite using [*tox*]. However, you’ll probably want a more traditional environment as well. 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. 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: ```bash layout python python$(cat .python-version-default) ``` You can now install the package with its development dependencies into the virtual environment: ```console $ pip install -e .[dev] ``` Now you can run the test suite: ```console $ python -m pytest ``` To avoid committing code that violates our style guide, we strongly advise you to install [*pre-commit*] and its hooks: ```console $ pre-commit install ``` This is not strictly necessary, because our [*tox*] file contains an environment that runs: ```console $ pre-commit run --all-files ``` and our CI has integration with [pre-commit.ci](https://pre-commit.ci). But it's way more comfortable to run it locally and Git catching avoidable errors. ## Code - Obey [PEP 8](https://www.python.org/dev/peps/pep-0008/) and [PEP 257](https://www.python.org/dev/peps/pep-0257/). We use the `"""`-on-separate-lines style for docstrings: ```python def func(x): """ Do something. :param str x: A very important parameter. :rtype: str """ ``` - If you add or change public APIs, tag the docstring using `.. versionadded:: 16.0.0 WHAT` or `.. versionchanged:: 16.2.0 WHAT`. - We use [*isort*](https://github.com/PyCQA/isort) to sort our imports, and we use [*Black*](https://github.com/psf/black) with line length of 79 characters to format our code. 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. If you don't, [CI] will catch it for you – but that seems like a waste of your time! ## Tests - Write your asserts as `expected == actual` to line them up nicely: ```python x = f() assert 42 == x.some_attribute assert "foo" == x._a_private_attribute ``` - To run the test suite, all you need is a recent [*tox*]. It will ensure the test suite runs with all dependencies against all Python versions just as it will in our [CI]. 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`. 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. - Write [good test docstrings](https://jml.io/pages/test-docstrings.html). - If you've changed or added public APIs, please update our type stubs (files ending in `.pyi`). ## Documentation - Use [semantic newlines] in [*reStructuredText*] and *Markdown* files (files ending in `.rst` and `.md`): ```rst This is a sentence. This is another sentence. ``` - 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: ```rst Last line of previous section. Header of New Top Section ------------------------- Header of New Section ^^^^^^^^^^^^^^^^^^^^^ First line of new section. ``` ### Changelog 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! - The changelog follows the [*Keep a Changelog*](https://keepachangelog.com/en/1.0.0/) standard. Please add the best-fitting section if it's missing for the current release. We use the following order: `Security`, `Removed`, `Deprecated`, `Added`, `Changed`, `Fixed`. - As with other docs, please use [semantic newlines] in the changelog. - Make the last line a link to your pull request. You probably have to open the pull request first to know the number. - Wrap symbols like modules, functions, or classes into backticks so they are rendered in a `monospace font`. - Wrap arguments into asterisks like in docstrings: `Added new argument *an_argument*.` - If you mention functions or other callables, add parentheses at the end of their names: `hatch-fancy-pypi-readme.func()` or `hatch-fancy-pypi-readme.Class.method()`. This makes the changelog a lot more readable. - Prefer simple past tense or constructions with "now". For example: * Added `hatch-fancy-pypi-readme.func()`. * `hatch-fancy-pypi-readme.func()` now doesn't crash the Large Hadron Collider anymore when passed the *foobar* argument. #### Example entries ```markdown - Added `hatch-fancy-pypi-readme.func()`. The feature really *is* awesome. [#1](https://github.com/hynek/hatch-fancy-pypi-readme/pull/1) ``` or: ```markdown - `hatch-fancy-pypi-readme.func()` now doesn't crash the Large Hadron Collider anymore when passed the *foobar* argument. The bug really *was* nasty. [#1](https://github.com/hynek/hatch-fancy-pypi-readme/pull/1) ``` [CI]: https://github.com/hynek/hatch-fancy-pypi-readme/actions [Hynek Schlawack]: https://hynek.me/about/ [*pre-commit*]: https://pre-commit.com/ [*tox*]: https://tox.wiki/ [semantic newlines]: https://rhodesmill.org/brandon/2012/one-sentence-per-line/ [*reStructuredText*]: https://www.sphinx-doc.org/en/stable/usage/restructuredtext/basics.html hynek-hatch-fancy-pypi-readme-0109610/.github/FUNDING.yml000066400000000000000000000000221454450152200225600ustar00rootroot00000000000000--- github: hynek hynek-hatch-fancy-pypi-readme-0109610/.github/SECURITY.md000066400000000000000000000004251454450152200225430ustar00rootroot00000000000000# Security Policy ## Supported Versions We are following [CalVer](https://calver.org) with generous backwards-compatibility guarantees. Therefore we only support the latest version. ## Reporting a Vulnerability To report a security vulnerability, please email the author. hynek-hatch-fancy-pypi-readme-0109610/.github/dependabot.yml000066400000000000000000000001671454450152200236050ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "monthly" hynek-hatch-fancy-pypi-readme-0109610/.github/workflows/000077500000000000000000000000001454450152200230065ustar00rootroot00000000000000hynek-hatch-fancy-pypi-readme-0109610/.github/workflows/ci.yml000066400000000000000000000077121454450152200241330ustar00rootroot00000000000000--- name: CI on: push: branches: [main] tags: ["*"] pull_request: branches: [main] workflow_dispatch: env: FORCE_COLOR: "1" # Make tools pretty. PYTHONIOENCODING: utf-8 PIP_DISABLE_PIP_VERSION_CHECK: "1" PIP_NO_PYTHON_VERSION_WARNING: "1" jobs: build-package: name: Build & verify package runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: hynek/build-and-inspect-python-package@v2 tests: name: Tests on ${{ matrix.python-version }} runs-on: ubuntu-latest needs: build-package strategy: fail-fast: false matrix: python-version: - "3.7" - "3.8" - "3.9" - "3.10" - "3.11" - "3.12" steps: - name: Download pre-built packages uses: actions/download-artifact@v4 with: name: Packages path: dist - run: tar xf dist/*.tar.gz --strip-components=1 # needed for config files - uses: actions/setup-python@v4 with: cache: pip python-version: ${{ matrix.python-version }} allow-prereleases: true - run: python -Im pip install tox - run: | python -Im tox run \ --installpkg dist/*.whl \ -f py$(echo ${{ matrix.python-version }} | tr -d .) - name: Upload coverage data uses: actions/upload-artifact@v3 with: name: coverage-data path: .coverage.* if-no-files-found: ignore coverage: runs-on: ubuntu-latest needs: tests if: always() steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v4 with: cache: pip python-version-file: .python-version-default - name: Download coverage data uses: actions/download-artifact@v3 with: name: coverage-data - name: Combine coverage and fail if it's <100%. run: | python -Im pip install --upgrade coverage[toml] python -Im coverage combine python -Im coverage html --skip-covered --skip-empty # Report and write to summary. python -Im coverage report --format=markdown >> $GITHUB_STEP_SUMMARY # Report again and fail if under 100%. python -Im coverage report --fail-under=100 - name: Upload HTML report if check failed. uses: actions/upload-artifact@v4 with: name: html-report path: htmlcov if: ${{ failure() }} mypy: name: Mypy runs-on: ubuntu-latest needs: build-package steps: - name: Download pre-built packages uses: actions/download-artifact@v4 with: name: Packages path: dist - run: tar xf dist/*.tar.gz --strip-components=1 # needed for config files - uses: actions/setup-python@v4 with: cache: pip python-version-file: .python-version-default - run: python -Im pip install tox - run: python -Im tox run -e mypy install-dev: name: Verify dev env runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest, windows-latest] steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v4 with: cache: pip python-version-file: .python-version-default - run: python -Im pip install -e .[dev] - run: python -Ic 'import hatch_fancy_pypi_readme' - run: python -m hatch_fancy_pypi_readme tests/example_pyproject.toml - run: hatch-fancy-pypi-readme tests/example_pyproject.toml # Ensure everything required is passing for branch protection. required-checks-pass: if: always() needs: - coverage - install-dev - mypy runs-on: ubuntu-latest steps: - name: Decide whether the needed jobs succeeded or failed uses: re-actors/alls-green@release/v1 with: jobs: ${{ toJSON(needs) }} hynek-hatch-fancy-pypi-readme-0109610/.github/workflows/pypi-package.yml000066400000000000000000000030441454450152200261040ustar00rootroot00000000000000--- name: Build & maybe upload PyPI package on: push: branches: [main] tags: ["*"] release: types: - published workflow_dispatch: permissions: contents: read id-token: write jobs: # Always build & lint package. build-package: name: Build & verify package runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: hynek/build-and-inspect-python-package@v2 # Upload to Test PyPI on every tag on main. release-test-pypi: name: Publish tagged version to test.pypi.org environment: release-test-pypi if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest needs: build-package steps: - name: Download packages built by build-and-inspect-python-package uses: actions/download-artifact@v4 with: name: Packages path: dist - name: Upload package to Test PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: repository-url: https://test.pypi.org/legacy/ # Upload to real PyPI on GitHub Releases. release-pypi: name: Publish released package to pypi.org environment: release-pypi if: github.event.action == 'published' runs-on: ubuntu-latest needs: build-package steps: - name: Download packages built by build-and-inspect-python-package uses: actions/download-artifact@v4 with: name: Packages path: dist - name: Upload package to PyPI uses: pypa/gh-action-pypi-publish@release/v1 hynek-hatch-fancy-pypi-readme-0109610/.gitignore000066400000000000000000000002111454450152200213730ustar00rootroot00000000000000*.egg-info *.pyc *.pyo .DS_Store .cache .coverage* .direnv .envrc .mypy_cache .pytest_cache .tox .vscode build dist htmlcov tmp Justfile hynek-hatch-fancy-pypi-readme-0109610/.pre-commit-config.yaml000066400000000000000000000007621454450152200236770ustar00rootroot00000000000000--- ci: autoupdate_schedule: monthly repos: - repo: https://github.com/psf/black rev: 23.12.1 hooks: - id: black - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.1.9 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.5.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer exclude: (.*\.svg) - id: check-toml - id: check-yaml hynek-hatch-fancy-pypi-readme-0109610/.python-version-default000066400000000000000000000000051454450152200240330ustar00rootroot000000000000003.12 hynek-hatch-fancy-pypi-readme-0109610/AUTHORS.md000066400000000000000000000010101454450152200210500ustar00rootroot00000000000000# Authors *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). 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). A full list of contributors can be found on GitHub’s [overview](https://github.com/hynek/hatch-fancy-pypi-readme/graphs/contributors). hynek-hatch-fancy-pypi-readme-0109610/CHANGELOG.md000066400000000000000000000074301454450152200212260ustar00rootroot00000000000000# Changelog All notable changes to this project will be documented in this file. 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/). The **first number** of the version is the year. The **second number** is incremented with each release, starting at 1 for each year. The **third number** is for emergencies when we need to start branches for older releases. ## [24.1.0](https://github.com/hynek/hatch-fancy-pypi-readme/compare/23.2.0...24.1.0) - 2024-01-01 ### Fixed - Added a default to an internal API that is used by *scikit-build-core*. ## [23.2.0](https://github.com/hynek/hatch-fancy-pypi-readme/compare/23.1.0...23.2.0) - 2023-12-31 ### Added - `$HFPR_VERSION` is now replaced by the package version in the PyPI readme. The version is not available in CLI mode, therefore it's replaced by the dummy value of `42.0`. [#39](https://github.com/hynek/hatch-fancy-pypi-readme/pull/39) ## [23.1.0](https://github.com/hynek/hatch-fancy-pypi-readme/compare/22.8.0...23.1.0) - 2023-05-22 ### Added - CLI support for `hatch.toml`. [#27](https://github.com/hynek/hatch-fancy-pypi-readme/issues/27) ## [22.8.0](https://github.com/hynek/hatch-fancy-pypi-readme/compare/22.7.0...22.8.0) - 2022-10-02 ### Added - 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. For example: `start-at = "## License"` gives you `## License` and everything that follows. [#16](https://github.com/hynek/hatch-fancy-pypi-readme/issues/16) ## [22.7.0](https://github.com/hynek/hatch-fancy-pypi-readme/compare/22.6.0...22.7.0) - 2022-09-12 ### Changed - Removed another circular dependency: this time the wonderful [*jsonschema*](https://python-jsonschema.readthedocs.io/). The price of building packaging tools is to not use packages. ## [22.6.0](https://github.com/hynek/hatch-fancy-pypi-readme/compare/22.5.0...22.6.0) - 2022-09-11 ### Changed - Unfortunately, life is unfair and depending on oneself is problematic for others packaging your code. So absolutely nothing changed again, except that we’re back to a boring PyPI readme so you don’t have to. ## [22.5.0](https://github.com/hynek/hatch-fancy-pypi-readme/compare/22.4.0...22.5.0) - 2022-09-10 ### Changed - Absolutely nothing – just working around the hen-egg problem to use substitutions in the PyPI readme! ## [22.4.0](https://github.com/hynek/hatch-fancy-pypi-readme/compare/22.3.0...22.4.0) - 2022-09-10 ### Added - It is now possible to run *regular expression*-based substitutions over the final readme. [#9](https://github.com/hynek/hatch-fancy-pypi-readme/issues/9) [#11](https://github.com/hynek/hatch-fancy-pypi-readme/issues/11) ## [22.3.0](https://github.com/hynek/hatch-fancy-pypi-readme/compare/22.2.0...22.3.0) - 2022-08-06 ### Added - Support for Python 3.7. While our Python version only applies when building a package, a package is built whenever it is installed. This includes *tox* environments. *hatch-fancy-pypi-readme* will always *at least* support the same Python version as the latest version of *Hatchling* – *Hatch*’s build backend – does. To get this version out, we had to stop dog-fooding *hatch-fancy-pypi-readme*. 😢 ## [22.2.0](https://github.com/hynek/hatch-fancy-pypi-readme/compare/22.1.0...22.2.0) - 2022-08-05 ### Changed - We can finally use *hatch-fancy-pypi-readme* for our own ✨fancy✨ PyPI readme! ### Fixed - Hopefully fixed readmes with emojis on Windows. ## [22.1.0](https://github.com/hynek/hatch-fancy-pypi-readme/tree/22.1.0) - 2022-08-05 ### Added - Initial release. hynek-hatch-fancy-pypi-readme-0109610/LICENSE.txt000066400000000000000000000021351454450152200212350ustar00rootroot00000000000000MIT License Copyright (c) 2022 Hynek Schlawack and the hatch-fancy-pypi-readme contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. hynek-hatch-fancy-pypi-readme-0109610/README.md000066400000000000000000000310651454450152200206750ustar00rootroot00000000000000# Your ✨Fancy✨ Project Deserves a ✨Fancy✨ PyPI Readme! [![Hatch project](https://img.shields.io/badge/%F0%9F%A5%9A-Hatch-4051b5.svg)](https://github.com/pypa/hatch) [![License: MIT](https://img.shields.io/badge/license-MIT-C06524)](https://github.com/hynek/hatch-fancy-pypi-readme/blob/main/LICENSE.txt) [![PyPI - Version](https://img.shields.io/pypi/v/hatch-fancy-pypi-readme.svg)](https://pypi.org/project/hatch-fancy-pypi-readme) [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/hatch-fancy-pypi-readme.svg)](https://pypi.org/project/hatch-fancy-pypi-readme) [![Downloads](https://static.pepy.tech/badge/hatch-fancy-pypi-readme/month)](https://pepy.tech/project/hatch-fancy-pypi-readme) *hatch-fancy-pypi-readme* is a [Hatch] metadata plugin for everyone who cares about the first impression of their project’s PyPI landing page. It allows you to define your PyPI project description in terms of concatenated fragments that are based on **static strings**, **files**, and most importantly: **parts of files** defined using **cut-off points** or **regular expressions**. Once you’ve assembled your readme, you can additionally run **regular expression-based substitutions** over it. For instance to make relative links absolute or to linkify users and issue numbers in your changelog. Do you want your PyPI readme to be the project readme, but without badges, followed by the license file, and the changelog section for *only the last* release? You’ve come to the right place! > [!NOTE] > “PyPI project description”, “PyPI landing page”, and “PyPI readme” all refer to the same thing. > In *setuptools* it’s called `long_description` and is the text shown on a project’s PyPI page. > We refer to it as “readme” because that’s how it’s called in [PEP 621](https://peps.python.org/pep-0621/)-based `pyproject.toml` files. ### Showcases 🧐 - [*attrs*](https://pypi.org/project/attrs/) ([`pyproject.toml`](https://github.com/python-attrs/attrs/blob/main/pyproject.toml)) - [Awkward Array](https://pypi.org/project/awkward/) ([`pyproject.toml`](https://github.com/scikit-hep/awkward/blob/main/pyproject.toml)) - [Black](https://pypi.org/project/black/) ([`pyproject.toml`](https://github.com/psf/black/blob/main/pyproject.toml)) - [*doc2dash*](https://pypi.org/project/doc2dash/) ([`pyproject.toml`](https://github.com/hynek/doc2dash/blob/main/pyproject.toml)) - [*environ-config*](https://pypi.org/project/environ-config/) ([`pyproject.toml`](https://github.com/hynek/environ-config/blob/main/pyproject.toml)) - [*jsonschema*](https://pypi.org/project/jsonschema/) ([`pyproject.toml`](https://github.com/python-jsonschema/jsonschema/blob/main/pyproject.toml)) - [Gradio](https://pypi.org/project/gradio/) ([`pyproject.toml`](https://github.com/gradio-app/gradio/blob/main/pyproject.toml)) - [*httpx*](https://pypi.org/project/httpx/) ([`pyproject.toml`](https://github.com/encode/httpx/blob/master/pyproject.toml)) - [OpenLLM](https://github.com/bentoml/OpenLLM) ([`pyproject.toml`](https://github.com/bentoml/OpenLLM/blob/main/openllm-python/pyproject.toml)) - [Pydantic](https://pypi.org/project/pydantic/) ([`pyproject.toml`](https://github.com/pydantic/pydantic/blob/main/pyproject.toml)) - [*pytermgui*](https://pypi.org/project/pytermgui/) ([`pyproject.toml`](https://github.com/bczsalba/pytermgui/blob/master/pyproject.toml)) - [*scikit-build*](https://pypi.org/project/scikit-build/) ([`pyproject.toml`](https://github.com/scikit-build/scikit-build/blob/main/pyproject.toml)) - [*stamina*](https://pypi.org/project/stamina/) ([`pyproject.toml`](https://github.com/hynek/stamina/blob/main/pyproject.toml)) - [*structlog*](https://pypi.org/project/structlog/) ([`pyproject.toml`](https://github.com/hynek/structlog/blob/main/pyproject.toml)) - [Twisted](https://pypi.org/project/twisted/) ([`pyproject.toml`](https://github.com/twisted/twisted/blob/trunk/pyproject.toml)) *hatch-fancy-pypi-readme* doesn’t use itself to avoid a circular dependency that can be problematic in some cases. The shoemaker’s kids always go barefoot. Please [open a pull request](https://github.com/hynek/hatch-fancy-pypi-readme/edit/main/README.md) to add *your* ✨fancy✨ project! ## Motivation The main reason for my (past) hesitancy to move away from `setup.py` files is that I like to make my PyPI readmes a lot more interesting, than what static strings or static files can offer me. For example [this](https://github.com/python-attrs/attrs/blob/b3dfebe2e10b44437c4f97d788fb5220d790efd0/setup.py#L110-L124) is the code that gave me the PyPI readme for [*attrs* 22.1.0](https://pypi.org/project/attrs/22.1.0/). Especially having a summary of the *latest* changes is something I’ve found users to appreciate. [Hatch]’s extensibility finally allowed me to build this plugin that allows you to switch away from `setup.py` without compromising on the user experience. Now *you* too can have fancy PyPI readmes – just by adding a few lines of configuration to your `pyproject.toml`. ## Configuration *hatch-fancy-pypi-readme* is, like [Hatch], configured in your project’s `pyproject.toml`[^hatch-toml]. [^hatch-toml]: As with Hatch, you can also use `hatch.toml` for configuration options that start with `tool.hatch` and leave that prefix out. That means `pyprojects.toml`’s `[tool.hatch.metadata.hooks.fancy-pypi-readme]` becomes `[metadata.hooks.fancy-pypi-readme]` when in `hatch.toml`. To keep the documentation simple, the more common `pyproject.toml` syntax is used throughout. First you add *hatch-fancy-pypi-readme* to your `[build-system]`: ```toml [build-system] requires = ["hatchling", "hatch-fancy-pypi-readme"] build-backend = "hatchling.build" ``` Next, you tell the build system that your readme is dynamic by adding it to the `project.dynamic` list: ```toml [project] # ... dynamic = ["readme"] ``` > [!IMPORTANT] > Don’t forget to remove the old `readme` key! Next, you add a `[tool.hatch.metadata.hooks.fancy-pypi-readme]` section. Here, you **must** supply a `content-type`. Currently, only `text/markdown` and `text/x-rst` are supported by PyPI. ```toml [tool.hatch.metadata.hooks.fancy-pypi-readme] content-type = "text/markdown" ``` ### Fragments Finally, you also **must** supply an *array* of `fragments`. A fragment is a piece of text that is appended to your readme in the order that it’s specified. We recommend TOML’s [syntactic sugar for arrays of wrapping the array name in double brackets](https://toml.io/en/v1.0.0#array-of-tables) and will use it throughout this documentation. #### Text Text fragments consist of a single `text` key and are appended to the readme exactly as you specify them: ```toml [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] text = "Fragment #1" [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] text = "Fragment #2" ``` results in: ``` Fragment #1Fragment #2 ``` Note that there’s no additional space or empty lines between fragments unless you specify them. #### File A file fragment reads a file specified by the `path` key and appends it: ```toml [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] path = "AUTHORS.md" ``` Additionally it’s possible to cut away parts of the file before appending it: - **`start-after`** cuts away everything *before and including* the string specified. - **`start-at`** cuts away everything before the string specified too, but the string itself is *preserved*. This is useful when you want to start at a heading without adding a marker *before* it. `start-after` and `start-at` are mutually exclusive. - **`end-before`** cuts away everything after. - **`pattern`** takes a [*regular expression*](https://docs.python.org/3/library/re.html) and returns the first group from it (you probably want to make your capture group non-greedy by appending a question mark: `(.*?)`). Internally, it uses ```python re.search(pattern, whatever_is_left_after_slicing, re.DOTALL).group(1) ``` to find it. Both Markdown and reStructuredText (reST) have comments (`` and `.. this is a reST comment`) that you can use for invisible markers: ```markdown # Boring Header This is the *interesting* body! Uninteresting Footer ``` together with: ```toml [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] path = "path.md" start-after = "\n\n" end-before = "\n\n" pattern = "the (.*?) body" ``` would append: ```markdown *interesting* ``` to your readme. > [!TIP] > > - You can insert the same file **multiple times** – each time a different part! > - The order of the options in a fragment block does *not* matter. > They’re always executed in the same order: > > 1. `start-after` / `start-at` > 2. `end-before` > 3. `pattern` For a complete example, please see our [example configuration][example-config]. ## Substitutions After a readme is assembled out of fragments, it’s possible to run an arbitrary number of [*regular expression*](https://docs.python.org/3/library/re.html)-based substitutions over it: ```toml [[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]] pattern = "This is a (.*) that we'll replace later." replacement = 'It was a "\1"!' ignore-case = true # optional; false by default ``` --- Substitutions can be useful for replacing relative links with absolute ones: ```toml [[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]] # Literal TOML strings (single quotes) need no escaping of backslashes. pattern = '\[(.+?)\]\(((?!https?://)\S+?)\)' replacement = '[\1](https://github.com/hynek/hatch-fancy-pypi-readme/tree/main\g<2>)' ``` Or expanding GitHub issue/pull request IDs to links: ```toml [[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]] # Regular TOML strings (double quotes) do need escaping. pattern = "#(\\d+)" replacement = "[#\\1](https://github.com/hynek/hatch-fancy-pypi-readme/issues/\\1)" ``` Again, please check out our [example configuration][example-config] for a complete example. ### Referencing Packaging Metadata If the final readme contains the string `$HFPR_VERSION`, it is replaced by the current package version. When running *hatch-fancy-pypi-readme* in CLI mode (as described in the next section), packaging metadata is not available. In that case `$HFPR_VERSION` is hardcoded to `42.0` so you can still test your readme. ## CLI Interface For faster feedback loops, *hatch-fancy-pypi-readme* comes with a CLI interface that takes a `pyproject.toml` file as an argument and renders out the readme that would go into respective package. Your can run it either as `hatch-fancy-pypi-readme` or `python -m hatch_fancy_pypi_readme`. If you don’t pass an argument, it looks for a `pyproject.toml` in the current directory. You can optionally pass a `-o` option to write the output into a file instead of to standard out. Since *hatch-fancy-pypi-readme* is part of the isolated build system, it shouldn’t be installed along with your projects. Therefore we recommend running it using [*pipx*](https://pypa.github.io/pipx/): ```shell $ pipx run hatch-fancy-pypi-readme ``` --- You can pipe the output into tools like [*rich-cli*](https://github.com/Textualize/rich-cli#markdown) or [*bat*](https://github.com/sharkdp/bat) to verify your markup. For example, if you run ```shell $ pipx run hatch-fancy-pypi-readme | pipx run rich-cli --markdown --hyperlinks - ``` with our [example configuration][example-config], you will get the following output: ![rich-cli output](rich-cli-out.svg) > [!WARNING] > While the execution model is somewhat different from the [Hatch]-Python packaging pipeline, it uses the same configuration validator and text renderer, so the fidelity should be high. > > It will **not** help you debug **packaging issues**, though. > > To verify your PyPI readme using the full packaging pipeline, check out my [*build-and-inspect-python-package*](https://github.com/hynek/build-and-inspect-python-package) GitHub Action. > > If you ensure that *hatch-fancy-pypi-readme* is installed in your Hatch environment (that means where the `hatch` CLI command lives – not your development environment), you can also let Hatch render it for you: > > - `hatch project metadata readme` gives you a rendered version of the readme. > - `hatch project metadata | jq -r .readme.text` gives you the raw Markdown (needs [*jq*](https://jqlang.github.io/jq/)). [example-config]: tests/example_pyproject.toml [Hatch]: https://hatch.pypa.io/ hynek-hatch-fancy-pypi-readme-0109610/pyproject.toml000066400000000000000000000101701454450152200223240ustar00rootroot00000000000000[build-system] requires = ["hatchling"] build-backend = "hatchling.build" [project] name = "hatch-fancy-pypi-readme" version = "24.1.0" description = "Fancy PyPI READMEs with Hatch" requires-python = ">=3.7" keywords = ["hatch", "pypi", "readme", "documentation"] authors = [{ name = "Hynek Schlawack", email = "hs@ox.cx" }] license = "MIT" classifiers = [ "Development Status :: 5 - Production/Stable", "Framework :: Hatch", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Topic :: Software Development :: Build Tools", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", ] dependencies = [ "hatchling", "tomli; python_version<'3.11'", "typing-extensions; python_version<'3.8'", ] [project.entry-points.hatch] fancy-pypi-readme = "hatch_fancy_pypi_readme.hooks" [project.scripts] hatch-fancy-pypi-readme = "hatch_fancy_pypi_readme.__main__:main" [project.optional-dependencies] tests = ["pytest", "build", "wheel"] dev = ["hatch-fancy-pypi-readme[tests]", "mypy"] [project.urls] Documentation = "https://github.com/hynek/hatch-fancy-pypi-readme#readme" Changelog = "https://github.com/hynek/hatch-fancy-pypi-readme/blob/main/CHANGELOG.md" "Source Code" = "https://github.com/hynek/hatch-fancy-pypi-readme" Funding = "https://github.com/sponsors/hynek" [project.readme] content-type = "text/markdown" text = """# Your ✨Fancy✨ Project Deserves a ✨Fancy✨ PyPI Readme! *hatch-fancy-pypi-readme* is an MIT-licensed metadata plugin for [Hatch](https://hatch.pypa.io/) by [Hynek Schlawack](https://hynek.me/). Its purpose is to help you to have fancy PyPI readmes – unlike *this* one you’re looking at right now. 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! """ [tool.pytest.ini_options] addopts = ["-ra", "--strict-markers", "--strict-config"] xfail_strict = true testpaths = "tests" markers = ["slow: marks tests as slow (deselect with '-m \"not slow\"')"] filterwarnings = ["once::Warning"] [tool.coverage.run] parallel = true branch = true source = ["hatch_fancy_pypi_readme"] [tool.coverage.paths] source = ["src", ".tox/py*/**/site-packages"] [tool.coverage.report] show_missing = true skip_covered = true omit = ["src/hatch_fancy_pypi_readme/hooks.py"] exclude_lines = [ # a more strict default pragma "\\# pragma: no cover\\b", # allow defensive code "^\\s*raise AssertionError\\b", "^\\s*raise NotImplementedError\\b", "^\\s*return NotImplemented\\b", "^\\s*raise$", # typing-related code "^if (False|TYPE_CHECKING):", ": \\.\\.\\.(\\s*#.*)?$", "^ +\\.\\.\\.$", "-> ['\"]?NoReturn['\"]?:", ] partial_branches = [ "pragma: no branch", # _cli._fail never returns, creating uncovered branches as far as coverage.py # is concerned. See # https://github.com/nedbat/coveragepy/issues/1433#issuecomment-1211465570 "^\\s*_fail\\(", ] [tool.black] line-length = 79 [tool.mypy] strict = true follow_imports = "normal" enable_error_code = ["ignore-without-code"] show_error_codes = true warn_no_return = true ignore_missing_imports = true [[tool.mypy.overrides]] module = "tests.*" ignore_errors = true [tool.ruff] src = ["src", "tests"] select = ["ALL"] ignore = [ "ANN", # Mypy is better at this. "C901", # Leave complexity to me. "COM", # Leave commas to Black. "D", # We have different ideas about docstrings. "E501", # leave line-length enforcement to Black "PLR0912", # Leave complexity to me. "TRY301", # Raise in try blocks can totally make sense. ] [tool.ruff.per-file-ignores] "src/hatch_fancy_pypi_readme/_cli.py" = ["T201"] # need print in CLI "tests/*" = [ "PLC1901", # empty strings are falsey, but are less specific in tests "PT005", # we use always underscores and explicit names "S101", # assert "SIM300", # Yoda rocks in tests ] [tool.ruff.isort] lines-between-types = 1 lines-after-imports = 2 hynek-hatch-fancy-pypi-readme-0109610/rich-cli-out.svg000066400000000000000000000402471454450152200224400ustar00rootroot00000000000000Rich╔═════════════════════════════════════════════════════════════════════════════╗ Level 1 Header ╚═════════════════════════════════════════════════════════════════════════════╝ This is Markdown in a literal string. Let's import AUTHORS.md without its header and last paragraph next: hatch-fancy-pypi-readme is written and maintained by Hynek Schlawack and  released under the MIT license. The development is kindly supported by Variomedia AG and all my amazing GitHub  Sponsors. ─────────────────────────────────────────────────────────────────────────────── Now let's add an extract from tests/example_changelog.md: 1.1.0 - 2022-08-04 Added  • Neat features. #4  • Here's a GitHub-relative link -- that would make no sense in a PyPI readme! Fixed  • Nasty bugs. #3 ─────────────────────────────────────────────────────────────────────────────── Pretty cool, huh? ✨ hynek-hatch-fancy-pypi-readme-0109610/src/000077500000000000000000000000001454450152200202005ustar00rootroot00000000000000hynek-hatch-fancy-pypi-readme-0109610/src/hatch_fancy_pypi_readme/000077500000000000000000000000001454450152200250255ustar00rootroot00000000000000hynek-hatch-fancy-pypi-readme-0109610/src/hatch_fancy_pypi_readme/__init__.py000066400000000000000000000001331454450152200271330ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2022 Hynek Schlawack # # SPDX-License-Identifier: MIT hynek-hatch-fancy-pypi-readme-0109610/src/hatch_fancy_pypi_readme/__main__.py000066400000000000000000000036231454450152200271230ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2022 Hynek Schlawack # # SPDX-License-Identifier: MIT from __future__ import annotations import argparse import sys from contextlib import closing from pathlib import Path from typing import TextIO from ._cli import cli_run if sys.version_info < (3, 11): import tomli as tomllib else: import tomllib def main() -> None: parser = argparse.ArgumentParser( description="Render a README from a pyproject.toml & hatch.toml." " If a hatch.toml is passed / detected, it's preferred." ) parser.add_argument( "pyproject_path", nargs="?", metavar="PATH-TO-PYPROJECT.TOML", default="pyproject.toml", help="Path to the pyproject.toml to use for rendering. " "Default: pyproject.toml in current directory.", ) parser.add_argument( "--hatch-toml", nargs="?", metavar="PATH-TO-HATCH.TOML", default=None, help="Path to an additional hatch.toml to use for rendering. " "Default: Auto-detect in the current directory.", ) parser.add_argument( "-o", help="Target file for output. Default: standard out.", metavar="TARGET-FILE-PATH", ) args = parser.parse_args() pyproject = tomllib.loads(Path(args.pyproject_path).read_text()) hatch_toml = _maybe_load_hatch_toml(args.hatch_toml) out: TextIO out = Path(args.o).open("w") if args.o else sys.stdout # noqa: SIM115 with closing(out): cli_run(pyproject, hatch_toml, out) def _maybe_load_hatch_toml(hatch_toml_arg: str | None) -> dict[str, object]: """ If hatch.toml is passed or detected, load it. """ if hatch_toml_arg: return tomllib.loads(Path(hatch_toml_arg).read_text()) if Path("hatch.toml").exists(): return tomllib.loads(Path("hatch.toml").read_text()) return {} if __name__ == "__main__": main() hynek-hatch-fancy-pypi-readme-0109610/src/hatch_fancy_pypi_readme/_builder.py000066400000000000000000000012241454450152200271630ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2022 Hynek Schlawack # # SPDX-License-Identifier: MIT from __future__ import annotations from typing import TYPE_CHECKING if TYPE_CHECKING: from ._fragments import Fragment from ._substitutions import Substituter def build_text( fragments: list[Fragment], substitutions: list[Substituter], version: str = "", ) -> str: """ Try avoiding breaking the API unnecessarily; it's used directly by scikit-build-core. """ text = "".join(f.render() for f in fragments) for sub in substitutions: text = sub.substitute(text) return text.replace("$HFPR_VERSION", version) hynek-hatch-fancy-pypi-readme-0109610/src/hatch_fancy_pypi_readme/_cli.py000066400000000000000000000037531454450152200263150ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2022 Hynek Schlawack # # SPDX-License-Identifier: MIT from __future__ import annotations import sys from contextlib import suppress from typing import Any, NoReturn, TextIO from hatch_fancy_pypi_readme.exceptions import ConfigurationError from ._builder import build_text from ._config import load_and_validate_config def cli_run( pyproject: dict[str, Any], hatch_toml: dict[str, Any], out: TextIO ) -> None: """ Best-effort verify config and print resulting PyPI readme. """ is_dynamic = False with suppress(KeyError): is_dynamic = "readme" in pyproject["project"]["dynamic"] if not is_dynamic: _fail("You must add 'readme' to 'project.dynamic'.") try: if ( pyproject["tool"]["hatch"]["metadata"]["hooks"][ "fancy-pypi-readme" ] and hatch_toml["metadata"]["hooks"]["fancy-pypi-readme"] ): _fail( "Both pyproject.toml and hatch.toml contain " "hatch-fancy-pypi-readme configuration." ) except KeyError: pass try: cfg = hatch_toml["metadata"]["hooks"]["fancy-pypi-readme"] except KeyError: try: cfg = pyproject["tool"]["hatch"]["metadata"]["hooks"][ "fancy-pypi-readme" ] except KeyError: _fail( "Missing configuration " "(`[tool.hatch.metadata.hooks.fancy-pypi-readme]` in" " pyproject.toml or `[metadata.hooks.fancy-pypi-readme]`" " in hatch.toml)", ) try: config = load_and_validate_config(cfg) except ConfigurationError as e: _fail( "Configuration has errors:\n\n" + "\n".join(f"- {msg}" for msg in e.errors), ) print(build_text(config.fragments, config.substitutions, "42.0"), file=out) def _fail(msg: str) -> NoReturn: print(msg, file=sys.stderr) sys.exit(1) hynek-hatch-fancy-pypi-readme-0109610/src/hatch_fancy_pypi_readme/_config.py000066400000000000000000000047251454450152200270130ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2022 Hynek Schlawack # # SPDX-License-Identifier: MIT from __future__ import annotations from dataclasses import dataclass from typing import Any, cast from ._fragments import VALID_FRAGMENTS, Fragment from ._substitutions import Substituter from .exceptions import ConfigurationError @dataclass class Config: content_type: str fragments: list[Fragment] substitutions: list[Substituter] _BASE = "tool.hatch.metadata.hooks.fancy-pypi-readme." def load_and_validate_config(config: dict[str, Any]) -> Config: errs = [] ct = config.get("content-type") if ct is None: errs.append(f"{_BASE}content-type is missing.") elif ct not in ("text/markdown", "text/x-rst"): errs.append( f"{_BASE}content-type: '{ct}' is not one of " "['text/markdown', 'text/x-rst']" ) try: fragments = _load_fragments(config.get("fragments")) except ConfigurationError as e: errs.extend(e.errors) try: subs_cfg = config.get("substitutions", []) if not isinstance(subs_cfg, list): raise ConfigurationError( [f"{_BASE}substitutions must be an array."] ) substitutions = [ Substituter.from_config(sub_cfg) for sub_cfg in subs_cfg ] except ConfigurationError as e: errs.extend(e.errors) if errs: raise ConfigurationError(errs) return Config( content_type=cast(str, ct), fragments=fragments, substitutions=substitutions, ) def _load_fragments(config: list[dict[str, str]] | None) -> list[Fragment]: """ Load fragments from *config*. """ if config is None: raise ConfigurationError([f"{_BASE}fragments is missing."]) if not config: raise ConfigurationError([f"{_BASE}fragments must not be empty."]) frags = [] errs = [] for frag_cfg in config: for frag in VALID_FRAGMENTS: if frag.key not in frag_cfg: continue try: frags.append(frag.from_config(frag_cfg)) except ConfigurationError as e: errs.extend(e.errors) # We have either detected and added or detected and errored, but in # any case we're done with this fragment. break else: errs.append(f"Unknown fragment type {frag_cfg!r}.") if errs: raise ConfigurationError(errs) return frags hynek-hatch-fancy-pypi-readme-0109610/src/hatch_fancy_pypi_readme/_fragments.py000066400000000000000000000065041454450152200275310ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2022 Hynek Schlawack # # SPDX-License-Identifier: MIT from __future__ import annotations import re import sys from dataclasses import dataclass from pathlib import Path from typing import ClassVar, Iterable if sys.version_info >= (3, 8): from typing import Protocol else: from typing_extensions import Protocol from .exceptions import ConfigurationError class Fragment(Protocol): key: ClassVar[str] @classmethod def from_config(cls, cfg: dict[str, str]) -> Fragment: ... def render(self) -> str: ... @dataclass class TextFragment: """ A static text fragment. """ key: ClassVar[str] = "text" _text: str @classmethod def from_config(cls, cfg: dict[str, str]) -> Fragment: text = cfg[cls.key] if not text: raise ConfigurationError(["Text fragments must not be empty."]) return cls(text) def render(self) -> str: return self._text @dataclass class FileFragment: """ A static text fragment. """ key: ClassVar[str] = "path" _contents: str @classmethod def from_config(cls, cfg: dict[str, str]) -> Fragment: path = Path(cfg.pop(cls.key)) start_after = cfg.pop("start-after", None) start_at = cfg.pop("start-at", None) end_before = cfg.pop("end-before", None) pattern = cfg.pop("pattern", None) errs: list[str] = [] try: contents = path.read_text(encoding="utf-8") except FileNotFoundError: raise ConfigurationError( [f"Fragment file '{path}' not found."] ) from None if start_after and start_at: raise ConfigurationError( [ "file fragment: 'start-after' and 'start-at' are " "mutually exclusive." ] ) if start_after is not None: try: _, contents = contents.split(start_after, 1) except ValueError: errs.append( f"file fragment: 'start-after' {start_after!r} not found." ) elif start_at is not None: p = contents.find(start_at) if p == -1: errs.append( f"file fragment: 'start-at' {start_at!r} not found." ) contents = contents[p:] if end_before is not None: try: contents, _ = contents.split(end_before, 1) except ValueError: errs.append( f"file fragment: 'end-before' {end_before!r} not found." ) if pattern: m = re.search(pattern, contents, re.DOTALL) if not m: errs.append(f"file fragment: pattern {pattern!r} not found.") else: try: contents = m.group(1) except IndexError: errs.append( "file fragment: pattern matches, but no group " "defined." ) if errs: raise ConfigurationError(errs) return cls(contents) def render(self) -> str: return self._contents VALID_FRAGMENTS: Iterable[type[Fragment]] = (TextFragment, FileFragment) hynek-hatch-fancy-pypi-readme-0109610/src/hatch_fancy_pypi_readme/_substitutions.py000066400000000000000000000030221454450152200304720ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2022 Hynek Schlawack # # SPDX-License-Identifier: MIT from __future__ import annotations import re from dataclasses import dataclass from typing import cast from hatch_fancy_pypi_readme.exceptions import ConfigurationError @dataclass class Substituter: pattern: re.Pattern[str] replacement: str @classmethod def from_config(cls, cfg: dict[str, str]) -> Substituter: errs = [] flags = 0 ignore_case = cfg.get("ignore-case", False) if not isinstance(ignore_case, bool): errs.append( f"Value {ignore_case!r} for 'ignore-case' is not a bool." ) if ignore_case: flags += re.IGNORECASE try: pattern = re.compile(cfg["pattern"], flags=flags) except KeyError: errs.append(f"Substitution {cfg} is missing a 'pattern' key.") except re.error as e: errs.append( f"{cfg['pattern']!r} is not a valid regular expression: {e}" ) replacement = cfg.get("replacement") if replacement is None: errs.append(f"Substitution {cfg} is missing a 'replacement' key.") elif not isinstance(replacement, str): errs.append(f"Replacement value {replacement!r} is not a string.") if errs: raise ConfigurationError(errs) return cls(pattern, cast(str, replacement)) def substitute(self, text: str) -> str: return self.pattern.sub(self.replacement, text) hynek-hatch-fancy-pypi-readme-0109610/src/hatch_fancy_pypi_readme/exceptions.py000066400000000000000000000004311454450152200275560ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2022 Hynek Schlawack # # SPDX-License-Identifier: MIT from __future__ import annotations from dataclasses import dataclass @dataclass class ConfigurationError(Exception): """ Configuration is invalid. """ errors: list[str] hynek-hatch-fancy-pypi-readme-0109610/src/hatch_fancy_pypi_readme/hooks.py000066400000000000000000000017401454450152200265240ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2022 Hynek Schlawack # # SPDX-License-Identifier: MIT from __future__ import annotations from typing import Any from hatchling.metadata.plugin.interface import MetadataHookInterface from hatchling.plugin import hookimpl from ._builder import build_text from ._config import load_and_validate_config class FancyReadmeMetadataHook(MetadataHookInterface): PLUGIN_NAME = "fancy-pypi-readme" def update(self, metadata: dict[str, Any]) -> None: """ Update the project table's metadata. """ config = load_and_validate_config(self.config) metadata["readme"] = { "content-type": config.content_type, "text": build_text( config.fragments, config.substitutions, version=metadata.get("version", ""), ), } @hookimpl def hatch_register_metadata_hook() -> type[MetadataHookInterface]: return FancyReadmeMetadataHook hynek-hatch-fancy-pypi-readme-0109610/src/hatch_fancy_pypi_readme/py.typed000066400000000000000000000000001454450152200265120ustar00rootroot00000000000000hynek-hatch-fancy-pypi-readme-0109610/tests/000077500000000000000000000000001454450152200205535ustar00rootroot00000000000000hynek-hatch-fancy-pypi-readme-0109610/tests/__init__.py000066400000000000000000000001331454450152200226610ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2022 Hynek Schlawack # # SPDX-License-Identifier: MIT hynek-hatch-fancy-pypi-readme-0109610/tests/conftest.py000066400000000000000000000032431454450152200227540ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2022 Hynek Schlawack # # SPDX-License-Identifier: MIT import shutil from pathlib import Path from tempfile import TemporaryDirectory import pytest @pytest.fixture(name="plugin_dir", scope="session") def _plugin_dir(): """ Install the plugin into a temporary directory with a random path to prevent pip from caching it. Copy only the src directory, pyproject.toml, and whatever is needed to build ourselves. """ with TemporaryDirectory() as d: directory = Path(d, "plugin") shutil.copytree(Path.cwd() / "src", directory / "src") for fn in [ "pyproject.toml", "AUTHORS.md", "CHANGELOG.md", "LICENSE.txt", "README.md", ]: shutil.copy(Path.cwd() / fn, directory / fn) yield directory.resolve() @pytest.fixture(name="new_project") def _new_project(plugin_dir, tmp_path, monkeypatch): """ Create, and cd into, a blank new project that is configured to use our temporary plugin installation. """ project_dir = tmp_path / "my-app" project_dir.mkdir() project_file = project_dir / "pyproject.toml" project_file.write_text( f"""\ [build-system] requires = ["hatchling", "hatch-fancy-pypi-readme @ {plugin_dir.as_uri()}"] build-backend = "hatchling.build" [project] name = "my-app" version = "1.0" dynamic = ["readme"] """, encoding="utf-8", ) package_dir = project_dir / "src" / "my_app" package_dir.mkdir(parents=True) package_root = package_dir / "__init__.py" package_root.write_text("") monkeypatch.chdir(project_dir) return project_dir hynek-hatch-fancy-pypi-readme-0109610/tests/example_changelog.md000066400000000000000000000011211454450152200245320ustar00rootroot00000000000000# Changelog This is a long-winded preamble that explains versioning and backwards-compatibility guarantees. Your don't want this as part of your PyPI readme! Note that there's issue/PR IDs behind the changelog entries. Wouldn't it be nice if they were links in your PyPI readme? ## 1.1.0 - 2022-08-04 ### Added - Neat features. #4 - Here's a [GitHub-relative link](README.md) -- that would make no sense in a PyPI readme! ### Fixed - Nasty bugs. #3 ## 1.0.0 - 2021-12-16 ### Added - Everything. #2 ## 0.0.1 - 2020-03-01 ### Removed - Precedency. #1 hynek-hatch-fancy-pypi-readme-0109610/tests/example_pyproject.toml000066400000000000000000000026151454450152200252060ustar00rootroot00000000000000[build-system] requires = ["hatchling", "hatch-fancy-pypi-readme"] build-backend = "hatchling.build" [project] name = "my-pkg" version = "1.0" dynamic = ["readme"] [tool.hatch.metadata.hooks.fancy-pypi-readme] content-type = "text/markdown" [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] text = '''# Level 1 Header This is *Markdown* in a literal string. Let's import `AUTHORS.md` without its header and last paragraph next: ''' [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] path = "AUTHORS.md" start-after = "Authors\n" end-before = "A full" [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] text = """ --- 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): """ [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] path = "tests/example_changelog.md" pattern = "\n\n\n(.*?)\n\n## " [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] text = "\n---\n\nPretty **cool**, huh? ✨" [[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]] pattern = "#(\\d+)" replacement = "[#\\1](https://github.com/hynek/hatch-fancy-pypi-readme/issues/\\1)" [[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]] pattern = '\[(.+?)\]\(((?!https?://)\S+?)\)' replacement = '[\1](https://github.com/hynek/hatch-fancy-pypi-readme/tree/main/\g<2>)' hynek-hatch-fancy-pypi-readme-0109610/tests/example_text.md000066400000000000000000000001721454450152200235740ustar00rootroot00000000000000# Boring Header This is the *interesting* body! Uninteresting Footer hynek-hatch-fancy-pypi-readme-0109610/tests/test_builder.py000066400000000000000000000015741454450152200236210ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2022 Hynek Schlawack # # SPDX-License-Identifier: MIT from hatch_fancy_pypi_readme._builder import build_text from hatch_fancy_pypi_readme._fragments import TextFragment class TestBuildText: def test_single_text_fragment(self): """ A single text fragment becomes the readme. """ assert "This is the README for 1.0!" == build_text( [TextFragment("This is the README for $HFPR_VERSION!")], [], "1.0" ) def test_multiple_text_fragment(self): """ A multiple text fragment are concatenated without adding any characters. """ assert "# Level 1\n\nThis is the README!" == build_text( [ TextFragment("# Level 1\n\n"), TextFragment("This is the README!"), ], [], "1.0", ) hynek-hatch-fancy-pypi-readme-0109610/tests/test_cli.py000066400000000000000000000162261454450152200227420ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2022 Hynek Schlawack # # SPDX-License-Identifier: MIT import sys from io import StringIO from pathlib import Path import pytest from hatch_fancy_pypi_readme.__main__ import _maybe_load_hatch_toml, tomllib from hatch_fancy_pypi_readme._cli import cli_run from .utils import run @pytest.fixture(name="pyproject", scope="session") def _pyproject(): return tomllib.loads( (Path("tests") / "example_pyproject.toml").read_text() ) @pytest.fixture(name="empty_pyproject") def _empty_pyproject(): return { "project": {"dynamic": ["foo", "readme", "bar"]}, "tool": {"hatch": {"metadata": {"hooks": {"fancy-pypi-readme": {}}}}}, } class TestCLIEndToEnd: @pytest.mark.usefixtures("new_project") def test_missing_config(self): """ Missing configuration is caught and gives helpful advice. Run it as it would be run by the user. """ out = run("hatch_fancy_pypi_readme", check=False) assert ( "Missing configuration " "(`[tool.hatch.metadata.hooks.fancy-pypi-readme]` in" " pyproject.toml or `[metadata.hooks.fancy-pypi-readme]` in" " hatch.toml)\n" == out ) def test_ok(self): """ A valid config is rendered. """ out = run("hatch_fancy_pypi_readme", "tests/example_pyproject.toml") assert out.startswith("# Level 1 Header") assert "1.0.0" not in out # Check substitutions assert ( "[GitHub-relative link](https://github.com/hynek/" "hatch-fancy-pypi-readme/tree/main/README.md)" in out ) assert ( "Neat features. [#4](https://github.com/hynek/" "hatch-fancy-pypi-readme/issues/4)" in out ) def test_ok_redirect(self, tmp_path): """ It's possible to redirect output into a file. """ out = tmp_path / "out.txt" assert "" == run( "hatch_fancy_pypi_readme", "tests/example_pyproject.toml", "-o", str(out), ) assert out.read_text().startswith("# Level 1 Header") def test_empty_explicit_hatch_toml(self, tmp_path): """ Explicit empty hatch.toml is ignored. """ hatch_toml = tmp_path / "hatch.toml" hatch_toml.write_text("") assert run( "hatch_fancy_pypi_readme", "tests/example_pyproject.toml", f"--hatch-toml={hatch_toml.resolve()}", ).startswith("# Level 1 Header") def test_config_in_hatch_toml(self, tmp_path, monkeypatch): """ Implicit empty hatch.toml is used. """ pyproject = tmp_path / "pyproject.toml" pyproject.write_text( """\ [build-system] requires = ["hatchling", "hatch-fancy-pypi-readme"] build-backend = "hatchling.build" [project] name = "my-pkg" version = "1.0" dynamic = ["readme"] """ ) hatch_toml = tmp_path / "hatch.toml" hatch_toml.write_text( """\ [metadata.hooks.fancy-pypi-readme] content-type = "text/markdown" [[metadata.hooks.fancy-pypi-readme.fragments]] text = '# Level 1 Header' """ ) monkeypatch.chdir(tmp_path) assert run("hatch_fancy_pypi_readme").startswith("# Level 1 Header") class TestCLI: def test_cli_run_missing_dynamic(self, capfd): """ Missing readme in dynamic is caught and gives helpful advice. """ with pytest.raises(SystemExit): cli_run({}, {}, sys.stdout) out, err = capfd.readouterr() assert "You must add 'readme' to 'project.dynamic'.\n" == err assert "" == out def test_cli_run_missing_config(self, capfd): """ Missing configuration is caught and gives helpful advice. """ with pytest.raises(SystemExit): cli_run( {"project": {"dynamic": ["foo", "readme", "bar"]}}, {}, sys.stdout, ) out, err = capfd.readouterr() assert ( "Missing configuration " "(`[tool.hatch.metadata.hooks.fancy-pypi-readme]` in" " pyproject.toml or `[metadata.hooks.fancy-pypi-readme]` in" " hatch.toml)\n" == err ) assert "" == out def test_cli_run_two_configs(self, capfd): """ Ambiguous two configs. """ meta = { "metadata": { "hooks": { "fancy-pypi-readme": {"content-type": "text/markdown"} } } } with pytest.raises(SystemExit): cli_run( { "project": { "dynamic": ["foo", "readme", "bar"], }, "tool": {"hatch": meta}, }, meta, sys.stdout, ) out, err = capfd.readouterr() assert ( "Both pyproject.toml and hatch.toml contain " "hatch-fancy-pypi-readme configuration.\n" == err ) assert "" == out def test_cli_run_config_error(self, capfd, empty_pyproject): """ Configuration errors are detected and give helpful advice. """ with pytest.raises(SystemExit): cli_run(empty_pyproject, {}, sys.stdout) out, err = capfd.readouterr() assert ( "Configuration has errors:\n\n" "- tool.hatch.metadata.hooks.fancy-pypi-readme." "content-type is missing.\n" "- tool.hatch.metadata.hooks.fancy-pypi-readme.fragments " "is missing.\n" == err ) assert "" == out def test_cli_run_ok(self, capfd, pyproject): """ Correct configuration gives correct output to the file selected. """ sio = StringIO() cli_run(pyproject, {}, sio) out, err = capfd.readouterr() assert "" == err assert "" == out assert sio.getvalue().startswith("# Level 1 Header") class TestMaybeLoadHatchToml: def test_none(self, tmp_path, monkeypatch): """ If nothing is passed and not hatch.toml is found, return empty dict. """ monkeypatch.chdir(tmp_path) assert {} == _maybe_load_hatch_toml(None) def test_explicit(self, tmp_path, monkeypatch): """ If one is passed, return its parsed content and ignore files called hatch.toml. """ monkeypatch.chdir(tmp_path) hatch_toml = tmp_path / "hatch.toml" hatch_toml.write_text("gibberish") not_hatch_toml = tmp_path / "not-hatch.toml" not_hatch_toml.write_text("[foo]\nbar='qux'") assert {"foo": {"bar": "qux"}} == _maybe_load_hatch_toml( str(not_hatch_toml) ) def test_implicit(self, tmp_path, monkeypatch): """ If none is passed, but a hatch.toml is present in current dir, parse it. """ monkeypatch.chdir(tmp_path) hatch_toml = tmp_path / "hatch.toml" hatch_toml.write_text("[foo]\nbar='qux'") assert {"foo": {"bar": "qux"}} == _maybe_load_hatch_toml(None) hynek-hatch-fancy-pypi-readme-0109610/tests/test_config.py000066400000000000000000000162301454450152200234330ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2022 Hynek Schlawack # # SPDX-License-Identifier: MIT import pytest from hatch_fancy_pypi_readme._config import load_and_validate_config from hatch_fancy_pypi_readme.exceptions import ConfigurationError class TestValidateConfig: @pytest.mark.parametrize( "cfg", [{"content-type": "text/markdown", "fragments": [{"text": "foo"}]}], ) def test_valid(self, cfg): """ Valid configurations return empty error lists. """ load_and_validate_config(cfg) def test_missing_content_type(self): """ Missing content-type is caught. """ with pytest.raises(ConfigurationError) as ei: load_and_validate_config({"fragments": [{"text": "foo"}]}) assert ( [ "tool.hatch.metadata.hooks.fancy-pypi-readme." "content-type is missing." ] == ei.value.errors == ei.value.args[0] ) def test_wrong_content_type(self): """ Missing content-type is caught. """ with pytest.raises(ConfigurationError) as ei: load_and_validate_config( {"content-type": "text/html", "fragments": [{"text": "foo"}]} ) assert [ "tool.hatch.metadata.hooks.fancy-pypi-readme.content-type: " "'text/html' is not one of ['text/markdown', 'text/x-rst']" ] == ei.value.errors VALID_FOR_FRAG = {"content-type": "text/markdown"} def cow_add_frag(**kw): d = VALID_FOR_FRAG.copy() d["fragments"] = [kw] return d class TestValidateConfigFragments: def test_empty_fragments(self): """ Empty fragments are caught. """ with pytest.raises(ConfigurationError) as ei: load_and_validate_config( {"content-type": "text/markdown", "fragments": []} ) assert ( [ "tool.hatch.metadata.hooks.fancy-pypi-readme.fragments must " "not be empty." ] == ei.value.errors == ei.value.args[0] ) def test_missing_fragments(self): """ Missing fragments are caught. """ with pytest.raises(ConfigurationError) as ei: load_and_validate_config({"content-type": "text/markdown"}) assert ( [ "tool.hatch.metadata.hooks.fancy-pypi-readme.fragments" " is missing." ] == ei.value.errors == ei.value.args[0] ) def test_empty_fragment_dict(self): """ Empty fragment dicts are handled gracefully. """ with pytest.raises(ConfigurationError) as ei: load_and_validate_config( {"content-type": "text/markdown", "fragments": [{}]} ) assert ["Unknown fragment type {}."] == ei.value.errors def test_empty_text_fragment(self): """ Text fragments can't be empty. """ with pytest.raises(ConfigurationError) as ei: load_and_validate_config(cow_add_frag(text="")) assert ["Text fragments must not be empty."] == ei.value.errors def test_invalid_fragments(self): """ Invalid fragments are caught. """ with pytest.raises(ConfigurationError) as ei: load_and_validate_config( { "content-type": "text/markdown", "fragments": [ {"text": "this is ok"}, {"foo": "this is not"}, {"bar": "neither is this"}, ], } ) assert { "Unknown fragment type {'foo': 'this is not'}.", "Unknown fragment type {'bar': 'neither is this'}.", } == set(ei.value.errors) def test_fragment_loading_errors(self): """ Errors that happen while loading a fragment are propagated. """ with pytest.raises(ConfigurationError) as ei: load_and_validate_config( { "content-type": "text/markdown", "fragments": [{"path": "yolo"}], } ) assert ["Fragment file 'yolo' not found."] == ei.value.errors VALID_FOR_SUB = { "content-type": "text/markdown", "fragments": [{"text": "foobar"}], } def cow_add_sub(**kw): d = VALID_FOR_SUB.copy() d["substitutions"] = [kw] return d class TestValidateConfigSubstitutions: def test_invalid_substitution(self): """ Invalid substitutions are caught and reported. """ with pytest.raises(ConfigurationError) as ei: load_and_validate_config( { "content-type": "text/markdown", "fragments": [{"text": "foo"}], "substitutions": [{"foo": "bar"}], } ) assert { "Substitution {'foo': 'bar'} is missing a 'pattern' key.", "Substitution {'foo': 'bar'} is missing a 'replacement' key.", } == set(ei.value.errors) def test_empty(self): """ Empty dict is not valid. """ with pytest.raises(ConfigurationError) as ei: load_and_validate_config(cow_add_sub()) assert { "Substitution {} is missing a 'pattern' key.", "Substitution {} is missing a 'replacement' key.", } == set(ei.value.errors) def test_ignore_case_not_bool(self): """ Ignore case is either bool or nothing. """ with pytest.raises(ConfigurationError) as ei: load_and_validate_config( cow_add_sub( pattern="foo", replacement="bar", **{"ignore-case": 42} ) ) assert {"Value 42 for 'ignore-case' is not a bool."} == set( ei.value.errors ) def test_pattern_no_valid_regexp(self): """ Pattern must be a valid re-regexp. """ with pytest.raises(ConfigurationError) as ei: load_and_validate_config( cow_add_sub(pattern="foo???", replacement="bar") ) assert { "'foo???' is not a valid regular expression: multiple repeat at " "position 5" } == set(ei.value.errors) def test_replacement_not_a_string(self): """ Replacements must be strings. """ with pytest.raises(ConfigurationError) as ei: load_and_validate_config( cow_add_sub(pattern="foo", replacement=42) ) assert {"Replacement value 42 is not a string."} == set( ei.value.errors ) def test_substitutions_not_array(self): """ Substitutions key must be a list. """ cfg = VALID_FOR_SUB.copy() cfg["substitutions"] = {} with pytest.raises(ConfigurationError) as ei: load_and_validate_config(cfg) assert { "tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions must " "be an array." } == set(ei.value.errors) hynek-hatch-fancy-pypi-readme-0109610/tests/test_end_to_end.py000066400000000000000000000036201454450152200242630ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2022 Hynek Schlawack # # SPDX-License-Identifier: MIT import email.parser import pytest from .utils import append, run def build_project(*args, check=True): if not args: args = ["-w"] return run("build", *args, check=check) @pytest.mark.slow() def test_build(new_project): """ Build a fake project end-to-end and verify wheel contents. """ append( new_project / "pyproject.toml", """ [tool.hatch.metadata.hooks.fancy-pypi-readme] content-type = "text/markdown" [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] text = '''# Level 1 Fancy *Markdown*. ''' [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] text = "---\\nFooter" """, ) build_project() whl = new_project / "dist" / "my_app-1.0-py2.py3-none-any.whl" assert whl.exists() run("wheel", "unpack", whl) metadata = email.parser.Parser().parsestr( ( new_project / "my_app-1.0" / "my_app-1.0.dist-info" / "METADATA" ).read_text() ) assert "text/markdown" == metadata["Description-Content-Type"] assert ( "# Level 1\n\nFancy *Markdown*.\n---\nFooter" == metadata.get_payload() ) @pytest.mark.slow() def test_invalid_config(new_project): """ Missing config makes the build fail with a meaningful error message. """ pyp = new_project / "pyproject.toml" # If we leave out the config for good, the plugin doesn't get activated. pyp.write_text( pyp.read_text() + "[tool.hatch.metadata.hooks.fancy-pypi-readme]" ) out = build_project(check=False) assert "hatch_fancy_pypi_readme.exceptions.ConfigurationError:" in out assert ( "tool.hatch.metadata.hooks.fancy-pypi-readme.content-type " "is missing." in out ) assert ( "tool.hatch.metadata.hooks.fancy-pypi-readme.fragments " "is missing." in out ) hynek-hatch-fancy-pypi-readme-0109610/tests/test_fragments.py000066400000000000000000000140271454450152200241560ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2022 Hynek Schlawack # # SPDX-License-Identifier: MIT from __future__ import annotations import secrets from pathlib import Path import pytest from hatch_fancy_pypi_readme._fragments import FileFragment, TextFragment from hatch_fancy_pypi_readme.exceptions import ConfigurationError class TestTextFragment: def test_ok(self): """ The text that is passed in is rendered without changes. """ text = secrets.token_urlsafe() assert text == TextFragment.from_config({"text": text}).render() @pytest.fixture(name="txt_path") def _txt_path(): return Path("tests") / "example_text.md" @pytest.fixture(name="txt") def _txt(txt_path): return txt_path.read_text() class TestFileFragment: def test_simple_ok(self, txt, txt_path): """ Loading a file works. """ assert ( txt == FileFragment.from_config({"path": str(txt_path)}).render() ) def test_start_after_ok(self, txt_path): """ Specifying a `start-after` that exists in the file removes it along with what comes before. """ assert ( """This is the *interesting* body! Uninteresting Footer """ == FileFragment.from_config( { "path": str(txt_path), "start-after": "\n\n", } ).render() ) def test_start_at_ok(self, txt_path): """ Specifying a `start-at` that exists in the file removes everything before the string, but not the string itself. """ assert ( """This is the *interesting* body! Uninteresting Footer """ == FileFragment.from_config( { "path": str(txt_path), "start-at": "This is the *interesting* body!", } ).render() ) def test_end_before_ok(self, txt_path): """ Specifying an `end-before` that exists in the file cuts it off along with everything that follows. """ assert ( """# Boring Header This is the *interesting* body!""" == FileFragment.from_config( { "path": str(txt_path), "end-before": "\n\n", } ).render() ) def test_start_end_ok(self, txt_path): """ Specifying existing `start-after` and `end-before` returns exactly what's between them. """ assert ( "This is the *interesting* body!" == FileFragment.from_config( { "path": str(txt_path), "start-after": "\n\n", "end-before": "\n\n", } ).render() ) def test_start_after_end_before_not_found(self, txt_path): """ If `start-after` and/or `end-before` don't exist, a helpful error is raised. """ with pytest.raises(ConfigurationError) as ei: FileFragment.from_config( { "path": str(txt_path), "start-after": "nope", "end-before": "also nope", } ) assert [ "file fragment: 'start-after' 'nope' not found.", "file fragment: 'end-before' 'also nope' not found.", ] == ei.value.errors def test_start_at_end_before_not_found(self, txt_path): """ If `start-at` and/or `end-before` don't exist, a helpful error is raised. """ with pytest.raises(ConfigurationError) as ei: FileFragment.from_config( { "path": str(txt_path), "start-at": "nope", "end-before": "also nope", } ) assert [ "file fragment: 'start-at' 'nope' not found.", "file fragment: 'end-before' 'also nope' not found.", ] == ei.value.errors def test_start_after_at(self, txt_path): """ If both `start-after` and `start-at` are passed, abort with an error. """ with pytest.raises(ConfigurationError) as ei: FileFragment.from_config( { "path": str(txt_path), "start-after": "cut", "start-at": "cut", } ) assert [ "file fragment: 'start-after' and 'start-at' are mutually " "exclusive." ] == ei.value.errors def test_pattern_no_match(self, txt_path): """ If the pattern doesn't match, a helpful error is raises. """ with pytest.raises(ConfigurationError) as ei: FileFragment.from_config( { "path": str(txt_path), "pattern": r"wtf", } ) assert ["file fragment: pattern 'wtf' not found."] == ei.value.errors def test_pattern_no_group(self, txt_path): """ If the pattern matches but lacks a group, tell the user. """ with pytest.raises(ConfigurationError) as ei: FileFragment.from_config( { "path": str(txt_path), "pattern": r"Uninteresting", } ) assert [ "file fragment: pattern matches, but no group defined." ] == ei.value.errors def test_pattern_ok(self, txt_path): """ If the pattern matches and has a group, return it. """ assert ( "*interesting*" == FileFragment.from_config( { "path": str(txt_path), "pattern": r"the (.*) body", } ).render() ) hynek-hatch-fancy-pypi-readme-0109610/tests/test_substitutions.py000066400000000000000000000045451454450152200251330ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2022 Hynek Schlawack # # SPDX-License-Identifier: MIT from __future__ import annotations import pytest from hatch_fancy_pypi_readme._substitutions import Substituter VALID = {"pattern": "f(o)o", "replacement": r"bar\g<1>bar"} def cow_valid(**kw): d = VALID.copy() d.update(**kw) return d class TestSubstituter: def test_ok(self): """ Valid pattern leads to correct behavior. """ sub = Substituter.from_config(VALID) assert "xxx barobar yyy" == sub.substitute("xxx foo yyy") def test_twisted(self): """ Twisted example works. https://github.com/twisted/twisted/blob/eda9d29dc7fe34e7b207781e5674dc92f798bffe/setup.py#L19-L24 """ assert ( "For information on changes in this release, see the `NEWS `_ file." ) == Substituter.from_config( { "pattern": r"`([^`]+)\s+<(?!https?://)([^>]+)>`_", "replacement": r"`\1 `_", "ignore-case": True, } ).substitute( "For information on changes in this release, see the `NEWS `_ file." ) @pytest.mark.parametrize( ("pat", "repl", "text", "expect"), [ ( r"#(\d+)", r"[#\1](https://github.com/pydantic/pydantic/issues/\1)", "* Foo #4224, #4470 Bar", "* Foo [#4224](https://github.com/pydantic/pydantic/issues/" "4224), [#4470](https://github.com/pydantic/pydantic/issues/" "4470) Bar", ), ( r"( +)@([\w\-]+)", r"\1[@\2](https://github.com/\2)", "foo @github-user bar", "foo [@github-user](https://github.com/github-user) bar", ), ], ) def test_pydantic(self, pat, repl, text, expect): """ Pydantic examples work. https://github.com/hynek/hatch-fancy-pypi-readme/issues/9#issuecomment-1238584908 """ assert expect == Substituter.from_config( { "pattern": pat, "replacement": repl, "ignore-case": True, } ).substitute(text) hynek-hatch-fancy-pypi-readme-0109610/tests/utils.py000066400000000000000000000010271454450152200222650ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2022 Hynek Schlawack # # SPDX-License-Identifier: MIT import subprocess import sys import pytest def run(*args, check=True): process = subprocess.run( # noqa: PLW1510 [sys.executable, "-m", *args], # noqa: S603 stdout=subprocess.PIPE, stderr=subprocess.STDOUT, encoding="utf-8", ) if check and process.returncode: pytest.fail(process.stdout) return process.stdout def append(file, text): file.write_text(file.read_text() + text) hynek-hatch-fancy-pypi-readme-0109610/tox.ini000066400000000000000000000025731454450152200207330ustar00rootroot00000000000000[tox] min_version = 4 env_list = pre-commit, mypy, py3{7,8,9,10,11,12}, py3{7,10,11,12}-cli, coverage-report [testenv] package = wheel wheel_build_env = .pkg extras = tests pass_env = FORCE_COLOR NO_COLOR commands = pytest {posargs} [testenv:py3{7,10,12}-cli] deps = coverage[toml] commands = # Use -o only once, so we exercise both code paths. coverage run -m hatch_fancy_pypi_readme tests/example_pyproject.toml -o {envtmpdir}{/}t.md coverage run {envbindir}{/}hatch-fancy-pypi-readme tests/example_pyproject.toml [testenv:pre-commit] skip_install = true deps = pre-commit commands = pre-commit run --all-files [testenv:mypy] extras = tests deps = mypy commands = mypy src [testenv:py31{0,2}] deps = coverage[toml] commands = coverage run -m pytest {posargs} [testenv:coverage-report] ; Keep version in-sync with .python-version-default base_python = python3.12 deps = coverage[toml] skip_install = true commands = coverage combine coverage report [testenv:svg] description = Refresh SVG, test running using pipx. deps = pipx skip_install = true allowlist_externals = npx commands = pipx run --no-cache --spec . hatch-fancy-pypi-readme tests/example_pyproject.toml -o {envtmpdir}{/}t.md pipx run rich-cli --markdown --hyperlinks --export-svg rich-cli-out.svg --max-width 79 {envtmpdir}{/}t.md npx --quiet svgo rich-cli-out.svg