Skip to content
Open
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
7 changes: 7 additions & 0 deletions copier.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ use_rust:
default: false
help: 'Include Rust implementation via PyO3'

en_gb_oxendict:
type: bool
default: true
help: >-
Enforce en-GB Oxford ("-ize") spelling in documentation via a typos
gate in make markdownlint

python_version:
type: str
default: "3.10"
Expand Down
5 changes: 5 additions & 0 deletions template/.github/workflows/ci.yml.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ jobs:

- name: Audit dependencies
run: make audit
{%- if en_gb_oxendict %}

- name: Lint documentation
run: make markdownlint
{%- endif %}

- name: Test and Measure Coverage
uses: leynos/shared-actions/.github/actions/generate-coverage@455d9ed03477c0026da96c2541ca26569a74acac
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ def reciprocal(n: float) -> float:
return result
```

`else` emphasises the happy path and avoids odd control‑flow within `try`
`else` emphasizes the happy path and avoids odd control‑flow within `try`
blocks.

## 4) Message construction for raises (EM101/EM102) and logging practice (LOG004/LOG007/LOG009/LOG014/LOG015, TRY401)
Expand All @@ -110,7 +110,7 @@ raise RuntimeError(msg)
EM101/EM102 prefer a single message object; this reduces duplication and
clarifies intent.

### Logging: parameterised messages, module loggers, correct APIs
### Logging: parameterized messages, module loggers, correct APIs

```python
import logging
Expand Down
2 changes: 1 addition & 1 deletion template/.rules/python-pyproject.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ package = true
Reddit[^5])
- `description`, `readme`, `requires-python`: provide clarity about the
project and help tools like PyPI. (Python Packaging[^4], Reddit[^5])
- `license`, `authors`, `keywords`, `classifiers`: standardised metadata,
- `license`, `authors`, `keywords`, `classifiers`: standardized metadata,
which improves discoverability. (Python Packaging[^4], Reddit[^5])
- `dependencies`: runtime requirements, expressed in PEP 508 syntax.
(Astral Docs[^1], RidgeRun.ai[^2])
Expand Down
2 changes: 1 addition & 1 deletion template/.rules/python-typing.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

Use `Enum` for fixed sets of related constants. Use `enum.auto()` to avoid
repeating values manually. Use `IntEnum` or `StrEnum` when interoperability
with integers or strings is required (e.g. for database or JSON serialisation).
with integers or strings is required (e.g. for database or JSON serialization).

```python
import enum
Expand Down
25 changes: 19 additions & 6 deletions template/AGENTS.md.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
commit, ensure all of the following are met:
- New functionality or behaviour changes are fully validated by relevant unit
and behavioural tests.
- Bug fixes include a failing test before the fix and a passing test afterward.
- Bug fixes include a failing test before the fix and a passing test afterwards.
- Code passes lint checks.
- Formatting is correct and validated.
- **For Python files:**
Expand Down Expand Up @@ -103,7 +103,7 @@
- **Mermaid diagrams:** Passes validation using nixie (`make nixie`).
- **Committing:**
- Only changes that meet all quality gates should be committed.
- Write clear, descriptive commit messages that summarise the change, following:
- Write clear, descriptive commit messages that summarize the change, following:
- **Imperative mood** in the subject line (for example, "Fix bug", "Add feature").
- **Subject line length:** around 50 characters or fewer.
- **Body:** Separate subject from body with a blank line. Explain *what* and
Expand Down Expand Up @@ -166,8 +166,7 @@
invariant over a range of inputs, states, orderings, or transitions.
- Run relevant unit, behavioural, property, and end-to-end suites before and after
each change.

{% if use_rust %}
{%- if use_rust %}

## Rust specific guidance

Expand Down Expand Up @@ -288,7 +287,7 @@ project:
- Use `pytest` fixtures for shared setup in Python work. Use `rstest` fixtures for
shared setup in Rust work.
- Replace duplicated tests with `#[rstest(...)]` parameterized cases in Rust and
equivalent fixture-driven parameterisation in Python.
equivalent fixture-driven parameterization in Python.
- Prefer `mockall` for ad hoc mocks and stubs.
- For testing reliant on environment variables, use dependency injection and the
`mockable` crate where feasible. If mockable cannot be used, environment
Expand Down Expand Up @@ -348,11 +347,25 @@ project:
- Libraries may emit `metrics` and `tracing` instrumentation, but should not
install global recorders or subscribers.
- Applications should initialize exporters and subscribers early.
{% endif %}
{%- endif %}

## Markdown guidance

- Validate Markdown files using `make markdownlint`.
{%- if en_gb_oxendict %}
- `make markdownlint` also enforces en-GB-oxendict spelling with `typos`,
pinned by the Makefile `TYPOS_VERSION` variable, so local runs and CI use
the same version.
- The spelling configuration `typos.toml` is generated; never edit its
entries by hand. To handle a false positive or add a new Oxford `-ize`
stem, edit `scripts/generate_typos_config.py` (the `STEMS`,
`EXTRA_ACCEPTED_WORDS`, or `extend-ignore-re` lists) and regenerate with
`uv run scripts/generate_typos_config.py`. See the spelling gate section
of `docs/developers-guide.md` for details.
- Quoted APIs and identifiers keep their upstream spelling; put them in
backticks or fenced code blocks, which the spelling gate ignores, rather
than adding word-level exceptions.
{%- endif %}
- Run `make fmt` after documentation changes to format Markdown and fix table
markup.
- Validate Mermaid diagrams in Markdown by running `make nixie`.
Expand Down
14 changes: 13 additions & 1 deletion template/Makefile.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ PYLINT_TARGETS ?= $(PYTHON_TARGETS)
PYLINT_PYPY_SHIM_REF ?= 726d09f968b4d729ee4b29c71fc732e744854f3b
PYLINT_PYPY_SHIM = git+https://github.com/leynos/pylint-pypy-shim.git@$(PYLINT_PYPY_SHIM_REF)
PYLINT = $(UV_ENV) $(UV) tool run --python $(PYLINT_PYTHON) --from '$(PYLINT_PYPY_SHIM)' pylint-pypy
{%- if en_gb_oxendict %}
# Single source of truth for the typos version; CI consumes it through the
# markdownlint target, so the Makefile and CI cannot drift apart.
TYPOS_VERSION ?= 1.48.0
# The env prefix lets xargs execute the command despite the leading
# variable assignments in UV_ENV.
TYPOS = env $(UV_ENV) $(UV) tool run typos@$(TYPOS_VERSION)
MD_FILES_FIND = find . -type f -name '*.md' -not -path './.venv/*' -not -path './.uv-cache/*' -not -path './.uv-tools/*' -not -path './node_modules/*'{% if use_rust %} -not -path './$(RUST_CRATE_DIR)/target/*'{% endif %} -print0
{%- endif %}
{% if use_rust %}
CARGO ?= $(or $(shell command -v cargo 2>/dev/null),$(wildcard $(USER_CARGO)),cargo)
CARGO_AVAILABLE := $(shell command -v $(CARGO) 2>/dev/null)
Expand Down Expand Up @@ -161,8 +170,11 @@ rust-audit: ## Audit Rust extension dependencies for known vulnerabilities
cd $(RUST_CRATE_DIR) && $(CARGO) audit
{% endif %}

markdownlint: $(MDLINT) ## Lint Markdown files
markdownlint: $(MDLINT) ## Lint Markdown files{% if en_gb_oxendict %} and enforce en-GB-oxendict spelling{% endif %}
env -u NO_COLOR $(MDLINT) '**/*.md'
{%- if en_gb_oxendict %}
$(MD_FILES_FIND) | xargs -0 $(TYPOS) --config typos.toml --force-exclude
{%- endif %}

nixie: ## Validate Mermaid diagrams
$(call ensure_tool,$(NIXIE))
Expand Down
4 changes: 2 additions & 2 deletions template/README.md.jinja
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# {{ project_name }}

Example package generated from this Copier template.
{%- if use_rust %}

{% if use_rust %}
This variant includes a small Rust extension.
{% endif %}
{%- endif %}
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ Thresholds and Implications:

Code with high Cognitive Complexity is harder to read, understand, test, and
modify.[^8] SonarQube, for example, raises issues when a function's Cognitive
Complexity exceeds a certain threshold, signaling that the code should likely
Complexity exceeds a certain threshold, signalling that the code should likely
be refactored into smaller, more manageable pieces.[^8] The primary impact of
high Cognitive Complexity is a slowdown in development and an increase in
maintenance costs.[^8]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,53 @@ considered complete.
Run `make audit` as the dependency vulnerability gate. It runs `pip-audit` for
Python dependencies, and Rust-enabled projects also run `cargo audit` from the
`rust_extension` crate directory.
{%- if en_gb_oxendict %}

## Spelling gate

`make markdownlint` enforces en-GB-oxendict (Oxford) spelling over the
repository's Markdown prose with
[`typos`](https://github.com/crate-ci/typos), as required by the
[documentation style guide](documentation-style-guide.md). The configuration
lives in the repository-root `typos.toml` and works in two layers:

1. The `en-gb` locale corrects American spellings (`color` to `colour`,
`behavior` to `behaviour`, `analyzed` to `analysed`).
2. Generated `extend-words` entries restore Oxford spelling, which the
locale alone would not enforce: identity entries accept `-ize`
inflections that the locale would otherwise "correct" to `-ise`, and
`-ise` entries are corrected to `-ize`. Stems taking `-yse` (`analyse`,
`paralyse`) are left to the locale, which already enforces them.

`typos.toml` is a generated file. Never edit its entries by hand; change
`scripts/generate_typos_config.py` and regenerate:

```bash
uv run scripts/generate_typos_config.py
```

The generator script owns the maintainer-facing lists:

- `STEMS` — word stems that take Oxford `-ize`. When the gate flags a
legitimate `-ize` word (or silently accepts its `-ise` variant) because
the stem is missing, add the stem here and regenerate. Do not add
genuinely `-ise`-only words (`advise`, `revise`, `exercise`,
`supervise`).
- `EXTRA_ACCEPTED_WORDS` — words accepted verbatim, such as suffix
fragments quoted in prose and non-English example text.
- `extend-ignore-re` patterns in `HEADER` — regions exempt from spelling
checks: inline code spans, fenced code blocks, and tool names that keep
their upstream spelling. Quoted APIs keep US spelling per the
documentation style guide, so put them in backticks rather than adding
word-level exceptions.

The `typos` version is pinned once, in the Makefile `TYPOS_VERSION`
variable, and CI inherits the pin by calling `make markdownlint`. Bump the
pin there rather than installing a different version ad hoc. To fix
findings mechanically, append `--write-changes` to the gate's `typos`
command and review the rewrites before committing; corrections must not
touch code samples, API names, or quoted material.
{%- endif %}

## Automation scripts

Expand All @@ -35,28 +82,28 @@ actions under `.github/`.

- `.github/workflows/ci.yml` runs on pushes to `main` and on pull requests. It
sets up Python 3.13, installs `uv`, validates the generated `Makefile` with
`mbake`, runs `make build`, `make check-fmt`,
`make lint` (Ruff + `interrogate --fail-under 100 $(PYTHON_TARGETS)` + Pylint), `make
typecheck`, and `make audit`, then delegates coverage generation to the shared
coverage action. When the Rust extension is enabled, it also sets up Rust,
`mbake`, runs `make build`, `make check-fmt`, `make lint` (Ruff +
`interrogate --fail-under 100 $(PYTHON_TARGETS)` + Pylint), `make typecheck`,
and `make audit`, then delegates coverage generation to the shared coverage
action. When the Rust extension is enabled, it also sets up Rust,
installs Rust lint and test tools, and passes `rust_extension/Cargo.toml` to
coverage.
- `.github/workflows/act-validation.yml` runs rendered workflow validation in a
separate workflow. It installs `act`, checks Docker availability, and runs
`make test WITH_ACT=1` outside the coverage path.
- `.github/workflows/release.yml` publishes wheels when a `v*.*.*` tag is
pushed. It builds a pure Python wheel, creates a GitHub release with generated
release notes, downloads wheel artifacts, and uploads them to the tag release.
release notes, downloads wheel artefacts, and uploads them to the tag release.
- `.github/workflows/build-wheels.yml` is a reusable workflow for extension
builds. It accepts a Python version and builds wheels across Linux, Windows,
and macOS architectures via `.github/actions/build-wheels`.
- `.github/workflows/get-codescene-sha.yml` is manually dispatched. It fetches
the CodeScene coverage CLI installer, computes its SHA-256 digest, and writes
the result to the `CODESCENE_CLI_SHA256` repository variable.
- `.github/actions/build-wheels` wraps `cibuildwheel` with `uvx` and uploads
architecture-specific wheel artifacts.
architecture-specific wheel artefacts.
- `.github/actions/pure-python-wheel` builds a pure Python wheel with
`uv build --wheel` and uploads the resulting artifact.
`uv build --wheel` and uploads the resulting artefact.
- `.github/dependabot.yml` enables dependency update pull requests for GitHub
Actions and Python packages. Rust-enabled projects also receive Cargo updates.

Expand Down
4 changes: 4 additions & 0 deletions template/pyproject.toml.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ dev = [
"ty",
"pytest-timeout",
"pytest-xdist",
{%- if en_gb_oxendict %}
# TOML parser for the typos-config tests on baselines before 3.11.
"tomli; python_version < '3.11'",
{%- endif %}
]

[tool.ruff]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""Unit tests for the ``typos.toml`` generator script.

The generator is a standalone ``uv run`` script rather than a package
module, so it is loaded here through ``importlib`` from its file path.
"""

from __future__ import annotations

import importlib
import importlib.util
import pathlib
import sys
import typing as typ

import pytest

if typ.TYPE_CHECKING:
import types

# tomllib is stdlib from Python 3.11; older baselines fall back to the
# API-compatible tomli dev dependency. The dynamic import keeps static
# checkers happy at the project's requires-python floor.
_TOML_MODULE_NAME = "tomllib" if sys.version_info >= (3, 11) else "tomli"
toml_reader = importlib.import_module(_TOML_MODULE_NAME)

REPOSITORY_ROOT = pathlib.Path(__file__).resolve().parents[1]
SCRIPT_PATH = REPOSITORY_ROOT / "scripts" / "generate_typos_config.py"


@pytest.fixture(name="generator", scope="module")
def generator_fixture() -> types.ModuleType:
"""Load the generator script as a module from its file path."""
spec = importlib.util.spec_from_file_location("generate_typos_config", SCRIPT_PATH)
assert spec is not None
assert spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module


def test_render_config_emits_every_stem_and_suffix_pair(
generator: types.ModuleType,
) -> None:
"""Every stem inflection gets an -ise correction and an -ize identity."""
rendered = generator.render_config()
for stem in generator.STEMS:
for ise, ize in generator.SUFFIX_PAIRS:
assert f'{stem}{ise} = "{stem}{ize}"' in rendered
assert f'{stem}{ize} = "{stem}{ize}"' in rendered


def test_render_config_accepts_extra_words_verbatim(
generator: types.ModuleType,
) -> None:
"""Every extra accepted word gets an identity entry."""
rendered = generator.render_config()
for word in generator.EXTRA_ACCEPTED_WORDS:
assert f'{word} = "{word}"' in rendered


def test_render_config_ends_with_trailing_newline(
generator: types.ModuleType,
) -> None:
"""The rendered document ends with exactly one trailing newline."""
rendered = generator.render_config()
assert rendered.endswith("\n")
assert not rendered.endswith("\n\n")


def test_render_config_parses_as_valid_toml(generator: types.ModuleType) -> None:
"""The rendered configuration parses as TOML with no duplicate keys.

``tomllib`` raises ``TOMLDecodeError`` on duplicate keys, so parsing
guards against two stem/suffix combinations (or an extra accepted word)
colliding into the same ``extend-words`` entry. The exact entry count
additionally documents that every stem inflection and accepted word is
present.
"""
parsed = toml_reader.loads(generator.render_config())
extend_words = parsed["default"]["extend-words"]
expected = len(generator.EXTRA_ACCEPTED_WORDS) + 2 * len(generator.STEMS) * len(
generator.SUFFIX_PAIRS
)
assert len(extend_words) == expected


def test_main_writes_rendered_config_to_explicit_path(
generator: types.ModuleType,
tmp_path: pathlib.Path,
) -> None:
"""main() writes the rendered configuration to the given output path."""
output = tmp_path / "typos.toml"
generator.main(output)
assert output.read_text(encoding="utf-8") == generator.render_config()


def test_main_default_path_resolves_to_repository_root(
generator: types.ModuleType,
tmp_path: pathlib.Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""main() defaults to typos.toml one level above the script directory."""
fake_script = tmp_path / "scripts" / "generate_typos_config.py"
monkeypatch.setattr(generator, "__file__", str(fake_script))
generator.main()
written = tmp_path / "typos.toml"
assert written.read_text(encoding="utf-8") == generator.render_config()


def test_committed_config_matches_generator_output(
generator: types.ModuleType,
) -> None:
"""The committed typos.toml must not drift from the generator."""
committed = (REPOSITORY_ROOT / "typos.toml").read_text(encoding="utf-8")
assert committed == generator.render_config()


def test_committed_config_parses_as_valid_toml() -> None:
"""The committed typos.toml parses cleanly (no duplicate-key errors)."""
committed = (REPOSITORY_ROOT / "typos.toml").read_text(encoding="utf-8")
parsed = toml_reader.loads(committed)
assert parsed["default"]["locale"] == "en-gb"
assert parsed["default"]["extend-words"]
Loading
Loading