Skip to content

Commit 0dbc5f7

Browse files
authored
feat: use an opinionated project structure for the testing code (#172)
* add Makefile and switch to pyproject.toml * exclude package info in .gitignore * add 'clean' target * format the testing code * fix type error in conftest.py * remove requirements.txt * switch CI to use new structure (no uv) * add missing working dir * add initial HACKING.md * pytest keyword can just be the rule number * improve robustness of Makefile * update intro guidance and cross-links * Add small section to README * add guidance for code maintainers * adjust HACKING.md * bump action versions in pytest workflow * add workflow to run the linter * exclude more cache dirs in .gitignore * reformat updated code after merging from upstream * change duplicate function name in testing code
1 parent ac1d306 commit 0dbc5f7

File tree

13 files changed

+695
-32
lines changed

13 files changed

+695
-32
lines changed

.github/workflows/pytest.yml

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,22 +18,26 @@ jobs:
1818
python-version: [ '3.10', '3.12' ]
1919
steps:
2020
- name: Checkout
21-
uses: actions/checkout@v4
21+
uses: actions/checkout@v6
2222

2323
- name: Setup Python
24-
uses: actions/setup-python@v5
24+
uses: actions/setup-python@v6
2525
with:
2626
python-version: ${{ matrix.python-version }}
2727
cache: 'pip'
2828

29-
- name: Install Python dependencies
29+
- name: Create an environment with Python dependencies
30+
working-directory: tests
3031
run: |
31-
pip install -r tests/requirements.txt
32+
python3 -m venv .venv
33+
. .venv/bin/activate
34+
pip install -e .
3235
3336
- name: Run pytest suite
37+
working-directory: tests
3438
env:
3539
# Set to 1 once manifest covers ALL style rules to enforce coverage.
3640
VALE_ENFORCE_COVERAGE: 0
3741
run: |
38-
python -m pytest -q || python -m pytest -vv
39-
42+
. .venv/bin/activate
43+
make run
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
name: Lint the testing code
2+
3+
on:
4+
pull_request:
5+
paths: [ tests/** ]
6+
branches: [ main ]
7+
# Allow manual trigger
8+
workflow_dispatch: {}
9+
10+
permissions:
11+
contents: read
12+
13+
jobs:
14+
testing-code-lint:
15+
runs-on: ubuntu-latest
16+
steps:
17+
- name: Checkout
18+
uses: actions/checkout@v6
19+
20+
- name: Install uv
21+
uses: astral-sh/setup-uv@v7
22+
23+
- name: Lint the testing code
24+
working-directory: tests
25+
run: make lint

.gitignore

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
node_modules
22
coverage
33
build/
4+
*.egg-info
45
.venv
56
.vscode
6-
.pytest_cache/
7-
tests/__pycache__/
7+
.*_cache/
8+
tests/__pycache__/

HACKING.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
In this guide:
2+
3+
- [Guidance for maintainers of the rules](#guidance-for-maintainers-of-the-rules)
4+
- [Add test cases for a rule](#add-test-cases-for-a-rule)
5+
- [Run all test cases](#run-the-test-cases)
6+
- [Run selected test cases](#run-selected-test-cases)
7+
- [Guidance for maintainers of the testing code](#guidance-for-maintainers-of-the-testing-code)
8+
9+
# Guidance for maintainers of the rules
10+
11+
See first: [Introduction to Vale rule development](getting-started.md)
12+
13+
## Add test cases for a rule
14+
15+
Make sure that the rule has suitable test cases in [tests/data/manifest.yml](tests/data/manifest.yml).
16+
17+
## Run all test cases
18+
19+
We recommend that you first install [uv](https://docs.astral.sh/uv/). To install uv on Ubuntu:
20+
21+
```
22+
sudo snap install astral-uv --classic
23+
```
24+
25+
To run the test cases for every rule:
26+
27+
- **If uv is installed**
28+
29+
```text
30+
make -C tests run
31+
```
32+
33+
- **If uv is not installed**
34+
35+
```text
36+
cd tests
37+
python3 -m venv .venv
38+
. .venv/bin/activate
39+
pip install -e .
40+
make run
41+
```
42+
43+
## Run selected test cases
44+
45+
Behind the scenes, we're using [pytest](https://docs.pytest.org/en/stable/) to run each test case.
46+
47+
To run the test cases for a particular rule, such as `003-Ubuntu-names-versions`:
48+
49+
- **If uv is installed**
50+
51+
```text
52+
uv run --directory tests pytest -vv -k 003
53+
```
54+
55+
- **If uv is not installed**
56+
57+
```text
58+
# (Provided the working dir is 'tests' and the virtual environment is active)
59+
pytest -vv -k 003
60+
```
61+
62+
# Guidance for maintainers of the testing code
63+
64+
The code in the `tests` directory uses Python with [pytest](https://docs.pytest.org/en/stable/). We require the code to be well-formatted and pass static checks.
65+
66+
Our tools of choice are:
67+
68+
- [ruff](https://docs.astral.sh/ruff/) for formatting and checking code conventions
69+
- [pyright](https://microsoft.github.io/pyright/) for checking types
70+
71+
If you've already installed ruff, you should be able to use it in the `tests` directory with no trouble. pyright is less straightforward, as it needs to be run in a virtual environment that contains the testing code's dependencies.
72+
73+
Instead of manually running these tools, we strongly recommend that you install [uv](https://docs.astral.sh/uv/) and use `make` in the `tests` directory.
74+
75+
| Command | Purpose |
76+
|---------------|---------------------------------------------------------------|
77+
| `make format` | Use ruff to format the testing code |
78+
| `make lint` | Use ruff to check code conventions and pyright to check types |
79+
| `make run` | Use pytest to run the test cases for every rule |

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,11 @@ Anyone is welcome to submit a PR to add additional rules. However, no additions
3434

3535
For a reference on rule syntax, see the Vale [documentation on Styles][Vale styles].
3636

37-
If you are completely new to developing Vale rules, see this [introductory guide](https://github.com/canonical/documentation-style-guide/blob/8c7fee862b2258c692439ef430198e393bdc30c4/getting-started.md).
37+
If you are completely new to developing Vale rules, see this [introductory guide](getting-started.md).
38+
39+
### Testing the rules
40+
41+
This repo includes automated tests of the rules, which you can run locally, and which run in CI. See [Guidance for maintainers of the rules](HACKING.md).
3842

3943
### Using the rules
4044

getting-started.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@ The goal of this guide is to curate existing resources and provide pointers for
66
* [Install the Vale CLI tool](#install-the-vale-cli-tool)
77
* [Develop a rule](#develop-a-rule)
88
* [Basics](#basics)
9+
* [Add test cases](#add-test-cases)
910
* [Regex](#regex)
10-
* [Test rules](#test-rules)
11+
* [Manually test the rules](#manually-test-the-rules)
1112
* [Test all rules](#test-all-rules)
1213
* [Test a specific rule](#test-a-specific-rule)
14+
* [Resources](#resources-3)
1315
* [Troubleshooting](#troubleshooting)
16+
* [Conditionally ignoring rules](#conditionally-ignoring-rules)
1417

1518

1619
## Install the Vale CLI tool
@@ -67,6 +70,10 @@ There are several other extensions, each with a particular set of keys to fine-t
6770

6871
You can now use the [documentation](https://vale.sh/docs/topics/styles/#extension-points) to find the extension point that best suits your rule, and see what parameters it requires.
6972

73+
### Add test cases
74+
75+
This repo includes automated tests of the rules, which you can run locally, and which run in CI. As you develop your rule, you should add test cases that exercise your rule. See [Guidance for maintainers of the rules](HACKING.md).
76+
7077
### Resources
7178

7279
* Rule examples:
@@ -96,9 +103,12 @@ The single quotes indicate the beginning and end of the regex, and the `.*?` qua
96103
- [Regex editor](https://regex101.com/): Online tool that helps you compose and test your expressions. I like how it highlights capture groups in different colors.
97104
- Before composing a complicated regex, remember to check for [existing rules](#resources) that might have done this already.
98105

99-
## Test rules
106+
## Manually test the rules
107+
108+
> [!NOTE]
109+
> This repo includes automated tests of the rules, which you can run locally, and which run in CI. See [Guidance for maintainers of the rules](HACKING.md).
100110

101-
Create a file in the root directory of the repository and fill it with text that you expect your rule to catch. It can be in any text format (`.txt`, `.md`, `.rst`...)
111+
To manually test your rule, create a file in the root directory of the repository and fill it with text that you expect your rule to catch. It can be in any text format (`.txt`, `.md`, `.rst`...)
102112

103113
Besides the text file, the `vale` command needs a `vale.ini` config file. This file already exists in the root folder `documentation-style-guide/`. Vale will automatically find the `vale.ini` if you run the command in the same directory.
104114

tests/Makefile

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# If we can, use uv (https://docs.astral.sh/uv/) to manage a virtual environment and run commands.
2+
# Otherwise, require an active virtual environment and directly run commands.
3+
uv := $(shell command -v uv 2>/dev/null)
4+
prerun :=
5+
runner :=
6+
ifneq ($(strip $(uv)),)
7+
runner := uv run
8+
else ifndef VIRTUAL_ENV
9+
prerun = $(error You have not activated a virtual environment)
10+
endif
11+
12+
.DEFAULT_GOAL := help
13+
14+
.PHONY: run format lint clean help
15+
16+
run:
17+
$(prerun)
18+
$(runner) pytest -vv
19+
20+
format:
21+
$(prerun)
22+
$(runner) ruff format
23+
24+
lint:
25+
$(prerun)
26+
$(runner) ruff check
27+
$(runner) ruff format --diff
28+
$(runner) pyright
29+
30+
clean:
31+
@rm -rf *.egg-info .venv __pycache__ .pytest_cache .ruff_cache
32+
33+
help:
34+
@echo "Test the Vale rules make run"
35+
@echo "Format the testing code make format"
36+
@echo "Check the quality of the testing code make lint"

tests/conftest.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
Extensible: add new rules/cases via `tests/data/manifest.yml`.
55
Set environment variable VALE_ENFORCE_COVERAGE=1 to enforce full rule coverage.
66
"""
7+
78
from __future__ import annotations
89

910
import json
@@ -33,13 +34,15 @@ class ExpectedResult(BaseModel):
3334
severity: optional; if provided we assert all findings share this severity.
3435
message_regex: optional regex that all messages must match.
3536
"""
37+
3638
triggers: List[str] = Field(default_factory=list)
3739
severity: str | None = None
3840
message_regex: str | None = None
3941

4042

4143
class TestCase(BaseModel):
4244
"""A single test case for a rule."""
45+
4346
id: str
4447
filetypes: List[str]
4548
content: str
@@ -48,12 +51,14 @@ class TestCase(BaseModel):
4851

4952
class RuleDefinition(BaseModel):
5053
"""All test cases for a single Vale rule."""
54+
5155
name: str
5256
cases: List[TestCase]
5357

5458

5559
class Manifest(BaseModel):
5660
"""Root manifest model loaded from YAML."""
61+
5762
rules: List[RuleDefinition]
5863

5964
@classmethod
@@ -67,7 +72,7 @@ def from_yaml_dict(cls, data: dict) -> "Manifest":
6772
"""
6873
rules_dict = data.get("rules", {})
6974
rules = [
70-
{"name": rule_name, "cases": rule_data.get("cases", [])}
75+
RuleDefinition(name=rule_name, cases=rule_data.get("cases", []))
7176
for rule_name, rule_data in rules_dict.items()
7277
]
7378
return cls(rules=rules)
@@ -93,11 +98,7 @@ def _load_manifest() -> Manifest:
9398
def _discover_rule_ids() -> List[str]:
9499
if not os.path.isdir(STYLES_DIR):
95100
return []
96-
return sorted(
97-
f.rsplit(".", 1)[0]
98-
for f in os.listdir(STYLES_DIR)
99-
if f.endswith(".yml")
100-
)
101+
return sorted(f.rsplit(".", 1)[0] for f in os.listdir(STYLES_DIR) if f.endswith(".yml"))
101102

102103

103104
@pytest.fixture(scope="session")
@@ -176,7 +177,7 @@ def _run_vale(target_file: str, rule_id: str) -> List[ValeResult]:
176177
results: List[ValeResult] = []
177178
for h in raw_hits:
178179
span = None
179-
if 'Span' in h and isinstance(h.get('Span'), list):
180+
if "Span" in h and isinstance(h.get("Span"), list):
180181
span_list = h.get("Span")
181182
if isinstance(span_list, list) and len(span_list) == 2:
182183
span = (span_list[0], span_list[1])
@@ -195,12 +196,15 @@ def _run_vale(target_file: str, rule_id: str) -> List[ValeResult]:
195196
def _iter_cases(manifest: Manifest) -> Iterable[Tuple[str, TestCase]]:
196197
return manifest.iter_cases()
197198

199+
198200
def _idfn(param):
199201
rule, case = param
200202
return f"{rule}::{case.id}"
201203

202204

203205
_ALL_CASES = list(_iter_cases(_load_manifest()))
206+
207+
204208
@pytest.fixture(params=_ALL_CASES, ids=_idfn)
205209
def case_definition(request):
206210
"""Parametrized (rule_id, TestCase) tuple for each case in the manifest."""
@@ -250,10 +254,11 @@ def _assert_case(rule_id: str, case: TestCase, results: List[ValeResult]):
250254

251255
# For tokens with multiplicity in EXPECTED, ensure counts are met.
252256
from collections import Counter
257+
253258
exp_counts = Counter(expected_list)
254259
act_counts = Counter(actual_list)
255260
multiplicity_failures = [
256-
f"{tok} (expected >= {exp_counts[tok]}, got {act_counts.get(tok,0)})"
261+
f"{tok} (expected >= {exp_counts[tok]}, got {act_counts.get(tok, 0)})"
257262
for tok in exp_counts
258263
if exp_counts[tok] > 1 and act_counts.get(tok, 0) < exp_counts[tok]
259264
]
@@ -278,10 +283,12 @@ def _assert_case(rule_id: str, case: TestCase, results: List[ValeResult]):
278283
def vale_runner():
279284
return _run_vale
280285

286+
281287
@pytest.fixture
282288
def assert_case():
283289
return _assert_case
284290

291+
285292
@pytest.fixture(scope="session")
286293
def manifest_schema(manifest: Manifest) -> Dict[str, Any]:
287294
"""Provide the JSON schema for the Manifest (Draft generation by Pydantic)."""
@@ -306,6 +313,7 @@ def _test_markdown(markdown_input: str):
306313
check=True,
307314
)
308315

316+
309317
@pytest.fixture
310318
def test_markdown():
311319
return _test_markdown

tests/pyproject.toml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
[project]
2+
name = "test_style_guide"
3+
version = "0.0.1" # The version number isn't meaningful unless we publish the package.
4+
requires-python = ">=3.10"
5+
dependencies = [
6+
"docutils",
7+
"pydantic>=2.0",
8+
"pytest",
9+
"PyYAML",
10+
"rst2html",
11+
"vale==3.13.0.0",
12+
]
13+
14+
[dependency-groups]
15+
dev = [
16+
"pyright",
17+
"ruff",
18+
]
19+
20+
[tool.ruff]
21+
line-length = 99

tests/requirements.txt

Lines changed: 0 additions & 6 deletions
This file was deleted.

0 commit comments

Comments
 (0)