├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── copier.yml ├── docs ├── changelog.md ├── code_of_conduct.md ├── contributing.md ├── css │ └── custom.css ├── generate.md ├── index.md ├── license.md ├── requirements.md ├── update.md └── work.md ├── extensions.py ├── mkdocs.yml ├── project ├── .envrc.jinja ├── .github │ ├── FUNDING.yml.jinja │ ├── ISSUE_TEMPLATE │ │ ├── 1-bug.md.jinja │ │ ├── 2-feature.md.jinja │ │ ├── 3-docs.md.jinja │ │ ├── 4-change.md.jinja │ │ └── config.yml.jinja │ └── workflows │ │ ├── ci.yml.jinja │ │ └── release.yml.jinja ├── .gitignore.jinja ├── CHANGELOG.md.jinja ├── CODE_OF_CONDUCT.md.jinja ├── CONTRIBUTING.md.jinja ├── LICENSE.jinja ├── Makefile.jinja ├── README.md.jinja ├── config │ ├── coverage.ini.jinja │ ├── git-changelog.toml.jinja │ ├── mypy.ini.jinja │ ├── pytest.ini.jinja │ ├── ruff.toml.jinja │ └── vscode │ │ ├── launch.json.jinja │ │ ├── settings.json.jinja │ │ └── tasks.json.jinja ├── docs │ ├── .overrides │ │ ├── partials │ │ │ ├── comments.html.jinja │ │ │ └── path-item.html │ │ └── {% if author_username == 'pawamoy' %}main.html{% endif %}.jinja │ ├── changelog.md.jinja │ ├── code_of_conduct.md.jinja │ ├── contributing.md.jinja │ ├── credits.md.jinja │ ├── css │ │ ├── material.css.jinja │ │ ├── mkdocstrings.css.jinja │ │ └── {% if insiders %}insiders.css{% endif %}.jinja │ ├── index.md.jinja │ ├── js │ │ ├── feedback.js.jinja │ │ └── {% if insiders %}insiders.js{% endif %}.jinja │ ├── license.md.jinja │ ├── reference │ │ └── api.md.jinja │ └── {% if insiders %}insiders{% endif %} │ │ ├── changelog.md.jinja │ │ ├── goals.yml.jinja │ │ ├── index.md.jinja │ │ └── installation.md.jinja ├── duties.py.jinja ├── mkdocs.yml.jinja ├── pyproject.toml.jinja ├── scripts │ ├── gen_credits.py.jinja │ ├── get_version.py.jinja │ ├── make │ ├── make.py.jinja │ └── {% if insiders %}insiders.py{% endif %}.jinja ├── src │ └── {{python_package_import_name}} │ │ ├── __init__.py.jinja │ │ ├── _internal │ │ ├── __init__.py │ │ ├── debug.py.jinja │ │ └── {% if python_package_command_line_name %}cli.py{% endif %}.jinja │ │ ├── py.typed.jinja │ │ └── {% if python_package_command_line_name %}__main__.py{% endif %}.jinja ├── tests │ ├── __init__.py.jinja │ ├── conftest.py.jinja │ ├── test_api.py.jinja │ └── {% if python_package_command_line_name %}test_cli.py{% endif %}.jinja └── {{_copier_conf.answers_file}}.jinja ├── requirements.txt ├── ruff.toml └── tests ├── helpers.sh ├── reset_history.sh ├── test_filenames.sh ├── test_licenses.py ├── test_project.sh └── wait_ci.sh /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | pull_request: 6 | branches: 7 | - main 8 | 9 | defaults: 10 | run: 11 | shell: bash 12 | 13 | env: 14 | LANG: "en_US.utf-8" 15 | LC_ALL: "en_US.utf-8" 16 | PYTHONIOENCODING: "UTF-8" 17 | PYTHON_VERSIONS: "" 18 | 19 | jobs: 20 | 21 | test-filenames: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@v4 26 | - name: Test file names 27 | run: bash tests/test_filenames.sh 28 | 29 | test-licenses: 30 | runs-on: ubuntu-latest 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v4 34 | - name: Configure Git 35 | run: | 36 | git config --global init.defaultBranch main 37 | git config --global user.email "dev@pawamoy.fr" 38 | git config --global user.name "Timothée Mazzucotelli" 39 | - name: Setup Python 40 | uses: actions/setup-python@v5 41 | with: 42 | python-version: "3.12" 43 | - name: Setup uv 44 | uses: astral-sh/setup-uv@v5 45 | - name: Test licenses 46 | run: uv run --with jinja2 --with pyyaml --with reuse python tests/test_licenses.py 47 | 48 | test-project: 49 | strategy: 50 | matrix: 51 | os: 52 | - ubuntu-latest 53 | - macos-latest 54 | - windows-latest 55 | python-version: 56 | - "3.9" 57 | - "3.10" 58 | - "3.11" 59 | - "3.12" 60 | - "3.13" 61 | - "3.14" 62 | 63 | runs-on: ${{ matrix.os }} 64 | continue-on-error: ${{ matrix.python-version == '3.14'}} 65 | 66 | steps: 67 | - name: Checkout 68 | uses: actions/checkout@v4 69 | with: 70 | fetch-depth: 0 71 | fetch-tags: true 72 | 73 | - name: Configure Git 74 | run: | 75 | git config --global init.defaultBranch main 76 | git config --global user.email "dev@pawamoy.fr" 77 | git config --global user.name "Timothée Mazzucotelli" 78 | 79 | - name: Setup Python 80 | uses: actions/setup-python@v5 81 | with: 82 | python-version: ${{ matrix.python-version }} 83 | allow-prereleases: true 84 | 85 | - name: Setup uv 86 | uses: astral-sh/setup-uv@v5 87 | with: 88 | enable-cache: true 89 | cache-dependency-glob: project/pyproject.toml.jinja 90 | 91 | - name: Install Copier 92 | env: 93 | PYO3_USE_ABI3_FORWARD_COMPATIBILITY: "1" 94 | run: uv tool install copier --with copier-templates-extensions 95 | 96 | - name: Test project generation and workflow 97 | run: bash tests/test_project.sh 98 | 99 | test-project-ci: 100 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' 101 | runs-on: ubuntu-latest 102 | steps: 103 | - name: Checkout 104 | uses: actions/checkout@v4 105 | with: 106 | fetch-depth: 0 107 | fetch-tags: true 108 | 109 | - name: Configure Git 110 | run: | 111 | git config --global init.defaultBranch main 112 | git config --global user.email "dev@pawamoy.fr" 113 | git config --global user.name "Timothée Mazzucotelli" 114 | 115 | - name: Setup Python 116 | uses: actions/setup-python@v5 117 | with: 118 | python-version: "3.12" 119 | 120 | - name: Setup uv 121 | uses: astral-sh/setup-uv@v5 122 | with: 123 | enable-cache: true 124 | cache-dependency-glob: project/pyproject.toml.jinja 125 | 126 | - name: Install Copier 127 | run: uv tool install copier --with copier-templates-extensions 128 | 129 | - name: Trigger and wait project's own CI 130 | env: 131 | GH_TOKEN: ${{ secrets.TESTING_PUSH_TOKEN }} 132 | run: bash tests/wait_ci.sh 133 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: push 4 | 5 | permissions: 6 | contents: write 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | if: startsWith(github.ref, 'refs/tags/') 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | fetch-tags: true 18 | - name: Setup Python 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: "3.12" 22 | - name: Setup uv 23 | uses: astral-sh/setup-uv@v5 24 | - name: Prepare release notes 25 | run: uv tool run git-changelog --release-notes > release-notes.md 26 | - name: Create release 27 | uses: softprops/action-gh-release@v2 28 | with: 29 | body_path: release-notes.md 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | tests/tmp/ 3 | site/ 4 | *.py[cod] 5 | __pycache__/ 6 | .venv/ 7 | .vscode/ 8 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at dev@pawamoy.fr. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are welcome, and they are greatly appreciated! 4 | Every little bit helps, and credit will always be given. 5 | 6 | ## Environment setup 7 | 8 | You only need two tools, [uv](https://github.com/astral-sh/uv) 9 | and [Copier](https://github.com/copier-org/copier). 10 | 11 | ```bash 12 | curl -LsSf https://astral.sh/uv/install.sh | sh 13 | uv tool install copier --with copier-templates-extensions 14 | ``` 15 | 16 | Then you can clone the repository, enter it and set it up with: 17 | 18 | ```bash 19 | git clone https://github.com/pawamoy/copier-uv 20 | cd copier-uv 21 | make setup 22 | ``` 23 | 24 | ## Running tests 25 | 26 | To run the tests, use: 27 | 28 | ```bash 29 | make test 30 | ``` 31 | 32 | ## Serving docs 33 | 34 | To serve the docs locally, use: 35 | 36 | ```bash 37 | make docs 38 | ``` 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2019, Timothée Mazzucotelli 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: $(MAKECMDGOALS) 2 | 3 | changelog: 4 | @.venv/bin/git-changelog -Tio CHANGELOG.md -Bauto -c angular -n pep440 5 | 6 | clean: 7 | @rm -rf tests/tmp 8 | 9 | cleantests: 10 | @rm -rf tests/tmp/.git 11 | @rm -rf tests/tmp/empty 12 | @rm -rf tests/tmp/CHANGELOG.md 13 | 14 | docs: 15 | @.venv/bin/mkdocs serve 16 | 17 | docs-deploy: 18 | @.venv/bin/mkdocs gh-deploy 19 | 20 | format: 21 | @.venv/bin/ruff format . project/scripts/make 22 | 23 | gen generate: 24 | @bash -c 'source tests/helpers.sh && generate ${PWD} tests/tmp' 25 | 26 | reset-history: gen 27 | @bash tests/reset_history.sh 28 | 29 | release: 30 | @test -n "$(version)" || { echo "error: usage: make release version=x.y.z" >&2; exit 1; } 31 | @git add CHANGELOG.md 32 | @git commit -m "docs: Update changelog for version $(version)" 33 | @git tag $(version) 34 | @git push 35 | @git push --tags 36 | 37 | setup: 38 | @uv venv --seed 39 | @uv pip install -r requirements.txt 40 | 41 | test: cleantests 42 | @bash tests/test_filenames.sh 43 | @bash tests/test_project.sh 44 | @.venv/bin/python tests/test_licenses.py 45 | 46 | wait-ci: 47 | @bash tests/wait_ci.sh 48 | 49 | DUTIES = \ 50 | test-changelog \ 51 | test-check \ 52 | test-check-api \ 53 | test-check-docs \ 54 | test-check-quality \ 55 | test-check-types \ 56 | test-clean \ 57 | test-coverage \ 58 | test-docs \ 59 | test-docs-deploy \ 60 | test-format \ 61 | test-help \ 62 | test-lock \ 63 | test-release \ 64 | test-setup \ 65 | test-test 66 | 67 | $(DUTIES): 68 | @cd tests/tmp && make $(subst test-,,$@) 69 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Copier UV 2 | 3 | [![ci](https://github.com/pawamoy/copier-uv/workflows/ci/badge.svg)](https://github.com/pawamoy/copier-uv/actions?query=workflow%3Aci) 4 | [![documentation](https://img.shields.io/badge/docs-mkdocs%20material-blue.svg?style=flat)](https://pawamoy.github.io/copier-uv/) 5 | [![gitter](https://badges.gitter.im/join%20chat.svg)](https://app.gitter.im/#/room/#copier-uv/community:gitter.im) 6 | 7 | [Copier](https://github.com/copier-org/copier) template 8 | for Python projects managed by [uv](https://github.com/astral-sh/uv). 9 | 10 | This copier template is mainly for my own usage, 11 | but feel free to try it out, or fork it! 12 | 13 | Similar templates: 14 | 15 | - [copier-pdm](https://github.com/pawamoy/copier-pdm), with [PDM](https://github.com/pdm-project/pdm) 16 | - [copier-poetry](https:///github.com/pawamoy/copier-poetry), with [Poetry](https://github.com/python-poetry/poetry) 17 | 18 | ## Features 19 | 20 | - [uv](https://github.com/astral-sh/uv) setup, with pre-defined `pyproject.toml` 21 | - Pre-configured tools for code formatting, quality analysis and testing: 22 | [ruff](https://github.com/charliermarsh/ruff), 23 | [mypy](https://github.com/python/mypy), 24 | - Tests run with [pytest](https://github.com/pytest-dev/pytest) and plugins, with [coverage](https://github.com/nedbat/coveragepy) support 25 | - Documentation built with [MkDocs](https://github.com/mkdocs/mkdocs) 26 | ([Material theme](https://github.com/squidfunk/mkdocs-material) 27 | and "autodoc" [mkdocstrings plugin](https://github.com/mkdocstrings/mkdocstrings)) 28 | - Cross-platform tasks with [duty](https://github.com/pawamoy/duty) 29 | - Support for GitHub workflows 30 | - Auto-generated `CHANGELOG.md` from Git (conventional) commits 31 | - All licenses from [choosealicense.com](https://choosealicense.com/appendix/) 32 | - Support for Insiders versions of projects (e.g. see [@pawamoy's insiders](https://pawamoy.github.io/insiders/)) 33 | 34 | ## Quick setup and usage 35 | 36 | Make sure all the 37 | [requirements](https://pawamoy.github.io/copier-uv/requirements) 38 | are met, then: 39 | 40 | ```bash 41 | copier copy --trust "https://github.com/pawamoy/copier-uv.git" /path/to/your/new/project 42 | ``` 43 | 44 | Or even shorter: 45 | 46 | ```bash 47 | copier copy --trust "gh:pawamoy/copier-uv" /path/to/your/new/project 48 | ``` 49 | 50 | See the [documentation](https://pawamoy.github.io/copier-uv) 51 | for more details. 52 | -------------------------------------------------------------------------------- /copier.yml: -------------------------------------------------------------------------------- 1 | # CONFIGURATION ------------------------- 2 | _min_copier_version: "9" 3 | _envops: 4 | autoescape: false 5 | keep_trailing_newline: true 6 | _subdirectory: project 7 | _templates_suffix: .jinja 8 | _preserve_symlinks: true 9 | _jinja_extensions: 10 | - copier_templates_extensions.TemplateExtensionLoader 11 | - extensions.py:CurrentYearExtension 12 | - extensions.py:GitExtension 13 | - extensions.py:SlugifyExtension 14 | - extensions.py:GitHubIDsforGiscusExtension 15 | _skip_if_exists: 16 | - CHANGELOG.md 17 | - docs/insiders/changelog.md 18 | 19 | # PROMPT -------------------------------- 20 | project_name: 21 | type: str 22 | help: Your project name 23 | 24 | project_description: 25 | type: str 26 | help: Your project description 27 | 28 | author_fullname: 29 | type: str 30 | help: Your full name 31 | default: "{{ 'Timothée Mazzucotelli' | git_user_name }}" 32 | 33 | author_email: 34 | type: str 35 | help: Your email 36 | default: "{{ 'dev@pawamoy.fr' | git_user_email }}" 37 | 38 | author_username: 39 | type: str 40 | help: Your username (e.g. on GitHub) 41 | default: pawamoy 42 | 43 | repository_provider: 44 | type: str 45 | help: Your repository provider 46 | default: github.com 47 | choices: 48 | - github.com 49 | 50 | repository_namespace: 51 | type: str 52 | help: Your repository namespace 53 | default: "{{ author_username }}" 54 | 55 | repository_name: 56 | type: str 57 | help: Your repository name 58 | default: "{{ project_name | slugify }}" 59 | 60 | copyright_holder: 61 | type: str 62 | help: The name of the person/entity holding the copyright 63 | default: "{{ author_fullname }}" 64 | 65 | copyright_holder_email: 66 | type: str 67 | help: The email of the person/entity holding the copyright 68 | default: "{{ author_email }}" 69 | 70 | copyright_date: 71 | type: str 72 | help: The copyright date 73 | default: "{{ current_year }}" 74 | 75 | copyright_license: 76 | type: str 77 | help: Your project's license 78 | default: ISC 79 | choices: 80 | Academic Free License v3.0: AFL-3.0 81 | Apache License 2.0: Apache-2.0 82 | Artistic License 2.0: Artistic-2.0 83 | BSD 2-Clause "Simplified" License: BSD-2-Clause 84 | BSD 3-Clause Clear License: BSD-3-Clause-Clear 85 | BSD 3-Clause "New" or "Revised" License: BSD-3-Clause 86 | Boost Software License 1.0: BSL-1.0 87 | Creative Commons Attribution 4.0 International: CC-BY-4.0 88 | Creative Commons Attribution Share Alike 4.0 International: CC-BY-SA-4.0 89 | Creative Commons Zero v1.0 Universal: CC0-1.0 90 | Do What The F*ck You Want To Public License: WTFPL 91 | Educational Community License v2.0: ECL-2.0 92 | Eclipse Public License 1.0: EPL-1.0 93 | Eclipse Public License 2.0: EPL-2.0 94 | European Union Public License 1.1: EUPL-1.1 95 | European Union Public License 1.2: EUPL-1.2 96 | GNU Affero General Public License v3.0: AGPL-3.0 97 | GNU General Public License v2.0 only: GPL-2.0 98 | GNU General Public License v3.0 only: GPL-3.0 99 | GNU Lesser General Public License v2.1 only: LGPL-2.1 100 | GNU Lesser General Public License v3.0 only: LGPL-3.0 101 | ISC License: ISC 102 | LaTeX Project Public License v1.3c: LPPL-1.3c 103 | MIT License: MIT 104 | Mozilla Public License 2.0: MPL-2.0 105 | Microsoft Public License: MS-PL 106 | Microsoft Reciprocal License: MS-RL 107 | University of Illinois/NCSA Open Source License: NCSA 108 | SIL Open Font License 1.1: OFL-1.1 109 | Open Software License 3.0: OSL-3.0 110 | PostgreSQL License: PostgreSQL 111 | The Unlicense: Unlicense 112 | zlib License: Zlib 113 | 114 | python_package_distribution_name: 115 | type: str 116 | help: Your Python package distribution name (for `pip install NAME`) 117 | default: "{{ project_name | slugify }}" 118 | 119 | python_package_import_name: 120 | type: str 121 | help: Your Python package import name (for `import NAME` in Python code) 122 | default: "{{ project_name | slugify('_') }}" 123 | 124 | python_package_command_line_name: 125 | type: str 126 | help: Your CLI name if any (for use in the shell) 127 | default: "{{ project_name | slugify }}" 128 | 129 | insiders: 130 | type: bool 131 | help: Does this project have an Insiders version? 132 | default: false 133 | 134 | insiders_repository_name: 135 | type: str 136 | help: Your Insiders repository name 137 | default: "{{ repository_name }}" 138 | when: "{{ insiders }}" 139 | 140 | insiders_email: 141 | type: str 142 | help: The email to use for Insiders-related questions 143 | default: "{{ author_email }}" 144 | when: "{{ insiders }}" 145 | 146 | public_release: 147 | type: bool 148 | help: Is this project already released publicly? 149 | default: false 150 | when: "{{ insiders }}" 151 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | --8<-- "CHANGELOG.md" 2 | -------------------------------------------------------------------------------- /docs/code_of_conduct.md: -------------------------------------------------------------------------------- 1 | --8<-- "CODE_OF_CONDUCT.md" 2 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | --8<-- "CONTRIBUTING.md" 2 | -------------------------------------------------------------------------------- /docs/css/custom.css: -------------------------------------------------------------------------------- 1 | /* More space at the bottom of the page */ 2 | .md-main__inner { 3 | margin-bottom: 1.5rem; 4 | } 5 | -------------------------------------------------------------------------------- /docs/generate.md: -------------------------------------------------------------------------------- 1 | # Generating a project 2 | 3 | To generate a project, run the following command: 4 | 5 | ```bash 6 | copier copy --trust "https://github.com/pawamoy/copier-uv.git" /path/to/your/new/project 7 | ``` 8 | 9 | Or with a shorter command: 10 | 11 | ```bash 12 | copier copy --trust "gh:pawamoy/copier-uv" /path/to/your/new/project 13 | ``` 14 | 15 | You can even generate a project without installing Copier, using [uv](https://docs.astral.sh/uv/): 16 | 17 | ```bash 18 | uvx --with copier-templates-extensions copier copy --trust "gh:pawamoy/copier-uv" /path/to/your/new/project 19 | ``` 20 | 21 | ## Questions 22 | 23 | *The following examples are not up-to-date, 24 | they simply illustrate how the template works.* 25 | 26 | Copier will ask you to answer some questions: 27 | 28 | ``` 29 | Your project name 30 | project_name? Format: str 31 | 🎤 [None]: My Project 32 | ``` 33 | 34 | The project name can use spaces and capitals. 35 | It will be used as the main heading in the README for example. 36 | 37 | --- 38 | 39 | ``` 40 | Your project description 41 | project_description? Format: str 42 | 🎤 [None]: My super project! 43 | ``` 44 | 45 | The project description can be as long as you want, 46 | and use any character you want. 47 | 48 | --- 49 | 50 | ``` 51 | Your full name 52 | author_fullname? Format: str 53 | 🎤 [None]: Timothée Mazzucotelli 54 | ``` 55 | 56 | The full name will appear as "author" in the project's metadata. 57 | 58 | --- 59 | 60 | ``` 61 | Your email 62 | author_email? Format: str 63 | 🎤 [None]: dev@pawamoy.fr 64 | ``` 65 | 66 | The email will appear as "author email" in the project's metadata. 67 | 68 | --- 69 | 70 | ``` 71 | Your username (e.g. on GitHub) 72 | author_username? Format: str 73 | 🎤 [None]: pawamoy 74 | ``` 75 | 76 | The username you are using on the git repository provider. 77 | 78 | --- 79 | 80 | ``` 81 | Your repository provider 82 | repository_provider? Format: str 83 | 🎤 84 | (1) github.com 85 | Choice [1]: 86 | ``` 87 | 88 | The repository provider on which you plan to store your project. 89 | This template only supports GitHub, but the question is left in 90 | case we add support for more providers in the future. 91 | 92 | --- 93 | 94 | ``` 95 | Your repository namespace 96 | repository_namespace? Format: str 97 | 🎤 [pawamoy]: 98 | ``` 99 | 100 | The namespace (or account) on which the project will be stored. 101 | It defaults to the username you provided. 102 | 103 | --- 104 | 105 | ``` 106 | Your repository name 107 | repository_name? Format: str 108 | 🎤 [my-project]: 109 | ``` 110 | 111 | The repository name on the selected provider. 112 | It defaults to a slugified version of your project's name. 113 | 114 | --- 115 | 116 | ``` 117 | The name of the person/entity holding the copyright 118 | copyright_holder? Format: str 119 | 🎤 [Timothée Mazzucotelli]: 120 | ``` 121 | 122 | The name of the person or entity holding the copyright on the project. 123 | It defaults to the full name your provided. 124 | 125 | --- 126 | 127 | ``` 128 | The email of the person/entity holding the copyright 129 | copyright_holder_email? Format: str 130 | 🎤 [dev@pawamoy.frr]: 131 | ``` 132 | 133 | The email of the person or entity holding the copyright on the project. 134 | It defaults to the email your provided. 135 | 136 | --- 137 | 138 | ``` 139 | The copyright date 140 | copyright_date? Format: str 141 | 🎤 [2021]: 142 | ``` 143 | 144 | The date to apply to the copyright. 145 | It defaults to the current year. 146 | 147 | --- 148 | 149 | ``` 150 | Your project's license 151 | copyright_license? Format: str 152 | 🎤 153 | (1) Academic Free License v3.0 154 | (2) Apache License 2.0 155 | (3) Artistic License 2.0 156 | (4) BSD 2-Clause "Simplified" License 157 | (5) BSD 3-Clause Clear License 158 | (6) BSD 3-Clause "New" or "Revised" License 159 | (7) Boost Software License 1.0 160 | (8) Creative Commons Attribution 4.0 161 | (9) Creative Commons Attribution Share Alike 4.0 162 | (10) Creative Commons Zero v1.0 Universal 163 | (11) Do What The F*ck You Want To Public License 164 | (12) Educational Community License v2.0 165 | (13) Eclipse Public License 1.0 166 | (14) Eclipse Public License 2.0 167 | (15) European Union Public License 1.1 168 | (16) European Union Public License 1.2 169 | (17) GNU Affero General Public License v3.0 170 | (18) GNU General Public License v2.0 171 | (19) GNU General Public License v3.0 172 | (20) GNU Lesser General Public License v2.1 173 | (21) GNU Lesser General Public License v3.0 174 | (22) ISC License 175 | (23) LaTeX Project Public License v1.3c 176 | (24) MIT License 177 | (25) Mozilla Public License 2.0 178 | (26) Microsoft Public License 179 | (27) Microsoft Reciprocal License 180 | (28) University of Illinois/NCSA Open Source License 181 | (29) SIL Open Font License 1.1 182 | (30) Open Software License 3.0 183 | (31) PostgreSQL License 184 | (32) The Unlicense 185 | (33) zlib License 186 | Choice [22]: 187 | ``` 188 | 189 | A license from choosealicence.com. 190 | It defaults to ISC License (my personal preference). 191 | 192 | --- 193 | 194 | ``` 195 | Your Python package distribution name (for `pip install NAME`) 196 | python_package_distribution_name? Format: str 197 | 🎤 [my-project]: 198 | ``` 199 | 200 | The name under which your Python package will be distributed. 201 | This will be the name of your project on pypi.org for example. 202 | 203 | --- 204 | 205 | ``` 206 | Your Python package import name (for `import NAME` in Python code) 207 | python_package_import_name? Format: str 208 | 🎤 [my_project]: 209 | ``` 210 | 211 | The name that will be used to import your package in Python code. 212 | Yes, distribution name and import name can be different! 213 | 214 | --- 215 | 216 | ``` 217 | Your CLI name if any (for use in the shell) 218 | python_package_command_line_name? Format: str 219 | 🎤 [my-project]: 220 | ``` 221 | 222 | The name of the command users will type in the command line 223 | to run your program. 224 | 225 | ## Output 226 | 227 | At this point, Copier will generate the project in the specified directory: 228 | 229 | ``` 230 | create pyproject.toml 231 | create mkdocs.yml 232 | create duties.py 233 | create .copier-answers.yml 234 | create README.md 235 | create Makefile 236 | create LICENSE 237 | create CREDITS.md 238 | create CONTRIBUTING.md 239 | create CODE_OF_CONDUCT.md 240 | create CHANGELOG.md 241 | create .gitignore 242 | create tests/ 243 | create tests/test_cli.py 244 | create tests/conftest.py 245 | create tests/__init__.py 246 | create src/ 247 | create src/my_project/ 248 | create src/my_project/cli.py 249 | create src/my_project/__main__.py 250 | create src/my_project/__init__.py 251 | create scripts/ 252 | create scripts/setup.sh 253 | create scripts/post_generation.py 254 | create scripts/multirun.sh 255 | create docs/ 256 | create docs/index.md 257 | create docs/credits.md 258 | create docs/contributing.md 259 | create docs/code_of_conduct.md 260 | create docs/changelog.md 261 | create docs/reference/ 262 | create docs/reference/cli.md 263 | create docs/css/ 264 | create docs/css/mkdocstrings.css 265 | create config/ 266 | create config/pytest.ini 267 | create config/mypy.ini 268 | create config/coverage.ini 269 | create .github/ 270 | create .github/FUNDING.yml 271 | create .github/workflows/ 272 | create .github/workflows/ci.yml 273 | create .github/ISSUE_TEMPLATE/ 274 | create .github/ISSUE_TEMPLATE/feature_request.md 275 | create .github/ISSUE_TEMPLATE/bug_report.md 276 | 277 | > Running task 1 of 1: python scripts/post_generation.py 278 | Project successfully generated! 279 | Run `make` to show the available actions. 280 | ``` 281 | 282 | See the next chapter to learn how to work on this new project. 283 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --8<-- "README.md" 2 | -------------------------------------------------------------------------------- /docs/license.md: -------------------------------------------------------------------------------- 1 | ``` 2 | --8<-- "LICENSE" 3 | ``` 4 | -------------------------------------------------------------------------------- /docs/requirements.md: -------------------------------------------------------------------------------- 1 | # Requirements 2 | 3 | To use this Copier template, you will need: 4 | 5 | - [Git v2](https://git-scm.com/) 6 | - [Python 3](https://www.python.org) 7 | - [Copier](https://copier.readthedocs.io/en/stable/) 8 | 9 | To install Git version 2, [follow the official instructions](https://git-scm.com/downloads). 10 | 11 | To install Python 3, download and install it from [the official website](https://www.python.org/downloads/), or install it with [uv](https://docs.astral.sh/uv/): 12 | 13 | ```bash 14 | curl -LsSf https://astral.sh/uv/install.sh | sh 15 | uv python install 3.12 16 | ``` 17 | 18 | To install Copier, use [`uv`](https://docs.astral.sh/uv/) or [`pipx`](https://pipx.pypa.io/stable/): 19 | 20 | ```bash 21 | uv tool install copier --with copier-templates-extensions 22 | ``` 23 | 24 | ```bash 25 | pipx install copier 26 | pipx inject copier copier-templates-extensions 27 | ``` 28 | -------------------------------------------------------------------------------- /docs/update.md: -------------------------------------------------------------------------------- 1 | # Updating a project 2 | 3 | Copier has an "update" feature. 4 | It means that, once a project is generated, 5 | you can keep updating it with the latest changes 6 | that happen in the template. 7 | 8 | It's particularly useful when you manage a lot of projects, 9 | all generated from the same template, and you want to 10 | apply a change to all your projects. 11 | 12 | Example: the template fixed a bug in the Makefile. 13 | You don't want to apply it manually to your projects. 14 | 15 | To update your project, go into its directory, 16 | and run `copier update`. Your repository must be clean 17 | (no modified files) when running this command. 18 | 19 | Copier will use the previous answers you gave when 20 | generating the project, to re-generate it in a temporary 21 | directory, compare the two versions, and apply patches 22 | to your documents. When it's not sure, or when there's 23 | a conflict, it will ask you if you want to skip that 24 | change or force it. Your previous answers are stored 25 | in the `.copier-answers.yml` file at the root 26 | of the project directory: 27 | 28 | ``` 29 | 📁 my-project 30 | ├── 📄 .copier-answers.yml 31 | └── 📄 etc. 32 | ``` 33 | 34 | And the file looks like this: 35 | 36 | ```yaml 37 | # Changes here will be overwritten by Copier 38 | _commit: 0.1.10 39 | _src_path: gh:pawamoy/copier-uv 40 | author_email: dev@pawamoy.fr 41 | author_fullname: "Timothée Mazzucotelli" 42 | author_username: pawamoy 43 | copyright_date: '2020' 44 | copyright_holder: "Timothée Mazzucotelli" 45 | copyright_holder_email: dev@pawamoy.fr 46 | copyright_license: ISC License 47 | project_description: Automatic documentation from sources, for MkDocs. 48 | project_name: mkdocstrings 49 | python_package_command_line_name: "" 50 | python_package_distribution_name: mkdocstrings 51 | python_package_import_name: mkdocstrings 52 | repository_name: mkdocstrings 53 | repository_namespace: mkdocstrings 54 | repository_provider: github.com 55 | ``` 56 | 57 | If you want to use all previous answers 58 | without copier prompting you for each answer, 59 | run `copier update --force`. 60 | 61 | Since we are generally using Git in our projects, 62 | my recommendation is to not think at all 63 | and blindly apply every change Copier proposes. 64 | Indeed, you'll be able to see the diff with `git diff`, 65 | un-apply changes on whole files with `git checkout -- FILE` 66 | if they are not relevant, 67 | or do partial, interactive commits with `git add -p` 68 | or within your IDE interface 69 | (PyCharm and VSCode have good support and UX 70 | for selecting and committing changes). 71 | -------------------------------------------------------------------------------- /docs/work.md: -------------------------------------------------------------------------------- 1 | # Working on a project 2 | 3 | The generated project has this structure: 4 | 5 | ``` 6 | 📁 your_project ------------------- # your freshly created project! 7 | ├── 📄 CHANGELOG.md --------------- # 8 | ├── 📄 CODE_OF_CONDUCT.md --------- # 9 | ├── 📁 config --------------------- # tools configuration files 10 | │   ├── 📄 coverage.ini ----------- # 11 | │   ├── 📄 mypy.ini --------------- # 12 | │   ├── 📄 pytest.ini ------------- # 13 | │   └── 📄 ruff.toml -------------- # 14 | ├── 📄 CONTRIBUTING.md ------------ # 15 | ├── 📁 docs ----------------------- # documentation pages 16 | │   ├── 📄 changelog.md ----------- # 17 | │   ├── 📄 code_of_conduct.md ----- # 18 | │   ├── 📄 contributing.md -------- # 19 | │   ├── 📄 credits.md ------------- # 20 | │   ├── 📁 css -------------------- # extra CSS files 21 | │   │   ├── 📄 material.css ------- # 22 | │   │   └── 📄 mkdocstrings.css --- # 23 | │   ├── 📄 index.md --------------- # 24 | │   └── 📄 license.md ------------- # 25 | ├── 📄 duties.py ------------------ # the project's tasks 26 | ├── 📄 LICENSE -------------------- # 27 | ├── 📄 Makefile ------------------- # for auto-completion (it calls scripts/make) 28 | ├── 📄 mkdocs.yml ----------------- # docs configuration 29 | ├── 📄 pyproject.toml ------------- # project metadata and dependencies 30 | ├── 📄 README.md ------------------ # 31 | ├── 📁 scripts -------------------- # helper scripts 32 | │   ├── 📄 gen_credits.py --------- # script to generate credits 33 | │   ├── 📄 gen_ref_nav.py --------- # script to generate code reference nav 34 | │   └── 📄 make ------------------- # a convenience script to run tasks 35 | ├── 📁 src ------------------------ # the source code directory 36 | │   └── 📁 your_package ----------- # your package 37 | │   ├── 📄 cli.py ------------- # the command line entry point 38 | │   ├── 📄 __init__.py -------- # 39 | │   ├── 📄 __main__.py -------- # 40 | │   └── 📄 py.typed ----------- # 41 | └── 📁 tests ---------------------- # the tests directory 42 | ├── 📄 conftest.py ------------ # pytest fixtures, etc. 43 | ├── 📄 __init__.py ------------ # 44 | └── 📄 test_cli.py ------------ # 45 | ``` 46 | 47 | ## Environment 48 | 49 | The project is configured to use [direnv](https://direnv.net/). 50 | If direnv is loaded in your shell, allow it in the project with 51 | `direnv allow`. It will add the `scripts` folder to your PATH 52 | when you enter the repository (and remove it when you exit it). 53 | The `scripts` folder has a `make` Bash script in it: 54 | it will shadow any `make` command you have in your PATH 55 | (this is indented!). 56 | 57 | If you don't have or don't use direnv, you can still use 58 | the official `make` command, though you won't be able 59 | to pass arguments to some of the actions. 60 | 61 | In the rest of the documentation, we will use `make` commands, 62 | but you can also directly call `scripts/make`. 63 | 64 | See [Tasks](#tasks) to learn more. 65 | 66 | ## Python versions 67 | 68 | To specify which Python versions you would like to work with, 69 | define the `PYTHON_VERSIONS` environment variable: 70 | 71 | ```bash 72 | export PYTHON_VERSIONS="3.10 3.11 3.12" 73 | ``` 74 | 75 | By default it is set to active versions of Python 76 | (non-EOL, in development). 77 | 78 | ## Initialize Git Repository 79 | 80 | This project uses dynamic versioning based on Git tags. Initialize your project as a Git repository: 81 | 82 | ``` 83 | git init . 84 | ``` 85 | 86 | ## Dependencies and virtual environments 87 | 88 | Dependencies are managed by [uv](https://github.com/astral-sh/uv). 89 | 90 | Use `make setup` or `uv sync` to install the dependencies. 91 | 92 | Dependencies are written in `pyproject.toml`. 93 | Runtime dependencies are listed under the `[project]` and `[project.optional-dependencies]` sections, 94 | and development dependencies are listed under the `[dependency-groups]` section. 95 | 96 | Example: 97 | 98 | ```toml title="pyproject.toml" 99 | [project] 100 | dependencies = [ 101 | "fastapi>=1.0", 102 | "importlib-metadata>=2.0", 103 | ] 104 | 105 | [project.optional-dependencies] 106 | test = [ 107 | "pytest", 108 | ] 109 | 110 | [dependency-groups] 111 | ci = [ 112 | "ruff", 113 | ] 114 | ``` 115 | 116 | ## Tasks 117 | 118 | The tasks are written in Python (for cross-platform compatibility), 119 | and based on the task-runner called [duty](https://github.com/pawamoy/duty). 120 | They are written in the `duties.py` file, 121 | and decorated with the `@duty` decorator. 122 | 123 | Example: 124 | 125 | ```python title="duties.py" 126 | @duty 127 | def check_docs(ctx): 128 | """Check if the documentation builds correctly.""" 129 | ctx.run("mkdocs build -s", title="Building documentation") 130 | ``` 131 | 132 | To run a task, use `make TASK [ARG=VALUE...]`. 133 | You can run multiple tasks at once: `make TASK1 ARG=VALUE TASK2`. 134 | You can list the available tasks with `make help`. 135 | 136 | Available tasks: 137 | 138 | - `build`: Build source and wheel distributions. 139 | - `changelog`: Update the changelog in-place with latest commits. 140 | See [the Changelog section](#changelog). 141 | - `check`: Check it all! 142 | See [the Quality Analysis section](#quality-analysis). 143 | - `check-quality`: Check the code quality. 144 | See [the check-quality section](#check-quality). 145 | - `check-docs`: Check if the documentation builds correctly. 146 | See [the check-docs section](#check-docs). 147 | - `check-types`: Check that the code is correctly typed. 148 | See [the check-types section](#check-types). 149 | - `clean`: Delete temporary files. 150 | - `coverage`: Report coverage as text and HTML. 151 | - `docs`: Serve the documentation (localhost:8000). 152 | See [the Documentation section](#documentation). 153 | Arguments: 154 | - `host="127.0.0.1"`: The host to serve the docs from. 155 | - `port=8000`: The port to serve the docs on. 156 | - `format`: Run formatting tools on the code. 157 | - `publish`: Publish source and wheel distributions to PyPI. 158 | - `release`: Release a new Python package. 159 | See [the Releases section](#releases). 160 | Arguments: 161 | - `version` The Python package version. 162 | - `test`: Run the test suite. 163 | See [the Tests section](#tests). 164 | Arguments: 165 | - `match=""`: A pytest expression to filter selected tests. 166 | - `vscode`: Configure VSCode for the project. 167 | See [VSCode setup](#vscode-setup). 168 | 169 | The `make` script provides some additional commands: 170 | 171 | - `make help`: Print available commands and tasks. 172 | - `make setup`: Install project and dependencies for all configured Python versions, 173 | as well as in a default virtual environment (`.venv` + `.venvs/*`). 174 | - `make run command --args`: run arbitrary commands in the default Python virtual environment (`.venv`). 175 | This command can be useful to run a Python interpreter without having to activate the venv: 176 | `make run python`. 177 | - `make multirun command --args`: run arbitrary commands for all configured Python versions (`.venvs/*`). 178 | This command can be useful to check something on all Python versions: 179 | `make multirun python -c 'import sys; print(sys.version_info)'`. 180 | - `make allrun command --args`: run arbitrary commands in *all* Python virtual environments (`.venv` + `.venvs/*`). 181 | This command can be useful to override some of the installed dependencies, 182 | or to install local packages as editable: 183 | `make allrun pip install -U some-dependency==2.0`, `make allrun pip install -e ../some-project`. 184 | - `make 3.x command --args`: run arbitrary commands for a specific Python versions (`.venvs/3.x`). 185 | This command can be useful to check something on a specific Python version: 186 | `make 3.13 duty docs`. 187 | 188 | ### VSCode setup 189 | 190 | If you work in VSCode, we provide a `make vscode` action 191 | that configures settings and tasks. **It will overwrite the following existing 192 | files, so make sure to back them up:** 193 | 194 | - `.vscode/launch.json` 195 | - `.vscode/settings.json` 196 | - `.vscode/tasks.json` 197 | 198 | ## Workflow 199 | 200 | The first thing you should run when entering your repository is: 201 | 202 | ```bash 203 | make setup 204 | ``` 205 | 206 | If you don't have the `make` command, 207 | you can use `scripts/make setup` directly, 208 | or even just `uv venv; uv pip install` 209 | if you don't plan on using multiple Python versions. 210 | 211 | This will install the project's dependencies in virtual environments: 212 | one venv per chosen Python version in `.venvs/$python_version`, 213 | and one default venv in `.venv/`. 214 | 215 | The chosen Python versions are defined in the `scripts/make` Bash script. 216 | 217 | Now you can start writing and editing code in `src/your_package`. 218 | 219 | - You can auto-format the code with `make format`. 220 | - You can run a quality analysis with `make check`. 221 | - Once you wrote tests for your new code, 222 | you can run the test suite with `make test`. 223 | - Once you are ready to publish a new release, 224 | run `make changelog`, then `make release version=x.y.z`, 225 | where `x.y.z` is the version added to the changelog. 226 | 227 | To summarize, the typical workflow is: 228 | 229 | ```bash 230 | make setup # only once 231 | 232 | 233 | make format # to auto-format the code 234 | 235 | 236 | make test # to run the test suite 237 | 238 | make check # to check if everything is OK 239 | 240 | 241 | 242 | make changelog # to update the changelog 243 | 244 | 245 | make release version=x.y.z 246 | ``` 247 | 248 | ## Quality analysis 249 | 250 | The quality checks are started with: 251 | 252 | ``` 253 | make check 254 | ``` 255 | 256 | This action is actually a composition of several checks: 257 | 258 | - `check-quality`: Check the code quality. 259 | - `check-docs`: Check if the documentation builds correctly. 260 | - `check-types`: Check if the code is correctly typed. 261 | - `check-api`: Check for breaking changes in your Python API. 262 | 263 | For example, if you are only interested in checking types, 264 | run `make check-types`. 265 | 266 | ### check-quality 267 | 268 | The code quality analysis is done 269 | with [Ruff](https://github.com/astral-sh/ruff). 270 | The analysis is configured in `config/ruff.toml`. 271 | In this file, you can deactivate rules 272 | or activate others to customize your analysis. 273 | Rules identifiers always start with one or more capital letters, 274 | like `D`, `S` or `BLK`, then followed by a number. 275 | 276 | You can ignore a rule on a specific code line by appending 277 | a `noqa` comment ("no quality analysis/assurance"): 278 | 279 | ```python title="src/your_package/module.py" 280 | print("a code line that triggers a Ruff warning") # noqa: ID 281 | ``` 282 | 283 | ...where ID is the identifier of the rule you want to ignore for this line. 284 | 285 | Example: 286 | 287 | ```python title="src/your_package/module.py" 288 | import subprocess 289 | ``` 290 | 291 | ```console 292 | $ make check-quality 293 | ✗ Checking code quality (1) 294 | > ruff check --config=config/ruff.toml src/ tests/ scripts/ 295 | src/your_package/module.py:2:1: S404 Consider possible security implications associated with subprocess module. 296 | ``` 297 | 298 | Now add a comment to ignore this warning. 299 | 300 | ```python title="src/your_package/module.py" 301 | import subprocess # noqa: S404 302 | ``` 303 | 304 | ```console 305 | $ make check-quality 306 | ✓ Checking code quality 307 | ``` 308 | 309 | You can disable multiple different warnings on a single line 310 | by separating them with commas: 311 | 312 | ```python title="src/your_package/module.py" 313 | markdown_docstring = """ 314 | Look at this docstring: 315 | 316 | ```python 317 | \"\"\" 318 | print("code block") 319 | \"\"\" 320 | ``` 321 | """ # noqa: D300,D301 322 | ``` 323 | 324 | You can disable a warning globally by adding its ID 325 | into the list in `config/ruff.toml`. 326 | 327 | You can also disable warnings per file, like so: 328 | 329 | ```toml title="config/ruff.toml" 330 | [per-file-ignores] 331 | "src/your_package/your_module.py" = [ 332 | "T201", # Print statement 333 | ] 334 | ``` 335 | 336 | ### check-docs 337 | 338 | This action builds the documentation with strict behavior: 339 | any warning will be considered an error and the command will fail. 340 | 341 | The warnings/errors can be about incorrect docstring format, 342 | or invalid cross-references. 343 | 344 | See the [Documentation section](#documentation) for more information. 345 | 346 | ### check-types 347 | 348 | This action runs [`mypy`](http://mypy-lang.org/) on the source code 349 | to find potential typing errors. 350 | 351 | If you cannot or don't know how to fix a typing error in your code, 352 | as a last resort you can ignore this specific error with a comment: 353 | 354 | ```python title="src/your_package/module.py" 355 | result = data_dict.get(key, None).value # type: ignore[ID] 356 | ``` 357 | 358 | ...where ID is the name of the warning, for example `arg-type` or `union-attr`. 359 | 360 | ### check-api 361 | 362 | This actions runs [Griffe](https://github.com/mkdocstrings/griffe) 363 | to search for API breaking changes since latest version. It is set 364 | to allow failures, and is more about providing information than 365 | preventing CI to pass. 366 | 367 | ## Tests 368 | 369 | Run the test suite with: 370 | 371 | ``` 372 | make test 373 | ``` 374 | 375 | Behind the scenes, it uses [`pytest`](https://docs.pytest.org/en/stable/) 376 | and plugins to collect and run the tests, and output a report. 377 | 378 | Code source coverage is computed thanks to 379 | [coveragepy](https://coverage.readthedocs.io/en/coverage-5.1/). 380 | 381 | Sometimes you don't want to run the whole test suite, 382 | but rather one particular test, or group of tests. 383 | Pytest provides a `-k` option to allow filtering the tests. 384 | The `test` command therefore accept a `match=` argument 385 | to specify the value of Pytest's `-k` option: 386 | 387 | ``` 388 | make test match=training 389 | make test match="app and route2" 390 | ``` 391 | 392 | Example of output: 393 | 394 | ``` 395 | Test session starts (platform: linux, Python 3.8.6, pytest 6.2.1, pytest-sugar 0.9.4) 396 | Using --randomly-seed=281943462 397 | rootdir: /home/pawamoy/data/dev/pawamoy/duty, configfile: config/pytest.ini 398 | plugins: randomly-3.5.0, xdist-2.2.0, forked-1.3.0, cov-2.10.1, sugar-0.9.4 399 | collecting ... 400 | tests/test_logic.py ✓✓✓✓✓✓✓✓✓✓✓✓ 15% █▋ 401 | tests/test_cli.py ✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓ 86% ████████▋ 402 | ✓✓✓✓✓✓✓✓✓✓✓ 100% ██████████ 403 | 404 | ----------- coverage: platform linux, python 3.8.6-final-0 ----------- 405 | Name Stmts Miss Branch BrPart Cover 406 | -------------------------------------------------------- 407 | src/duty/cli.py 62 0 20 0 100.00% 408 | src/duty/logic.py 71 0 18 0 100.00% 409 | -------------------------------------------------------- 410 | TOTAL 133 0 38 0 100.00% 411 | 412 | 413 | Results (0.76s): 414 | 78 passed 415 | ``` 416 | 417 | ## Continuous Integration 418 | 419 | The quality checks and tests are executed in parallel 420 | in a [GitHub Workflow](https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions). 421 | The CI is configured in `.github/workflows/ci.yml`. 422 | 423 | To force a step to pass even when it fails, 424 | add `nofail=CI` or `nofail=True` to the corresponding 425 | `ctx.run` instruction in `duties.py` 426 | 427 | ## Changelog 428 | 429 | Changelogs are absolutely useful when your software 430 | is updated regularly, to inform your users about the new features 431 | that were added or the bugs that were fixed. 432 | 433 | But writing a changelog manually is a cumbersome process. 434 | 435 | This is why we offer, with this template, 436 | a way to automatically update the changelog. 437 | There is one requirement though for it to work: 438 | you must use the 439 | [Angular commit message convention](https://github.com/angular/angular/blob/master/CONTRIBUTING.md#commit). 440 | 441 | For a quick reference: 442 | 443 | ``` 444 | [(scope)]: Subject 445 | 446 | [Body] 447 | ``` 448 | 449 | Scope and body are optional. Type can be: 450 | 451 | - `build`: About packaging, building wheels, etc. 452 | - `chore`: About packaging or repo/files management. 453 | - `ci`: About Continuous Integration. 454 | - `docs`: About documentation. 455 | - `feat`: New feature. 456 | - `fix`: Bug fix. 457 | - `perf`: About performance. 458 | - `refactor`: Changes which are not features nor bug fixes. 459 | - `style`: A change in code style/format. 460 | - `tests`: About tests. 461 | 462 | The two most important are `feat` and `fix` types. 463 | For other types of commits, you can do as you like. 464 | 465 | Subject (and body) must be valid Markdown. 466 | If you write a body, please add issues references at the end: 467 | 468 | ``` 469 | Body. 470 | 471 | References: #10, #11. 472 | Fixes #15. 473 | ``` 474 | 475 | Examples: 476 | 477 | ``` 478 | feat: Add training route 479 | ``` 480 | 481 | ``` 482 | fix: Stop deleting user data 483 | ``` 484 | 485 | Following that convention will allow to generate 486 | new entries in the changelog while following the rules 487 | of [semantic versioning](https://semver.org/). 488 | 489 | Once you are ready to publish a new release of your package, 490 | run the following command: 491 | 492 | ``` 493 | make changelog 494 | ``` 495 | 496 | This will update the changelog in-place, using the latest, 497 | unpublished-yet commits. 498 | 499 | If this group of commits contains only bug fixes (`fix:`) 500 | and/or commits that are not interesting for users (`chore:`, `style:`, etc.), 501 | the changelog will gain a new **patch** entry. 502 | It means that the new suggested version will be a patch bump 503 | of the previous one: `0.1.1` becomes `0.1.2`. 504 | 505 | If this group of commits contains at least one feature (`feat:`), 506 | the changelog will gain a new **minor** entry. 507 | It means that the new suggested version will be a minor bump 508 | of the previous one: `0.1.1` becomes `0.2.0`. 509 | 510 | If there is, in this group, a commit whose body contains 511 | something like `Breaking change`, 512 | the changelog will gain a new **major** entry, 513 | unless the version is still an "alpha" version 514 | (starting with 0), in which case it gains a **minor** entry. 515 | It means that the new suggested version will be a major bump 516 | of the previous one: `1.2.1` becomes `2.0.0`, 517 | but `0.2.1` is only bumped up to `0.3.0`. 518 | Moving from "alpha" status to "beta" or "stable" status 519 | is a choice left to the developers, 520 | when they consider the package is ready for it. 521 | 522 | Finally, once your changelog has been updated, 523 | make sure its contents are correct (add, remove or edit anything 524 | you need), and use the new version (the one that was added 525 | into the changelog) to create a new release: 526 | 527 | ``` 528 | make release version=x.y.z 529 | ``` 530 | 531 | ...where x.y.z is the version added in the changelog. 532 | 533 | ## Releases 534 | 535 | As seen in the previous section, you can use the `release` command 536 | to publish new versions of the Python package. 537 | 538 | Usually, just before running `make release version=x.y.z`, 539 | you run `make changelog` to update the changelog and 540 | use the newly added version as the argument to `make release`. 541 | 542 | For example, if after running `make changelog`, the diff 543 | shows a new `0.5.1` entry in the changelog, you must 544 | release this exact same version with `make release version=0.5.1`. 545 | 546 | The `release` action does several things, in this order: 547 | 548 | - Stage the changelog file (`CHANGELOG.md`) 549 | - Commit the changes with a message like `chore: Prepare release 0.5.1` 550 | - Tag the commit with that version 551 | - Push the commits 552 | - Push the tags 553 | - Build the package dist and wheel 554 | - Publish the dist and wheel to PyPI.org 555 | - Build and deploy the documentation site 556 | 557 | ## Documentation 558 | 559 | The documentation is built with [Mkdocs](https://www.mkdocs.org/), 560 | the [Material for Mkdocs](https://squidfunk.github.io/mkdocs-material/) theme, 561 | and the [mkdocstrings](https://github.com/pawamoy/mkdocstrings) plugin. 562 | 563 | ### Writing 564 | 565 | The pages are written in Markdown, and thanks to `mkdocstrings`, 566 | even your Python docstrings can be written in Markdown. 567 | `mkdocstrings` particularly supports the 568 | [Google-style](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html) 569 | for docstrings. 570 | 571 | The documentation configuration is written into `mkdocs.yml`, 572 | at the root of the project. The Markdown pages are written 573 | in the `docs/` directory. You can use any level of nesting you want. 574 | The left-sidebar navigation is configured through the `nav` key 575 | in `mkdocs.yml`. 576 | 577 | For example, with these docs structure: 578 | 579 | ``` 580 | 📁 docs 581 | ├── 📄 changelog.md 582 | ├── 📄 index.md 583 | └── 📁 reference 584 | ├── 📄 cli.md 585 | └── 📄 logic.md 586 | ``` 587 | 588 | ...you can have these navigation items in `mkdocs.yml`: 589 | 590 | ```yaml title="mkdocs.yml" 591 | nav: 592 | - Overview: index.md 593 | - Code Reference: 594 | - cli.py: reference/cli.md 595 | - logic.py: reference/logic.md 596 | - Changelog: changelog.md 597 | ``` 598 | 599 | Note that we matched the sections in the navigation with the folder tree, 600 | but that is not mandatory. 601 | 602 | `mkdocstrings` allows you to inject documentation of Python objects 603 | in Markdown pages with the following syntax: 604 | 605 | ```md 606 | ::: path.to.object 607 | OPTIONS 608 | ``` 609 | 610 | ...where `OPTIONS` is a YAML block containing configuration options 611 | for both the selection of Python objects and their rendering. 612 | 613 | You can document an entire module or even package with a single instruction: 614 | 615 | ```md 616 | ::: your_package 617 | ``` 618 | 619 | ...but it's usually better to have each module injected in a separate page. 620 | 621 | For more information about `mkdocstrings`, 622 | check [its documentation](https://pawamoy.github.io/mkdocstrings). 623 | 624 | ### Serving 625 | 626 | MkDocs provides a development server with files watching and live-reload. 627 | Run `make docs` to serve your documentation on `localhost:8000`. 628 | 629 | If you run it in a remote host (Linux VM) and would like to access it 630 | from your local browser, bind the server to 0.0.0.0 instead: 631 | 632 | ```bash 633 | make docs host=0.0.0.0 634 | ``` 635 | 636 | If needed, you can also change the port used: 637 | 638 | ```bash 639 | make docs host=0.0.0.0 port=5000 640 | ``` 641 | 642 | ### Deploying 643 | 644 | MkDocs has a `gh-deploy` command that will deploy 645 | you documentation on GitHub pages. 646 | We make use of this command in the `docs-deploy` action: 647 | 648 | ```bash 649 | make docs-deploy 650 | ``` 651 | 652 | If you'd prefer to deploy on ReadTheDocs instead, 653 | you will likely have to write 654 | a `readthedocs.yml` configuration file 655 | and enable the project on ReadTheDocs. 656 | -------------------------------------------------------------------------------- /extensions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | import subprocess 5 | import unicodedata 6 | from datetime import date 7 | 8 | from copier_templates_extensions import ContextHook 9 | from jinja2.ext import Extension 10 | 11 | 12 | def git_user_name(default: str) -> str: 13 | return subprocess.getoutput("git config user.name").strip() or default 14 | 15 | 16 | def git_user_email(default: str) -> str: 17 | return subprocess.getoutput("git config user.email").strip() or default 18 | 19 | 20 | def slugify(value, separator="-"): 21 | value = unicodedata.normalize("NFKD", str(value)).encode("ascii", "ignore").decode("ascii") 22 | value = re.sub(r"[^\w\s-]", "", value.lower()) 23 | return re.sub(r"[-_\s]+", separator, value).strip("-_") 24 | 25 | 26 | class GitExtension(Extension): 27 | def __init__(self, environment): 28 | super().__init__(environment) 29 | environment.filters["git_user_name"] = git_user_name 30 | environment.filters["git_user_email"] = git_user_email 31 | 32 | 33 | class SlugifyExtension(Extension): 34 | def __init__(self, environment): 35 | super().__init__(environment) 36 | environment.filters["slugify"] = slugify 37 | 38 | 39 | class CurrentYearExtension(Extension): 40 | def __init__(self, environment): 41 | super().__init__(environment) 42 | environment.globals["current_year"] = date.today().year 43 | 44 | 45 | class GitHubIDsforGiscusExtension(ContextHook): 46 | repo_placeholder = "REPLACE WITH REPOSITORY NODE ID" 47 | category_placeholder = "REPLACE WITH DISCUSSION CATEGORY ID" 48 | update = False 49 | query = """ 50 | { 51 | repository(owner: "%(owner)s", name: "%(name)s") { 52 | discussionCategories(first: 100) { 53 | nodes { 54 | id 55 | name 56 | } 57 | } 58 | } 59 | } 60 | """ 61 | 62 | repo_id: str | None = None 63 | category_id: str | None = None 64 | 65 | def hook(self, context): 66 | try: 67 | repository_namespace = context["repository_namespace"] 68 | repository_name = context["repository_name"] 69 | except KeyError: 70 | return 71 | 72 | if self.repo_id is None: 73 | command = f"gh api repos/{repository_namespace}/{repository_name} --jq .node_id" 74 | try: 75 | process = subprocess.run( 76 | command, shell=True, check=True, text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL 77 | ) 78 | except subprocess.CalledProcessError: 79 | self.repo_id = self.repo_placeholder 80 | else: 81 | self.repo_id = process.stdout.strip() or self.repo_placeholder 82 | context["giscus_repo_id"] = self.repo_id 83 | 84 | if self.category_id is None: 85 | jq_filter = ( 86 | "--jq '.data.repository.discussionCategories.nodes[] | select(.name == \"Documentation\") | .id'" 87 | ) 88 | command = f"gh api graphql -f query='{self.query}' {jq_filter}" % { 89 | "owner": repository_namespace, 90 | "name": repository_name, 91 | } 92 | try: 93 | process = subprocess.run( 94 | command, shell=True, check=True, text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL 95 | ) 96 | except subprocess.CalledProcessError: 97 | self.category_id = self.category_placeholder 98 | else: 99 | self.category_id = process.stdout.strip() or self.category_placeholder 100 | context["giscus_discussion_category_id"] = self.category_id 101 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: "Copier UV" 2 | site_description: "Copier template for Python projects managed by uv" 3 | site_url: "https://pawamoy.github.io/copier-uv" 4 | repo_url: "https://github.com/pawamoy/copier-uv" 5 | repo_name: "pawamoy/copier-uv" 6 | 7 | validation: 8 | omitted_files: warn 9 | absolute_links: warn 10 | unrecognized_links: warn 11 | 12 | nav: 13 | - Home: 14 | - Overview: index.md 15 | - Contributing: contributing.md 16 | - Code of Conduct: code_of_conduct.md 17 | - Changelog: changelog.md 18 | - License: license.md 19 | - Usage: 20 | - Requirements: requirements.md 21 | - Generating a project: generate.md 22 | - Working on a project: work.md 23 | - Updating a project: update.md 24 | - Author's website: https://pawamoy.github.io/ 25 | 26 | theme: 27 | name: material 28 | icon: 29 | logo: material/currency-sign 30 | features: 31 | - content.action.edit 32 | - content.action.view 33 | - content.code.annotate 34 | - content.code.copy 35 | - content.tooltips 36 | - navigation.footer 37 | - navigation.indexes 38 | - navigation.sections 39 | - navigation.tabs 40 | - navigation.tabs.sticky 41 | - navigation.top 42 | - search.highlight 43 | - search.suggest 44 | - toc.follow 45 | palette: 46 | - media: "(prefers-color-scheme)" 47 | toggle: 48 | icon: material/brightness-auto 49 | name: Switch to light mode 50 | - media: "(prefers-color-scheme: light)" 51 | scheme: default 52 | primary: teal 53 | accent: purple 54 | toggle: 55 | icon: material/weather-sunny 56 | name: Switch to dark mode 57 | - media: "(prefers-color-scheme: dark)" 58 | scheme: slate 59 | primary: black 60 | accent: lime 61 | toggle: 62 | icon: material/weather-night 63 | name: Switch to system preference 64 | 65 | markdown_extensions: 66 | - admonition 67 | - callouts 68 | - pymdownx.emoji 69 | - pymdownx.magiclink 70 | - pymdownx.snippets: 71 | check_paths: true 72 | - pymdownx.superfences 73 | - pymdownx.tabbed 74 | - pymdownx.tasklist 75 | - toc: 76 | permalink: "¤" 77 | 78 | extra: 79 | social: 80 | - icon: fontawesome/brands/github 81 | link: https://github.com/pawamoy 82 | - icon: fontawesome/brands/mastodon 83 | link: https://fosstodon.org/@pawamoy 84 | - icon: fontawesome/brands/twitter 85 | link: https://twitter.com/pawamoy 86 | - icon: fontawesome/brands/gitter 87 | link: https://app.gitter.im/#/room/#copier-uv/community:gitter.im 88 | 89 | extra_css: 90 | - css/custom.css 91 | -------------------------------------------------------------------------------- /project/.envrc.jinja: -------------------------------------------------------------------------------- 1 | PATH_add scripts 2 | -------------------------------------------------------------------------------- /project/.github/FUNDING.yml.jinja: -------------------------------------------------------------------------------- 1 | github: {{ author_username }} 2 | polar: {{ author_username }} 3 | -------------------------------------------------------------------------------- /project/.github/ISSUE_TEMPLATE/1-bug.md.jinja: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a bug report to help us improve. 4 | title: "bug: " 5 | labels: unconfirmed 6 | assignees: [pawamoy] 7 | --- 8 | 9 | ### Description of the bug 10 | 11 | 12 | ### To Reproduce 13 | 28 | 29 | ``` 30 | WRITE MRE / INSTRUCTIONS HERE 31 | ``` 32 | 33 | ### Full traceback 34 | 36 | 37 |
Full traceback 38 | 39 | ```python 40 | PASTE TRACEBACK HERE 41 | ``` 42 | 43 |
44 | 45 | ### Expected behavior 46 | 47 | 48 | ### Environment information 49 | 51 | 52 | ```bash 53 | {% if python_package_command_line_name %}{{ python_package_command_line_name }} --debug-info 54 | {%- else %}python -m {{ python_package_import_name }}._internal.debug{% endif %} # | xclip -selection clipboard 55 | ``` 56 | 57 | PASTE MARKDOWN OUTPUT HERE 58 | 59 | ### Additional context 60 | 63 | -------------------------------------------------------------------------------- /project/.github/ISSUE_TEMPLATE/2-feature.md.jinja: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project. 4 | title: "feature: " 5 | labels: feature 6 | assignees: pawamoy 7 | --- 8 | 9 | ### Is your feature request related to a problem? Please describe. 10 | 11 | 12 | ### Describe the solution you'd like 13 | 14 | 15 | ### Describe alternatives you've considered 16 | 17 | 18 | ### Additional context 19 | 20 | -------------------------------------------------------------------------------- /project/.github/ISSUE_TEMPLATE/3-docs.md.jinja: -------------------------------------------------------------------------------- 1 | --- 2 | name: Documentation update 3 | about: Point at unclear, missing or outdated documentation. 4 | title: "docs: " 5 | labels: docs 6 | assignees: pawamoy 7 | --- 8 | 9 | ### Is something unclear, missing or outdated in our documentation? 10 | 11 | 12 | ### Relevant code snippets 13 | 14 | 15 | ### Link to the relevant documentation section 16 | 17 | -------------------------------------------------------------------------------- /project/.github/ISSUE_TEMPLATE/4-change.md.jinja: -------------------------------------------------------------------------------- 1 | --- 2 | name: Change request 3 | about: Suggest any other kind of change for this project. 4 | title: "change: " 5 | assignees: pawamoy 6 | --- 7 | 8 | ### Is your change request related to a problem? Please describe. 9 | 10 | 11 | ### Describe the solution you'd like 12 | 13 | 14 | ### Describe alternatives you've considered 15 | 16 | 17 | ### Additional context 18 | 19 | -------------------------------------------------------------------------------- /project/.github/ISSUE_TEMPLATE/config.yml.jinja: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: I have a question / I need help 4 | url: https://github.com/{{ repository_namespace }}/{{ repository_name }}/discussions/new?category=q-a 5 | about: Ask and answer questions in the Discussions tab. 6 | -------------------------------------------------------------------------------- /project/.github/workflows/ci.yml.jinja: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | pull_request: 6 | branches: 7 | - main 8 | 9 | defaults: 10 | run: 11 | shell: bash 12 | 13 | env: 14 | LANG: en_US.utf-8 15 | LC_ALL: en_US.utf-8 16 | PYTHONIOENCODING: UTF-8 17 | PYTHON_VERSIONS: "" 18 | 19 | jobs: 20 | 21 | quality: 22 | 23 | runs-on: ubuntu-latest 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v4 28 | with: 29 | fetch-depth: 0 30 | fetch-tags: true 31 | 32 | - name: Setup Python 33 | uses: actions/setup-python@v5 34 | with: 35 | python-version: "3.12" 36 | 37 | - name: Setup uv 38 | uses: astral-sh/setup-uv@v5 39 | with: 40 | enable-cache: true 41 | cache-dependency-glob: pyproject.toml 42 | 43 | - name: Install dependencies 44 | run: make setup 45 | 46 | - name: Check if the documentation builds correctly 47 | run: make check-docs 48 | 49 | - name: Check the code quality 50 | run: make check-quality 51 | 52 | - name: Check if the code is correctly typed 53 | run: make check-types 54 | 55 | - name: Check for breaking changes in the API 56 | run: make check-api 57 | 58 | - name: Store objects inventory for tests 59 | uses: actions/upload-artifact@v4 60 | with: 61 | name: objects.inv 62 | path: site/objects.inv 63 | 64 | {% if insiders -%} 65 | exclude-test-jobs: 66 | runs-on: ubuntu-latest 67 | outputs: 68 | jobs: {% raw %}${{ steps.exclude-jobs.outputs.jobs }}{% endraw %} 69 | steps: 70 | - id: exclude-jobs 71 | run: | 72 | if ${{ "{{" }} github.repository_owner == '{{ author_username }}-insiders' {{ "}}" }}; then 73 | echo 'jobs=[ 74 | {"os": "macos-latest"}, 75 | {"os": "windows-latest"}, 76 | {"python-version": "3.10"}, 77 | {"python-version": "3.11"}, 78 | {"python-version": "3.12"}, 79 | {"python-version": "3.13"}, 80 | {"python-version": "3.14"} 81 | ]' | tr -d '[:space:]' >> $GITHUB_OUTPUT 82 | else 83 | echo 'jobs=[ 84 | {"os": "macos-latest", "resolution": "lowest-direct"}, 85 | {"os": "windows-latest", "resolution": "lowest-direct"} 86 | ]' | tr -d '[:space:]' >> $GITHUB_OUTPUT 87 | fi 88 | 89 | {% endif -%} 90 | tests: 91 | 92 | needs: 93 | - quality 94 | {% if insiders -%} 95 | - exclude-test-jobs 96 | {% endif -%} 97 | strategy: 98 | matrix: 99 | os: 100 | - ubuntu-latest 101 | - macos-latest 102 | - windows-latest 103 | python-version: 104 | - "3.9" 105 | - "3.10" 106 | - "3.11" 107 | - "3.12" 108 | - "3.13" 109 | - "3.14" 110 | resolution: 111 | - highest 112 | - lowest-direct 113 | {%- if insiders %} 114 | exclude: {% raw %}${{ fromJSON(needs.exclude-test-jobs.outputs.jobs) }}{% endraw %} 115 | {%- else %} 116 | exclude: 117 | - os: macos-latest 118 | resolution: lowest-direct 119 | - os: windows-latest 120 | resolution: lowest-direct 121 | {%- endif %} 122 | runs-on: {% raw %}${{ matrix.os }}{% endraw %} 123 | continue-on-error: {% raw %}${{ matrix.python-version == '3.14' }}{% endraw %} 124 | 125 | steps: 126 | - name: Checkout 127 | uses: actions/checkout@v4 128 | with: 129 | fetch-depth: 0 130 | fetch-tags: true 131 | 132 | - name: Setup Python 133 | uses: actions/setup-python@v5 134 | with: 135 | python-version: {% raw %}${{ matrix.python-version }}{% endraw %} 136 | allow-prereleases: true 137 | 138 | - name: Setup uv 139 | uses: astral-sh/setup-uv@v5 140 | with: 141 | enable-cache: true 142 | cache-dependency-glob: pyproject.toml 143 | cache-suffix: {% raw %}${{ matrix.resolution }}{% endraw %} 144 | 145 | - name: Install dependencies 146 | env: 147 | UV_RESOLUTION: {% raw %}${{ matrix.resolution }}{% endraw %} 148 | run: make setup 149 | 150 | - name: Download objects inventory 151 | uses: actions/download-artifact@v4 152 | with: 153 | name: objects.inv 154 | path: site/ 155 | 156 | - name: Run the test suite 157 | run: make test 158 | -------------------------------------------------------------------------------- /project/.github/workflows/release.yml.jinja: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: push 4 | permissions: 5 | contents: write 6 | 7 | jobs: 8 | release: 9 | runs-on: ubuntu-latest 10 | if: startsWith(github.ref, 'refs/tags/') 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | fetch-tags: true 17 | - name: Setup Python 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: "3.12" 21 | - name: Setup uv 22 | uses: astral-sh/setup-uv@v5 23 | {%- if insiders %} 24 | - name: Build dists 25 | if: github.repository_owner == '{{ author_username }}-insiders' 26 | run: uv tool run --from build pyproject-build 27 | - name: Upload dists artifact 28 | uses: actions/upload-artifact@v4 29 | if: github.repository_owner == '{{ author_username }}-insiders' 30 | with: 31 | name: {{ repository_name }}-insiders 32 | path: ./dist/* 33 | - name: Prepare release notes 34 | if: github.repository_owner != '{{ author_username }}-insiders' 35 | run: uv tool run git-changelog --release-notes > release-notes.md 36 | - name: Create release with assets 37 | uses: softprops/action-gh-release@v2 38 | if: github.repository_owner == '{{ author_username }}-insiders' 39 | with: 40 | files: ./dist/* 41 | - name: Create release 42 | uses: softprops/action-gh-release@v2 43 | if: github.repository_owner != '{{ author_username }}-insiders' 44 | with: 45 | body_path: release-notes.md 46 | {%- else %} 47 | - name: Prepare release notes 48 | run: uv tool run git-changelog --release-notes > release-notes.md 49 | - name: Create release 50 | uses: softprops/action-gh-release@v2 51 | with: 52 | body_path: release-notes.md 53 | {%- endif %} 54 | -------------------------------------------------------------------------------- /project/.gitignore.jinja: -------------------------------------------------------------------------------- 1 | # editors 2 | .idea/ 3 | .vscode/ 4 | 5 | # python 6 | *.egg-info/ 7 | *.py[cod] 8 | .venv/ 9 | .venvs/ 10 | /build/ 11 | /dist/ 12 | 13 | # tools 14 | .coverage* 15 | /.pdm-build/ 16 | /htmlcov/ 17 | /site/ 18 | uv.lock 19 | 20 | # cache 21 | .cache/ 22 | .pytest_cache/ 23 | .mypy_cache/ 24 | .ruff_cache/ 25 | __pycache__/ 26 | -------------------------------------------------------------------------------- /project/CHANGELOG.md.jinja: -------------------------------------------------------------------------------- 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](http://keepachangelog.com/en/1.0.0/) 6 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 7 | 8 | 9 | -------------------------------------------------------------------------------- /project/CODE_OF_CONDUCT.md.jinja: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | 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. 6 | 7 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. 8 | 9 | ## Our Standards 10 | 11 | Examples of behavior that contributes to a positive environment for our community include: 12 | 13 | * Demonstrating empathy and kindness toward other people 14 | * Being respectful of differing opinions, viewpoints, and experiences 15 | * Giving and gracefully accepting constructive feedback 16 | * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience 17 | * Focusing on what is best not just for us as individuals, but for the overall community 18 | 19 | Examples of unacceptable behavior include: 20 | 21 | * The use of sexualized language or imagery, and sexual attention or advances of any kind 22 | * Trolling, insulting or derogatory comments, and personal or political attacks 23 | * Public or private harassment 24 | * Publishing others' private information, such as a physical or email address, without their explicit permission 25 | * Other conduct which could reasonably be considered inappropriate in a professional setting 26 | 27 | ## Enforcement Responsibilities 28 | 29 | 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. 30 | 31 | 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. 32 | 33 | ## Scope 34 | 35 | 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. 36 | 37 | ## Enforcement 38 | 39 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at {{ author_email }}. All complaints will be reviewed and investigated promptly and fairly. 40 | 41 | All community leaders are obligated to respect the privacy and security of the reporter of any incident. 42 | 43 | ## Enforcement Guidelines 44 | 45 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: 46 | 47 | ### 1. Correction 48 | 49 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. 50 | 51 | **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. 52 | 53 | ### 2. Warning 54 | 55 | **Community Impact**: A violation through a single incident or series of actions. 56 | 57 | **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. 58 | 59 | ### 3. Temporary Ban 60 | 61 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. 62 | 63 | **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. 64 | 65 | ### 4. Permanent Ban 66 | 67 | **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. 68 | 69 | **Consequence**: A permanent ban from any sort of public interaction within the community. 70 | 71 | ## Attribution 72 | 73 | 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]. 74 | 75 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 76 | 77 | 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]. 78 | 79 | [homepage]: https://www.contributor-covenant.org 80 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 81 | [Mozilla CoC]: https://github.com/mozilla/diversity 82 | [FAQ]: https://www.contributor-covenant.org/faq 83 | [translations]: https://www.contributor-covenant.org/translations 84 | 85 | -------------------------------------------------------------------------------- /project/CONTRIBUTING.md.jinja: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are welcome, and they are greatly appreciated! Every little bit helps, and credit will always be given. 4 | 5 | ## Environment setup 6 | 7 | Nothing easier! 8 | 9 | Fork and clone the repository, then: 10 | 11 | ```bash 12 | cd {{ repository_name }} 13 | make setup 14 | ``` 15 | 16 | > NOTE: If it fails for some reason, you'll need to install [uv](https://github.com/astral-sh/uv) manually. 17 | > 18 | > You can install it with: 19 | > 20 | > ```bash 21 | > curl -LsSf https://astral.sh/uv/install.sh | sh 22 | > ``` 23 | > 24 | > Now you can try running `make setup` again, or simply `uv sync`. 25 | 26 | You now have the dependencies installed. 27 | 28 | {% if python_package_command_line_name -%} 29 | You can run the application with `make run {{ python_package_command_line_name }} [ARGS...]`. 30 | 31 | {% endif -%} 32 | 33 | Run `make help` to see all the available actions! 34 | 35 | ## Tasks 36 | 37 | The entry-point to run commands and tasks is the `make` Python script, located in the `scripts` directory. Try running `make` to show the available commands and tasks. The *commands* do not need the Python dependencies to be installed, 38 | while the *tasks* do. The cross-platform tasks are written in Python, thanks to [duty](https://github.com/pawamoy/duty). 39 | 40 | If you work in VSCode, we provide [an action to configure VSCode](https://pawamoy.github.io/copier-uv/work/#vscode-setup) for the project. 41 | 42 | ## Development 43 | 44 | As usual: 45 | 46 | 1. create a new branch: `git switch -c feature-or-bugfix-name` 47 | 1. edit the code and/or the documentation 48 | 49 | **Before committing:** 50 | 51 | 1. run `make format` to auto-format the code 52 | 1. run `make check` to check everything (fix any warning) 53 | 1. run `make test` to run the tests (fix any issue) 54 | 1. if you updated the documentation or the project dependencies: 55 | 1. run `make docs` 56 | 1. go to http://localhost:8000 and check that everything looks good 57 | 1. follow our [commit message convention](#commit-message-convention) 58 | 59 | If you are unsure about how to fix or ignore a warning, just let the continuous integration fail, and we will help you during review. 60 | 61 | Don't bother updating the changelog, we will take care of this. 62 | 63 | ## Commit message convention 64 | 65 | Commit messages must follow our convention based on the [Angular style](https://gist.github.com/stephenparish/9941e89d80e2bc58a153#format-of-the-commit-message) or the [Karma convention](https://karma-runner.github.io/4.0/dev/git-commit-msg.html): 66 | 67 | ``` 68 | [(scope)]: Subject 69 | 70 | [Body] 71 | ``` 72 | 73 | **Subject and body must be valid Markdown.** Subject must have proper casing (uppercase for first letter if it makes sense), but no dot at the end, and no punctuation in general. 74 | 75 | Scope and body are optional. Type can be: 76 | 77 | - `build`: About packaging, building wheels, etc. 78 | - `chore`: About packaging or repo/files management. 79 | - `ci`: About Continuous Integration. 80 | - `deps`: Dependencies update. 81 | - `docs`: About documentation. 82 | - `feat`: New feature. 83 | - `fix`: Bug fix. 84 | - `perf`: About performance. 85 | - `refactor`: Changes that are not features or bug fixes. 86 | - `style`: A change in code style/format. 87 | - `tests`: About tests. 88 | 89 | If you write a body, please add trailers at the end (for example issues and PR references, or co-authors), without relying on GitHub's flavored Markdown: 90 | 91 | ``` 92 | Body. 93 | 94 | Issue #10: https://github.com/namespace/project/issues/10 95 | Related to PR namespace/other-project#15: https://github.com/namespace/other-project/pull/15 96 | ``` 97 | 98 | These "trailers" must appear at the end of the body, without any blank lines between them. The trailer title can contain any character except colons `:`. We expect a full URI for each trailer, not just GitHub autolinks (for example, full GitHub URLs for commits and issues, not the hash or the #issue-number). 99 | 100 | We do not enforce a line length on commit messages summary and body, but please avoid very long summaries, and very long lines in the body, unless they are part of code blocks that must not be wrapped. 101 | 102 | ## Pull requests guidelines 103 | 104 | Link to any related issue in the Pull Request message. 105 | 106 | During the review, we recommend using fixups: 107 | 108 | ```bash 109 | # SHA is the SHA of the commit you want to fix 110 | git commit --fixup=SHA 111 | ``` 112 | 113 | Once all the changes are approved, you can squash your commits: 114 | 115 | ```bash 116 | git rebase -i --autosquash main 117 | ``` 118 | 119 | And force-push: 120 | 121 | ```bash 122 | git push -f 123 | ``` 124 | 125 | If this seems all too complicated, you can push or force-push each new commit, and we will squash them ourselves if needed, before merging. 126 | -------------------------------------------------------------------------------- /project/Makefile.jinja: -------------------------------------------------------------------------------- 1 | # If you have `direnv` loaded in your shell, and allow it in the repository, 2 | # the `make` command will point at the `scripts/make` shell script. 3 | # This Makefile is just here to allow auto-completion in the terminal. 4 | 5 | actions = \ 6 | allrun \ 7 | changelog \ 8 | check \ 9 | check-api \ 10 | check-docs \ 11 | check-quality \ 12 | check-types \ 13 | clean \ 14 | coverage \ 15 | docs \ 16 | {%- if repository_provider == "github.com" %} 17 | docs-deploy \ 18 | {%- endif %} 19 | format \ 20 | help \ 21 | multirun \ 22 | release \ 23 | run \ 24 | setup \ 25 | test \ 26 | vscode 27 | 28 | .PHONY: $(actions) 29 | $(actions): 30 | @python scripts/make "$@" 31 | -------------------------------------------------------------------------------- /project/README.md.jinja: -------------------------------------------------------------------------------- 1 | # {{ project_name }} 2 | 3 | {% if repository_provider == "github.com" and (not insiders or public_release) -%} 4 | [![ci](https://github.com/{{ repository_namespace }}/{{ repository_name }}/workflows/ci/badge.svg)](https://github.com/{{ repository_namespace }}/{{ repository_name }}/actions?query=workflow%3Aci) 5 | {% endif -%} 6 | [![documentation](https://img.shields.io/badge/docs-mkdocs-708FCC.svg?style=flat)](https://{{ repository_namespace }}.{{ repository_provider[:-4] }}.io/{{ repository_name }}/) 7 | {% if not insiders or public_release -%} 8 | [![pypi version](https://img.shields.io/pypi/v/{{ python_package_distribution_name }}.svg)](https://pypi.org/project/{{ python_package_distribution_name }}/) 9 | {% endif -%} 10 | [![gitter](https://badges.gitter.im/join%20chat.svg)](https://app.gitter.im/#/room/#{{ repository_name }}:gitter.im) 11 | 12 | {{ project_description }} 13 | 14 | ## Installation 15 | 16 | {% if not insiders or public_release -%} 17 | ```bash 18 | pip install {{ python_package_distribution_name }} 19 | ``` 20 | 21 | {% if python_package_command_line_name -%} 22 | With [`uv`](https://docs.astral.sh/uv/): 23 | 24 | ```bash 25 | uv tool install {{ python_package_distribution_name }} 26 | ``` 27 | {%- endif -%} 28 | {%- else -%} 29 | This project is available to sponsors only, through my Insiders program. See Insiders [explanation](https://{{ repository_namespace }}.github.io/{{ repository_name }}/insiders/) and [installation instructions](https://{{ repository_namespace }}.github.io/{{ repository_name }}/insiders/installation/). 30 | {%- endif %} 31 | -------------------------------------------------------------------------------- /project/config/coverage.ini.jinja: -------------------------------------------------------------------------------- 1 | [coverage:run] 2 | branch = true 3 | parallel = true 4 | source = 5 | src/ 6 | tests/ 7 | 8 | [coverage:paths] 9 | equivalent = 10 | src/ 11 | .venv/lib/*/site-packages/ 12 | .venvs/*/lib/*/site-packages/ 13 | 14 | [coverage:report] 15 | precision = 2 16 | omit = 17 | src/*/__init__.py 18 | src/*/__main__.py 19 | tests/__init__.py 20 | exclude_lines = 21 | pragma: no cover 22 | if TYPE_CHECKING 23 | 24 | [coverage:json] 25 | output = htmlcov/coverage.json 26 | -------------------------------------------------------------------------------- /project/config/git-changelog.toml.jinja: -------------------------------------------------------------------------------- 1 | bump = "auto" 2 | convention = "angular" 3 | in-place = true 4 | output = "CHANGELOG.md" 5 | parse-refs = false 6 | parse-trailers = true 7 | sections = ["build", "deps", "feat", "fix", "refactor"] 8 | template = "keepachangelog" 9 | versioning = "pep440" 10 | -------------------------------------------------------------------------------- /project/config/mypy.ini.jinja: -------------------------------------------------------------------------------- 1 | [mypy] 2 | ignore_missing_imports = true 3 | exclude = tests/fixtures/ 4 | warn_unused_ignores = true 5 | show_error_codes = true 6 | -------------------------------------------------------------------------------- /project/config/pytest.ini.jinja: -------------------------------------------------------------------------------- 1 | [pytest] 2 | python_files = 3 | test_*.py 4 | addopts = 5 | --cov 6 | --cov-config config/coverage.ini 7 | testpaths = 8 | tests 9 | 10 | # action:message_regex:warning_class:module_regex:line 11 | filterwarnings = 12 | error 13 | # TODO: remove once pytest-xdist 4 is released 14 | ignore:.*rsyncdir:DeprecationWarning:xdist 15 | -------------------------------------------------------------------------------- /project/config/ruff.toml.jinja: -------------------------------------------------------------------------------- 1 | target-version = "py39" 2 | line-length = 120 3 | 4 | [lint] 5 | exclude = [ 6 | "tests/fixtures/*.py", 7 | ] 8 | select = [ 9 | "A", "ANN", "ARG", 10 | "B", "BLE", 11 | "C", "C4", 12 | "COM", 13 | "D", "DTZ", 14 | "E", "ERA", "EXE", 15 | "F", "FBT", 16 | "G", 17 | "I", "ICN", "INP", "ISC", 18 | "N", 19 | "PGH", "PIE", "PL", "PLC", "PLE", "PLR", "PLW", "PT", "PYI", 20 | "Q", 21 | "RUF", "RSE", "RET", 22 | "S", "SIM", "SLF", 23 | "T", "T10", "T20", "TCH", "TID", "TRY", 24 | "UP", 25 | "W", 26 | "YTT", 27 | ] 28 | ignore = [ 29 | "A001", # Variable is shadowing a Python builtin 30 | "ANN101", # Missing type annotation for self 31 | "ANN102", # Missing type annotation for cls 32 | "ANN204", # Missing return type annotation for special method __str__ 33 | "ANN401", # Dynamically typed expressions (typing.Any) are disallowed 34 | "ARG005", # Unused lambda argument 35 | "C901", # Too complex 36 | "D105", # Missing docstring in magic method 37 | "D417", # Missing argument description in the docstring 38 | "E501", # Line too long 39 | "ERA001", # Commented out code 40 | "G004", # Logging statement uses f-string 41 | "PLR0911", # Too many return statements 42 | "PLR0912", # Too many branches 43 | "PLR0913", # Too many arguments to function call 44 | "PLR0915", # Too many statements 45 | "SLF001", # Private member accessed 46 | "TRY003", # Avoid specifying long messages outside the exception class 47 | ] 48 | 49 | [lint.per-file-ignores] 50 | "src/**/cli.py" = [ 51 | "T201", # Print statement 52 | ] 53 | "src/*/debug.py" = [ 54 | "T201", # Print statement 55 | ] 56 | "!src/*/*.py" = [ 57 | "D100", # Missing docstring in public module 58 | ] 59 | "!src/**.py" = [ 60 | "D101", # Missing docstring in public class 61 | "D103", # Missing docstring in public function 62 | ] 63 | "scripts/*.py" = [ 64 | "INP001", # File is part of an implicit namespace package 65 | "T201", # Print statement 66 | ] 67 | "tests/**.py" = [ 68 | "ARG005", # Unused lambda argument 69 | "FBT001", # Boolean positional arg in function definition 70 | "PLR2004", # Magic value used in comparison 71 | "S101", # Use of assert detected 72 | ] 73 | 74 | [lint.flake8-quotes] 75 | docstring-quotes = "double" 76 | 77 | [lint.flake8-tidy-imports] 78 | ban-relative-imports = "all" 79 | 80 | [lint.isort] 81 | known-first-party = ["{{ python_package_import_name }}"] 82 | 83 | [lint.pydocstyle] 84 | convention = "google" 85 | 86 | [format] 87 | exclude = [ 88 | "tests/fixtures/*.py", 89 | ] 90 | docstring-code-format = true 91 | docstring-code-line-length = 80 92 | -------------------------------------------------------------------------------- /project/config/vscode/launch.json.jinja: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "python (current file)", 6 | "type": "debugpy", 7 | "request": "launch", 8 | "program": "${file}", 9 | "console": "integratedTerminal", 10 | "justMyCode": false, 11 | "args": "${command:pickArgs}" 12 | }, 13 | { 14 | "name": "run", 15 | "type": "debugpy", 16 | "request": "launch", 17 | "module": "{{ python_package_import_name }}", 18 | "console": "integratedTerminal", 19 | "justMyCode": false, 20 | "args": "${command:pickArgs}" 21 | }, 22 | { 23 | "name": "docs", 24 | "type": "debugpy", 25 | "request": "launch", 26 | "module": "mkdocs", 27 | "justMyCode": false, 28 | "args": [ 29 | "serve", 30 | "-v" 31 | ] 32 | }, 33 | { 34 | "name": "test", 35 | "type": "debugpy", 36 | "request": "launch", 37 | "module": "pytest", 38 | "justMyCode": false, 39 | "args": [ 40 | "-c=config/pytest.ini", 41 | "-vvv", 42 | "--no-cov", 43 | "--dist=no", 44 | "tests", 45 | "-k=${input:tests_selection}" 46 | ] 47 | } 48 | ], 49 | "inputs": [ 50 | { 51 | "id": "tests_selection", 52 | "type": "promptString", 53 | "description": "Tests selection", 54 | "default": "" 55 | } 56 | ] 57 | } -------------------------------------------------------------------------------- /project/config/vscode/settings.json.jinja: -------------------------------------------------------------------------------- 1 | { 2 | "files.watcherExclude": { 3 | "**/.venv*/**": true, 4 | "**/.venvs*/**": true, 5 | "**/venv*/**": true 6 | }, 7 | "mypy-type-checker.args": [ 8 | "--config-file=config/mypy.ini" 9 | ], 10 | "python.testing.unittestEnabled": false, 11 | "python.testing.pytestEnabled": true, 12 | "python.testing.pytestArgs": [ 13 | "--config-file=config/pytest.ini" 14 | ], 15 | "ruff.enable": true, 16 | "ruff.format.args": [ 17 | "--config=config/ruff.toml" 18 | ], 19 | "ruff.lint.args": [ 20 | "--config=config/ruff.toml" 21 | ], 22 | "yaml.schemas": { 23 | "https://squidfunk.github.io/mkdocs-material/schema.json": "mkdocs.yml" 24 | }, 25 | "yaml.customTags": [ 26 | "!ENV scalar", 27 | "!ENV sequence", 28 | "!relative scalar", 29 | "tag:yaml.org,2002:python/name:materialx.emoji.to_svg", 30 | "tag:yaml.org,2002:python/name:materialx.emoji.twemoji", 31 | "tag:yaml.org,2002:python/name:pymdownx.superfences.fence_code_format" 32 | ] 33 | } -------------------------------------------------------------------------------- /project/config/vscode/tasks.json.jinja: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "changelog", 6 | "type": "process", 7 | "command": "scripts/make", 8 | "args": ["changelog"] 9 | }, 10 | { 11 | "label": "check", 12 | "type": "process", 13 | "command": "scripts/make", 14 | "args": ["check"] 15 | }, 16 | { 17 | "label": "check-quality", 18 | "type": "process", 19 | "command": "scripts/make", 20 | "args": ["check-quality"] 21 | }, 22 | { 23 | "label": "check-types", 24 | "type": "process", 25 | "command": "scripts/make", 26 | "args": ["check-types"] 27 | }, 28 | { 29 | "label": "check-docs", 30 | "type": "process", 31 | "command": "scripts/make", 32 | "args": ["check-docs"] 33 | }, 34 | { 35 | "label": "check-api", 36 | "type": "process", 37 | "command": "scripts/make", 38 | "args": ["check-api"] 39 | }, 40 | { 41 | "label": "clean", 42 | "type": "process", 43 | "command": "scripts/make", 44 | "args": ["clean"] 45 | }, 46 | { 47 | "label": "docs", 48 | "type": "process", 49 | "command": "scripts/make", 50 | "args": ["docs"] 51 | }, 52 | { 53 | "label": "docs-deploy", 54 | "type": "process", 55 | "command": "scripts/make", 56 | "args": ["docs-deploy"] 57 | }, 58 | { 59 | "label": "format", 60 | "type": "process", 61 | "command": "scripts/make", 62 | "args": ["format"] 63 | }, 64 | { 65 | "label": "release", 66 | "type": "process", 67 | "command": "scripts/make", 68 | "args": ["release", "${input:version}"] 69 | }, 70 | { 71 | "label": "setup", 72 | "type": "process", 73 | "command": "scripts/make", 74 | "args": ["setup"] 75 | }, 76 | { 77 | "label": "test", 78 | "type": "process", 79 | "command": "scripts/make", 80 | "args": ["test", "coverage"], 81 | "group": "test" 82 | }, 83 | { 84 | "label": "vscode", 85 | "type": "process", 86 | "command": "scripts/make", 87 | "args": ["vscode"] 88 | } 89 | ], 90 | "inputs": [ 91 | { 92 | "id": "version", 93 | "type": "promptString", 94 | "description": "Version" 95 | } 96 | ] 97 | } -------------------------------------------------------------------------------- /project/docs/.overrides/partials/comments.html.jinja: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /project/docs/.overrides/partials/path-item.html: -------------------------------------------------------------------------------- 1 | {# Fix breadcrumbs for when mkdocs-section-index is used. #} 2 | {# See https://github.com/squidfunk/mkdocs-material/issues/7614. #} 3 | 4 | 5 | {% macro render_content(nav_item) %} 6 | 7 | {{ nav_item.title }} 8 | 9 | {% endmacro %} 10 | 11 | 12 | {% macro render(nav_item, ref=nav_item) %} 13 | {% if nav_item.is_page %} 14 |
  • 15 | 16 | {{ render_content(ref) }} 17 | 18 |
  • 19 | {% elif nav_item.children %} 20 | {{ render(nav_item.children | first, ref) }} 21 | {% endif %} 22 | {% endmacro %} 23 | -------------------------------------------------------------------------------- /project/docs/.overrides/{% if author_username == 'pawamoy' %}main.html{% endif %}.jinja: -------------------------------------------------------------------------------- 1 | {% raw %}{% extends "base.html" %}{% endraw %} 2 | 3 | {% raw %}{% block announce %}{% endraw %} 4 | {% if insiders %} 5 | Fund this project through 6 | sponsorship 7 | 8 | {% raw %}{% include ".icons/octicons/heart-fill-16.svg" %}{% endraw %} 9 | — 10 | {% endif %} 11 | Follow 12 | @pawamoy on 13 | 14 | 15 | {% raw %}{% include ".icons/fontawesome/brands/mastodon.svg" %}{% endraw %} 16 | 17 | Fosstodon 18 | 19 | for updates 20 | {% raw %}{% endblock %}{% endraw %} 21 | -------------------------------------------------------------------------------- /project/docs/changelog.md.jinja: -------------------------------------------------------------------------------- 1 | --- 2 | title: Changelog 3 | --- 4 | 5 | --8<-- "CHANGELOG.md" 6 | -------------------------------------------------------------------------------- /project/docs/code_of_conduct.md.jinja: -------------------------------------------------------------------------------- 1 | --- 2 | title: Code of Conduct 3 | --- 4 | 5 | --8<-- "CODE_OF_CONDUCT.md" 6 | -------------------------------------------------------------------------------- /project/docs/contributing.md.jinja: -------------------------------------------------------------------------------- 1 | --- 2 | title: Contributing 3 | --- 4 | 5 | --8<-- "CONTRIBUTING.md" 6 | -------------------------------------------------------------------------------- /project/docs/credits.md.jinja: -------------------------------------------------------------------------------- 1 | --- 2 | title: Credits 3 | hide: 4 | - toc 5 | --- 6 | 7 | ```python exec="yes" 8 | --8<-- "scripts/gen_credits.py" 9 | ``` 10 | -------------------------------------------------------------------------------- /project/docs/css/material.css.jinja: -------------------------------------------------------------------------------- 1 | /* More space at the bottom of the page. */ 2 | .md-main__inner { 3 | margin-bottom: 1.5rem; 4 | } 5 | -------------------------------------------------------------------------------- /project/docs/css/mkdocstrings.css.jinja: -------------------------------------------------------------------------------- 1 | /* Indentation. */ 2 | div.doc-contents:not(.first) { 3 | padding-left: 25px; 4 | border-left: .05rem solid var(--md-typeset-table-color); 5 | } 6 | 7 | /* Mark external links as such. */ 8 | a.external::after, 9 | a.autorefs-external::after { 10 | /* https://primer.style/octicons/arrow-up-right-24 */ 11 | mask-image: url('data:image/svg+xml,'); 12 | -webkit-mask-image: url('data:image/svg+xml,'); 13 | content: ' '; 14 | 15 | display: inline-block; 16 | vertical-align: middle; 17 | position: relative; 18 | 19 | height: 1em; 20 | width: 1em; 21 | background-color: currentColor; 22 | } 23 | 24 | a.external:hover::after, 25 | a.autorefs-external:hover::after { 26 | background-color: var(--md-accent-fg-color); 27 | } 28 | 29 | /* Tree-like output for backlinks. */ 30 | .doc-backlink-list { 31 | --tree-clr: var(--md-default-fg-color); 32 | --tree-font-size: 1rem; 33 | --tree-item-height: 1; 34 | --tree-offset: 1rem; 35 | --tree-thickness: 1px; 36 | --tree-style: solid; 37 | display: grid; 38 | list-style: none !important; 39 | } 40 | 41 | .doc-backlink-list li > span:first-child { 42 | text-indent: .3rem; 43 | } 44 | .doc-backlink-list li { 45 | padding-inline-start: var(--tree-offset); 46 | border-left: var(--tree-thickness) var(--tree-style) var(--tree-clr); 47 | position: relative; 48 | margin-left: 0 !important; 49 | 50 | &:last-child { 51 | border-color: transparent; 52 | } 53 | &::before{ 54 | content: ''; 55 | position: absolute; 56 | top: calc(var(--tree-item-height) / 2 * -1 * var(--tree-font-size) + var(--tree-thickness)); 57 | left: calc(var(--tree-thickness) * -1); 58 | width: calc(var(--tree-offset) + var(--tree-thickness) * 2); 59 | height: calc(var(--tree-item-height) * var(--tree-font-size)); 60 | border-left: var(--tree-thickness) var(--tree-style) var(--tree-clr); 61 | border-bottom: var(--tree-thickness) var(--tree-style) var(--tree-clr); 62 | } 63 | &::after{ 64 | content: ''; 65 | position: absolute; 66 | border-radius: 50%; 67 | background-color: var(--tree-clr); 68 | top: calc(var(--tree-item-height) / 2 * 1rem); 69 | left: var(--tree-offset) ; 70 | translate: calc(var(--tree-thickness) * -1) calc(var(--tree-thickness) * -1); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /project/docs/css/{% if insiders %}insiders.css{% endif %}.jinja: -------------------------------------------------------------------------------- 1 | @keyframes heart { 2 | 3 | 0%, 4 | 40%, 5 | 80%, 6 | 100% { 7 | transform: scale(1); 8 | } 9 | 10 | 20%, 11 | 60% { 12 | transform: scale(1.15); 13 | } 14 | } 15 | 16 | @keyframes vibrate { 17 | 0%, 2%, 4%, 6%, 8%, 10%, 12%, 14%, 16%, 18% { 18 | -webkit-transform: translate3d(-2px, 0, 0); 19 | transform: translate3d(-2px, 0, 0); 20 | } 21 | 1%, 3%, 5%, 7%, 9%, 11%, 13%, 15%, 17%, 19% { 22 | -webkit-transform: translate3d(2px, 0, 0); 23 | transform: translate3d(2px, 0, 0); 24 | } 25 | 20%, 100% { 26 | -webkit-transform: translate3d(0, 0, 0); 27 | transform: translate3d(0, 0, 0); 28 | } 29 | } 30 | 31 | .heart { 32 | color: #e91e63; 33 | } 34 | 35 | .pulse { 36 | animation: heart 1000ms infinite; 37 | } 38 | 39 | .vibrate { 40 | animation: vibrate 2000ms infinite; 41 | } 42 | 43 | .new-feature svg { 44 | fill: var(--md-accent-fg-color) !important; 45 | } 46 | 47 | a.insiders { 48 | color: #e91e63; 49 | } 50 | 51 | .sponsorship-list { 52 | width: 100%; 53 | } 54 | 55 | .sponsorship-item { 56 | border-radius: 100%; 57 | display: inline-block; 58 | height: 1.6rem; 59 | margin: 0.1rem; 60 | overflow: hidden; 61 | width: 1.6rem; 62 | } 63 | 64 | .sponsorship-item:focus, .sponsorship-item:hover { 65 | transform: scale(1.1); 66 | } 67 | 68 | .sponsorship-item img { 69 | filter: grayscale(100%) opacity(75%); 70 | height: auto; 71 | width: 100%; 72 | } 73 | 74 | .sponsorship-item:focus img, .sponsorship-item:hover img { 75 | filter: grayscale(0); 76 | } 77 | 78 | .sponsorship-item.private { 79 | background: var(--md-default-fg-color--lightest); 80 | color: var(--md-default-fg-color); 81 | font-size: .6rem; 82 | font-weight: 700; 83 | line-height: 1.6rem; 84 | text-align: center; 85 | } 86 | 87 | .mastodon { 88 | color: #897ff8; 89 | border-radius: 100%; 90 | box-shadow: inset 0 0 0 .05rem currentcolor; 91 | display: inline-block; 92 | height: 1.2rem !important; 93 | padding: .25rem; 94 | transition: all .25s; 95 | vertical-align: bottom !important; 96 | width: 1.2rem; 97 | } 98 | 99 | .premium-sponsors { 100 | text-align: center; 101 | } 102 | 103 | #silver-sponsors img { 104 | height: 140px; 105 | } 106 | 107 | #bronze-sponsors img { 108 | height: 140px; 109 | } 110 | 111 | #bronze-sponsors p { 112 | display: flex; 113 | flex-wrap: wrap; 114 | justify-content: center; 115 | } 116 | 117 | #bronze-sponsors a { 118 | display: block; 119 | flex-shrink: 0; 120 | } 121 | 122 | .sponsors-total { 123 | font-weight: bold; 124 | } -------------------------------------------------------------------------------- /project/docs/index.md.jinja: -------------------------------------------------------------------------------- 1 | --- 2 | title: Overview 3 | hide: 4 | - feedback 5 | --- 6 | 7 | --8<-- "README.md" 8 | -------------------------------------------------------------------------------- /project/docs/js/feedback.js.jinja: -------------------------------------------------------------------------------- 1 | const feedback = document.forms.feedback; 2 | feedback.hidden = false; 3 | 4 | feedback.addEventListener("submit", function(ev) { 5 | ev.preventDefault(); 6 | const commentElement = document.getElementById("feedback"); 7 | commentElement.style.display = "block"; 8 | feedback.firstElementChild.disabled = true; 9 | const data = ev.submitter.getAttribute("data-md-value"); 10 | const note = feedback.querySelector(".md-feedback__note [data-md-value='" + data + "']"); 11 | if (note) { 12 | note.hidden = false; 13 | } 14 | }) 15 | -------------------------------------------------------------------------------- /project/docs/js/{% if insiders %}insiders.js{% endif %}.jinja: -------------------------------------------------------------------------------- 1 | function humanReadableAmount(amount) { 2 | const strAmount = String(amount); 3 | if (strAmount.length >= 4) { 4 | return `${strAmount.slice(0, strAmount.length - 3)},${strAmount.slice(-3)}`; 5 | } 6 | return strAmount; 7 | } 8 | 9 | function getJSON(url, callback) { 10 | var xhr = new XMLHttpRequest(); 11 | xhr.open('GET', url, true); 12 | xhr.responseType = 'json'; 13 | xhr.onload = function () { 14 | var status = xhr.status; 15 | if (status === 200) { 16 | callback(null, xhr.response); 17 | } else { 18 | callback(status, xhr.response); 19 | } 20 | }; 21 | xhr.send(); 22 | } 23 | 24 | function updatePremiumSponsors(dataURL, rank) { 25 | let capRank = rank.charAt(0).toUpperCase() + rank.slice(1); 26 | getJSON(dataURL + `/sponsors${capRank}.json`, function (err, sponsors) { 27 | const sponsorsDiv = document.getElementById(`${rank}-sponsors`); 28 | if (sponsors.length > 0) { 29 | let html = ''; 30 | html += `${capRank} sponsors

    ` 31 | sponsors.forEach(function (sponsor) { 32 | html += ` 33 | 34 | ${sponsor.name} 35 | 36 | ` 37 | }); 38 | html += '

    ' 39 | sponsorsDiv.innerHTML = html; 40 | } 41 | }); 42 | } 43 | 44 | function updateInsidersPage(author_username) { 45 | const sponsorURL = `https://github.com/sponsors/${author_username}` 46 | const dataURL = `https://raw.githubusercontent.com/${author_username}/sponsors/main`; 47 | getJSON(dataURL + '/numbers.json', function (err, numbers) { 48 | document.getElementById('sponsors-count').innerHTML = numbers.count; 49 | Array.from(document.getElementsByClassName('sponsors-total')).forEach(function (element) { 50 | element.innerHTML = '$ ' + humanReadableAmount(numbers.total); 51 | }); 52 | getJSON(dataURL + '/sponsors.json', function (err, sponsors) { 53 | const sponsorsElem = document.getElementById('sponsors'); 54 | const privateSponsors = numbers.count - sponsors.length; 55 | sponsors.forEach(function (sponsor) { 56 | sponsorsElem.innerHTML += ` 57 | 58 | 59 | 60 | `; 61 | }); 62 | if (privateSponsors > 0) { 63 | sponsorsElem.innerHTML += ` 64 | 65 | +${privateSponsors} 66 | 67 | `; 68 | } 69 | }); 70 | }); 71 | updatePremiumSponsors(dataURL, "gold"); 72 | updatePremiumSponsors(dataURL, "silver"); 73 | updatePremiumSponsors(dataURL, "bronze"); 74 | } 75 | -------------------------------------------------------------------------------- /project/docs/license.md.jinja: -------------------------------------------------------------------------------- 1 | --- 2 | title: License 3 | hide: 4 | - feedback 5 | --- 6 | 7 | # License 8 | 9 | ``` 10 | --8<-- "LICENSE" 11 | ``` 12 | -------------------------------------------------------------------------------- /project/docs/reference/api.md.jinja: -------------------------------------------------------------------------------- 1 | --- 2 | title: API reference 3 | hide: 4 | - navigation 5 | --- 6 | 7 | # ::: {{ python_package_import_name }} 8 | -------------------------------------------------------------------------------- /project/docs/{% if insiders %}insiders{% endif %}/changelog.md.jinja: -------------------------------------------------------------------------------- 1 | --- 2 | title: Changelog 3 | --- 4 | 5 | # Changelog 6 | 7 | ## {{ project_name }} Insiders 8 | 9 | ### 1.0.0 April 22, 2023 { id="1.0.0" } 10 | 11 | - Release first Insiders version 12 | -------------------------------------------------------------------------------- /project/docs/{% if insiders %}insiders{% endif %}/goals.yml.jinja: -------------------------------------------------------------------------------- 1 | goals: 2 | 500: 3 | name: PlasmaVac User Guide 4 | features: [] 5 | 1000: 6 | name: GraviFridge Fluid Renewal 7 | features: [] 8 | 1500: 9 | name: HyperLamp Navigation Tips 10 | features: [] 11 | 2000: 12 | name: FusionDrive Ejection Configuration 13 | features: [] 14 | -------------------------------------------------------------------------------- /project/docs/{% if insiders %}insiders{% endif %}/index.md.jinja: -------------------------------------------------------------------------------- 1 | --- 2 | title: Insiders 3 | --- 4 | 5 | # Insiders 6 | 7 | *{{ project_name }}* follows the **sponsorware** release strategy, which means that new features are first exclusively released to sponsors as part of [Insiders][]. Read on to learn [what sponsorships achieve][sponsorship], [how to become a sponsor][sponsors] to get access to Insiders, and [what's in it for you][features]! 8 | 9 | ## What is Insiders? 10 | 11 | *{{ project_name }} Insiders* is a private fork of *{{ project_name }}*, hosted as a private GitHub repository. Almost[^1] [all new features][features] are developed as part of this fork, which means that they are immediately available to all eligible sponsors, as they are granted access to this private repository. 12 | 13 | [^1]: In general, every new feature is first exclusively released to sponsors, but sometimes upstream dependencies enhance existing features that must be supported by *{{ project_name }}*. 14 | 15 | Every feature is tied to a [funding goal][funding] in monthly subscriptions. When a funding goal is hit, the features that are tied to it are merged back into *{{ project_name }}* and released for general availability, making them available to all users. Bugfixes are always released in tandem. 16 | 17 | Sponsorships start as low as [**$10 a month**][sponsors].[^2] 18 | 19 | [^2]: Note that $10 a month is the minimum amount to become eligible for Insiders. While GitHub Sponsors also allows to sponsor lower amounts or one-time amounts, those can't be granted access to Insiders due to technical reasons. Such contributions are still very much welcome as they help ensuring the project's sustainability. 20 | 21 | ## What sponsorships achieve 22 | 23 | Sponsorships make this project sustainable, as they buy the maintainers of this project time – a very scarce resource – which is spent on the development of new features, bug fixing, stability improvement, issue triage and general support. The biggest bottleneck in Open Source is time.[^3] 24 | 25 | [^3]: Making an Open Source project sustainable is exceptionally hard: maintainers burn out, projects are abandoned. That's not great and very unpredictable. The sponsorware model ensures that if you decide to use *{{ project_name }}*, you can be sure that bugs are fixed quickly and new features are added regularly. 26 | 27 | If you're unsure if you should sponsor this project, check out the list of [completed funding goals][goals completed] to learn whether you're already using features that were developed with the help of sponsorships. You're most likely using at least a handful of them, [thanks to our awesome sponsors][sponsors]! 28 | 29 | ## What's in it for me? 30 | 31 | ```python exec="1" session="insiders" 32 | data_source = "docs/insiders/goals.yml" 33 | ``` 34 | 35 | 36 | ```python exec="1" session="insiders" idprefix="" 37 | --8<-- "scripts/insiders.py" 38 | 39 | if unreleased_features: 40 | print( 41 | "The moment you [become a sponsor](#how-to-become-a-sponsor), you'll get **immediate " 42 | f"access to {len(unreleased_features)} additional features** that you can start using right away, and " 43 | "which are currently exclusively available to sponsors:\n" 44 | ) 45 | 46 | for feature in unreleased_features: 47 | feature.render(badge=True) 48 | {%- if author_username == "pawamoy" %} 49 | 50 | print( 51 | "\n\nThese are just the features related to this project. " 52 | "[See the complete feature list on the author's main Insiders page](https://pawamoy.github.io/insiders/#whats-in-it-for-me)." 53 | ) 54 | {%- endif %} 55 | else: 56 | print( 57 | "The moment you [become a sponsor](#how-to-become-a-sponsor), you'll get immediate " 58 | "access to all released features that you can start using right away, and " 59 | "which are exclusively available to sponsors. At this moment, there are no " 60 | "Insiders features for this project, but checkout the [next funding goals](#goals) " 61 | "to see what's coming 62 | {%- if author_username == "pawamoy" -%} 63 | , as well as **[the feature list for all Insiders projects](https://pawamoy.github.io/insiders/#whats-in-it-for-me).** 64 | {%- else -%} 65 | . 66 | {%- endif -%} 67 | " 68 | ) 69 | ``` 70 | 71 | 72 | Additionally, your sponsorship will give more weight to your upvotes on issues, helping us prioritize work items in our backlog. For more information on how we prioritize work, see this page: [Backlog management][backlog]. 73 | 74 | ## How to become a sponsor 75 | 76 | Thanks for your interest in sponsoring! In order to become an eligible sponsor with your GitHub account, visit [{{ author_username }}'s sponsor profile][github sponsor profile], and complete a sponsorship of **$10 a month or more**. You can use your individual or organization GitHub account for sponsoring. 77 | 78 | Sponsorships lower than $10 a month are also very much appreciated, and useful. They won't grant you access to Insiders, but they will be counted towards reaching sponsorship goals. Every sponsorship helps us implementing new features and releasing them to the public. 79 | 80 | **Important:** By default, when you're sponsoring **[@{{ author_username }}][github sponsor profile]** through a GitHub organization, all the publicly visible members of the organization will be invited to join our private repositories. If you wish to only grant access to a subset of users, please send a short email to {{ insiders_email }} with the name of your organization and the GitHub accounts of the users that should be granted access. 81 | 82 | **Tip:** to ensure that access is not tied to a particular individual GitHub account, you can create a bot account (i.e. a GitHub account that is not tied to a specific individual), and use this account for the sponsoring. After being granted access to our private repositories, the bot account can create private forks of our private repositories into your own organization, which all members of your organization will have access to. 83 | 84 | You can cancel your sponsorship anytime.[^5] 85 | 86 | [^5]: If you cancel your sponsorship, GitHub schedules a cancellation request which will become effective at the end of the billing cycle. This means that even though you cancel your sponsorship, you will keep your access to Insiders as long as your cancellation isn't effective. All charges are processed by GitHub through Stripe. As we don't receive any information regarding your payment, and GitHub doesn't offer refunds, sponsorships are non-refundable. 87 | 88 | 89 | [:octicons-heart-fill-24:{ .pulse }   Join our awesome sponsors][github sponsor profile]{ .md-button .md-button--primary } 90 | 91 |
    92 |
    93 |
    94 |
    95 |
    96 |
    97 |
    98 | 99 |
    100 | 101 | 102 | If you sponsor publicly, you're automatically added here with a link to your profile and avatar to show your support for *{{ project_name }}*. Alternatively, if you wish to keep your sponsorship private, you'll be a silent +1. You can select visibility during checkout and change it afterwards. 103 | 104 | 105 | ## Funding 106 | 107 | ### Goals 108 | 109 | The following section lists all funding goals. Each goal contains a list of features prefixed with a checkmark symbol, denoting whether a feature is :octicons-check-circle-fill-24:{ style="color: #00e676" } already available or :octicons-check-circle-fill-24:{ style="color: var(--md-default-fg-color--lightest)" } planned, but not yet implemented. When the funding goal is hit, the features are released for general availability. 110 | 111 | ```python exec="1" session="insiders" idprefix="" 112 | for goal in goals.values(): 113 | if not goal.complete: 114 | goal.render() 115 | ``` 116 | 117 | ### Goals completed 118 | 119 | This section lists all funding goals that were previously completed, which means that those features were part of Insiders, but are now generally available and can be used by all users. 120 | 121 | ```python exec="1" session="insiders" idprefix="" 122 | for goal in goals.values(): 123 | if goal.complete: 124 | goal.render() 125 | ``` 126 | 127 | ## Frequently asked questions 128 | 129 | ### Compatibility 130 | 131 | > We're building an open source project and want to allow outside collaborators to use *{{ project_name }}* locally without having access to Insiders. Is this still possible? 132 | 133 | Yes. Insiders is compatible with *{{ project_name }}*. Almost all new features and configuration options are either backward-compatible or implemented behind feature flags. Most Insiders features enhance the overall experience, though while these features add value for the users of your project, they shouldn't be necessary for previewing when making changes to content. 134 | 135 | ### Payment 136 | 137 | > We don't want to pay for sponsorship every month. Are there any other options? 138 | 139 | Yes. You can sponsor on a yearly basis by [switching your GitHub account to a yearly billing cycle][billing cycle]. If for some reason you cannot do that, you could also create a dedicated GitHub account with a yearly billing cycle, which you only use for sponsoring (some sponsors already do that). 140 | 141 | If you have any problems or further questions, please reach out to {{ insiders_email }}. 142 | 143 | ### Terms 144 | 145 | > Are we allowed to use Insiders under the same terms and conditions as *{{ project_name }}*? 146 | 147 | Yes. Whether you're an individual or a company, you may use *{{ project_name }} Insiders* precisely under the same terms as *{{ project_name }}*, which are given by the [{{ copyright_license }} license][license]. However, we kindly ask you to respect our **fair use policy**: 148 | 149 | - Please **don't distribute the source code** of Insiders. You may freely use it for public, private or commercial projects, privately fork or mirror it, but please don't make the source code public, as it would counteract the sponsorware strategy. 150 | - If you cancel your subscription, your access to the private repository is revoked, and you will miss out on all future updates of Insiders. However, you may **use the latest version** that's available to you **as long as you like**. Just remember that [GitHub deletes private forks][private forks]. 151 | 152 | [backlog]: https://pawamoy.github.io/backlog/ 153 | [insiders]: #what-is-insiders 154 | [sponsorship]: #what-sponsorships-achieve 155 | [sponsors]: #how-to-become-a-sponsor 156 | [features]: #whats-in-it-for-me 157 | [funding]: #funding 158 | [goals completed]: #goals-completed 159 | [github sponsor profile]: https://github.com/sponsors/{{ author_username }} 160 | [billing cycle]: https://docs.github.com/en/github/setting-up-and-managing-billing-and-payments-on-github/changing-the-duration-of-your-billing-cycle 161 | [license]: ../license.md 162 | [private forks]: https://docs.github.com/en/github/setting-up-and-managing-your-github-user-account/removing-a-collaborator-from-a-personal-repository 163 | 164 | 165 | 166 | -------------------------------------------------------------------------------- /project/docs/{% if insiders %}insiders{% endif %}/installation.md.jinja: -------------------------------------------------------------------------------- 1 | --- 2 | title: Getting started with Insiders 3 | --- 4 | 5 | # Getting started with Insiders 6 | 7 | *{{ project_name }} Insiders* is a compatible drop-in replacement for *{{ project_name }}*, and can be installed similarly using `pip` or `git`. Note that in order to access the Insiders repository, you need to [become an eligible sponsor][] of @{{ author_username }} on GitHub. 8 | 9 | ## Installation 10 | 11 | ### with the `insiders` tool 12 | 13 | [`insiders`][insiders-tool] is a tool that helps you keep up-to-date versions of Insiders projects in the PyPI index of your choice (self-hosted, Google registry, Artifactory, etc.). 14 | 15 | **We kindly ask that you do not upload the distributions to public registries, as it is against our [Terms of use][].** 16 | 17 | ### with pip (ssh/https) 18 | 19 | *{{ project_name }} Insiders* can be installed with `pip` [using SSH][install-pip-ssh]: 20 | 21 | ```bash 22 | pip install git+ssh://git@github.com/{{ author_username }}-insiders/{{ insiders_repository_name }}.git 23 | ``` 24 | 25 | Or using HTTPS: 26 | 27 | ```bash 28 | pip install git+https://${GH_TOKEN}@github.com/{{ author_username }}-insiders/{{ insiders_repository_name }}.git 29 | ``` 30 | 31 | >? NOTE: **How to get a GitHub personal access token?** The `GH_TOKEN` environment variable is a GitHub token. It can be obtained by creating a [personal access token][github-pat] for your GitHub account. It will give you access to the Insiders repository, programmatically, from the command line or GitHub Actions workflows: 32 | > 33 | > 1. Go to https://github.com/settings/tokens 34 | > 2. Click on [Generate a new token][github-pat-new] 35 | > 3. Enter a name and select the [`repo`][scopes] scope 36 | > 4. Generate the token and store it in a safe place 37 | > 38 | > Note that the personal access token must be kept secret at all times, as it allows the owner to access your private repositories. 39 | 40 | ### with Git 41 | 42 | Of course, you can use *{{ project_name }} Insiders* directly using Git: 43 | 44 | ``` 45 | git clone git@github.com:{{ author_username }}-insiders/{{ insiders_repository_name }} 46 | ``` 47 | 48 | When cloning with Git, the package must be installed: 49 | 50 | ``` 51 | pip install -e {{ insiders_repository_name }} 52 | ``` 53 | 54 | ## Upgrading 55 | 56 | When upgrading Insiders, you should always check the version of *{{ project_name }}* which makes up the first part of the version qualifier. For example, a version like `8.x.x.4.x.x` means that Insiders `4.x.x` is currently based on `8.x.x`. 57 | 58 | If the major version increased, it's a good idea to consult the [changelog][] and go through the steps to ensure your configuration is up to date and all necessary changes have been made. 59 | 60 | [become an eligible sponsor]: ./index.md#how-to-become-a-sponsor 61 | [changelog]: ./changelog.md 62 | [github-pat]: https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token 63 | [github-pat-new]: https://github.com/settings/tokens/new 64 | [insiders-tool]: https://pawamoy.github.io/insiders-project/ 65 | [install-pip-ssh]: https://docs.github.com/en/authentication/connecting-to-github-with-ssh 66 | [scopes]: https://docs.github.com/en/developers/apps/scopes-for-oauth-apps#available-scopes 67 | [terms of use]: ./index.md#terms 68 | -------------------------------------------------------------------------------- /project/duties.py.jinja: -------------------------------------------------------------------------------- 1 | """Development tasks.""" 2 | 3 | from __future__ import annotations 4 | 5 | import os 6 | import re 7 | import sys 8 | from contextlib import contextmanager 9 | {%- if insiders %} 10 | from functools import wraps 11 | {%- endif %} 12 | from importlib.metadata import version as pkgversion 13 | from pathlib import Path 14 | from typing import TYPE_CHECKING{% if insiders %}, Any, Callable{% endif %} 15 | 16 | from duty import duty, tools 17 | 18 | if TYPE_CHECKING: 19 | from collections.abc import Iterator 20 | 21 | from duty.context import Context 22 | 23 | 24 | PY_SRC_PATHS = (Path(_) for _ in ("src", "tests", "duties.py", "scripts")) 25 | PY_SRC_LIST = tuple(str(_) for _ in PY_SRC_PATHS) 26 | PY_SRC = " ".join(PY_SRC_LIST) 27 | CI = os.environ.get("CI", "0") in {"1", "true", "yes", ""} 28 | WINDOWS = os.name == "nt" 29 | PTY = not WINDOWS and not CI 30 | MULTIRUN = os.environ.get("MULTIRUN", "0") == "1" 31 | 32 | 33 | def pyprefix(title: str) -> str: 34 | if MULTIRUN: 35 | prefix = f"(python{sys.version_info.major}.{sys.version_info.minor})" 36 | return f"{prefix:14}{title}" 37 | return title 38 | 39 | 40 | {%- if insiders %} 41 | 42 | 43 | def not_from_insiders(func: Callable) -> Callable: 44 | @wraps(func) 45 | def wrapper(ctx: Context, *args: Any, **kwargs: Any) -> None: 46 | origin = ctx.run("git config --get remote.origin.url", silent=True) 47 | if "pawamoy-insiders/griffe" in origin: 48 | ctx.run( 49 | lambda: False, 50 | title="Not running this task from insiders repository (do that from public repo instead!)", 51 | ) 52 | return 53 | func(ctx, *args, **kwargs) 54 | 55 | return wrapper 56 | {%- endif %} 57 | 58 | 59 | @contextmanager 60 | def material_insiders() -> Iterator[bool]: 61 | if "+insiders" in pkgversion("mkdocs-material"): 62 | os.environ["MATERIAL_INSIDERS"] = "true" 63 | try: 64 | yield True 65 | finally: 66 | os.environ.pop("MATERIAL_INSIDERS") 67 | else: 68 | yield False 69 | 70 | 71 | def _get_changelog_version() -> str: 72 | changelog_version_re = re.compile(r"^## \[(\d+\.\d+\.\d+)\].*$") 73 | with Path(__file__).parent.joinpath("CHANGELOG.md").open("r", encoding="utf8") as file: 74 | return next(filter(bool, map(changelog_version_re.match, file))).group(1) # type: ignore[union-attr] 75 | 76 | 77 | @duty 78 | def changelog(ctx: Context, bump: str = "") -> None: 79 | """Update the changelog in-place with latest commits. 80 | 81 | Parameters: 82 | bump: Bump option passed to git-changelog. 83 | """ 84 | ctx.run(tools.git_changelog(bump=bump or None), title="Updating changelog") 85 | ctx.run(tools.yore.check(bump=bump or _get_changelog_version()), title="Checking legacy code") 86 | 87 | 88 | @duty(pre=["check-quality", "check-types", "check-docs", "check-api"]) 89 | def check(ctx: Context) -> None: 90 | """Check it all!""" 91 | 92 | 93 | @duty 94 | def check_quality(ctx: Context) -> None: 95 | """Check the code quality.""" 96 | ctx.run( 97 | tools.ruff.check(*PY_SRC_LIST, config="config/ruff.toml"), 98 | title=pyprefix("Checking code quality"), 99 | ) 100 | 101 | 102 | @duty 103 | def check_docs(ctx: Context) -> None: 104 | """Check if the documentation builds correctly.""" 105 | Path("htmlcov").mkdir(parents=True, exist_ok=True) 106 | Path("htmlcov/index.html").touch(exist_ok=True) 107 | with material_insiders(): 108 | ctx.run( 109 | tools.mkdocs.build(strict=True, verbose=True), 110 | title=pyprefix("Building documentation"), 111 | ) 112 | 113 | 114 | @duty 115 | def check_types(ctx: Context) -> None: 116 | """Check that the code is correctly typed.""" 117 | os.environ["FORCE_COLOR"] = "1" 118 | ctx.run( 119 | tools.mypy(*PY_SRC_LIST, config_file="config/mypy.ini"), 120 | title=pyprefix("Type-checking"), 121 | ) 122 | 123 | 124 | @duty 125 | def check_api(ctx: Context, *cli_args: str) -> None: 126 | """Check for API breaking changes.""" 127 | ctx.run( 128 | tools.griffe.check("{{ python_package_import_name }}", search=["src"], color=True).add_args(*cli_args), 129 | title="Checking for API breaking changes", 130 | nofail=True, 131 | ) 132 | 133 | 134 | @duty 135 | def docs(ctx: Context, *cli_args: str, host: str = "127.0.0.1", port: int = 8000) -> None: 136 | """Serve the documentation (localhost:8000). 137 | 138 | Parameters: 139 | host: The host to serve the docs from. 140 | port: The port to serve the docs on. 141 | """ 142 | with material_insiders(): 143 | ctx.run( 144 | tools.mkdocs.serve(dev_addr=f"{host}:{port}").add_args(*cli_args), 145 | title="Serving documentation", 146 | capture=False, 147 | ) 148 | 149 | 150 | @duty 151 | def docs_deploy(ctx: Context{% if insiders %}, *, force: bool = False{% endif %}) -> None: 152 | """Deploy the documentation to GitHub pages. 153 | {%- if insiders %} 154 | 155 | Parameters: 156 | force: Whether to force deployment, even from non-Insiders version. 157 | {% endif -%} 158 | """ 159 | os.environ["DEPLOY"] = "true" 160 | with material_insiders() as insiders: 161 | if not insiders: 162 | ctx.run(lambda: False, title="Not deploying docs without Material for MkDocs Insiders!") 163 | {%- if insiders %} 164 | origin = ctx.run("git config --get remote.origin.url", silent=True, allow_overrides=False) 165 | if "{{ author_username }}-insiders/{{ insiders_repository_name }}" in origin: 166 | ctx.run( 167 | "git remote add upstream git@github.com:{{ repository_namespace }}/{{ repository_name }}", 168 | silent=True, 169 | nofail=True, 170 | allow_overrides=False, 171 | ) 172 | ctx.run( 173 | tools.mkdocs.gh_deploy(remote_name="upstream", force=True), 174 | title="Deploying documentation", 175 | ) 176 | elif force: 177 | ctx.run( 178 | tools.mkdocs.gh_deploy(force=True), 179 | title="Deploying documentation", 180 | ) 181 | else: 182 | ctx.run( 183 | lambda: False, 184 | title="Not deploying docs from public repository (do that from insiders instead!)", 185 | nofail=True, 186 | ) 187 | {%- else %} 188 | ctx.run(tools.mkdocs.gh_deploy(), title="Deploying documentation") 189 | {%- endif %} 190 | 191 | 192 | @duty 193 | def format(ctx: Context) -> None: 194 | """Run formatting tools on the code.""" 195 | ctx.run( 196 | tools.ruff.check(*PY_SRC_LIST, config="config/ruff.toml", fix_only=True, exit_zero=True), 197 | title="Auto-fixing code", 198 | ) 199 | ctx.run(tools.ruff.format(*PY_SRC_LIST, config="config/ruff.toml"), title="Formatting code") 200 | 201 | 202 | @duty 203 | def build(ctx: Context) -> None: 204 | """Build source and wheel distributions.""" 205 | ctx.run( 206 | tools.build(), 207 | title="Building source and wheel distributions", 208 | pty=PTY, 209 | ) 210 | 211 | 212 | @duty 213 | {%- if insiders %} 214 | @not_from_insiders 215 | {%- endif %} 216 | def publish(ctx: Context) -> None: 217 | """Publish source and wheel distributions to PyPI.""" 218 | if not Path("dist").exists(): 219 | ctx.run("false", title="No distribution files found") 220 | dists = [str(dist) for dist in Path("dist").iterdir()] 221 | ctx.run( 222 | tools.twine.upload(*dists, skip_existing=True), 223 | title="Publishing source and wheel distributions to PyPI", 224 | pty=PTY, 225 | ) 226 | 227 | 228 | @duty{% if not testing %}(post=["build", "publish", "docs-deploy"]){% endif %} 229 | {%- if insiders %} 230 | @not_from_insiders 231 | {%- endif %} 232 | def release(ctx: Context, version: str = "") -> None: 233 | """Release a new Python package. 234 | 235 | Parameters: 236 | version: The new version number to use. 237 | """ 238 | if not (version := (version or input("> Version to release: ")).strip()): 239 | ctx.run("false", title="A version must be provided") 240 | ctx.run("git add pyproject.toml CHANGELOG.md", title="Staging files", pty=PTY) 241 | ctx.run(["git", "commit", "-m", f"chore: Prepare release {version}"], title="Committing changes", pty=PTY) 242 | ctx.run(f"git tag {version}", title="Tagging commit", pty=PTY) 243 | {%- if not testing %} 244 | ctx.run("git push", title="Pushing commits", pty=False) 245 | ctx.run("git push --tags", title="Pushing tags", pty=False) 246 | {%- endif %} 247 | 248 | 249 | @duty(silent=True, aliases=["cov"]) 250 | def coverage(ctx: Context) -> None: 251 | """Report coverage as text and HTML.""" 252 | ctx.run(tools.coverage.combine(), nofail=True) 253 | ctx.run(tools.coverage.report(rcfile="config/coverage.ini"), capture=False) 254 | ctx.run(tools.coverage.html(rcfile="config/coverage.ini")) 255 | 256 | 257 | @duty 258 | def test(ctx: Context, *cli_args: str, match: str = "") -> None: 259 | """Run the test suite. 260 | 261 | Parameters: 262 | match: A pytest expression to filter selected tests. 263 | """ 264 | py_version = f"{sys.version_info.major}{sys.version_info.minor}" 265 | os.environ["COVERAGE_FILE"] = f".coverage.{py_version}" 266 | ctx.run( 267 | tools.pytest( 268 | "tests", 269 | config_file="config/pytest.ini", 270 | select=match, 271 | color="yes", 272 | ).add_args("-n", "auto", *cli_args), 273 | title=pyprefix("Running tests"), 274 | ) 275 | -------------------------------------------------------------------------------- /project/mkdocs.yml.jinja: -------------------------------------------------------------------------------- 1 | site_name: "{{ project_name }}" 2 | site_description: "{{ project_description }}" 3 | site_url: "https://{{ repository_namespace }}.{{ repository_provider.rsplit('.', 1)[0] }}.io/{{ repository_name }}" 4 | repo_url: "https://{{ repository_provider }}/{{ repository_namespace }}/{{ repository_name }}" 5 | repo_name: "{{ repository_namespace }}/{{ repository_name }}" 6 | site_dir: "{% if repository_provider == 'github.com' %}site{% endif %}" 7 | watch: [mkdocs.yml, README.md, CONTRIBUTING.md, CHANGELOG.md, src/{{ python_package_import_name }}] 8 | copyright: Copyright © {{ copyright_date }} {{ copyright_holder }} 9 | edit_uri: edit/main/docs/ 10 | 11 | validation: 12 | omitted_files: warn 13 | absolute_links: warn 14 | unrecognized_links: warn 15 | 16 | nav: 17 | - Home: 18 | - Overview: index.md 19 | - Changelog: changelog.md 20 | - Credits: credits.md 21 | - License: license.md 22 | - API reference: reference/api.md 23 | - Development: 24 | - Contributing: contributing.md 25 | - Code of Conduct: code_of_conduct.md 26 | - Coverage report: coverage.md 27 | {%- if insiders %} 28 | - Insiders: 29 | - insiders/index.md 30 | - Getting started: 31 | - Installation: insiders/installation.md 32 | - Changelog: insiders/changelog.md 33 | {%- endif %} 34 | {%- if author_username == "pawamoy" %} 35 | - Author's website: https://{{ author_username }}.{{ repository_provider.rsplit('.', 1)[0] }}.io/ 36 | {%- endif %} 37 | 38 | theme: 39 | name: material 40 | custom_dir: docs/.overrides 41 | {%- if author_username == "pawamoy" %} 42 | icon: 43 | logo: material/currency-sign 44 | {%- endif %} 45 | features: 46 | - announce.dismiss 47 | - content.action.edit 48 | - content.action.view 49 | - content.code.annotate 50 | - content.code.copy 51 | - content.tooltips 52 | - navigation.footer 53 | - navigation.instant.preview 54 | - navigation.path 55 | - navigation.sections 56 | - navigation.tabs 57 | - navigation.tabs.sticky 58 | - navigation.top 59 | - search.highlight 60 | - search.suggest 61 | - toc.follow 62 | palette: 63 | - media: "(prefers-color-scheme)" 64 | toggle: 65 | icon: material/brightness-auto 66 | name: Switch to light mode 67 | - media: "(prefers-color-scheme: light)" 68 | scheme: default 69 | primary: teal 70 | accent: purple 71 | toggle: 72 | icon: material/weather-sunny 73 | name: Switch to dark mode 74 | - media: "(prefers-color-scheme: dark)" 75 | scheme: slate 76 | primary: black 77 | accent: lime 78 | toggle: 79 | icon: material/weather-night 80 | name: Switch to system preference 81 | 82 | extra_css: 83 | - css/material.css 84 | - css/mkdocstrings.css 85 | {%- if insiders %} 86 | - css/insiders.css 87 | {%- endif %} 88 | 89 | extra_javascript: 90 | - js/feedback.js 91 | 92 | markdown_extensions: 93 | - attr_list 94 | - admonition 95 | - callouts 96 | - footnotes 97 | - pymdownx.emoji: 98 | emoji_index: !!python/name:material.extensions.emoji.twemoji 99 | emoji_generator: !!python/name:material.extensions.emoji.to_svg 100 | - pymdownx.magiclink 101 | - pymdownx.snippets: 102 | base_path: [!relative $config_dir] 103 | check_paths: true 104 | - pymdownx.superfences 105 | - pymdownx.tabbed: 106 | alternate_style: true 107 | slugify: !!python/object/apply:pymdownx.slugs.slugify 108 | kwds: 109 | case: lower 110 | - pymdownx.tasklist: 111 | custom_checkbox: true 112 | - toc: 113 | permalink: {% if author_username == "pawamoy" %}"¤"{% else %}true{% endif %} 114 | 115 | plugins: 116 | - search 117 | - autorefs 118 | - markdown-exec 119 | - section-index 120 | - coverage 121 | - mkdocstrings: 122 | handlers: 123 | python: 124 | inventories: 125 | - https://docs.python.org/3/objects.inv 126 | paths: [src] 127 | options: 128 | backlinks: tree 129 | docstring_options: 130 | ignore_init_summary: true 131 | docstring_section_style: list 132 | filters: ["!^_"] 133 | heading_level: 1 134 | inherited_members: true 135 | merge_init_into_class: true 136 | separate_signature: true 137 | show_root_heading: true 138 | show_root_full_path: false 139 | show_signature_annotations: true 140 | show_source: {{ "false" if insiders else "true" }} 141 | show_symbol_type_heading: true 142 | show_symbol_type_toc: true 143 | signature_crossrefs: true 144 | summary: true 145 | - llmstxt: 146 | full_output: llms-full.txt 147 | sections: 148 | Usage: 149 | - index.md 150 | API: 151 | - reference/api.md 152 | - git-revision-date-localized: 153 | enabled: !ENV [DEPLOY, false] 154 | enable_creation_date: true 155 | type: timeago 156 | - minify: 157 | minify_html: !ENV [DEPLOY, false] 158 | - group: 159 | enabled: !ENV [MATERIAL_INSIDERS, false] 160 | plugins: 161 | - typeset 162 | 163 | extra: 164 | social: 165 | - icon: fontawesome/brands/{{ repository_provider.rsplit(".", 1)[0] }} 166 | link: https://{{ repository_provider }}/{{ author_username }} 167 | {%- if author_username == "pawamoy" %} 168 | - icon: fontawesome/brands/mastodon 169 | link: https://fosstodon.org/@{{ author_username }} 170 | - icon: fontawesome/brands/twitter 171 | link: https://twitter.com/{{ author_username }} 172 | {%- endif %} 173 | - icon: fontawesome/brands/gitter 174 | link: https://gitter.im/{{ repository_name }}/community 175 | - icon: fontawesome/brands/python 176 | link: https://pypi.org/project/{{ python_package_distribution_name }}/ 177 | analytics: 178 | feedback: 179 | title: Was this page helpful? 180 | ratings: 181 | - icon: material/emoticon-happy-outline 182 | name: This page was helpful 183 | data: 1 184 | note: Thanks for your feedback! 185 | - icon: material/emoticon-sad-outline 186 | name: This page could be improved 187 | data: 0 188 | note: Let us know how we can improve this page. 189 | -------------------------------------------------------------------------------- /project/pyproject.toml.jinja: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["pdm-backend"] 3 | build-backend = "pdm.backend" 4 | 5 | [project] 6 | name = "{{ python_package_distribution_name }}" 7 | description = "{{ project_description }}" 8 | authors = [{name = "{{ author_fullname }}", email = "{{ author_email }}"}] 9 | license = "{{ copyright_license }}" 10 | license-files = ["LICENSE"] 11 | readme = "README.md" 12 | requires-python = ">=3.9" 13 | keywords = [] 14 | dynamic = ["version"] 15 | classifiers = [ 16 | "Development Status :: 4 - Beta", 17 | "Intended Audience :: Developers", 18 | "Programming Language :: Python", 19 | "Programming Language :: Python :: 3", 20 | "Programming Language :: Python :: 3 :: Only", 21 | "Programming Language :: Python :: 3.9", 22 | "Programming Language :: Python :: 3.10", 23 | "Programming Language :: Python :: 3.11", 24 | "Programming Language :: Python :: 3.12", 25 | "Programming Language :: Python :: 3.13", 26 | "Programming Language :: Python :: 3.14", 27 | "Topic :: Documentation", 28 | "Topic :: Software Development", 29 | "Topic :: Utilities", 30 | "Typing :: Typed", 31 | ] 32 | dependencies = [] 33 | 34 | [project.urls] 35 | Homepage = "https://{{ repository_namespace }}.{{ repository_provider.rsplit('.', 1)[0] }}.io/{{ repository_name }}" 36 | Documentation = "https://{{ repository_namespace }}.{{ repository_provider.rsplit('.', 1)[0] }}.io/{{ repository_name }}" 37 | Changelog = "https://{{ repository_namespace }}.{{ repository_provider.rsplit('.', 1)[0] }}.io/{{ repository_name }}/changelog" 38 | Repository = "https://{{ repository_provider }}/{{ repository_namespace }}/{{ repository_name }}" 39 | Issues = "https://{{ repository_provider }}/{{ repository_namespace }}/{{ repository_name }}/issues" 40 | Discussions = "https://{{ repository_provider }}/{{ repository_namespace }}/{{ repository_name }}/discussions" 41 | Gitter = "https://gitter.im/{% if repository_namespace != author_username %}{{ repository_namespace }}/{{ repository_name }}{% else %}{{ repository_name }}/community{% endif %}" 42 | {%- if repository_provider == "github.com" %} 43 | Funding = "https://{{ repository_provider }}/sponsors/{{ author_username }}" 44 | {%- endif %} 45 | 46 | {% if python_package_command_line_name -%} 47 | [project.scripts] 48 | {{ python_package_command_line_name }} = "{{ python_package_import_name }}.cli:main" 49 | 50 | {% endif -%} 51 | 52 | [tool.pdm.version] 53 | source = "call" 54 | getter = "scripts.get_version:get_version" 55 | 56 | [tool.pdm.build] 57 | # Include as much as possible in the source distribution, to help redistributors. 58 | excludes = ["**/.pytest_cache", "**/.mypy_cache"] 59 | source-includes = [ 60 | "config", 61 | "docs", 62 | "scripts", 63 | "share", 64 | "tests", 65 | "duties.py", 66 | "mkdocs.yml", 67 | "*.md", 68 | "LICENSE", 69 | ] 70 | 71 | [tool.pdm.build.wheel-data] 72 | # Manual pages can be included in the wheel. 73 | # Depending on the installation tool, they will be accessible to users. 74 | # pipx supports it, uv does not yet, see https://github.com/astral-sh/uv/issues/4731. 75 | data = [ 76 | {path = "share/**/*", relative-to = "."}, 77 | ] 78 | 79 | [dependency-groups] 80 | maintain = [ 81 | "build>=1.2", 82 | "git-changelog>=2.5", 83 | "twine>=5.1", 84 | "yore>=0.3.3", 85 | ] 86 | ci = [ 87 | "duty>=1.6", 88 | "ruff>=0.4", 89 | "pytest>=8.2", 90 | "pytest-cov>=5.0", 91 | "pytest-randomly>=3.15", 92 | "pytest-xdist>=3.6", 93 | "mypy>=1.10", 94 | "types-markdown>=3.6", 95 | "types-pyyaml>=6.0", 96 | ] 97 | docs = [ 98 | "markdown-callouts>=0.4", 99 | "markdown-exec>=1.8", 100 | "mkdocs>=1.6", 101 | "mkdocs-coverage>=1.0", 102 | "mkdocs-git-revision-date-localized-plugin>=1.2", 103 | "mkdocs-llmstxt>=0.2", 104 | "mkdocs-material>=9.5", 105 | "mkdocs-minify-plugin>=0.8", 106 | "mkdocs-section-index>=0.3", 107 | "mkdocstrings[python]>=0.29", 108 | # YORE: EOL 3.10: Remove line. 109 | "tomli>=2.0; python_version < '3.11'", 110 | ] 111 | 112 | [tool.uv] 113 | default-groups = ["maintain", "ci", "docs"] 114 | -------------------------------------------------------------------------------- /project/scripts/gen_credits.py.jinja: -------------------------------------------------------------------------------- 1 | # Script to generate the project's credits. 2 | 3 | from __future__ import annotations 4 | 5 | import os 6 | import sys 7 | from collections import defaultdict 8 | from collections.abc import Iterable 9 | from importlib.metadata import distributions 10 | from itertools import chain 11 | from pathlib import Path 12 | from textwrap import dedent 13 | from typing import Union 14 | 15 | from jinja2 import StrictUndefined 16 | from jinja2.sandbox import SandboxedEnvironment 17 | from packaging.requirements import Requirement 18 | 19 | # YORE: EOL 3.10: Replace block with line 2. 20 | if sys.version_info >= (3, 11): 21 | import tomllib 22 | else: 23 | import tomli as tomllib 24 | 25 | project_dir = Path(os.getenv("MKDOCS_CONFIG_DIR", ".")) 26 | with project_dir.joinpath("pyproject.toml").open("rb") as pyproject_file: 27 | pyproject = tomllib.load(pyproject_file) 28 | project = pyproject["project"] 29 | project_name = project["name"] 30 | devdeps = [dep for group in pyproject["dependency-groups"].values() for dep in group if not dep.startswith("-e")] 31 | 32 | PackageMetadata = dict[str, Union[str, Iterable[str]]] 33 | Metadata = dict[str, PackageMetadata] 34 | 35 | 36 | def _merge_fields(metadata: dict) -> PackageMetadata: 37 | fields = defaultdict(list) 38 | for header, value in metadata.items(): 39 | fields[header.lower()].append(value.strip()) 40 | return { 41 | field: value if len(value) > 1 or field in ("classifier", "requires-dist") else value[0] 42 | for field, value in fields.items() 43 | } 44 | 45 | 46 | def _norm_name(name: str) -> str: 47 | return name.replace("_", "-").replace(".", "-").lower() 48 | 49 | 50 | def _requirements(deps: list[str]) -> dict[str, Requirement]: 51 | return {_norm_name((req := Requirement(dep)).name): req for dep in deps} 52 | 53 | 54 | def _extra_marker(req: Requirement) -> str | None: 55 | if not req.marker: 56 | return None 57 | try: 58 | return next(marker[2].value for marker in req.marker._markers if getattr(marker[0], "value", None) == "extra") 59 | except StopIteration: 60 | return None 61 | 62 | 63 | def _get_metadata() -> Metadata: 64 | metadata = {} 65 | for pkg in distributions(): 66 | name = _norm_name(pkg.name) # type: ignore[attr-defined,unused-ignore] 67 | metadata[name] = _merge_fields(pkg.metadata) # type: ignore[arg-type] 68 | metadata[name]["spec"] = set() 69 | metadata[name]["extras"] = set() 70 | metadata[name].setdefault("summary", "") 71 | _set_license(metadata[name]) 72 | return metadata 73 | 74 | 75 | def _set_license(metadata: PackageMetadata) -> None: 76 | license_field = metadata.get("license-expression", metadata.get("license", "")) 77 | license_name = license_field if isinstance(license_field, str) else " + ".join(license_field) 78 | check_classifiers = license_name in ("UNKNOWN", "Dual License", "") or license_name.count("\n") 79 | if check_classifiers: 80 | license_names = [] 81 | for classifier in metadata["classifier"]: 82 | if classifier.startswith("License ::"): 83 | license_names.append(classifier.rsplit("::", 1)[1].strip()) 84 | license_name = " + ".join(license_names) 85 | metadata["license"] = license_name or "?" 86 | 87 | 88 | def _get_deps(base_deps: dict[str, Requirement], metadata: Metadata) -> Metadata: 89 | deps = {} 90 | for dep_name, dep_req in base_deps.items(): 91 | if dep_name not in metadata or dep_name == "{{ python_package_distribution_name }}": 92 | continue 93 | metadata[dep_name]["spec"] |= {str(spec) for spec in dep_req.specifier} # type: ignore[operator] 94 | metadata[dep_name]["extras"] |= dep_req.extras # type: ignore[operator] 95 | deps[dep_name] = metadata[dep_name] 96 | 97 | again = True 98 | while again: 99 | again = False 100 | for pkg_name in metadata: 101 | if pkg_name in deps: 102 | for pkg_dependency in metadata[pkg_name].get("requires-dist", []): 103 | requirement = Requirement(pkg_dependency) 104 | dep_name = _norm_name(requirement.name) 105 | extra_marker = _extra_marker(requirement) 106 | if ( 107 | dep_name in metadata 108 | and dep_name not in deps 109 | and dep_name != project["name"] 110 | and (not extra_marker or extra_marker in deps[pkg_name]["extras"]) 111 | ): 112 | metadata[dep_name]["spec"] |= {str(spec) for spec in requirement.specifier} # type: ignore[operator] 113 | deps[dep_name] = metadata[dep_name] 114 | again = True 115 | 116 | return deps 117 | 118 | 119 | def _render_credits() -> str: 120 | metadata = _get_metadata() 121 | dev_dependencies = _get_deps(_requirements(devdeps), metadata) 122 | prod_dependencies = _get_deps( 123 | _requirements( 124 | chain( # type: ignore[arg-type] 125 | project.get("dependencies", []), 126 | chain(*project.get("optional-dependencies", {}).values()), 127 | ), 128 | ), 129 | metadata, 130 | ) 131 | 132 | template_data = { 133 | "project_name": project_name, 134 | "prod_dependencies": sorted(prod_dependencies.values(), key=lambda dep: str(dep["name"]).lower()), 135 | "dev_dependencies": sorted(dev_dependencies.values(), key=lambda dep: str(dep["name"]).lower()), 136 | "more_credits": "{% if author_username == "pawamoy" %}http://pawamoy.github.io/credits/{% endif %}", 137 | } 138 | template_text = dedent( 139 | """{% raw %} 140 | # Credits 141 | 142 | These projects were used to build *{{ project_name }}*. **Thank you!** 143 | 144 | [Python](https://www.python.org/) | 145 | [uv](https://github.com/astral-sh/uv) | 146 | [copier-uv](https://github.com/pawamoy/copier-uv) 147 | 148 | {% macro dep_line(dep) -%} 149 | [{{ dep.name }}](https://pypi.org/project/{{ dep.name }}/) | {{ dep.summary }} | {{ ("`" ~ dep.spec|sort(reverse=True)|join(", ") ~ "`") if dep.spec else "" }} | `{{ dep.version }}` | {{ dep.license }} 150 | {%- endmacro %} 151 | 152 | {% if prod_dependencies -%} 153 | ### Runtime dependencies 154 | 155 | Project | Summary | Version (accepted) | Version (last resolved) | License 156 | ------- | ------- | ------------------ | ----------------------- | ------- 157 | {% for dep in prod_dependencies -%} 158 | {{ dep_line(dep) }} 159 | {% endfor %} 160 | 161 | {% endif -%} 162 | {% if dev_dependencies -%} 163 | ### Development dependencies 164 | 165 | Project | Summary | Version (accepted) | Version (last resolved) | License 166 | ------- | ------- | ------------------ | ----------------------- | ------- 167 | {% for dep in dev_dependencies -%} 168 | {{ dep_line(dep) }} 169 | {% endfor %} 170 | 171 | {% endif -%} 172 | {% if more_credits %}**[More credits from the author]({{ more_credits }})**{% endif %} 173 | {% endraw %}""", 174 | ) 175 | jinja_env = SandboxedEnvironment(undefined=StrictUndefined) 176 | return jinja_env.from_string(template_text).render(**template_data) 177 | 178 | 179 | print(_render_credits()) 180 | -------------------------------------------------------------------------------- /project/scripts/get_version.py.jinja: -------------------------------------------------------------------------------- 1 | # Get current project version from Git tags or changelog. 2 | 3 | import re 4 | from contextlib import suppress 5 | from pathlib import Path 6 | 7 | from pdm.backend.hooks.version import SCMVersion, Version, default_version_formatter, get_version_from_scm 8 | 9 | _root = Path(__file__).parent.parent 10 | _changelog = _root / "CHANGELOG.md" 11 | _changelog_version_re = re.compile(r"^## \[(\d+\.\d+\.\d+)\].*$") 12 | _default_scm_version = SCMVersion(Version("0.0.0"), None, False, None, None) # noqa: FBT003 13 | 14 | 15 | def get_version() -> str: 16 | scm_version = get_version_from_scm(_root) or _default_scm_version 17 | if scm_version.version <= Version("0.1"): # Missing Git tags? 18 | with suppress(OSError, StopIteration): # noqa: SIM117 19 | with _changelog.open("r", encoding="utf8") as file: 20 | match = next(filter(None, map(_changelog_version_re.match, file))) 21 | scm_version = scm_version._replace(version=Version(match.group(1))) 22 | return default_version_formatter(scm_version) 23 | 24 | 25 | if __name__ == "__main__": 26 | print(get_version()) 27 | -------------------------------------------------------------------------------- /project/scripts/make: -------------------------------------------------------------------------------- 1 | make.py -------------------------------------------------------------------------------- /project/scripts/make.py.jinja: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from __future__ import annotations 3 | 4 | import os 5 | import shutil 6 | import subprocess 7 | import sys 8 | from contextlib import contextmanager 9 | from pathlib import Path 10 | from textwrap import dedent 11 | from typing import TYPE_CHECKING, Any 12 | 13 | if TYPE_CHECKING: 14 | from collections.abc import Iterator 15 | 16 | 17 | PYTHON_VERSIONS = os.getenv("PYTHON_VERSIONS", "3.9 3.10 3.11 3.12 3.13 3.14").split() 18 | 19 | 20 | def shell(cmd: str, *, capture_output: bool = False, **kwargs: Any) -> str | None: 21 | """Run a shell command.""" 22 | if capture_output: 23 | return subprocess.check_output(cmd, shell=True, text=True, **kwargs) # noqa: S602 24 | subprocess.run(cmd, shell=True, check=True, stderr=subprocess.STDOUT, **kwargs) # noqa: S602 25 | return None 26 | 27 | 28 | @contextmanager 29 | def environ(**kwargs: str) -> Iterator[None]: 30 | """Temporarily set environment variables.""" 31 | original = dict(os.environ) 32 | os.environ.update(kwargs) 33 | try: 34 | yield 35 | finally: 36 | os.environ.clear() 37 | os.environ.update(original) 38 | 39 | 40 | def uv_install(venv: Path) -> None: 41 | """Install dependencies using uv.""" 42 | with environ(UV_PROJECT_ENVIRONMENT=str(venv), PYO3_USE_ABI3_FORWARD_COMPATIBILITY="1"): 43 | if "CI" in os.environ: 44 | shell("uv sync --no-editable") 45 | else: 46 | shell("uv sync") 47 | 48 | 49 | def setup() -> None: 50 | """Setup the project.""" 51 | if not shutil.which("uv"): 52 | raise ValueError("make: setup: uv must be installed, see https://github.com/astral-sh/uv") 53 | 54 | print("Installing dependencies (default environment)") 55 | default_venv = Path(".venv") 56 | if not default_venv.exists(): 57 | shell("uv venv") 58 | uv_install(default_venv) 59 | 60 | if PYTHON_VERSIONS: 61 | for version in PYTHON_VERSIONS: 62 | print(f"\nInstalling dependencies (python{version})") 63 | venv_path = Path(f".venvs/{version}") 64 | if not venv_path.exists(): 65 | shell(f"uv venv --python {version} {venv_path}") 66 | with environ(UV_PROJECT_ENVIRONMENT=str(venv_path.resolve())): 67 | uv_install(venv_path) 68 | 69 | 70 | def run(version: str, cmd: str, *args: str, **kwargs: Any) -> None: 71 | """Run a command in a virtual environment.""" 72 | kwargs = {"check": True, **kwargs} 73 | uv_run = ["uv", "run", "--no-sync"] 74 | if version == "default": 75 | with environ(UV_PROJECT_ENVIRONMENT=".venv"): 76 | subprocess.run([*uv_run, cmd, *args], **kwargs) # noqa: S603, PLW1510 77 | else: 78 | with environ(UV_PROJECT_ENVIRONMENT=f".venvs/{version}", MULTIRUN="1"): 79 | subprocess.run([*uv_run, cmd, *args], **kwargs) # noqa: S603, PLW1510 80 | 81 | 82 | def multirun(cmd: str, *args: str, **kwargs: Any) -> None: 83 | """Run a command for all configured Python versions.""" 84 | if PYTHON_VERSIONS: 85 | for version in PYTHON_VERSIONS: 86 | run(version, cmd, *args, **kwargs) 87 | else: 88 | run("default", cmd, *args, **kwargs) 89 | 90 | 91 | def allrun(cmd: str, *args: str, **kwargs: Any) -> None: 92 | """Run a command in all virtual environments.""" 93 | run("default", cmd, *args, **kwargs) 94 | if PYTHON_VERSIONS: 95 | multirun(cmd, *args, **kwargs) 96 | 97 | 98 | def clean() -> None: 99 | """Delete build artifacts and cache files.""" 100 | paths_to_clean = ["build", "dist", "htmlcov", "site", ".coverage*", ".pdm-build"] 101 | for path in paths_to_clean: 102 | shutil.rmtree(path, ignore_errors=True) 103 | 104 | cache_dirs = {".cache", ".pytest_cache", ".mypy_cache", ".ruff_cache", "__pycache__"} 105 | for dirpath in Path(".").rglob("*/"): 106 | if dirpath.parts[0] not in (".venv", ".venvs") and dirpath.name in cache_dirs: 107 | shutil.rmtree(dirpath, ignore_errors=True) 108 | 109 | 110 | def vscode() -> None: 111 | """Configure VSCode to work on this project.""" 112 | shutil.copytree("config/vscode", ".vscode", dirs_exist_ok=True) 113 | 114 | 115 | def main() -> int: 116 | """Main entry point.""" 117 | args = list(sys.argv[1:]) 118 | if not args or args[0] == "help": 119 | if len(args) > 1: 120 | run("default", "duty", "--help", args[1]) 121 | else: 122 | print( 123 | dedent( 124 | """ 125 | Available commands 126 | help Print this help. Add task name to print help. 127 | setup Setup all virtual environments (install dependencies). 128 | run Run a command in the default virtual environment. 129 | multirun Run a command for all configured Python versions. 130 | allrun Run a command in all virtual environments. 131 | 3.x Run a command in the virtual environment for Python 3.x. 132 | clean Delete build artifacts and cache files. 133 | vscode Configure VSCode to work on this project. 134 | """, 135 | ), 136 | flush=True, 137 | ) 138 | if os.path.exists(".venv"): 139 | print("\nAvailable tasks", flush=True) 140 | run("default", "duty", "--list") 141 | return 0 142 | 143 | while args: 144 | cmd = args.pop(0) 145 | 146 | if cmd == "run": 147 | run("default", *args) 148 | return 0 149 | 150 | if cmd == "multirun": 151 | multirun(*args) 152 | return 0 153 | 154 | if cmd == "allrun": 155 | allrun(*args) 156 | return 0 157 | 158 | if cmd.startswith("3."): 159 | run(cmd, *args) 160 | return 0 161 | 162 | opts = [] 163 | while args and (args[0].startswith("-") or "=" in args[0]): 164 | opts.append(args.pop(0)) 165 | 166 | if cmd == "clean": 167 | clean() 168 | elif cmd == "setup": 169 | setup() 170 | elif cmd == "vscode": 171 | vscode() 172 | elif cmd == "check": 173 | multirun("duty", "check-quality", "check-types", "check-docs") 174 | run("default", "duty", "check-api") 175 | elif cmd in {"check-quality", "check-docs", "check-types", "test"}: 176 | multirun("duty", cmd, *opts) 177 | else: 178 | run("default", "duty", cmd, *opts) 179 | 180 | return 0 181 | 182 | 183 | if __name__ == "__main__": 184 | try: 185 | sys.exit(main()) 186 | except subprocess.CalledProcessError as process: 187 | if process.output: 188 | print(process.output, file=sys.stderr) 189 | sys.exit(process.returncode) 190 | -------------------------------------------------------------------------------- /project/scripts/{% if insiders %}insiders.py{% endif %}.jinja: -------------------------------------------------------------------------------- 1 | # Functions related to Insiders funding goals. 2 | 3 | from __future__ import annotations 4 | 5 | import json 6 | import logging 7 | import os 8 | import posixpath 9 | from dataclasses import dataclass 10 | from datetime import date, datetime, timedelta 11 | from itertools import chain 12 | from pathlib import Path 13 | from typing import TYPE_CHECKING, cast 14 | from urllib.error import HTTPError 15 | from urllib.parse import urljoin 16 | from urllib.request import urlopen 17 | 18 | import yaml 19 | 20 | if TYPE_CHECKING: 21 | from collections.abc import Iterable 22 | 23 | logger = logging.getLogger(f"mkdocs.logs.{__name__}") 24 | 25 | 26 | def human_readable_amount(amount: int) -> str: 27 | str_amount = str(amount) 28 | if len(str_amount) >= 4: # noqa: PLR2004 29 | return f"{str_amount[: len(str_amount) - 3]},{str_amount[-3:]}" 30 | return str_amount 31 | 32 | 33 | @dataclass 34 | class Project: 35 | name: str 36 | url: str 37 | 38 | 39 | @dataclass 40 | class Feature: 41 | name: str 42 | ref: str | None 43 | since: date | None 44 | project: Project | None 45 | 46 | def url(self, rel_base: str = "..") -> str | None: # noqa: D102 47 | if not self.ref: 48 | return None 49 | if self.project: 50 | rel_base = self.project.url 51 | return posixpath.join(rel_base, self.ref.lstrip("/")) 52 | 53 | def render(self, rel_base: str = "..", *, badge: bool = False) -> None: # noqa: D102 54 | new = "" 55 | if badge: 56 | recent = self.since and date.today() - self.since <= timedelta(days=60) # noqa: DTZ011 57 | if recent: 58 | ft_date = self.since.strftime("%B %d, %Y") # type: ignore[union-attr] 59 | new = f' :material-alert-decagram:{{ "{{" }} .new-feature .vibrate title="Added on {ft_date}" {{ "}}" }}' 60 | project = f"[{self.project.name}]({self.project.url}) — " if self.project else "" 61 | feature = f"[{self.name}]({self.url(rel_base)})" if self.ref else self.name 62 | print(f"- [{'x' if self.since else ' '}] {project}{feature}{new}") 63 | 64 | 65 | @dataclass 66 | class Goal: 67 | name: str 68 | amount: int 69 | features: list[Feature] 70 | complete: bool = False 71 | 72 | @property 73 | def human_readable_amount(self) -> str: # noqa: D102 74 | return human_readable_amount(self.amount) 75 | 76 | def render(self, rel_base: str = "..") -> None: # noqa: D102 77 | print(f"#### $ {self.human_readable_amount} — {self.name}\n") 78 | if self.features: 79 | for feature in self.features: 80 | feature.render(rel_base) 81 | print("") 82 | else: 83 | print("There are no features in this goal for this project. ") 84 | {%- if author_username == "pawamoy" %} 85 | print( 86 | "[See the features in this goal **for all Insiders projects.**]" 87 | f"(https://pawamoy.github.io/insiders/#{self.amount}-{self.name.lower().replace(' ', '-')})", 88 | ) 89 | {%- endif %} 90 | 91 | 92 | def load_goals(data: str, funding: int = 0, project: Project | None = None) -> dict[int, Goal]: 93 | goals_data = yaml.safe_load(data)["goals"] 94 | return { 95 | amount: Goal( 96 | name=goal_data["name"], 97 | amount=amount, 98 | complete=funding >= amount, 99 | features=[ 100 | Feature( 101 | name=feature_data["name"], 102 | ref=feature_data.get("ref"), 103 | since=feature_data.get("since") and datetime.strptime(feature_data["since"], "%Y/%m/%d").date(), # noqa: DTZ007 104 | project=project, 105 | ) 106 | for feature_data in goal_data["features"] 107 | ], 108 | ) 109 | for amount, goal_data in goals_data.items() 110 | } 111 | 112 | 113 | def _load_goals_from_disk(path: str, funding: int = 0) -> dict[int, Goal]: 114 | project_dir = os.getenv("MKDOCS_CONFIG_DIR", ".") 115 | try: 116 | data = Path(project_dir, path).read_text() 117 | except OSError as error: 118 | raise RuntimeError(f"Could not load data from disk: {path}") from error 119 | return load_goals(data, funding) 120 | 121 | 122 | def _load_goals_from_url(source_data: tuple[str, str, str], funding: int = 0) -> dict[int, Goal]: 123 | project_name, project_url, data_fragment = source_data 124 | data_url = urljoin(project_url, data_fragment) 125 | try: 126 | with urlopen(data_url) as response: # noqa: S310 127 | data = response.read() 128 | except HTTPError as error: 129 | raise RuntimeError(f"Could not load data from network: {data_url}") from error 130 | return load_goals(data, funding, project=Project(name=project_name, url=project_url)) 131 | 132 | 133 | def _load_goals(source: str | tuple[str, str, str], funding: int = 0) -> dict[int, Goal]: 134 | if isinstance(source, str): 135 | return _load_goals_from_disk(source, funding) 136 | return _load_goals_from_url(source, funding) 137 | 138 | 139 | def funding_goals(source: str | list[str | tuple[str, str, str]], funding: int = 0) -> dict[int, Goal]: 140 | if isinstance(source, str): 141 | return _load_goals_from_disk(source, funding) 142 | goals = {} 143 | for src in source: 144 | source_goals = _load_goals(src, funding) 145 | for amount, goal in source_goals.items(): 146 | if amount not in goals: 147 | goals[amount] = goal 148 | else: 149 | goals[amount].features.extend(goal.features) 150 | return {amount: goals[amount] for amount in sorted(goals)} 151 | 152 | 153 | def feature_list(goals: Iterable[Goal]) -> list[Feature]: 154 | return list(chain.from_iterable(goal.features for goal in goals)) 155 | 156 | 157 | def load_json(url: str) -> str | list | dict: 158 | with urlopen(url) as response: # noqa: S310 159 | return json.loads(response.read().decode()) 160 | 161 | 162 | data_source = globals()["data_source"] 163 | sponsor_url = "https://github.com/sponsors/{{ author_username }}" 164 | data_url = "https://raw.githubusercontent.com/{{ author_username }}/sponsors/main" 165 | numbers: dict[str, int] = load_json(f"{data_url}/numbers.json") # type: ignore[assignment] 166 | sponsors: list[dict] = load_json(f"{data_url}/sponsors.json") # type: ignore[assignment] 167 | current_funding = numbers["total"] 168 | sponsors_count = numbers["count"] 169 | goals = funding_goals(data_source, funding=current_funding) 170 | ongoing_goals = [goal for goal in goals.values() if not goal.complete] 171 | unreleased_features = sorted( 172 | (ft for ft in feature_list(ongoing_goals) if ft.since), 173 | key=lambda ft: cast("date", ft.since), 174 | reverse=True, 175 | ) 176 | -------------------------------------------------------------------------------- /project/src/{{python_package_import_name}}/__init__.py.jinja: -------------------------------------------------------------------------------- 1 | """{{ project_name }} package. 2 | 3 | {{ project_description }} 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | {%- if python_package_command_line_name %} 9 | 10 | from {{ python_package_import_name }}._internal.cli import get_parser, main 11 | 12 | __all__: list[str] = ["get_parser", "main"] 13 | {%- else %} 14 | __all__: list[str] = [] 15 | {%- endif %} 16 | -------------------------------------------------------------------------------- /project/src/{{python_package_import_name}}/_internal/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawamoy/copier-uv/5a0e4917405e7518f2c5c367580b75b9411dd0a3/project/src/{{python_package_import_name}}/_internal/__init__.py -------------------------------------------------------------------------------- /project/src/{{python_package_import_name}}/_internal/debug.py.jinja: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import platform 5 | import sys 6 | from dataclasses import dataclass 7 | from importlib import metadata 8 | 9 | 10 | @dataclass 11 | class _Variable: 12 | """Dataclass describing an environment variable.""" 13 | 14 | name: str 15 | """Variable name.""" 16 | value: str 17 | """Variable value.""" 18 | 19 | 20 | @dataclass 21 | class _Package: 22 | """Dataclass describing a Python package.""" 23 | 24 | name: str 25 | """Package name.""" 26 | version: str 27 | """Package version.""" 28 | 29 | 30 | @dataclass 31 | class _Environment: 32 | """Dataclass to store environment information.""" 33 | 34 | interpreter_name: str 35 | """Python interpreter name.""" 36 | interpreter_version: str 37 | """Python interpreter version.""" 38 | interpreter_path: str 39 | """Path to Python executable.""" 40 | platform: str 41 | """Operating System.""" 42 | packages: list[_Package] 43 | """Installed packages.""" 44 | variables: list[_Variable] 45 | """Environment variables.""" 46 | 47 | 48 | def _interpreter_name_version() -> tuple[str, str]: 49 | if hasattr(sys, "implementation"): 50 | impl = sys.implementation.version 51 | version = f"{impl.major}.{impl.minor}.{impl.micro}" 52 | kind = impl.releaselevel 53 | if kind != "final": 54 | version += kind[0] + str(impl.serial) 55 | return sys.implementation.name, version 56 | return "", "0.0.0" 57 | 58 | 59 | def _get_version(dist: str = "{{ python_package_distribution_name }}") -> str: 60 | """Get version of the given distribution. 61 | 62 | Parameters: 63 | dist: A distribution name. 64 | 65 | Returns: 66 | A version number. 67 | """ 68 | try: 69 | return metadata.version(dist) 70 | except metadata.PackageNotFoundError: 71 | return "0.0.0" 72 | 73 | 74 | def _get_debug_info() -> _Environment: 75 | """Get debug/environment information. 76 | 77 | Returns: 78 | Environment information. 79 | """ 80 | py_name, py_version = _interpreter_name_version() 81 | packages = ["{{ python_package_distribution_name }}"] 82 | variables = ["PYTHONPATH", *[var for var in os.environ if var.startswith("{{ python_package_distribution_name.upper().replace("-", "_") }}")]] 83 | return _Environment( 84 | interpreter_name=py_name, 85 | interpreter_version=py_version, 86 | interpreter_path=sys.executable, 87 | platform=platform.platform(), 88 | variables=[_Variable(var, val) for var in variables if (val := os.getenv(var))], 89 | packages=[_Package(pkg, _get_version(pkg)) for pkg in packages], 90 | ) 91 | 92 | 93 | def _print_debug_info() -> None: 94 | """Print debug/environment information.""" 95 | info = _get_debug_info() 96 | print(f"- __System__: {info.platform}") 97 | print(f"- __Python__: {info.interpreter_name} {info.interpreter_version} ({info.interpreter_path})") 98 | print("- __Environment variables__:") 99 | for var in info.variables: 100 | print(f" - `{var.name}`: `{var.value}`") 101 | print("- __Installed packages__:") 102 | for pkg in info.packages: 103 | print(f" - `{pkg.name}` v{pkg.version}") 104 | 105 | 106 | if __name__ == "__main__": 107 | _print_debug_info() 108 | -------------------------------------------------------------------------------- /project/src/{{python_package_import_name}}/_internal/{% if python_package_command_line_name %}cli.py{% endif %}.jinja: -------------------------------------------------------------------------------- 1 | # Why does this file exist, and why not put this in `__main__`? 2 | # 3 | # You might be tempted to import things from `__main__` later, 4 | # but that will cause problems: the code will get executed twice: 5 | # 6 | # - When you run `python -m {{ python_package_import_name }}` python will execute 7 | # `__main__.py` as a script. That means there won't be any 8 | # `{{ python_package_import_name }}.__main__` in `sys.modules`. 9 | # - When you import `__main__` it will get executed again (as a module) because 10 | # there's no `{{ python_package_import_name }}.__main__` in `sys.modules`. 11 | 12 | from __future__ import annotations 13 | 14 | import argparse 15 | import sys 16 | from typing import Any 17 | 18 | from {{ python_package_import_name }}._internal import debug 19 | 20 | 21 | class _DebugInfo(argparse.Action): 22 | def __init__(self, nargs: int | str | None = 0, **kwargs: Any) -> None: 23 | super().__init__(nargs=nargs, **kwargs) 24 | 25 | def __call__(self, *args: Any, **kwargs: Any) -> None: # noqa: ARG002 26 | debug._print_debug_info() 27 | sys.exit(0) 28 | 29 | 30 | def get_parser() -> argparse.ArgumentParser: 31 | """Return the CLI argument parser. 32 | 33 | Returns: 34 | An argparse parser. 35 | """ 36 | parser = argparse.ArgumentParser(prog="{{ python_package_command_line_name }}") 37 | parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {debug._get_version()}") 38 | parser.add_argument("--debug-info", action=_DebugInfo, help="Print debug information.") 39 | return parser 40 | 41 | 42 | def main(args: list[str] | None = None) -> int: 43 | """Run the main program. 44 | 45 | This function is executed when you type `{{ python_package_command_line_name }}` or `python -m {{ python_package_import_name }}`. 46 | 47 | Parameters: 48 | args: Arguments passed from the command line. 49 | 50 | Returns: 51 | An exit code. 52 | """ 53 | parser = get_parser() 54 | opts = parser.parse_args(args=args) 55 | print(opts) 56 | return 0 57 | -------------------------------------------------------------------------------- /project/src/{{python_package_import_name}}/py.typed.jinja: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pawamoy/copier-uv/5a0e4917405e7518f2c5c367580b75b9411dd0a3/project/src/{{python_package_import_name}}/py.typed.jinja -------------------------------------------------------------------------------- /project/src/{{python_package_import_name}}/{% if python_package_command_line_name %}__main__.py{% endif %}.jinja: -------------------------------------------------------------------------------- 1 | """Entry-point module, in case you use `python -m {{ python_package_import_name }}`. 2 | 3 | Why does this file exist, and why `__main__`? For more info, read: 4 | 5 | - https://www.python.org/dev/peps/pep-0338/ 6 | - https://docs.python.org/3/using/cmdline.html#cmdoption-m 7 | """ 8 | 9 | import sys 10 | 11 | from {{ python_package_import_name }}._internal.cli import main 12 | 13 | if __name__ == "__main__": 14 | sys.exit(main(sys.argv[1:])) 15 | -------------------------------------------------------------------------------- /project/tests/__init__.py.jinja: -------------------------------------------------------------------------------- 1 | """Tests suite for `{{ python_package_import_name }}`.""" 2 | 3 | from pathlib import Path 4 | 5 | TESTS_DIR = Path(__file__).parent 6 | TMP_DIR = TESTS_DIR / "tmp" 7 | FIXTURES_DIR = TESTS_DIR / "fixtures" 8 | -------------------------------------------------------------------------------- /project/tests/conftest.py.jinja: -------------------------------------------------------------------------------- 1 | """Configuration for the pytest test suite.""" 2 | -------------------------------------------------------------------------------- /project/tests/test_api.py.jinja: -------------------------------------------------------------------------------- 1 | """Tests for our own API exposition.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections import defaultdict 6 | from pathlib import Path 7 | from typing import TYPE_CHECKING 8 | 9 | import griffe 10 | import pytest 11 | from mkdocstrings import Inventory 12 | 13 | import {{ python_package_import_name }} 14 | 15 | if TYPE_CHECKING: 16 | from collections.abc import Iterator 17 | 18 | 19 | @pytest.fixture(name="loader", scope="module") 20 | def _fixture_loader() -> griffe.GriffeLoader: 21 | loader = griffe.GriffeLoader() 22 | loader.load("{{ python_package_import_name }}") 23 | loader.resolve_aliases() 24 | return loader 25 | 26 | 27 | @pytest.fixture(name="internal_api", scope="module") 28 | def _fixture_internal_api(loader: griffe.GriffeLoader) -> griffe.Module: 29 | return loader.modules_collection["{{ python_package_import_name }}._internal"] 30 | 31 | 32 | @pytest.fixture(name="public_api", scope="module") 33 | def _fixture_public_api(loader: griffe.GriffeLoader) -> griffe.Module: 34 | return loader.modules_collection["{{ python_package_import_name }}"] 35 | 36 | 37 | def _yield_public_objects( 38 | obj: griffe.Module | griffe.Class, 39 | *, 40 | modules: bool = False, 41 | modulelevel: bool = True, 42 | inherited: bool = False, 43 | special: bool = False, 44 | ) -> Iterator[griffe.Object | griffe.Alias]: 45 | for member in obj.all_members.values() if inherited else obj.members.values(): 46 | try: 47 | if member.is_module: 48 | if member.is_alias or not member.is_public: 49 | continue 50 | if modules: 51 | yield member 52 | yield from _yield_public_objects( 53 | member, # type: ignore[arg-type] 54 | modules=modules, 55 | modulelevel=modulelevel, 56 | inherited=inherited, 57 | special=special, 58 | ) 59 | elif member.is_public and (special or not member.is_special): 60 | yield member 61 | else: 62 | continue 63 | if member.is_class and not modulelevel: 64 | yield from _yield_public_objects( 65 | member, # type: ignore[arg-type] 66 | modules=modules, 67 | modulelevel=False, 68 | inherited=inherited, 69 | special=special, 70 | ) 71 | except (griffe.AliasResolutionError, griffe.CyclicAliasError): 72 | continue 73 | 74 | 75 | @pytest.fixture(name="modulelevel_internal_objects", scope="module") 76 | def _fixture_modulelevel_internal_objects(internal_api: griffe.Module) -> list[griffe.Object | griffe.Alias]: 77 | return list(_yield_public_objects(internal_api, modulelevel=True)) 78 | 79 | 80 | @pytest.fixture(name="internal_objects", scope="module") 81 | def _fixture_internal_objects(internal_api: griffe.Module) -> list[griffe.Object | griffe.Alias]: 82 | return list(_yield_public_objects(internal_api, modulelevel=False, special=True)) 83 | 84 | 85 | @pytest.fixture(name="public_objects", scope="module") 86 | def _fixture_public_objects(public_api: griffe.Module) -> list[griffe.Object | griffe.Alias]: 87 | return list(_yield_public_objects(public_api, modulelevel=False, inherited=True, special=True)) 88 | 89 | 90 | @pytest.fixture(name="inventory", scope="module") 91 | def _fixture_inventory() -> Inventory: 92 | inventory_file = Path(__file__).parent.parent / "site" / "objects.inv" 93 | if not inventory_file.exists(): 94 | raise pytest.skip("The objects inventory is not available.") 95 | with inventory_file.open("rb") as file: 96 | return Inventory.parse_sphinx(file) 97 | 98 | 99 | def test_exposed_objects(modulelevel_internal_objects: list[griffe.Object | griffe.Alias]) -> None: 100 | """All public objects in the internal API are exposed under `{{ python_package_import_name }}`.""" 101 | not_exposed = [ 102 | obj.path 103 | for obj in modulelevel_internal_objects 104 | if obj.name not in {{ python_package_import_name }}.__all__ or not hasattr({{ python_package_import_name }}, obj.name) 105 | ] 106 | assert not not_exposed, "Objects not exposed:\n" + "\n".join(sorted(not_exposed)) 107 | 108 | 109 | def test_unique_names(modulelevel_internal_objects: list[griffe.Object | griffe.Alias]) -> None: 110 | """All internal objects have unique names.""" 111 | names_to_paths = defaultdict(list) 112 | for obj in modulelevel_internal_objects: 113 | names_to_paths[obj.name].append(obj.path) 114 | non_unique = [paths for paths in names_to_paths.values() if len(paths) > 1] 115 | assert not non_unique, "Non-unique names:\n" + "\n".join(str(paths) for paths in non_unique) 116 | 117 | 118 | def test_single_locations(public_api: griffe.Module) -> None: 119 | """All objects have a single public location.""" 120 | 121 | def _public_path(obj: griffe.Object | griffe.Alias) -> bool: 122 | return obj.is_public and (obj.parent is None or _public_path(obj.parent)) 123 | 124 | multiple_locations = {} 125 | for obj_name in {{ python_package_import_name}}.__all__: 126 | obj = public_api[obj_name] 127 | if obj.aliases and ( 128 | public_aliases := [path for path, alias in obj.aliases.items() if path != obj.path and _public_path(alias)] 129 | ): 130 | multiple_locations[obj.path] = public_aliases 131 | assert not multiple_locations, "Multiple public locations:\n" + "\n".join( 132 | f"{path}: {aliases}" for path, aliases in multiple_locations.items() 133 | ) 134 | 135 | 136 | def test_api_matches_inventory(inventory: Inventory, public_objects: list[griffe.Object | griffe.Alias]) -> None: 137 | """All public objects are added to the inventory.""" 138 | ignore_names = {"__getattr__", "__init__", "__repr__", "__str__", "__post_init__"} 139 | not_in_inventory = [ 140 | obj.path for obj in public_objects if obj.name not in ignore_names and obj.path not in inventory 141 | ] 142 | msg = "Objects not in the inventory (try running `make run mkdocs build`):\n{paths}" 143 | assert not not_in_inventory, msg.format(paths="\n".join(sorted(not_in_inventory))) 144 | 145 | 146 | def test_inventory_matches_api( 147 | inventory: Inventory, 148 | public_objects: list[griffe.Object | griffe.Alias], 149 | loader: griffe.GriffeLoader, 150 | ) -> None: 151 | """The inventory doesn't contain any additional Python object.""" 152 | not_in_api = [] 153 | public_api_paths = {obj.path for obj in public_objects} 154 | public_api_paths.add("{{ python_package_import_name }}") 155 | for item in inventory.values(): 156 | if ( 157 | item.domain == "py" 158 | and "(" not in item.name 159 | and (item.name == "{{ python_package_import_name }}" or item.name.startswith("{{ python_package_import_name }}.")) 160 | ): 161 | obj = loader.modules_collection[item.name] 162 | if obj.path not in public_api_paths and not any(path in public_api_paths for path in obj.aliases): 163 | not_in_api.append(item.name) 164 | msg = "Inventory objects not in public API (try running `make run mkdocs build`):\n{paths}" 165 | assert not not_in_api, msg.format(paths="\n".join(sorted(not_in_api))) 166 | 167 | 168 | def test_no_module_docstrings_in_internal_api(internal_api: griffe.Module) -> None: 169 | """No module docstrings should be written in our internal API. 170 | 171 | The reasoning is that docstrings are addressed to users of the public API, 172 | but internal modules are not exposed to users, so they should not have docstrings. 173 | """ 174 | 175 | def _modules(obj: griffe.Module) -> Iterator[griffe.Module]: 176 | for member in obj.modules.values(): 177 | yield member 178 | yield from _modules(member) 179 | 180 | for obj in _modules(internal_api): 181 | assert not obj.docstring 182 | -------------------------------------------------------------------------------- /project/tests/{% if python_package_command_line_name %}test_cli.py{% endif %}.jinja: -------------------------------------------------------------------------------- 1 | """Tests for the CLI.""" 2 | 3 | from __future__ import annotations 4 | 5 | import pytest 6 | 7 | from {{ python_package_import_name }} import main 8 | from {{ python_package_import_name }}._internal import debug 9 | 10 | 11 | def test_main() -> None: 12 | """Basic CLI test.""" 13 | assert main([]) == 0 14 | 15 | 16 | def test_show_help(capsys: pytest.CaptureFixture) -> None: 17 | """Show help. 18 | 19 | Parameters: 20 | capsys: Pytest fixture to capture output. 21 | """ 22 | with pytest.raises(SystemExit): 23 | main(["-h"]) 24 | captured = capsys.readouterr() 25 | assert "{{ python_package_command_line_name }}" in captured.out 26 | 27 | 28 | def test_show_version(capsys: pytest.CaptureFixture) -> None: 29 | """Show version. 30 | 31 | Parameters: 32 | capsys: Pytest fixture to capture output. 33 | """ 34 | with pytest.raises(SystemExit): 35 | main(["-V"]) 36 | captured = capsys.readouterr() 37 | assert debug._get_version() in captured.out 38 | 39 | 40 | def test_show_debug_info(capsys: pytest.CaptureFixture) -> None: 41 | """Show debug information. 42 | 43 | Parameters: 44 | capsys: Pytest fixture to capture output. 45 | """ 46 | with pytest.raises(SystemExit): 47 | main(["--debug-info"]) 48 | captured = capsys.readouterr().out.lower() 49 | assert "python" in captured 50 | assert "system" in captured 51 | assert "environment" in captured 52 | assert "packages" in captured 53 | -------------------------------------------------------------------------------- /project/{{_copier_conf.answers_file}}.jinja: -------------------------------------------------------------------------------- 1 | # Changes here will be overwritten by Copier. 2 | {{_copier_answers|to_nice_yaml}} 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | copier 2 | copier-templates-extensions 3 | git-changelog 4 | jinja2 5 | markdown-callouts 6 | mkdocs 7 | mkdocs-material 8 | reuse 9 | ruff 10 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | target-version = "py39" 2 | line-length = 120 3 | 4 | [format] 5 | docstring-code-format = true 6 | docstring-code-line-length = 80 7 | -------------------------------------------------------------------------------- /tests/helpers.sh: -------------------------------------------------------------------------------- 1 | generate() { 2 | copier copy -f --trust -r HEAD "$1" "$2" \ 3 | -d testing=true \ 4 | -d project_name="Pawamoy Testing" \ 5 | -d project_description='Testing this great template' \ 6 | -d author_fullname="Timothee Mazzucotelli" \ 7 | -d author_username="pawamoy" \ 8 | -d author_email="dev@pawamoy.fr" \ 9 | -d insiders=true \ 10 | -d public_release=false 11 | } 12 | -------------------------------------------------------------------------------- /tests/reset_history.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | cd tests/tmp || exit 1 3 | rm -rf .git 4 | git init . 5 | if [ -n "${GH_TOKEN}" ]; then 6 | git remote add origin https://pawamoy:${GH_TOKEN}@github.com/pawamoy/pawamoy-testing.git 7 | else 8 | git remote add origin git@github.com:pawamoy/pawamoy-testing.git 9 | fi 10 | git add -A 11 | git commit -m "feat: Initial commit" 12 | git tag 0.1.0 13 | git push origin main -f --tags 14 | -------------------------------------------------------------------------------- /tests/test_filenames.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eu 3 | 4 | filenames_with_double_quotes="$(find . -type f | grep '"' || true)" 5 | if [ -n "${filenames_with_double_quotes}" ]; then 6 | echo "Double-quotes are not valid characters on Windows" >&2 7 | echo "Found in the following file names:" >&2 8 | echo "${filenames_with_double_quotes}" >&2 9 | exit 1 10 | fi 11 | -------------------------------------------------------------------------------- /tests/test_licenses.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import json 3 | from pathlib import Path 4 | 5 | import yaml 6 | import reuse 7 | from jinja2 import Environment 8 | 9 | 10 | with open("copier.yml") as file: 11 | copier = yaml.safe_load(file) 12 | licenses = {identifier: name for name, identifier in copier["copyright_license"]["choices"].items()} 13 | 14 | with Path(reuse.__file__).parent.joinpath("resources", "licenses.json").open() as file: 15 | reuse_licenses = {ldata["licenseId"]: ldata["name"] for ldata in json.load(file)["licenses"]} 16 | 17 | errors = [] 18 | for identifier, name in licenses.items(): 19 | if identifier not in reuse_licenses: 20 | errors.append(f"License {identifier} is not supported by REUSE.") 21 | elif name != reuse_licenses[identifier]: 22 | errors.append(f"License {identifier} has a different name in REUSE: {name!r} != {reuse_licenses[identifier]!r}") 23 | 24 | if errors: 25 | print(*errors, sep="\n") 26 | raise SystemExit(1) 27 | 28 | 29 | env = Environment() 30 | template = env.from_string(Path("project/LICENSE.jinja").read_text()) 31 | 32 | 33 | for license in licenses: 34 | print(f"Testing license: {license}") 35 | rendered = template.render( 36 | project_name="Pawamoy Testing", 37 | project_description="Testing this great template", 38 | author_fullname="Timothée Mazzucotelli", 39 | author_username="pawamoy", 40 | author_email="dev@pawamoy.fr", 41 | copyright_license=license, 42 | copyright_holder="Timothée Mazzucotelli", 43 | copyright_date="2024", 44 | ) 45 | 46 | assert rendered, "License is empty!" 47 | -------------------------------------------------------------------------------- /tests/test_project.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eu 3 | 4 | PYTHON_VERSIONS="${PYTHON_VERSIONS-3.9 3.10 3.11 3.12 3.13 3.14}" 5 | 6 | . tests/helpers.sh 7 | output=tests/tmp 8 | make() { 9 | ./scripts/make "$@" 10 | } 11 | 12 | echo 13 | echo "///////////////////////////////////////////" 14 | echo " GENERATING PROJECT" 15 | echo "///////////////////////////////////////////" 16 | echo 17 | generate "${PWD}" "${output}" 18 | cd "${output}" 19 | cat .copier-answers.yml 20 | git init . 21 | git remote add origin git@github.com:pawamoy/pawamoy-testing 22 | 23 | echo 24 | echo "///////////////////////////////////////////" 25 | echo " TESTING PROJECT" 26 | echo "///////////////////////////////////////////" 27 | echo 28 | echo ">>> Creating initial commit (feat)" 29 | python <>> Printing help" 42 | make help 43 | echo 44 | if [ -z "${SKIP_SETUP:-}" ]; then 45 | echo ">>> Setting up Python environments" 46 | make setup 47 | echo 48 | echo ">>> Printing help again" 49 | make help 50 | echo 51 | fi 52 | echo ">>> Configuring VSCode" 53 | make vscode 54 | echo 55 | echo ">>> Testing arbitrary commands" 56 | pycode="import sys; print(sys.version.split(' ', 1)[0].rsplit('.', 1)[0])" 57 | make run python -c "print('run: ', end=''); ${pycode}" 58 | make multirun python -c "print('multirun: ', end=''); ${pycode}" 59 | make allrun python -c "print('allrun: ', end=''); ${pycode}" 60 | if [ -n "${PYTHON_VERSIONS}" ]; then 61 | version="$(python -c "${pycode}")" 62 | make "${version}" python -c "print('3.x: ', end=''); ${pycode}" | grep -F "${version}" 63 | fi 64 | echo 65 | echo ">>> Formatting and asserting there are no changes" 66 | make format 67 | diff="$(git status --porcelain=v1 2>/dev/null)" 68 | if [ -n "${diff}" ]; then 69 | echo 70 | echo "Status:" 71 | echo "${diff}" 72 | echo "Diff:" 73 | git diff 74 | exit 1 75 | fi 76 | echo 77 | echo ">>> Running quality checks" 78 | make check 79 | echo 80 | echo ">>> Running tests" 81 | make test 82 | echo 83 | echo ">>> Creating second commit (fix)" 84 | touch empty 85 | git add empty 86 | git commit -m "fix: Fix all bugs" 87 | echo 88 | echo ">>> Updating changelog and releasing version" 89 | make changelog release version=0.1.1 90 | echo 91 | echo ">>> Checking changelog's contents" 92 | make run failprint -- grep '0\.1\.0' CHANGELOG.md 93 | make run failprint -- grep '0\.1\.1' CHANGELOG.md 94 | make run failprint -- grep 'Features' CHANGELOG.md 95 | make run failprint -- grep 'Bug Fixes' CHANGELOG.md 96 | echo 97 | echo ">>> Cleaning directory" 98 | make clean 99 | -------------------------------------------------------------------------------- /tests/wait_ci.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | echo "Fetching previous run number..." 3 | previous_run=$(gh run list --repo pawamoy/pawamoy-testing --branch 0.1.0 --limit 1 --workflow "ci.yml" --json number --jq ".[0].number") 4 | 5 | echo "Resetting project's Git history..." 6 | rm -rf tests/tmp/* 7 | make reset-history 8 | cd tests/tmp || exit 1 9 | 10 | wait_time=20 11 | echo "Waiting project's workflow completion (${wait_time}s iterations)..." 12 | while true; do 13 | if output=$(gh run list --repo pawamoy/pawamoy-testing --branch 0.1.0 --limit 1 --workflow "ci.yml" --json number,status,conclusion); then 14 | number=$(jq -r ".[0].number" <<< "$output") 15 | if [ "${number}" -gt "${previous_run}" ]; then 16 | status=$(jq -r ".[0].status" <<< "$output") 17 | if [ "${status}" = "completed" ]; then 18 | conclusion=$(jq -r ".[0].conclusion" <<< "$output") 19 | echo "Workflow completed: ${conclusion}" 20 | if [ "${conclusion}" = "success" ]; then 21 | break 22 | else 23 | exit 1 24 | fi 25 | else 26 | echo "Workflow status: ${status}" 27 | fi 28 | else 29 | echo "Workflow not created" 30 | fi 31 | else 32 | echo "Workflow not created" 33 | fi 34 | sleep ${wait_time} 35 | done 36 | 37 | echo "Deleting all testing releases..." 38 | for tag in $(gh release list --repo pawamoy/pawamoy-testing --json tagName --jq ".[].tagName"); do 39 | gh release delete "${tag}" --repo pawamoy/pawamoy-testing --yes --cleanup-tag || true 40 | done --------------------------------------------------------------------------------