Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
version: 2
updates:
- package-ecosystem: uv
directory: /
schedule:
interval: weekly
groups:
python:
patterns: ["*"]
commit-message:
prefix: chore
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
commit-message:
prefix: chore
70 changes: 70 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
name: CI

on:
push:
branches: [main]
pull_request:

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v10.2.0
- run: uv sync --locked
- run: uv run invoke lint

test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: astral-sh/setup-uv@v10.2.0
- run: uv sync --locked
- run: uv run invoke test

secrets:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: astral-sh/setup-uv@v10.2.0
- run: uvx pre-commit run gitleaks --all-files

# Runs the checks that need Docker or Node inside each generated variant,
# the same way the generated project's own CI would.
generated:
runs-on: ubuntu-latest
needs: [lint, test]
strategy:
matrix:
api: [false, true]
name: generated (api=${{ matrix.api }})
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: astral-sh/setup-uv@v10.2.0
- uses: actions/setup-node@v7
if: matrix.api
with:
node-version: lts/*
- run: uv sync --locked
- run: uv run invoke render --dest "$RUNNER_TEMP/demo" ${{ matrix.api && '--api' || '' }}
- working-directory: ${{ runner.temp }}/demo
run: |
uv sync --locked
uv run invoke audit
uv run invoke smoke
- if: matrix.api
working-directory: ${{ runner.temp }}/demo
run: uv run invoke api-test
13 changes: 13 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
__pycache__/
*.py[cod]
.venv/
dist/
build/
*.egg-info/
.pytest_cache/
.ruff_cache/
.mypy_cache/
.coverage
htmlcov/
.env
.DS_Store
48 changes: 48 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
default_install_hook_types: [pre-commit, commit-msg]

repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
exclude: ^template/
- id: check-toml
exclude: ^template/
- id: check-added-large-files
- id: check-merge-conflict
- id: detect-private-key

- repo: https://github.com/compilerla/conventional-pre-commit
rev: v4.4.0
hooks:
- id: conventional-pre-commit
stages: [commit-msg]
args: [feat, fix, refactor, test, docs, chore, perf]

- repo: https://github.com/gitleaks/gitleaks
rev: v8.30.1
hooks:
- id: gitleaks

- repo: local
hooks:
- id: ruff-check
name: ruff check
entry: uv run ruff check --fix
language: system
types: [python]
exclude: ^template/
- id: ruff-format
name: ruff format
entry: uv run ruff format
language: system
types: [python]
exclude: ^template/
- id: mypy
name: mypy
entry: uv run mypy
language: system
types: [python]
pass_filenames: false
1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.13
35 changes: 35 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# CLAUDE.md

Guidance for AI coding agents working on this Copier template.

## Layout

- `copier.yml`: questions, validators and post-generation tasks.
- `template/`: the generated project. Files ending in `.jinja` are rendered; everything else is copied verbatim. Conditional files and folders use names like `{% if api %}api.py{% endif %}.jinja`.
- `tests/test_template.py`: renders variants with Copier and runs each one's own `invoke lint` and `invoke test`.
- `tasks.py`: tasks for the template repo itself. CI runs the same tasks.

## Commands

```sh
uv run invoke lint # ruff and mypy on tasks.py and tests
uv run invoke test # render every variant and check it
uv run invoke render --dest /tmp/demo --api # inspect a rendered project
```

After changing anything under `template/`, run `invoke test`. For changes touching Docker, Bruno or `invoke smoke`/`api-test`, also render a project and run those tasks inside it.

## Rules

- **Generated projects must pass their own CI.** Every change to `template/` keeps `lint`, `test`, `audit`, `smoke` and (for the API variant) `api-test` green.
- **Jinja and GitHub Actions both use `{{ }}`.** Wrap Actions expressions in rendered files in `{% raw %}...{% endraw %}`. Bruno `.bru` files aren't rendered, so their `{{baseUrl}}` needs no escaping.
- **Keep the CLI and API variants in step.** Shared behaviour belongs outside the `{% if api %}` blocks.
- Keep example code small and obviously replaceable; the template is a starting point, not a framework.
- Keep the generated `CLAUDE.md` in step with the generated project's tooling.

## Conventions

- Branch as `<type>/<issue>-<short-description>`; start from an issue.
- Commits are `<type>: <description>` with types `feat`, `fix`, `refactor`, `test`, `docs`, `chore`, `perf`. CI and tooling changes are `chore`.
- Never add AI attribution (`Co-Authored-By` trailers or "Generated with" footers) to commits, pull requests, issues or comments.
- Comments explain *why*, not *what*.
73 changes: 72 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,74 @@
# python-template

A [Copier](https://copier.readthedocs.io) template for production-ready Python projects.
A [Copier](https://copier.readthedocs.io) template for production-ready Python projects. Every generated project is linted, typed, tested, scanned and containerised from its first commit, and it can pull in later template improvements with one command.

```sh
uvx copier copy --trust gh:Qualixto/python-template my-project
```

`--trust` lets Copier run the post-generation tasks: `git init` and `uv lock`.

---

## What you get

| Area | Tooling | Why |
|---|---|---|
| Environments | [uv](https://docs.astral.sh/uv/), `.python-version`, `uv.lock` | One fast tool for Python, dependencies and lockfiles |
| Lint and format | [ruff](https://docs.astral.sh/ruff/), including bandit-style `S` rules | One tool instead of four, with security linting built in |
| Types | mypy `--strict` over `src`, `tests` and `tasks.py` | Type errors fail the build, not production |
| Tests | pytest with branch coverage, failing under 90% | Coverage is a gate, not a report nobody reads |
| Tasks | [invoke](https://www.pyinvoke.org) `tasks.py` | CI runs the same tasks as you do locally, so they can't drift |
| Git hooks | pre-commit: hygiene, gitleaks, ruff, mypy, conventional commits; tests on push | Problems are caught before they reach a pull request |
| Security | gitleaks, `uv audit`, Dependabot for uv, Actions and Docker | Secrets and vulnerable dependencies are caught automatically |
| Container | uv base image, cached dependency layer, non-root user, smoke test | The image is tested, not just built |
| CI | GitHub Actions: lint, test, audit, secrets, smoke | Fails by default; nothing merges red |
| Ways of working | `DEFINITION_OF_DONE.md`, ADRs in `docs/adr/`, `CLAUDE.md` | Shared standards for people and AI agents alike |

### Optional: API service

Answer **yes** to the API question to add a FastAPI service that wraps an upstream HTTP API:

- `create_app` takes the upstream client as an argument, so tests inject a fake via `httpx2.MockTransport`.
- Upstream failures, timeouts and malformed payloads map to deliberate status codes and are covered by tests.
- A fake upstream in `tests/` lets you run the whole stack locally with `invoke serve`.
- A [Bruno](https://www.usebruno.com) collection doubles as API documentation and as end-to-end tests (`invoke api-test`), run in CI.

## Questions

| Question | Default |
|---|---|
| `project_name` | required |
| `project_slug` | derived from the name, e.g. `my-project` |
| `package_name` | derived from the slug, e.g. `my_project` |
| `description` | empty |
| `author_name`, `author_email` | required |
| `python_version` | `3.13` (3.12 to 3.14) |
| `api` | `false` |
| `license` | `Apache-2.0` (or `MIT`, `Proprietary`) |

## Keeping projects up to date

Generated projects record their answers in `.copier-answers.yml`. To apply later template changes:

```sh
uvx copier update --trust
```

Copier merges the changes, and anything that conflicts with your edits shows up as a normal git conflict.

## Developing the template

```sh
uv sync
uv run pre-commit install
uv run invoke lint # the template's own tooling
uv run invoke test # renders every variant and runs its lint and test tasks
uv run invoke render --dest /tmp/demo --api
```

CI also builds and smoke-tests the Docker image and runs the Bruno suite for each generated variant.

## Licence

[Apache-2.0](LICENSE). Maintained by [Qualixto](https://qualixto.com).
78 changes: 78 additions & 0 deletions copier.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
_min_copier_version: "9.5"
_subdirectory: template
_answers_file: .copier-answers.yml

_message_after_copy: |
Created {{ project_name }} in {{ _copier_conf.dst_path }}.

Next steps:
cd {{ _copier_conf.dst_path }}
uv sync
uv run pre-commit install
uv run invoke --list

_tasks:
- command: "[ -d .git ] || git init -q -b main"
when: "{{ _copier_operation == 'copy' }}"
- "uv lock"

project_name:
type: str
help: Human-readable project name
validator: "{% if not project_name.strip() %}Required{% endif %}"

project_slug:
type: str
help: Distribution and repository name
default: "{{ project_name.strip().lower().replace(' ', '-').replace('_', '-').replace('.', '-') }}"

package_name:
type: str
help: Python import name
default: "{{ project_slug.replace('-', '_') }}"
validator: >-
{% if not package_name.isidentifier() or package_name != package_name.lower() %}
Must be a lowercase Python identifier
{% endif %}

description:
type: str
help: One-line description
default: ""

author_name:
type: str
help: Author name

author_email:
type: str
help: Author email

python_version:
type: str
help: Python version
choices:
- "3.12"
- "3.13"
- "3.14"
default: "3.13"

api:
type: bool
help: Include a FastAPI service with a fake upstream and Bruno API tests?
default: false

license:
type: str
help: Licence
choices:
- Apache-2.0
- MIT
- Proprietary
default: Apache-2.0

copyright_holder:
type: str
help: Copyright holder
default: "{{ author_name }}"
when: "{{ license != 'Proprietary' }}"
41 changes: 41 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
[project]
name = "python-template"
version = "0.1.0"
description = "Copier template for production-ready Python projects"
requires-python = ">=3.13"
dependencies = []

[dependency-groups]
dev = [
"copier>=9.18.2",
"invoke>=3.0.3",
"mypy>=2.4.0",
"pre-commit>=4.6.2",
"pytest>=9.1.1",
"ruff>=0.16.10",
]

[tool.uv]
package = false

[tool.pytest.ini_options]
testpaths = ["tests"]
# Rendering uncommitted template changes is the point of the tests.
filterwarnings = ["ignore:Dirty template changes"]

[tool.ruff]
extend-exclude = ["template"]

[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM", "S"]

[tool.ruff.lint.per-file-ignores]
"tests/**" = ["S101", "S603", "S607"]

[tool.mypy]
strict = true
files = ["tests", "tasks.py"]

[[tool.mypy.overrides]]
module = ["copier"]
ignore_missing_imports = true
Loading
Loading